Initial commit

This commit is contained in:
yuding
2026-04-20 10:13:52 +08:00
commit 7954b98fc2
50 changed files with 10143 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.DS_Store

22
README.md Normal file
View File

@@ -0,0 +1,22 @@
# BIM Dashboard Vite + Vue 3
Single-entry Vue 3 app (managed by Vue Router) for module routes.
## Run
```bash
npm install
npm run dev
```
Open routes directly:
- `/home`
- `/project`
- `/subcontract`
- `/measurement`
- `/plan`
- `/progress`
- `/change`
- `/material`
- `/inspection`

View File

@@ -0,0 +1,663 @@
# iflow-engine SDK 快速开始
## 安装
### NPM
```bash
npm install iflow-engine
```
### Yarn
```bash
yarn add iflow-engine
```
### PNPM
```bash
pnpm add iflow-engine
```
## 引擎概览
SDK 提供三个独立引擎类,按需引入,互不依赖:
| 类名 | 用途 | 定位 |
|---|---|---|
| `BimEngine` | 3D BIM 模型可视化 | 完整功能(工具栏、测量、剖切、漫游等 UI 组件) |
| `BimEngine2d` | 2D CAD/DWG 图纸查看 | 轻量级,无 UI 管理器 |
| `BimEngine720` | 720° 全景图查看 | 轻量级,无 UI 管理器 |
```typescript
// ESM 按需导入
import { BimEngine } from 'iflow-engine'; // 3D
import { BimEngine2d } from 'iflow-engine'; // 2D
import { BimEngine720 } from 'iflow-engine'; // 720°
// 也可以一次导入全部
import { BimEngine, BimEngine2d, BimEngine720 } from 'iflow-engine';
```
```html
<!-- UMD 方式(原生 HTML -->
<script src="./lib/iflow-engine.umd.js"></script>
<script>
const { BimEngine, BimEngine2d, BimEngine720 } = IflowEngine;
</script>
```
> **注意**:三个引擎类共用同一个容器时,需先销毁当前实例再创建新实例。详见 [多引擎切换](#多引擎切换)。
---
## 一、3D 引擎BimEngine
完整的 BIM 3D 可视化引擎,包含工具栏、测量、剖切、漫游等全部 UI 功能。
### 基本用法
```typescript
import { BimEngine } from 'iflow-engine';
const engine = new BimEngine('container', {
locale: 'zh-CN', // 'zh-CN' | 'en-US'
theme: 'dark' // 'dark' | 'light'
});
// 初始化 3D 渲染引擎
engine.engine?.initialize({
backgroundColor: 0x333333,
showViewCube: true
});
// 加载模型
engine.engine?.loadModel(['https://example.com/model/'], {
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1]
});
```
### 构造参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `container` | `HTMLElement \| string` | ✅ | DOM 元素或元素 ID |
| `options.locale` | `'zh-CN' \| 'en-US'` | | 界面语言,默认 `'zh-CN'` |
| `options.theme` | `'dark' \| 'light'` | | 主题,默认 `'dark'` |
### 管理器
BimEngine 构造时自动初始化以下管理器:
```typescript
engine.engine // EngineManager - 3D 引擎(加载模型、控制渲染)
engine.toolbar // ToolbarManager - 底部工具栏
engine.buttonGroup // ButtonGroupManager - 按钮组
engine.dialog // DialogManager - 弹窗管理
engine.rightKey // RightKeyManager - 右键菜单
engine.constructTreeBtn // ConstructTreeManagerBtn - 构件树
engine.measure // MeasureDialogManager - 测量工具
engine.sectionPlane // SectionPlaneDialogManager - 拾取面剖切
engine.sectionAxis // SectionAxisDialogManager - 轴向剖切
engine.sectionBox // SectionBoxDialogManager - 剖切盒
engine.walkControl // WalkControlManager - 漫游控制
engine.componentDetail // ComponentDetailManager - 构件详情
engine.aiChat // AiChatManager - AI 对话
engine.setting // SettingDialogManager - 设置
```
### Vue 3 示例
```vue
<template>
<div ref="containerRef" style="width: 100vw; height: 100vh;" />
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { BimEngine } from 'iflow-engine';
const containerRef = ref<HTMLElement>();
let engine: BimEngine | null = null;
onMounted(() => {
if (!containerRef.value) return;
engine = new BimEngine(containerRef.value, {
locale: 'zh-CN',
theme: 'dark'
});
engine.engine?.initialize({ backgroundColor: 0x333333 });
engine.engine?.loadModel(['https://example.com/model/']);
});
onUnmounted(() => {
engine?.destroy();
engine = null;
});
</script>
```
### React 示例
```tsx
import { useRef, useEffect } from 'react';
import { BimEngine } from 'iflow-engine';
export function BimViewer() {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const engine = new BimEngine(containerRef.current, {
locale: 'zh-CN',
theme: 'dark'
});
engine.engine?.initialize({ backgroundColor: 0x333333 });
engine.engine?.loadModel(['https://example.com/model/']);
return () => engine.destroy();
}, []);
return <div ref={containerRef} style={{ width: '100vw', height: '100vh' }} />;
}
```
---
## 二、2D 图纸引擎BimEngine2d
轻量级 2D CAD/DWG 图纸查看引擎,无 UI 管理器,构造时自动初始化。
### 基本用法
```typescript
import { BimEngine2d } from 'iflow-engine';
const engine2d = new BimEngine2d('container', {
locale: 'zh-CN',
theme: 'dark',
backgroundColor: 0xf0f0f0,
gridEnabled: true,
axesEnabled: true
});
// 加载图纸
await engine2d.loadDrawing('https://example.com/drawing-id');
// 销毁
engine2d.destroy();
```
### 构造参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `container` | `HTMLElement \| string` | ✅ | DOM 元素或元素 ID |
| `options.locale` | `'zh-CN' \| 'en-US'` | | 界面语言 |
| `options.theme` | `'dark' \| 'light'` | | 主题 |
| `options.backgroundColor` | `number` | | 背景色,如 `0xffffff` |
| `options.gridEnabled` | `boolean` | | 是否显示网格 |
| `options.axesEnabled` | `boolean` | | 是否显示坐标轴 |
| `options.selectionColor` | `number` | | 选中构件颜色 |
| `options.highlightColor` | `number` | | 高亮构件颜色 |
| `options.enablePerformanceMonitoring` | `boolean` | | 是否启用性能监控 |
### API
```typescript
// 图纸操作
await engine2d.loadDrawing(url, options?) // 加载图纸
engine2d.getLayers() // 获取所有图层 → Drawing2dLayer[]
engine2d.setLayerVisible(name, visible) // 设置图层可见性
// 视图控制
engine2d.resetView() // 重置视图
engine2d.fitToView() // 适应全部内容
engine2d.setZoom(zoom) // 设置缩放级别
engine2d.getZoom() // 获取当前缩放级别
// 主题
engine2d.setTheme('dark' | 'light')
// 事件
const unsub = engine2d.on('event-name', (payload) => { ... })
unsub() // 取消订阅
engine2d.onRawEvent(event, handler) // 底层引擎事件
engine2d.offRawEvent(event, handler)
// 生命周期
engine2d.destroy() // 销毁引擎
```
### Vue 3 示例
```vue
<template>
<div ref="containerRef" style="width: 100%; height: 100vh;" />
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { BimEngine2d } from 'iflow-engine';
const containerRef = ref<HTMLElement>();
let engine2d: BimEngine2d | null = null;
onMounted(async () => {
if (!containerRef.value) return;
engine2d = new BimEngine2d(containerRef.value, {
theme: 'light',
gridEnabled: true
});
await engine2d.loadDrawing('https://example.com/drawing-id');
});
onUnmounted(() => {
engine2d?.destroy();
engine2d = null;
});
</script>
```
### React 示例
```tsx
import { useRef, useEffect } from 'react';
import { BimEngine2d } from 'iflow-engine';
export function DrawingViewer() {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const engine2d = new BimEngine2d(containerRef.current, {
theme: 'light',
gridEnabled: true
});
engine2d.loadDrawing('https://example.com/drawing-id');
return () => engine2d.destroy();
}, []);
return <div ref={containerRef} style={{ width: '100%', height: '100vh' }} />;
}
```
---
## 三、720° 全景引擎BimEngine720
轻量级 720° 全景图查看引擎,支持鼠标/触摸旋转和缩放,构造时自动初始化。
### 基本用法
```typescript
import { BimEngine720 } from 'iflow-engine';
const engine720 = new BimEngine720('container', {
fov: 75,
enableZoom: true,
enableRotate: true,
sphereRadius: 500
});
// 加载全景图
await engine720.loadPanorama('https://example.com/panorama.png');
// 销毁
engine720.destroy();
```
### 构造参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `container` | `HTMLElement \| string` | ✅ | DOM 元素或元素 ID |
| `options.locale` | `'zh-CN' \| 'en-US'` | | 界面语言 |
| `options.theme` | `'dark' \| 'light'` | | 主题 |
| `options.fov` | `number` | | 视场角,默认 `75` |
| `options.enableZoom` | `boolean` | | 是否启用缩放,默认 `true` |
| `options.enableRotate` | `boolean` | | 是否启用旋转,默认 `true` |
| `options.sphereRadius` | `number` | | 球体半径,默认 `500` |
| `options.rotateSpeed` | `number` | | 旋转速度 |
| `options.zoomSpeed` | `number` | | 缩放速度 |
| `options.enableDamping` | `boolean` | | 是否启用阻尼(惯性效果) |
| `options.dampingFactor` | `number` | | 阻尼因子 |
| `options.minFov` | `number` | | 最小视场角 |
| `options.maxFov` | `number` | | 最大视场角 |
### API
```typescript
// 全景操作
await engine720.loadPanorama(url, options?) // 加载全景图
await engine720.preloadPanoramas(urls) // 预加载多个全景图
// 视角控制
engine720.setFov(fov) // 设置视场角
engine720.getFov() // 获取当前视场角
engine720.lookAt(phi, theta, animated?) // 设置相机朝向
engine720.resetView() // 重置视图
// 主题
engine720.setTheme('dark' | 'light')
// 事件
const unsub = engine720.on('event-name', (payload) => { ... })
unsub() // 取消订阅
engine720.onRawEvent(event, handler) // 底层引擎事件
engine720.offRawEvent(event, handler)
// 生命周期
engine720.destroy() // 销毁引擎
```
### Vue 3 示例
```vue
<template>
<div ref="containerRef" style="width: 100%; height: 100vh;" />
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { BimEngine720 } from 'iflow-engine';
const containerRef = ref<HTMLElement>();
let engine720: BimEngine720 | null = null;
onMounted(async () => {
if (!containerRef.value) return;
engine720 = new BimEngine720(containerRef.value, {
fov: 75,
enableZoom: true,
enableRotate: true,
sphereRadius: 500
});
await engine720.loadPanorama('https://example.com/panorama.png');
});
onUnmounted(() => {
engine720?.destroy();
engine720 = null;
});
</script>
```
### React 示例
```tsx
import { useRef, useEffect } from 'react';
import { BimEngine720 } from 'iflow-engine';
export function PanoramaViewer() {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const engine720 = new BimEngine720(containerRef.current, {
fov: 75,
enableZoom: true,
enableRotate: true,
sphereRadius: 500
});
engine720.loadPanorama('https://example.com/panorama.png');
return () => engine720.destroy();
}, []);
return <div ref={containerRef} style={{ width: '100%', height: '100vh' }} />;
}
```
---
## 多引擎切换
三个引擎类可以共用同一个容器,但同一时刻只能有一个实例。切换时需先销毁当前实例:
```typescript
import { BimEngine, BimEngine2d, BimEngine720 } from 'iflow-engine';
let current: BimEngine | BimEngine2d | BimEngine720 | null = null;
function destroyCurrent() {
current?.destroy();
current = null;
}
// 切换到 3D
function switchTo3D() {
destroyCurrent();
const engine = new BimEngine('container', { theme: 'dark' });
engine.engine?.initialize({ backgroundColor: 0x333333 });
engine.engine?.loadModel(['https://example.com/model/']);
current = engine;
}
// 切换到 2D
function switchTo2D() {
destroyCurrent();
const engine2d = new BimEngine2d('container', { theme: 'dark' });
engine2d.loadDrawing('https://example.com/drawing-id');
current = engine2d;
}
// 切换到 720°
function switchTo720() {
destroyCurrent();
const engine720 = new BimEngine720('container', {
fov: 75,
enableZoom: true,
enableRotate: true,
sphereRadius: 500
});
engine720.loadPanorama('https://example.com/panorama.png');
current = engine720;
}
```
---
## 原生 HTML 完整示例
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>iflow-engine Demo</title>
<style>
body { margin: 0; font-family: sans-serif; }
#container { width: 100vw; height: 100vh; }
.btn-group { position: fixed; top: 10px; left: 10px; z-index: 100; }
.btn-group button { padding: 8px 16px; margin-right: 8px; cursor: pointer; }
</style>
</head>
<body>
<div class="btn-group">
<button onclick="switchTo3D()">3D 引擎</button>
<button onclick="switchTo2D()">2D 图纸</button>
<button onclick="switchTo720()">720° 全景</button>
</div>
<div id="container"></div>
<script src="./lib/iflow-engine.umd.js"></script>
<script>
var BimEngine = IflowEngine.BimEngine;
var BimEngine2d = IflowEngine.BimEngine2d;
var BimEngine720 = IflowEngine.BimEngine720;
var current = null;
function destroyCurrent() {
if (current) { current.destroy(); current = null; }
}
function switchTo3D() {
destroyCurrent();
var engine = new BimEngine('container', { locale: 'zh-CN', theme: 'dark' });
engine.engine.initialize({ backgroundColor: 0x333333, showViewCube: true });
engine.engine.loadModel(['https://example.com/model/']);
current = engine;
}
function switchTo2D() {
destroyCurrent();
var engine2d = new BimEngine2d('container', { theme: 'dark', gridEnabled: true });
engine2d.loadDrawing('https://example.com/drawing-id');
current = engine2d;
}
function switchTo720() {
destroyCurrent();
var engine720 = new BimEngine720('container', {
fov: 75, enableZoom: true, enableRotate: true, sphereRadius: 500
});
engine720.loadPanorama('https://example.com/panorama.png');
current = engine720;
}
// 默认加载 3D
switchTo3D();
</script>
</body>
</html>
```
---
## TypeScript 类型
SDK 导出所有相关类型,开箱即用:
```typescript
import type {
// 3D 引擎
EngineOptions,
ModelLoadOptions,
// 2D 引擎
Engine2dOptions,
DrawingLoadOptions,
Drawing2dLayer,
// 720 引擎
Engine720Options,
PanoramaLoadOptions,
PanoramaAnnotation,
// 通用
ThemeConfig,
ThemeType,
EngineEvents,
// UI 组件(仅 BimEngine 3D 使用)
DialogOptions,
DialogPosition,
ButtonConfig,
ButtonGroupOptions,
TreeOptions,
TreeNodeConfig,
} from 'iflow-engine';
```
---
## 主题与语言
三个引擎类均支持主题切换和语言设置:
```typescript
// 构造时设置
const engine = new BimEngine('container', { theme: 'dark', locale: 'zh-CN' });
const engine2d = new BimEngine2d('container', { theme: 'light' });
const engine720 = new BimEngine720('container', { theme: 'dark' });
// 运行时切换主题
engine.setTheme('light');
engine2d.setTheme('dark');
engine720.setTheme('light');
// 运行时切换语言(仅 3D 引擎支持)
engine.setLocale('en-US');
// 自定义主题(仅 3D 引擎支持)
engine.setCustomTheme({
name: 'custom',
primary: '#1890ff',
primaryHover: '#40a9ff',
// ...
});
```
---
## 常见问题
### 1. 如何只引入需要的引擎?
直接按需 import构建工具会自动 tree-shaking 未使用的代码:
```typescript
// 只用 2D不会打包 3D 和 720 的代码
import { BimEngine2d } from 'iflow-engine';
```
### 2. 多个引擎能同时存在吗?
可以,但需要**不同的容器**。同一个容器同时只能挂载一个引擎实例:
```typescript
const engine3d = new BimEngine('container-3d', { theme: 'dark' });
const engine2d = new BimEngine2d('container-2d', { theme: 'dark' });
const engine720 = new BimEngine720('container-720', { theme: 'dark' });
```
### 3. 切换引擎时页面闪烁
确保先调用 `destroy()` 再创建新实例。`destroy()` 会清空容器内容。
### 4. 模型/图纸加载失败
- 检查 URL 是否可访问
- 检查浏览器控制台是否有 CORS 错误
- 确保使用 HTTP 服务器运行(不能直接打开 HTML 文件)
### 5. 页面关闭时如何释放资源?
务必调用 `destroy()` 释放 WebGL 上下文和事件监听:
```typescript
window.addEventListener('beforeunload', () => {
engine?.destroy();
});
```
---
## 下一步
- [架构设计](架构设计.md) — 了解 Manager 模式和分层架构
- [引擎 API 对接](引擎API对接.md) — 底层引擎 API 详细说明
- [API 调用链](API调用链.md) — 调用关系与数据流
- [demo/](../demo/) — 完整可运行示例
---
**文档版本**: 2.0.0 | **更新日期**: 2026-03-10

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BIM Dashboard</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/entry.js"></script>
</body>
</html>

2022
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "bim-dashboard-vite",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"iflow-engine": "^2.5.12",
"pinia": "^3.0.4",
"vue": "^3.5.21",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.5"
}
}

BIN
public/assets/hdr/001.hdr Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
.originRotate{width:10px;height:10px;background-color:red}.originDiv{position:absolute;top:100px;left:100px;width:30px;height:30px;border-radius:50%;z-index:1000;pointer-events:none}.catchPoint{pointer-events:none;fill:#00f;stroke:#fff;stroke-width:1}.catchLine{position:absolute;width:10px;height:10px;pointer-events:none;background-color:green}.catchPlane{position:absolute;width:10px;height:10px;pointer-events:none;background-color:#ff0}.ViewCube{position:absolute;width:120px;height:120px;top:0;right:0;z-index:999}.homeViewWrapper{position:absolute;cursor:pointer;pointer-events:auto;background-image:url(/assets/viewcube/home.png);background-size:100% 100%;background-repeat:no-repeat;background-color:#fff0;color:#fff;top:10px;right:100;width:20px;height:20px;z-index:999;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none}.viewSettingWrapper{position:absolute;cursor:pointer;pointer-events:auto;background-size:100% 100%;background-repeat:no-repeat;font-weight:800;background-color:#fff0;color:#fff;top:30px;right:100;width:20px;height:20px;z-index:999;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none}.cameraToggleWrapper{position:absolute;cursor:pointer;pointer-events:auto;background-color:#ffffffe6;color:#333;font-size:26px;font-weight:700;display:none;align-items:center;justify-content:center;border-radius:4px;box-shadow:0 2px 4px #0003;transition:all .3s ease;top:70px;right:90px;width:44px;height:44px;z-index:999;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none}.cameraToggleWrapper.show{display:flex}.cameraToggleWrapper:hover{background-color:#fff;box-shadow:0 3px 6px #0000004d;transform:scale(1.1)}.screenshotWrapper{position:absolute;cursor:pointer;pointer-events:auto;background-color:#ffffffe6;color:#333;font-size:20px;display:none;align-items:center;justify-content:center;border-radius:4px;box-shadow:0 2px 4px #0003;transition:all .3s ease;top:120px;right:90px;width:44px;height:44px;z-index:999;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none}.screenshotWrapper.show{display:flex}.screenshotWrapper:hover{background-color:#fff;box-shadow:0 3px 6px #0000004d;transform:scale(1.1)}.fullscreenWrapper{position:absolute;cursor:pointer;pointer-events:auto;background-color:#ffffffe6;color:#333;font-size:26px;font-weight:700;display:none;align-items:center;justify-content:center;border-radius:4px;box-shadow:0 2px 4px #0003;transition:all .3s ease;top:170px;right:90px;width:44px;height:44px;z-index:999;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none}.fullscreenWrapper.show{display:flex}.fullscreenWrapper:hover{background-color:#fff;box-shadow:0 3px 6px #0000004d;transform:scale(1.1)}.measure-elevation{position:absolute;pointer-events:none}.measureSvg{position:absolute;width:1920px;height:1080px}.catch-point{position:absolute;width:10px;height:10px;background-color:red;border-radius:50%;pointer-events:none}.annotation-container{position:absolute;pointer-events:none;top:0;left:0}.measureLine{position:absolute;pointer-events:none}.measureBack{cursor:pointer;pointer-events:auto;fill:orange;top:-50px;rx:5;ry:5}.measureLabel{position:absolute;fill:#fff;font-size:14px;font-family:Arial,sans-serif;pointer-events:none}.measurePointer{fill:orange}.Hight{stroke:#fff;stroke-width:2}.allow-select{pointer-events:auto}.disallow-select{pointer-events:none}.angle-annotation{position:absolute;pointer-events:auto}

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1767704478351" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5751" width="32" height="32" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M341.333333 608L202.666667 469.333333H298.666667c10.666667-239.36 101.973333-426.666667 213.333333-426.666666 85.333333 0 160.853333 112.64 194.133333 275.2C868.693333 351.146667 981.333333 426.666667 981.333333 512c0 78.08-92.586667 146.346667-230.4 183.466667l12.373334-86.613334C844.8 585.386667 896 550.826667 896 512c0-45.226667-70.4-85.333333-176.213333-106.666667 3.413333 33.706667 5.546667 69.546667 5.546666 106.666667 0 259.413333-95.573333 469.333333-213.333333 469.333333-78.08 0-146.346667-92.586667-183.466667-230.4l86.613334 12.373334C438.613333 844.8 473.173333 896 512 896c70.826667 0 128-171.946667 128-384 0-42.666667-2.133333-83.2-6.4-121.6C595.2 386.133333 554.666667 384 512 384l-79.36 2.56 12.373333-85.76L512 298.666667c37.12 0 72.96 2.133333 106.666667 5.546666C597.333333 198.4 557.226667 128 512 128c-65.706667 0-120.32 149.333333-128 341.333333h96L341.333333 608M608 682.666667L469.333333 821.333333V725.333333c-239.36-10.666667-426.666667-101.973333-426.666666-213.333333 0-78.08 92.586667-146.346667 230.4-183.466667l-12.373334 86.613334C179.2 438.613333 128 473.173333 128 512c0 65.706667 149.333333 120.32 341.333333 128v-96L608 682.666667z" p-id="5752" fill="#1afa29"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

739
public/draco/DRACOLoader.js Normal file
View File

@@ -0,0 +1,739 @@
import {
BufferAttribute,
BufferGeometry,
Color,
ColorManagement,
FileLoader,
Loader,
LinearSRGBColorSpace,
SRGBColorSpace,
InterleavedBuffer,
InterleavedBufferAttribute
} from 'three';
const _taskCache = new WeakMap();
/**
* A loader for the Draco format.
*
* [Draco](https://google.github.io/draco/) is an open source library for compressing
* and decompressing 3D meshes and point clouds. Compressed geometry can be significantly smaller,
* at the cost of additional decoding time on the client device.
*
* Standalone Draco files have a `.drc` extension, and contain vertex positions, normals, colors,
* and other attributes. Draco files do not contain materials, textures, animation, or node hierarchies
* to use these features, embed Draco geometry inside of a glTF file. A normal glTF file can be converted
* to a Draco-compressed glTF file using [glTF-Pipeline](https://github.com/CesiumGS/gltf-pipeline).
* When using Draco with glTF, an instance of `DRACOLoader` will be used internally by {@link GLTFLoader}.
*
* It is recommended to create one DRACOLoader instance and reuse it to avoid loading and creating
* multiple decoder instances.
*
* `DRACOLoader` will automatically use either the JS or the WASM decoding library, based on
* browser capabilities.
*
* ```js
* const loader = new DRACOLoader();
* loader.setDecoderPath( '/examples/jsm/libs/draco/' );
*
* const geometry = await dracoLoader.loadAsync( 'models/draco/bunny.drc' );
* geometry.computeVertexNormals(); // optional
*
* dracoLoader.dispose();
* ```
*
* @augments Loader
* @three_import import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
*/
class DRACOLoader extends Loader {
/**
* Constructs a new Draco loader.
*
* @param {LoadingManager} [manager] - The loading manager.
*/
constructor( manager ) {
super( manager );
this.decoderPath = '';
this.decoderConfig = {};
this.decoderBinary = null;
this.decoderPending = null;
this.workerLimit = 4;
this.workerPool = [];
this.workerNextTaskID = 1;
this.workerSourceURL = '';
this.defaultAttributeIDs = {
position: 'POSITION',
normal: 'NORMAL',
color: 'COLOR',
uv: 'TEX_COORD'
};
this.defaultAttributeTypes = {
position: 'Float32Array',
normal: 'Float32Array',
color: 'Float32Array',
uv: 'Float32Array'
};
}
/**
* Provides configuration for the decoder libraries. Configuration cannot be changed after decoding begins.
*
* @param {string} path - The decoder path.
* @return {DRACOLoader} A reference to this loader.
*/
setDecoderPath( path ) {
this.decoderPath = path;
return this;
}
/**
* Provides configuration for the decoder libraries. Configuration cannot be changed after decoding begins.
*
* @param {{type:('js'|'wasm')}} config - The decoder config.
* @return {DRACOLoader} A reference to this loader.
*/
setDecoderConfig( config ) {
this.decoderConfig = config;
return this;
}
/**
* Sets the maximum number of Web Workers to be used during decoding.
* A lower limit may be preferable if workers are also for other tasks in the application.
*
* @param {number} workerLimit - The worker limit.
* @return {DRACOLoader} A reference to this loader.
*/
setWorkerLimit( workerLimit ) {
this.workerLimit = workerLimit;
return this;
}
/**
* Starts loading from the given URL and passes the loaded Draco asset
* to the `onLoad()` callback.
*
* @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
* @param {function(BufferGeometry)} onLoad - Executed when the loading process has been finished.
* @param {onProgressCallback} onProgress - Executed while the loading is in progress.
* @param {onErrorCallback} onError - Executed when errors occur.
*/
load( url, onLoad, onProgress, onError ) {
const loader = new FileLoader( this.manager );
loader.setPath( this.path );
loader.setResponseType( 'arraybuffer' );
loader.setRequestHeader( this.requestHeader );
loader.setWithCredentials( this.withCredentials );
loader.load( url, ( buffer ) => {
this.parse( buffer, onLoad, onError );
}, onProgress, onError );
}
/**
* Parses the given Draco data.
*
* @param {ArrayBuffer} buffer - The raw Draco data as an array buffer.
* @param {function(BufferGeometry)} onLoad - Executed when the loading/parsing process has been finished.
* @param {onErrorCallback} onError - Executed when errors occur.
*/
parse( buffer, onLoad, onError = ()=>{} ) {
this.decodeDracoFile( buffer, onLoad, null, null, SRGBColorSpace, onError ).catch( onError );
}
//
decodeDracoFile( buffer, callback, attributeIDs, attributeTypes, vertexColorSpace = LinearSRGBColorSpace, onError = () => {} ) {
const taskConfig = {
attributeIDs: attributeIDs || this.defaultAttributeIDs,
attributeTypes: attributeTypes || this.defaultAttributeTypes,
useUniqueIDs: !! attributeIDs,
vertexColorSpace: vertexColorSpace,
};
return this.decodeGeometry( buffer, taskConfig ).then( callback ).catch( onError );
}
decodeGeometry( buffer, taskConfig ) {
const taskKey = JSON.stringify( taskConfig );
// Check for an existing task using this buffer. A transferred buffer cannot be transferred
// again from this thread.
if ( _taskCache.has( buffer ) ) {
const cachedTask = _taskCache.get( buffer );
if ( cachedTask.key === taskKey ) {
return cachedTask.promise;
} else if ( buffer.byteLength === 0 ) {
// Technically, it would be possible to wait for the previous task to complete,
// transfer the buffer back, and decode again with the second configuration. That
// is complex, and I don't know of any reason to decode a Draco buffer twice in
// different ways, so this is left unimplemented.
throw new Error(
'THREE.DRACOLoader: Unable to re-decode a buffer with different ' +
'settings. Buffer has already been transferred.'
);
}
}
//
let worker;
const taskID = this.workerNextTaskID ++;
const taskCost = buffer.byteLength;
// Obtain a worker and assign a task, and construct a geometry instance
// when the task completes.
const geometryPending = this._getWorker( taskID, taskCost )
.then( ( _worker ) => {
worker = _worker;
return new Promise( ( resolve, reject ) => {
worker._callbacks[ taskID ] = { resolve, reject };
worker.postMessage( { type: 'decode', id: taskID, taskConfig, buffer }, [ buffer ] );
// this.debug();
} );
} )
.then( ( message ) => this._createGeometry( message.geometry ) );
// Remove task from the task list.
// Note: replaced '.finally()' with '.catch().then()' block - iOS 11 support (#19416)
geometryPending
.catch( () => true )
.then( () => {
if ( worker && taskID ) {
this._releaseTask( worker, taskID );
// this.debug();
}
} );
// Cache the task result.
_taskCache.set( buffer, {
key: taskKey,
promise: geometryPending
} );
return geometryPending;
}
_createGeometry( geometryData ) {
const geometry = new BufferGeometry();
if ( geometryData.index ) {
geometry.setIndex( new BufferAttribute( geometryData.index.array, 1 ) );
}
for ( let i = 0; i < geometryData.attributes.length; i ++ ) {
const { name, array, itemSize, stride, vertexColorSpace } = geometryData.attributes[ i ];
let attribute;
if ( itemSize === stride ) {
attribute = new BufferAttribute( array, itemSize );
} else {
const buffer = new InterleavedBuffer( array, stride );
attribute = new InterleavedBufferAttribute( buffer, itemSize, 0 );
}
if ( name === 'color' ) {
this._assignVertexColorSpace( attribute, vertexColorSpace );
attribute.normalized = ( array instanceof Float32Array ) === false;
}
geometry.setAttribute( name, attribute );
}
return geometry;
}
_assignVertexColorSpace( attribute, inputColorSpace ) {
// While .drc files do not specify colorspace, the only 'official' tooling
// is PLY and OBJ converters, which use sRGB. We'll assume sRGB when a .drc
// file is passed into .load() or .parse(). GLTFLoader uses internal APIs
// to decode geometry, and vertex colors are already Linear-sRGB in there.
if ( inputColorSpace !== SRGBColorSpace ) return;
const _color = new Color();
for ( let i = 0, il = attribute.count; i < il; i ++ ) {
_color.fromBufferAttribute( attribute, i );
ColorManagement.colorSpaceToWorking( _color, SRGBColorSpace );
attribute.setXYZ( i, _color.r, _color.g, _color.b );
}
}
_loadLibrary( url, responseType ) {
const loader = new FileLoader( this.manager );
loader.setPath( this.decoderPath );
loader.setResponseType( responseType );
loader.setWithCredentials( this.withCredentials );
return new Promise( ( resolve, reject ) => {
loader.load( url, resolve, undefined, reject );
} );
}
preload() {
this._initDecoder();
return this;
}
_initDecoder() {
if ( this.decoderPending ) return this.decoderPending;
const useJS = typeof WebAssembly !== 'object' || this.decoderConfig.type === 'js';
const librariesPending = [];
if ( useJS ) {
librariesPending.push( this._loadLibrary( 'draco_decoder.js', 'text' ) );
} else {
librariesPending.push( this._loadLibrary( 'draco_wasm_wrapper.js', 'text' ) );
librariesPending.push( this._loadLibrary( 'draco_decoder.wasm', 'arraybuffer' ) );
}
this.decoderPending = Promise.all( librariesPending )
.then( ( libraries ) => {
const jsContent = libraries[ 0 ];
if ( ! useJS ) {
this.decoderConfig.wasmBinary = libraries[ 1 ];
}
const fn = DRACOWorker.toString();
const body = [
'/* draco decoder */',
jsContent,
'',
'/* worker */',
fn.substring( fn.indexOf( '{' ) + 1, fn.lastIndexOf( '}' ) )
].join( '\n' );
this.workerSourceURL = URL.createObjectURL( new Blob( [ body ] ) );
} );
return this.decoderPending;
}
_getWorker( taskID, taskCost ) {
return this._initDecoder().then( () => {
if ( this.workerPool.length < this.workerLimit ) {
const worker = new Worker( this.workerSourceURL );
worker._callbacks = {};
worker._taskCosts = {};
worker._taskLoad = 0;
worker.postMessage( { type: 'init', decoderConfig: this.decoderConfig } );
worker.onmessage = function ( e ) {
const message = e.data;
switch ( message.type ) {
case 'decode':
worker._callbacks[ message.id ].resolve( message );
break;
case 'error':
worker._callbacks[ message.id ].reject( message );
break;
default:
console.error( 'THREE.DRACOLoader: Unexpected message, "' + message.type + '"' );
}
};
this.workerPool.push( worker );
} else {
this.workerPool.sort( function ( a, b ) {
return a._taskLoad > b._taskLoad ? - 1 : 1;
} );
}
const worker = this.workerPool[ this.workerPool.length - 1 ];
worker._taskCosts[ taskID ] = taskCost;
worker._taskLoad += taskCost;
return worker;
} );
}
_releaseTask( worker, taskID ) {
worker._taskLoad -= worker._taskCosts[ taskID ];
delete worker._callbacks[ taskID ];
delete worker._taskCosts[ taskID ];
}
debug() {
console.log( 'Task load: ', this.workerPool.map( ( worker ) => worker._taskLoad ) );
}
dispose() {
for ( let i = 0; i < this.workerPool.length; ++ i ) {
this.workerPool[ i ].terminate();
}
this.workerPool.length = 0;
if ( this.workerSourceURL !== '' ) {
URL.revokeObjectURL( this.workerSourceURL );
}
return this;
}
}
/* WEB WORKER */
function DRACOWorker() {
let decoderConfig;
let decoderPending;
onmessage = function ( e ) {
const message = e.data;
switch ( message.type ) {
case 'init':
decoderConfig = message.decoderConfig;
decoderPending = new Promise( function ( resolve/*, reject*/ ) {
decoderConfig.onModuleLoaded = function ( draco ) {
// Module is Promise-like. Wrap before resolving to avoid loop.
resolve( { draco: draco } );
};
DracoDecoderModule( decoderConfig ); // eslint-disable-line no-undef
} );
break;
case 'decode':
const buffer = message.buffer;
const taskConfig = message.taskConfig;
decoderPending.then( ( module ) => {
const draco = module.draco;
const decoder = new draco.Decoder();
try {
const geometry = decodeGeometry( draco, decoder, new Int8Array( buffer ), taskConfig );
const buffers = geometry.attributes.map( ( attr ) => attr.array.buffer );
if ( geometry.index ) buffers.push( geometry.index.array.buffer );
self.postMessage( { type: 'decode', id: message.id, geometry }, buffers );
} catch ( error ) {
console.error( error );
self.postMessage( { type: 'error', id: message.id, error: error.message } );
} finally {
draco.destroy( decoder );
}
} );
break;
}
};
function decodeGeometry( draco, decoder, array, taskConfig ) {
const attributeIDs = taskConfig.attributeIDs;
const attributeTypes = taskConfig.attributeTypes;
let dracoGeometry;
let decodingStatus;
const geometryType = decoder.GetEncodedGeometryType( array );
if ( geometryType === draco.TRIANGULAR_MESH ) {
dracoGeometry = new draco.Mesh();
decodingStatus = decoder.DecodeArrayToMesh( array, array.byteLength, dracoGeometry );
} else if ( geometryType === draco.POINT_CLOUD ) {
dracoGeometry = new draco.PointCloud();
decodingStatus = decoder.DecodeArrayToPointCloud( array, array.byteLength, dracoGeometry );
} else {
throw new Error( 'THREE.DRACOLoader: Unexpected geometry type.' );
}
if ( ! decodingStatus.ok() || dracoGeometry.ptr === 0 ) {
throw new Error( 'THREE.DRACOLoader: Decoding failed: ' + decodingStatus.error_msg() );
}
const geometry = { index: null, attributes: [] };
// Gather all vertex attributes.
for ( const attributeName in attributeIDs ) {
const attributeType = self[ attributeTypes[ attributeName ] ];
let attribute;
let attributeID;
// A Draco file may be created with default vertex attributes, whose attribute IDs
// are mapped 1:1 from their semantic name (POSITION, NORMAL, ...). Alternatively,
// a Draco file may contain a custom set of attributes, identified by known unique
// IDs. glTF files always do the latter, and `.drc` files typically do the former.
if ( taskConfig.useUniqueIDs ) {
attributeID = attributeIDs[ attributeName ];
attribute = decoder.GetAttributeByUniqueId( dracoGeometry, attributeID );
} else {
attributeID = decoder.GetAttributeId( dracoGeometry, draco[ attributeIDs[ attributeName ] ] );
if ( attributeID === - 1 ) continue;
attribute = decoder.GetAttribute( dracoGeometry, attributeID );
}
const attributeResult = decodeAttribute( draco, decoder, dracoGeometry, attributeName, attributeType, attribute );
if ( attributeName === 'color' ) {
attributeResult.vertexColorSpace = taskConfig.vertexColorSpace;
}
geometry.attributes.push( attributeResult );
}
// Add index.
if ( geometryType === draco.TRIANGULAR_MESH ) {
geometry.index = decodeIndex( draco, decoder, dracoGeometry );
}
draco.destroy( dracoGeometry );
return geometry;
}
function decodeIndex( draco, decoder, dracoGeometry ) {
const numFaces = dracoGeometry.num_faces();
const numIndices = numFaces * 3;
const byteLength = numIndices * 4;
const ptr = draco._malloc( byteLength );
decoder.GetTrianglesUInt32Array( dracoGeometry, byteLength, ptr );
const index = new Uint32Array( draco.HEAPF32.buffer, ptr, numIndices ).slice();
draco._free( ptr );
return { array: index, itemSize: 1 };
}
function decodeAttribute( draco, decoder, dracoGeometry, attributeName, TypedArray, attribute ) {
const count = dracoGeometry.num_points();
const itemSize = attribute.num_components();
const dracoDataType = getDracoDataType( draco, TypedArray );
// Reference: https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#data-alignment
const srcByteStride = itemSize * TypedArray.BYTES_PER_ELEMENT;
const dstByteStride = Math.ceil( srcByteStride / 4 ) * 4;
const dstStride = dstByteStride / TypedArray.BYTES_PER_ELEMENT
const srcByteLength = count * srcByteStride;
const dstByteLength = count * dstByteStride;
const ptr = draco._malloc( srcByteLength );
decoder.GetAttributeDataArrayForAllPoints( dracoGeometry, attribute, dracoDataType, srcByteLength, ptr );
const srcArray = new TypedArray( draco.HEAPF32.buffer, ptr, srcByteLength / TypedArray.BYTES_PER_ELEMENT );
let dstArray;
if ( srcByteStride === dstByteStride ) {
// THREE.BufferAttribute
dstArray = srcArray.slice();
} else {
// THREE.InterleavedBufferAttribute
dstArray = new TypedArray( dstByteLength / TypedArray.BYTES_PER_ELEMENT );
let dstOffset = 0
for ( let i = 0, il = srcArray.length; i < il; i++ ) {
for ( let j = 0; j < itemSize; j++ ) {
dstArray[ dstOffset + j ] = srcArray[ i * itemSize + j ]
}
dstOffset += dstStride;
}
}
draco._free( ptr );
return {
name: attributeName,
count: count,
itemSize: itemSize,
array: dstArray,
stride: dstStride
};
}
function getDracoDataType( draco, TypedArray ) {
switch ( TypedArray ) {
case Float32Array: return draco.DT_FLOAT32;
case Int8Array: return draco.DT_INT8;
case Int16Array: return draco.DT_INT16;
case Int32Array: return draco.DT_INT32;
case Uint8Array: return draco.DT_UINT8;
case Uint16Array: return draco.DT_UINT16;
case Uint32Array: return draco.DT_UINT32;
}
}
}
export { DRACOLoader };

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,104 @@
var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.arrayIteratorImpl=function(f){var m=0;return function(){return m<f.length?{done:!1,value:f[m++]}:{done:!0}}};$jscomp.arrayIterator=function(f){return{next:$jscomp.arrayIteratorImpl(f)}};$jscomp.makeIterator=function(f){var m="undefined"!=typeof Symbol&&Symbol.iterator&&f[Symbol.iterator];return m?m.call(f):$jscomp.arrayIterator(f)};
$jscomp.getGlobal=function(f){return"undefined"!=typeof window&&window===f?f:"undefined"!=typeof global&&null!=global?global:f};$jscomp.global=$jscomp.getGlobal(this);$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(f,m,v){f!=Array.prototype&&f!=Object.prototype&&(f[m]=v.value)};
$jscomp.polyfill=function(f,m,v,t){if(m){v=$jscomp.global;f=f.split(".");for(t=0;t<f.length-1;t++){var h=f[t];h in v||(v[h]={});v=v[h]}f=f[f.length-1];t=v[f];m=m(t);m!=t&&null!=m&&$jscomp.defineProperty(v,f,{configurable:!0,writable:!0,value:m})}};$jscomp.FORCE_POLYFILL_PROMISE=!1;
$jscomp.polyfill("Promise",function(f){function m(){this.batch_=null}function v(e){return e instanceof h?e:new h(function(l,f){l(e)})}if(f&&!$jscomp.FORCE_POLYFILL_PROMISE)return f;m.prototype.asyncExecute=function(e){if(null==this.batch_){this.batch_=[];var l=this;this.asyncExecuteFunction(function(){l.executeBatch_()})}this.batch_.push(e)};var t=$jscomp.global.setTimeout;m.prototype.asyncExecuteFunction=function(e){t(e,0)};m.prototype.executeBatch_=function(){for(;this.batch_&&this.batch_.length;){var e=
this.batch_;this.batch_=[];for(var l=0;l<e.length;++l){var f=e[l];e[l]=null;try{f()}catch(z){this.asyncThrow_(z)}}}this.batch_=null};m.prototype.asyncThrow_=function(e){this.asyncExecuteFunction(function(){throw e;})};var h=function(e){this.state_=0;this.result_=void 0;this.onSettledCallbacks_=[];var l=this.createResolveAndReject_();try{e(l.resolve,l.reject)}catch(S){l.reject(S)}};h.prototype.createResolveAndReject_=function(){function e(e){return function(h){f||(f=!0,e.call(l,h))}}var l=this,f=!1;
return{resolve:e(this.resolveTo_),reject:e(this.reject_)}};h.prototype.resolveTo_=function(e){if(e===this)this.reject_(new TypeError("A Promise cannot resolve to itself"));else if(e instanceof h)this.settleSameAsPromise_(e);else{a:switch(typeof e){case "object":var l=null!=e;break a;case "function":l=!0;break a;default:l=!1}l?this.resolveToNonPromiseObj_(e):this.fulfill_(e)}};h.prototype.resolveToNonPromiseObj_=function(e){var l=void 0;try{l=e.then}catch(S){this.reject_(S);return}"function"==typeof l?
this.settleSameAsThenable_(l,e):this.fulfill_(e)};h.prototype.reject_=function(e){this.settle_(2,e)};h.prototype.fulfill_=function(e){this.settle_(1,e)};h.prototype.settle_=function(e,l){if(0!=this.state_)throw Error("Cannot settle("+e+", "+l+"): Promise already settled in state"+this.state_);this.state_=e;this.result_=l;this.executeOnSettledCallbacks_()};h.prototype.executeOnSettledCallbacks_=function(){if(null!=this.onSettledCallbacks_){for(var e=0;e<this.onSettledCallbacks_.length;++e)X.asyncExecute(this.onSettledCallbacks_[e]);
this.onSettledCallbacks_=null}};var X=new m;h.prototype.settleSameAsPromise_=function(e){var l=this.createResolveAndReject_();e.callWhenSettled_(l.resolve,l.reject)};h.prototype.settleSameAsThenable_=function(e,l){var f=this.createResolveAndReject_();try{e.call(l,f.resolve,f.reject)}catch(z){f.reject(z)}};h.prototype.then=function(e,f){function l(e,f){return"function"==typeof e?function(f){try{m(e(f))}catch(p){v(p)}}:f}var m,v,t=new h(function(e,f){m=e;v=f});this.callWhenSettled_(l(e,m),l(f,v));return t};
h.prototype.catch=function(e){return this.then(void 0,e)};h.prototype.callWhenSettled_=function(e,f){function l(){switch(h.state_){case 1:e(h.result_);break;case 2:f(h.result_);break;default:throw Error("Unexpected state: "+h.state_);}}var h=this;null==this.onSettledCallbacks_?X.asyncExecute(l):this.onSettledCallbacks_.push(l)};h.resolve=v;h.reject=function(e){return new h(function(f,h){h(e)})};h.race=function(e){return new h(function(f,h){for(var l=$jscomp.makeIterator(e),m=l.next();!m.done;m=l.next())v(m.value).callWhenSettled_(f,
h)})};h.all=function(e){var f=$jscomp.makeIterator(e),m=f.next();return m.done?v([]):new h(function(e,h){function l(f){return function(h){t[f]=h;z--;0==z&&e(t)}}var t=[],z=0;do t.push(void 0),z++,v(m.value).callWhenSettled_(l(t.length-1),h),m=f.next();while(!m.done)})};return h},"es6","es3");
var DracoDecoderModule=function(){var f="undefined"!==typeof document&&document.currentScript?document.currentScript.src:void 0;"undefined"!==typeof __filename&&(f=f||__filename);return function(m){function v(k){return a.locateFile?a.locateFile(k,M):M+k}function t(a,c){a||z("Assertion failed: "+c)}function h(a,c,b){var d=c+b;for(b=c;a[b]&&!(b>=d);)++b;if(16<b-c&&a.subarray&&xa)return xa.decode(a.subarray(c,b));for(d="";c<b;){var k=a[c++];if(k&128){var e=a[c++]&63;if(192==(k&224))d+=String.fromCharCode((k&
31)<<6|e);else{var f=a[c++]&63;k=224==(k&240)?(k&15)<<12|e<<6|f:(k&7)<<18|e<<12|f<<6|a[c++]&63;65536>k?d+=String.fromCharCode(k):(k-=65536,d+=String.fromCharCode(55296|k>>10,56320|k&1023))}}else d+=String.fromCharCode(k)}return d}function X(a,c){return a?h(ca,a,c):""}function e(a,c){0<a%c&&(a+=c-a%c);return a}function l(k){ka=k;a.HEAP8=T=new Int8Array(k);a.HEAP16=new Int16Array(k);a.HEAP32=P=new Int32Array(k);a.HEAPU8=ca=new Uint8Array(k);a.HEAPU16=new Uint16Array(k);a.HEAPU32=new Uint32Array(k);
a.HEAPF32=new Float32Array(k);a.HEAPF64=new Float64Array(k)}function S(k){for(;0<k.length;){var c=k.shift();if("function"==typeof c)c();else{var b=c.func;"number"===typeof b?void 0===c.arg?a.dynCall_v(b):a.dynCall_vi(b,c.arg):b(void 0===c.arg?null:c.arg)}}}function z(k){if(a.onAbort)a.onAbort(k);k+="";ya(k);Y(k);za=!0;throw new WebAssembly.RuntimeError("abort("+k+"). Build with -s ASSERTIONS=1 for more info.");}function va(a){return String.prototype.startsWith?a.startsWith("data:application/octet-stream;base64,"):
0===a.indexOf("data:application/octet-stream;base64,")}function wa(){try{if(da)return new Uint8Array(da);if(la)return la(U);throw"both async and sync fetching of the wasm failed";}catch(k){z(k)}}function Ma(){return da||!ea&&!Z||"function"!==typeof fetch?new Promise(function(a,c){a(wa())}):fetch(U,{credentials:"same-origin"}).then(function(a){if(!a.ok)throw"failed to load wasm binary file at '"+U+"'";return a.arrayBuffer()}).catch(function(){return wa()})}function ba(){if(!ba.strings){var a={USER:"web_user",
LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:("object"===typeof navigator&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8",_:na},c;for(c in Aa)a[c]=Aa[c];var b=[];for(c in a)b.push(c+"="+a[c]);ba.strings=b}return ba.strings}function ma(k){function c(){if(!fa&&(fa=!0,!za)){Ba=!0;S(Ca);S(Da);if(a.onRuntimeInitialized)a.onRuntimeInitialized();if(a.postRun)for("function"==typeof a.postRun&&(a.postRun=[a.postRun]);a.postRun.length;)Ea.unshift(a.postRun.shift());
S(Ea)}}if(!(0<aa)){if(a.preRun)for("function"==typeof a.preRun&&(a.preRun=[a.preRun]);a.preRun.length;)Fa.unshift(a.preRun.shift());S(Fa);0<aa||(a.setStatus?(a.setStatus("Running..."),setTimeout(function(){setTimeout(function(){a.setStatus("")},1);c()},1)):c())}}function p(){}function u(a){return(a||p).__cache__}function N(a,c){var b=u(c),d=b[a];if(d)return d;d=Object.create((c||p).prototype);d.ptr=a;return b[a]=d}function V(a){if("string"===typeof a){for(var c=0,b=0;b<a.length;++b){var d=a.charCodeAt(b);
55296<=d&&57343>=d&&(d=65536+((d&1023)<<10)|a.charCodeAt(++b)&1023);127>=d?++c:c=2047>=d?c+2:65535>=d?c+3:c+4}c=Array(c+1);b=0;d=c.length;if(0<d){d=b+d-1;for(var k=0;k<a.length;++k){var e=a.charCodeAt(k);if(55296<=e&&57343>=e){var f=a.charCodeAt(++k);e=65536+((e&1023)<<10)|f&1023}if(127>=e){if(b>=d)break;c[b++]=e}else{if(2047>=e){if(b+1>=d)break;c[b++]=192|e>>6}else{if(65535>=e){if(b+2>=d)break;c[b++]=224|e>>12}else{if(b+3>=d)break;c[b++]=240|e>>18;c[b++]=128|e>>12&63}c[b++]=128|e>>6&63}c[b++]=128|
e&63}}c[b]=0}a=n.alloc(c,T);n.copy(c,T,a)}return a}function x(){throw"cannot construct a Status, no constructor in IDL";}function A(){this.ptr=Oa();u(A)[this.ptr]=this}function B(){this.ptr=Pa();u(B)[this.ptr]=this}function C(){this.ptr=Qa();u(C)[this.ptr]=this}function D(){this.ptr=Ra();u(D)[this.ptr]=this}function E(){this.ptr=Sa();u(E)[this.ptr]=this}function q(){this.ptr=Ta();u(q)[this.ptr]=this}function J(){this.ptr=Ua();u(J)[this.ptr]=this}function w(){this.ptr=Va();u(w)[this.ptr]=this}function F(){this.ptr=
Wa();u(F)[this.ptr]=this}function r(){this.ptr=Xa();u(r)[this.ptr]=this}function G(){this.ptr=Ya();u(G)[this.ptr]=this}function H(){this.ptr=Za();u(H)[this.ptr]=this}function O(){this.ptr=$a();u(O)[this.ptr]=this}function K(){this.ptr=ab();u(K)[this.ptr]=this}function g(){this.ptr=bb();u(g)[this.ptr]=this}function y(){this.ptr=cb();u(y)[this.ptr]=this}function Q(){throw"cannot construct a VoidPtr, no constructor in IDL";}function I(){this.ptr=db();u(I)[this.ptr]=this}function L(){this.ptr=eb();u(L)[this.ptr]=
this}m=m||{};var a="undefined"!==typeof m?m:{},Ga=!1,Ha=!1;a.onRuntimeInitialized=function(){Ga=!0;if(Ha&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.onModuleParsed=function(){Ha=!0;if(Ga&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.isVersionSupported=function(a){if("string"!==typeof a)return!1;a=a.split(".");return 2>a.length||3<a.length?!1:1==a[0]&&0<=a[1]&&3>=a[1]?!0:0!=a[0]||10<a[1]?!1:!0};var ha={},W;for(W in a)a.hasOwnProperty(W)&&(ha[W]=a[W]);var na="./this.program",
ea=!1,Z=!1,oa=!1,fb=!1,Ia=!1;ea="object"===typeof window;Z="function"===typeof importScripts;oa=(fb="object"===typeof process&&"object"===typeof process.versions&&"string"===typeof process.versions.node)&&!ea&&!Z;Ia=!ea&&!oa&&!Z;var M="",pa,qa;if(oa){M=__dirname+"/";var ra=function(a,c){pa||(pa=require("fs"));qa||(qa=require("path"));a=qa.normalize(a);return pa.readFileSync(a,c?null:"utf8")};var la=function(a){a=ra(a,!0);a.buffer||(a=new Uint8Array(a));t(a.buffer);return a};1<process.argv.length&&
(na=process.argv[1].replace(/\\/g,"/"));process.argv.slice(2);process.on("uncaughtException",function(a){throw a;});process.on("unhandledRejection",z);a.inspect=function(){return"[Emscripten Module object]"}}else if(Ia)"undefined"!=typeof read&&(ra=function(a){return read(a)}),la=function(a){if("function"===typeof readbuffer)return new Uint8Array(readbuffer(a));a=read(a,"binary");t("object"===typeof a);return a},"undefined"!==typeof print&&("undefined"===typeof console&&(console={}),console.log=print,
console.warn=console.error="undefined"!==typeof printErr?printErr:print);else if(ea||Z)Z?M=self.location.href:document.currentScript&&(M=document.currentScript.src),f&&(M=f),M=0!==M.indexOf("blob:")?M.substr(0,M.lastIndexOf("/")+1):"",ra=function(a){var c=new XMLHttpRequest;c.open("GET",a,!1);c.send(null);return c.responseText},Z&&(la=function(a){var c=new XMLHttpRequest;c.open("GET",a,!1);c.responseType="arraybuffer";c.send(null);return new Uint8Array(c.response)});var ya=a.print||console.log.bind(console),
Y=a.printErr||console.warn.bind(console);for(W in ha)ha.hasOwnProperty(W)&&(a[W]=ha[W]);ha=null;a.thisProgram&&(na=a.thisProgram);var da;a.wasmBinary&&(da=a.wasmBinary);"object"!==typeof WebAssembly&&Y("no native wasm support detected");var ia,gb=new WebAssembly.Table({initial:381,maximum:381,element:"anyfunc"}),za=!1,xa="undefined"!==typeof TextDecoder?new TextDecoder("utf8"):void 0;"undefined"!==typeof TextDecoder&&new TextDecoder("utf-16le");var T,ca,P,Ja=a.TOTAL_MEMORY||16777216;if(ia=a.wasmMemory?
a.wasmMemory:new WebAssembly.Memory({initial:Ja/65536}))var ka=ia.buffer;Ja=ka.byteLength;l(ka);P[4604]=5261456;var Fa=[],Ca=[],Da=[],Ea=[],Ba=!1,aa=0,sa=null,ja=null;a.preloadedImages={};a.preloadedAudios={};var U="draco_decoder.wasm";va(U)||(U=v(U));Ca.push({func:function(){hb()}});var Aa={},R={buffers:[null,[],[]],printChar:function(a,c){var b=R.buffers[a];0===c||10===c?((1===a?ya:Y)(h(b,0)),b.length=0):b.push(c)},varargs:0,get:function(a){R.varargs+=4;return P[R.varargs-4>>2]},getStr:function(){return X(R.get())},
get64:function(){var a=R.get();R.get();return a},getZero:function(){R.get()}},Ka={__cxa_allocate_exception:function(a){return ib(a)},__cxa_throw:function(a,c,b){"uncaught_exception"in ta?ta.uncaught_exceptions++:ta.uncaught_exceptions=1;throw a;},abort:function(){z()},emscripten_get_sbrk_ptr:function(){return 18416},emscripten_memcpy_big:function(a,c,b){ca.set(ca.subarray(c,c+b),a)},emscripten_resize_heap:function(a){if(2147418112<a)return!1;for(var c=Math.max(T.length,16777216);c<a;)c=536870912>=
c?e(2*c,65536):Math.min(e((3*c+2147483648)/4,65536),2147418112);a:{try{ia.grow(c-ka.byteLength+65535>>16);l(ia.buffer);var b=1;break a}catch(d){}b=void 0}return b?!0:!1},environ_get:function(a,c){var b=0;ba().forEach(function(d,e){var f=c+b;e=P[a+4*e>>2]=f;for(f=0;f<d.length;++f)T[e++>>0]=d.charCodeAt(f);T[e>>0]=0;b+=d.length+1});return 0},environ_sizes_get:function(a,c){var b=ba();P[a>>2]=b.length;var d=0;b.forEach(function(a){d+=a.length+1});P[c>>2]=d;return 0},fd_close:function(a){return 0},fd_seek:function(a,
c,b,d,e){return 0},fd_write:function(a,c,b,d){try{for(var e=0,f=0;f<b;f++){for(var g=P[c+8*f>>2],k=P[c+(8*f+4)>>2],h=0;h<k;h++)R.printChar(a,ca[g+h]);e+=k}P[d>>2]=e;return 0}catch(ua){return"undefined"!==typeof FS&&ua instanceof FS.ErrnoError||z(ua),ua.errno}},memory:ia,setTempRet0:function(a){},table:gb},La=function(){function e(c,b){a.asm=c.exports;aa--;a.monitorRunDependencies&&a.monitorRunDependencies(aa);0==aa&&(null!==sa&&(clearInterval(sa),sa=null),ja&&(c=ja,ja=null,c()))}function c(a){e(a.instance)}
function b(a){return Ma().then(function(a){return WebAssembly.instantiate(a,d)}).then(a,function(a){Y("failed to asynchronously prepare wasm: "+a);z(a)})}var d={env:Ka,wasi_unstable:Ka};aa++;a.monitorRunDependencies&&a.monitorRunDependencies(aa);if(a.instantiateWasm)try{return a.instantiateWasm(d,e)}catch(Na){return Y("Module.instantiateWasm callback failed with error: "+Na),!1}(function(){if(da||"function"!==typeof WebAssembly.instantiateStreaming||va(U)||"function"!==typeof fetch)return b(c);fetch(U,
{credentials:"same-origin"}).then(function(a){return WebAssembly.instantiateStreaming(a,d).then(c,function(a){Y("wasm streaming compile failed: "+a);Y("falling back to ArrayBuffer instantiation");b(c)})})})();return{}}();a.asm=La;var hb=a.___wasm_call_ctors=function(){return a.asm.__wasm_call_ctors.apply(null,arguments)},jb=a._emscripten_bind_Status_code_0=function(){return a.asm.emscripten_bind_Status_code_0.apply(null,arguments)},kb=a._emscripten_bind_Status_ok_0=function(){return a.asm.emscripten_bind_Status_ok_0.apply(null,
arguments)},lb=a._emscripten_bind_Status_error_msg_0=function(){return a.asm.emscripten_bind_Status_error_msg_0.apply(null,arguments)},mb=a._emscripten_bind_Status___destroy___0=function(){return a.asm.emscripten_bind_Status___destroy___0.apply(null,arguments)},Oa=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=function(){return a.asm.emscripten_bind_DracoUInt16Array_DracoUInt16Array_0.apply(null,arguments)},nb=a._emscripten_bind_DracoUInt16Array_GetValue_1=function(){return a.asm.emscripten_bind_DracoUInt16Array_GetValue_1.apply(null,
arguments)},ob=a._emscripten_bind_DracoUInt16Array_size_0=function(){return a.asm.emscripten_bind_DracoUInt16Array_size_0.apply(null,arguments)},pb=a._emscripten_bind_DracoUInt16Array___destroy___0=function(){return a.asm.emscripten_bind_DracoUInt16Array___destroy___0.apply(null,arguments)},Pa=a._emscripten_bind_PointCloud_PointCloud_0=function(){return a.asm.emscripten_bind_PointCloud_PointCloud_0.apply(null,arguments)},qb=a._emscripten_bind_PointCloud_num_attributes_0=function(){return a.asm.emscripten_bind_PointCloud_num_attributes_0.apply(null,
arguments)},rb=a._emscripten_bind_PointCloud_num_points_0=function(){return a.asm.emscripten_bind_PointCloud_num_points_0.apply(null,arguments)},sb=a._emscripten_bind_PointCloud___destroy___0=function(){return a.asm.emscripten_bind_PointCloud___destroy___0.apply(null,arguments)},Qa=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=function(){return a.asm.emscripten_bind_DracoUInt8Array_DracoUInt8Array_0.apply(null,arguments)},tb=a._emscripten_bind_DracoUInt8Array_GetValue_1=function(){return a.asm.emscripten_bind_DracoUInt8Array_GetValue_1.apply(null,
arguments)},ub=a._emscripten_bind_DracoUInt8Array_size_0=function(){return a.asm.emscripten_bind_DracoUInt8Array_size_0.apply(null,arguments)},vb=a._emscripten_bind_DracoUInt8Array___destroy___0=function(){return a.asm.emscripten_bind_DracoUInt8Array___destroy___0.apply(null,arguments)},Ra=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=function(){return a.asm.emscripten_bind_DracoUInt32Array_DracoUInt32Array_0.apply(null,arguments)},wb=a._emscripten_bind_DracoUInt32Array_GetValue_1=function(){return a.asm.emscripten_bind_DracoUInt32Array_GetValue_1.apply(null,
arguments)},xb=a._emscripten_bind_DracoUInt32Array_size_0=function(){return a.asm.emscripten_bind_DracoUInt32Array_size_0.apply(null,arguments)},yb=a._emscripten_bind_DracoUInt32Array___destroy___0=function(){return a.asm.emscripten_bind_DracoUInt32Array___destroy___0.apply(null,arguments)},Sa=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=function(){return a.asm.emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0.apply(null,arguments)},zb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=
function(){return a.asm.emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1.apply(null,arguments)},Ab=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=function(){return a.asm.emscripten_bind_AttributeOctahedronTransform_quantization_bits_0.apply(null,arguments)},Bb=a._emscripten_bind_AttributeOctahedronTransform___destroy___0=function(){return a.asm.emscripten_bind_AttributeOctahedronTransform___destroy___0.apply(null,arguments)},Ta=a._emscripten_bind_PointAttribute_PointAttribute_0=
function(){return a.asm.emscripten_bind_PointAttribute_PointAttribute_0.apply(null,arguments)},Cb=a._emscripten_bind_PointAttribute_size_0=function(){return a.asm.emscripten_bind_PointAttribute_size_0.apply(null,arguments)},Db=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=function(){return a.asm.emscripten_bind_PointAttribute_GetAttributeTransformData_0.apply(null,arguments)},Eb=a._emscripten_bind_PointAttribute_attribute_type_0=function(){return a.asm.emscripten_bind_PointAttribute_attribute_type_0.apply(null,
arguments)},Fb=a._emscripten_bind_PointAttribute_data_type_0=function(){return a.asm.emscripten_bind_PointAttribute_data_type_0.apply(null,arguments)},Gb=a._emscripten_bind_PointAttribute_num_components_0=function(){return a.asm.emscripten_bind_PointAttribute_num_components_0.apply(null,arguments)},Hb=a._emscripten_bind_PointAttribute_normalized_0=function(){return a.asm.emscripten_bind_PointAttribute_normalized_0.apply(null,arguments)},Ib=a._emscripten_bind_PointAttribute_byte_stride_0=function(){return a.asm.emscripten_bind_PointAttribute_byte_stride_0.apply(null,
arguments)},Jb=a._emscripten_bind_PointAttribute_byte_offset_0=function(){return a.asm.emscripten_bind_PointAttribute_byte_offset_0.apply(null,arguments)},Kb=a._emscripten_bind_PointAttribute_unique_id_0=function(){return a.asm.emscripten_bind_PointAttribute_unique_id_0.apply(null,arguments)},Lb=a._emscripten_bind_PointAttribute___destroy___0=function(){return a.asm.emscripten_bind_PointAttribute___destroy___0.apply(null,arguments)},Ua=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=
function(){return a.asm.emscripten_bind_AttributeTransformData_AttributeTransformData_0.apply(null,arguments)},Mb=a._emscripten_bind_AttributeTransformData_transform_type_0=function(){return a.asm.emscripten_bind_AttributeTransformData_transform_type_0.apply(null,arguments)},Nb=a._emscripten_bind_AttributeTransformData___destroy___0=function(){return a.asm.emscripten_bind_AttributeTransformData___destroy___0.apply(null,arguments)},Va=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=
function(){return a.asm.emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0.apply(null,arguments)},Ob=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=function(){return a.asm.emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1.apply(null,arguments)},Pb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=function(){return a.asm.emscripten_bind_AttributeQuantizationTransform_quantization_bits_0.apply(null,arguments)},
Qb=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=function(){return a.asm.emscripten_bind_AttributeQuantizationTransform_min_value_1.apply(null,arguments)},Rb=a._emscripten_bind_AttributeQuantizationTransform_range_0=function(){return a.asm.emscripten_bind_AttributeQuantizationTransform_range_0.apply(null,arguments)},Sb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=function(){return a.asm.emscripten_bind_AttributeQuantizationTransform___destroy___0.apply(null,arguments)},
Wa=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=function(){return a.asm.emscripten_bind_DracoInt8Array_DracoInt8Array_0.apply(null,arguments)},Tb=a._emscripten_bind_DracoInt8Array_GetValue_1=function(){return a.asm.emscripten_bind_DracoInt8Array_GetValue_1.apply(null,arguments)},Ub=a._emscripten_bind_DracoInt8Array_size_0=function(){return a.asm.emscripten_bind_DracoInt8Array_size_0.apply(null,arguments)},Vb=a._emscripten_bind_DracoInt8Array___destroy___0=function(){return a.asm.emscripten_bind_DracoInt8Array___destroy___0.apply(null,
arguments)},Xa=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=function(){return a.asm.emscripten_bind_MetadataQuerier_MetadataQuerier_0.apply(null,arguments)},Wb=a._emscripten_bind_MetadataQuerier_HasEntry_2=function(){return a.asm.emscripten_bind_MetadataQuerier_HasEntry_2.apply(null,arguments)},Xb=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=function(){return a.asm.emscripten_bind_MetadataQuerier_GetIntEntry_2.apply(null,arguments)},Yb=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=
function(){return a.asm.emscripten_bind_MetadataQuerier_GetIntEntryArray_3.apply(null,arguments)},Zb=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=function(){return a.asm.emscripten_bind_MetadataQuerier_GetDoubleEntry_2.apply(null,arguments)},$b=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=function(){return a.asm.emscripten_bind_MetadataQuerier_GetStringEntry_2.apply(null,arguments)},ac=a._emscripten_bind_MetadataQuerier_NumEntries_1=function(){return a.asm.emscripten_bind_MetadataQuerier_NumEntries_1.apply(null,
arguments)},bc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=function(){return a.asm.emscripten_bind_MetadataQuerier_GetEntryName_2.apply(null,arguments)},cc=a._emscripten_bind_MetadataQuerier___destroy___0=function(){return a.asm.emscripten_bind_MetadataQuerier___destroy___0.apply(null,arguments)},Ya=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=function(){return a.asm.emscripten_bind_DracoInt16Array_DracoInt16Array_0.apply(null,arguments)},dc=a._emscripten_bind_DracoInt16Array_GetValue_1=
function(){return a.asm.emscripten_bind_DracoInt16Array_GetValue_1.apply(null,arguments)},ec=a._emscripten_bind_DracoInt16Array_size_0=function(){return a.asm.emscripten_bind_DracoInt16Array_size_0.apply(null,arguments)},fc=a._emscripten_bind_DracoInt16Array___destroy___0=function(){return a.asm.emscripten_bind_DracoInt16Array___destroy___0.apply(null,arguments)},Za=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=function(){return a.asm.emscripten_bind_DracoFloat32Array_DracoFloat32Array_0.apply(null,
arguments)},gc=a._emscripten_bind_DracoFloat32Array_GetValue_1=function(){return a.asm.emscripten_bind_DracoFloat32Array_GetValue_1.apply(null,arguments)},hc=a._emscripten_bind_DracoFloat32Array_size_0=function(){return a.asm.emscripten_bind_DracoFloat32Array_size_0.apply(null,arguments)},ic=a._emscripten_bind_DracoFloat32Array___destroy___0=function(){return a.asm.emscripten_bind_DracoFloat32Array___destroy___0.apply(null,arguments)},$a=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=function(){return a.asm.emscripten_bind_GeometryAttribute_GeometryAttribute_0.apply(null,
arguments)},jc=a._emscripten_bind_GeometryAttribute___destroy___0=function(){return a.asm.emscripten_bind_GeometryAttribute___destroy___0.apply(null,arguments)},ab=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=function(){return a.asm.emscripten_bind_DecoderBuffer_DecoderBuffer_0.apply(null,arguments)},kc=a._emscripten_bind_DecoderBuffer_Init_2=function(){return a.asm.emscripten_bind_DecoderBuffer_Init_2.apply(null,arguments)},lc=a._emscripten_bind_DecoderBuffer___destroy___0=function(){return a.asm.emscripten_bind_DecoderBuffer___destroy___0.apply(null,
arguments)},bb=a._emscripten_bind_Decoder_Decoder_0=function(){return a.asm.emscripten_bind_Decoder_Decoder_0.apply(null,arguments)},mc=a._emscripten_bind_Decoder_GetEncodedGeometryType_1=function(){return a.asm.emscripten_bind_Decoder_GetEncodedGeometryType_1.apply(null,arguments)},nc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=function(){return a.asm.emscripten_bind_Decoder_DecodeBufferToPointCloud_2.apply(null,arguments)},oc=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=function(){return a.asm.emscripten_bind_Decoder_DecodeBufferToMesh_2.apply(null,
arguments)},pc=a._emscripten_bind_Decoder_GetAttributeId_2=function(){return a.asm.emscripten_bind_Decoder_GetAttributeId_2.apply(null,arguments)},qc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=function(){return a.asm.emscripten_bind_Decoder_GetAttributeIdByName_2.apply(null,arguments)},rc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=function(){return a.asm.emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3.apply(null,arguments)},sc=a._emscripten_bind_Decoder_GetAttribute_2=
function(){return a.asm.emscripten_bind_Decoder_GetAttribute_2.apply(null,arguments)},tc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=function(){return a.asm.emscripten_bind_Decoder_GetAttributeByUniqueId_2.apply(null,arguments)},uc=a._emscripten_bind_Decoder_GetMetadata_1=function(){return a.asm.emscripten_bind_Decoder_GetMetadata_1.apply(null,arguments)},vc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=function(){return a.asm.emscripten_bind_Decoder_GetAttributeMetadata_2.apply(null,
arguments)},wc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=function(){return a.asm.emscripten_bind_Decoder_GetFaceFromMesh_3.apply(null,arguments)},xc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=function(){return a.asm.emscripten_bind_Decoder_GetTriangleStripsFromMesh_2.apply(null,arguments)},yc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=function(){return a.asm.emscripten_bind_Decoder_GetTrianglesUInt16Array_3.apply(null,arguments)},zc=a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=
function(){return a.asm.emscripten_bind_Decoder_GetTrianglesUInt32Array_3.apply(null,arguments)},Ac=a._emscripten_bind_Decoder_GetAttributeFloat_3=function(){return a.asm.emscripten_bind_Decoder_GetAttributeFloat_3.apply(null,arguments)},Bc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=function(){return a.asm.emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3.apply(null,arguments)},Cc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=function(){return a.asm.emscripten_bind_Decoder_GetAttributeIntForAllPoints_3.apply(null,
arguments)},Dc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=function(){return a.asm.emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3.apply(null,arguments)},Ec=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=function(){return a.asm.emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3.apply(null,arguments)},Fc=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=function(){return a.asm.emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3.apply(null,arguments)},
Gc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=function(){return a.asm.emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3.apply(null,arguments)},Hc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=function(){return a.asm.emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3.apply(null,arguments)},Ic=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=function(){return a.asm.emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3.apply(null,arguments)},Jc=
a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=function(){return a.asm.emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5.apply(null,arguments)},Kc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=function(){return a.asm.emscripten_bind_Decoder_SkipAttributeTransform_1.apply(null,arguments)},Lc=a._emscripten_bind_Decoder___destroy___0=function(){return a.asm.emscripten_bind_Decoder___destroy___0.apply(null,arguments)},cb=a._emscripten_bind_Mesh_Mesh_0=function(){return a.asm.emscripten_bind_Mesh_Mesh_0.apply(null,
arguments)},Mc=a._emscripten_bind_Mesh_num_faces_0=function(){return a.asm.emscripten_bind_Mesh_num_faces_0.apply(null,arguments)},Nc=a._emscripten_bind_Mesh_num_attributes_0=function(){return a.asm.emscripten_bind_Mesh_num_attributes_0.apply(null,arguments)},Oc=a._emscripten_bind_Mesh_num_points_0=function(){return a.asm.emscripten_bind_Mesh_num_points_0.apply(null,arguments)},Pc=a._emscripten_bind_Mesh___destroy___0=function(){return a.asm.emscripten_bind_Mesh___destroy___0.apply(null,arguments)},
Qc=a._emscripten_bind_VoidPtr___destroy___0=function(){return a.asm.emscripten_bind_VoidPtr___destroy___0.apply(null,arguments)},db=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=function(){return a.asm.emscripten_bind_DracoInt32Array_DracoInt32Array_0.apply(null,arguments)},Rc=a._emscripten_bind_DracoInt32Array_GetValue_1=function(){return a.asm.emscripten_bind_DracoInt32Array_GetValue_1.apply(null,arguments)},Sc=a._emscripten_bind_DracoInt32Array_size_0=function(){return a.asm.emscripten_bind_DracoInt32Array_size_0.apply(null,
arguments)},Tc=a._emscripten_bind_DracoInt32Array___destroy___0=function(){return a.asm.emscripten_bind_DracoInt32Array___destroy___0.apply(null,arguments)},eb=a._emscripten_bind_Metadata_Metadata_0=function(){return a.asm.emscripten_bind_Metadata_Metadata_0.apply(null,arguments)},Uc=a._emscripten_bind_Metadata___destroy___0=function(){return a.asm.emscripten_bind_Metadata___destroy___0.apply(null,arguments)},Vc=a._emscripten_enum_draco_StatusCode_OK=function(){return a.asm.emscripten_enum_draco_StatusCode_OK.apply(null,
arguments)},Wc=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=function(){return a.asm.emscripten_enum_draco_StatusCode_DRACO_ERROR.apply(null,arguments)},Xc=a._emscripten_enum_draco_StatusCode_IO_ERROR=function(){return a.asm.emscripten_enum_draco_StatusCode_IO_ERROR.apply(null,arguments)},Yc=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=function(){return a.asm.emscripten_enum_draco_StatusCode_INVALID_PARAMETER.apply(null,arguments)},Zc=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=
function(){return a.asm.emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION.apply(null,arguments)},$c=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=function(){return a.asm.emscripten_enum_draco_StatusCode_UNKNOWN_VERSION.apply(null,arguments)},ad=a._emscripten_enum_draco_DataType_DT_INVALID=function(){return a.asm.emscripten_enum_draco_DataType_DT_INVALID.apply(null,arguments)},bd=a._emscripten_enum_draco_DataType_DT_INT8=function(){return a.asm.emscripten_enum_draco_DataType_DT_INT8.apply(null,
arguments)},cd=a._emscripten_enum_draco_DataType_DT_UINT8=function(){return a.asm.emscripten_enum_draco_DataType_DT_UINT8.apply(null,arguments)},dd=a._emscripten_enum_draco_DataType_DT_INT16=function(){return a.asm.emscripten_enum_draco_DataType_DT_INT16.apply(null,arguments)},ed=a._emscripten_enum_draco_DataType_DT_UINT16=function(){return a.asm.emscripten_enum_draco_DataType_DT_UINT16.apply(null,arguments)},fd=a._emscripten_enum_draco_DataType_DT_INT32=function(){return a.asm.emscripten_enum_draco_DataType_DT_INT32.apply(null,
arguments)},gd=a._emscripten_enum_draco_DataType_DT_UINT32=function(){return a.asm.emscripten_enum_draco_DataType_DT_UINT32.apply(null,arguments)},hd=a._emscripten_enum_draco_DataType_DT_INT64=function(){return a.asm.emscripten_enum_draco_DataType_DT_INT64.apply(null,arguments)},id=a._emscripten_enum_draco_DataType_DT_UINT64=function(){return a.asm.emscripten_enum_draco_DataType_DT_UINT64.apply(null,arguments)},jd=a._emscripten_enum_draco_DataType_DT_FLOAT32=function(){return a.asm.emscripten_enum_draco_DataType_DT_FLOAT32.apply(null,
arguments)},kd=a._emscripten_enum_draco_DataType_DT_FLOAT64=function(){return a.asm.emscripten_enum_draco_DataType_DT_FLOAT64.apply(null,arguments)},ld=a._emscripten_enum_draco_DataType_DT_BOOL=function(){return a.asm.emscripten_enum_draco_DataType_DT_BOOL.apply(null,arguments)},md=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=function(){return a.asm.emscripten_enum_draco_DataType_DT_TYPES_COUNT.apply(null,arguments)},nd=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=function(){return a.asm.emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE.apply(null,
arguments)},od=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=function(){return a.asm.emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD.apply(null,arguments)},pd=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=function(){return a.asm.emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH.apply(null,arguments)},qd=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=function(){return a.asm.emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM.apply(null,
arguments)},rd=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=function(){return a.asm.emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM.apply(null,arguments)},sd=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=function(){return a.asm.emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM.apply(null,arguments)},td=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=function(){return a.asm.emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM.apply(null,
arguments)},ud=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=function(){return a.asm.emscripten_enum_draco_GeometryAttribute_Type_INVALID.apply(null,arguments)},vd=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=function(){return a.asm.emscripten_enum_draco_GeometryAttribute_Type_POSITION.apply(null,arguments)},wd=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=function(){return a.asm.emscripten_enum_draco_GeometryAttribute_Type_NORMAL.apply(null,arguments)},xd=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=
function(){return a.asm.emscripten_enum_draco_GeometryAttribute_Type_COLOR.apply(null,arguments)},yd=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=function(){return a.asm.emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD.apply(null,arguments)},zd=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=function(){return a.asm.emscripten_enum_draco_GeometryAttribute_Type_GENERIC.apply(null,arguments)};a._setThrew=function(){return a.asm.setThrew.apply(null,arguments)};var ta=a.__ZSt18uncaught_exceptionv=
function(){return a.asm._ZSt18uncaught_exceptionv.apply(null,arguments)};a._free=function(){return a.asm.free.apply(null,arguments)};var ib=a._malloc=function(){return a.asm.malloc.apply(null,arguments)};a.stackSave=function(){return a.asm.stackSave.apply(null,arguments)};a.stackAlloc=function(){return a.asm.stackAlloc.apply(null,arguments)};a.stackRestore=function(){return a.asm.stackRestore.apply(null,arguments)};a.__growWasmMemory=function(){return a.asm.__growWasmMemory.apply(null,arguments)};
a.dynCall_ii=function(){return a.asm.dynCall_ii.apply(null,arguments)};a.dynCall_vi=function(){return a.asm.dynCall_vi.apply(null,arguments)};a.dynCall_iii=function(){return a.asm.dynCall_iii.apply(null,arguments)};a.dynCall_vii=function(){return a.asm.dynCall_vii.apply(null,arguments)};a.dynCall_iiii=function(){return a.asm.dynCall_iiii.apply(null,arguments)};a.dynCall_v=function(){return a.asm.dynCall_v.apply(null,arguments)};a.dynCall_viii=function(){return a.asm.dynCall_viii.apply(null,arguments)};
a.dynCall_viiii=function(){return a.asm.dynCall_viiii.apply(null,arguments)};a.dynCall_iiiiiii=function(){return a.asm.dynCall_iiiiiii.apply(null,arguments)};a.dynCall_iidiiii=function(){return a.asm.dynCall_iidiiii.apply(null,arguments)};a.dynCall_jiji=function(){return a.asm.dynCall_jiji.apply(null,arguments)};a.dynCall_viiiiii=function(){return a.asm.dynCall_viiiiii.apply(null,arguments)};a.dynCall_viiiii=function(){return a.asm.dynCall_viiiii.apply(null,arguments)};a.asm=La;var fa;a.then=function(e){if(fa)e(a);
else{var c=a.onRuntimeInitialized;a.onRuntimeInitialized=function(){c&&c();e(a)}}return a};ja=function c(){fa||ma();fa||(ja=c)};a.run=ma;if(a.preInit)for("function"==typeof a.preInit&&(a.preInit=[a.preInit]);0<a.preInit.length;)a.preInit.pop()();ma();p.prototype=Object.create(p.prototype);p.prototype.constructor=p;p.prototype.__class__=p;p.__cache__={};a.WrapperObject=p;a.getCache=u;a.wrapPointer=N;a.castObject=function(a,b){return N(a.ptr,b)};a.NULL=N(0);a.destroy=function(a){if(!a.__destroy__)throw"Error: Cannot destroy object. (Did you create it yourself?)";
a.__destroy__();delete u(a.__class__)[a.ptr]};a.compare=function(a,b){return a.ptr===b.ptr};a.getPointer=function(a){return a.ptr};a.getClass=function(a){return a.__class__};var n={buffer:0,size:0,pos:0,temps:[],needed:0,prepare:function(){if(n.needed){for(var c=0;c<n.temps.length;c++)a._free(n.temps[c]);n.temps.length=0;a._free(n.buffer);n.buffer=0;n.size+=n.needed;n.needed=0}n.buffer||(n.size+=128,n.buffer=a._malloc(n.size),t(n.buffer));n.pos=0},alloc:function(c,b){t(n.buffer);c=c.length*b.BYTES_PER_ELEMENT;
c=c+7&-8;n.pos+c>=n.size?(t(0<c),n.needed+=c,b=a._malloc(c),n.temps.push(b)):(b=n.buffer+n.pos,n.pos+=c);return b},copy:function(a,b,d){switch(b.BYTES_PER_ELEMENT){case 2:d>>=1;break;case 4:d>>=2;break;case 8:d>>=3}for(var c=0;c<a.length;c++)b[d+c]=a[c]}};x.prototype=Object.create(p.prototype);x.prototype.constructor=x;x.prototype.__class__=x;x.__cache__={};a.Status=x;x.prototype.code=x.prototype.code=function(){return jb(this.ptr)};x.prototype.ok=x.prototype.ok=function(){return!!kb(this.ptr)};x.prototype.error_msg=
x.prototype.error_msg=function(){return X(lb(this.ptr))};x.prototype.__destroy__=x.prototype.__destroy__=function(){mb(this.ptr)};A.prototype=Object.create(p.prototype);A.prototype.constructor=A;A.prototype.__class__=A;A.__cache__={};a.DracoUInt16Array=A;A.prototype.GetValue=A.prototype.GetValue=function(a){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);return nb(c,a)};A.prototype.size=A.prototype.size=function(){return ob(this.ptr)};A.prototype.__destroy__=A.prototype.__destroy__=function(){pb(this.ptr)};
B.prototype=Object.create(p.prototype);B.prototype.constructor=B;B.prototype.__class__=B;B.__cache__={};a.PointCloud=B;B.prototype.num_attributes=B.prototype.num_attributes=function(){return qb(this.ptr)};B.prototype.num_points=B.prototype.num_points=function(){return rb(this.ptr)};B.prototype.__destroy__=B.prototype.__destroy__=function(){sb(this.ptr)};C.prototype=Object.create(p.prototype);C.prototype.constructor=C;C.prototype.__class__=C;C.__cache__={};a.DracoUInt8Array=C;C.prototype.GetValue=
C.prototype.GetValue=function(a){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);return tb(c,a)};C.prototype.size=C.prototype.size=function(){return ub(this.ptr)};C.prototype.__destroy__=C.prototype.__destroy__=function(){vb(this.ptr)};D.prototype=Object.create(p.prototype);D.prototype.constructor=D;D.prototype.__class__=D;D.__cache__={};a.DracoUInt32Array=D;D.prototype.GetValue=D.prototype.GetValue=function(a){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);return wb(c,a)};D.prototype.size=D.prototype.size=
function(){return xb(this.ptr)};D.prototype.__destroy__=D.prototype.__destroy__=function(){yb(this.ptr)};E.prototype=Object.create(p.prototype);E.prototype.constructor=E;E.prototype.__class__=E;E.__cache__={};a.AttributeOctahedronTransform=E;E.prototype.InitFromAttribute=E.prototype.InitFromAttribute=function(a){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);return!!zb(c,a)};E.prototype.quantization_bits=E.prototype.quantization_bits=function(){return Ab(this.ptr)};E.prototype.__destroy__=E.prototype.__destroy__=
function(){Bb(this.ptr)};q.prototype=Object.create(p.prototype);q.prototype.constructor=q;q.prototype.__class__=q;q.__cache__={};a.PointAttribute=q;q.prototype.size=q.prototype.size=function(){return Cb(this.ptr)};q.prototype.GetAttributeTransformData=q.prototype.GetAttributeTransformData=function(){return N(Db(this.ptr),J)};q.prototype.attribute_type=q.prototype.attribute_type=function(){return Eb(this.ptr)};q.prototype.data_type=q.prototype.data_type=function(){return Fb(this.ptr)};q.prototype.num_components=
q.prototype.num_components=function(){return Gb(this.ptr)};q.prototype.normalized=q.prototype.normalized=function(){return!!Hb(this.ptr)};q.prototype.byte_stride=q.prototype.byte_stride=function(){return Ib(this.ptr)};q.prototype.byte_offset=q.prototype.byte_offset=function(){return Jb(this.ptr)};q.prototype.unique_id=q.prototype.unique_id=function(){return Kb(this.ptr)};q.prototype.__destroy__=q.prototype.__destroy__=function(){Lb(this.ptr)};J.prototype=Object.create(p.prototype);J.prototype.constructor=
J;J.prototype.__class__=J;J.__cache__={};a.AttributeTransformData=J;J.prototype.transform_type=J.prototype.transform_type=function(){return Mb(this.ptr)};J.prototype.__destroy__=J.prototype.__destroy__=function(){Nb(this.ptr)};w.prototype=Object.create(p.prototype);w.prototype.constructor=w;w.prototype.__class__=w;w.__cache__={};a.AttributeQuantizationTransform=w;w.prototype.InitFromAttribute=w.prototype.InitFromAttribute=function(a){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);return!!Ob(c,a)};
w.prototype.quantization_bits=w.prototype.quantization_bits=function(){return Pb(this.ptr)};w.prototype.min_value=w.prototype.min_value=function(a){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);return Qb(c,a)};w.prototype.range=w.prototype.range=function(){return Rb(this.ptr)};w.prototype.__destroy__=w.prototype.__destroy__=function(){Sb(this.ptr)};F.prototype=Object.create(p.prototype);F.prototype.constructor=F;F.prototype.__class__=F;F.__cache__={};a.DracoInt8Array=F;F.prototype.GetValue=F.prototype.GetValue=
function(a){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);return Tb(c,a)};F.prototype.size=F.prototype.size=function(){return Ub(this.ptr)};F.prototype.__destroy__=F.prototype.__destroy__=function(){Vb(this.ptr)};r.prototype=Object.create(p.prototype);r.prototype.constructor=r;r.prototype.__class__=r;r.__cache__={};a.MetadataQuerier=r;r.prototype.HasEntry=r.prototype.HasEntry=function(a,b){var c=this.ptr;n.prepare();a&&"object"===typeof a&&(a=a.ptr);b=b&&"object"===typeof b?b.ptr:V(b);return!!Wb(c,
a,b)};r.prototype.GetIntEntry=r.prototype.GetIntEntry=function(a,b){var c=this.ptr;n.prepare();a&&"object"===typeof a&&(a=a.ptr);b=b&&"object"===typeof b?b.ptr:V(b);return Xb(c,a,b)};r.prototype.GetIntEntryArray=r.prototype.GetIntEntryArray=function(a,b,d){var c=this.ptr;n.prepare();a&&"object"===typeof a&&(a=a.ptr);b=b&&"object"===typeof b?b.ptr:V(b);d&&"object"===typeof d&&(d=d.ptr);Yb(c,a,b,d)};r.prototype.GetDoubleEntry=r.prototype.GetDoubleEntry=function(a,b){var c=this.ptr;n.prepare();a&&"object"===
typeof a&&(a=a.ptr);b=b&&"object"===typeof b?b.ptr:V(b);return Zb(c,a,b)};r.prototype.GetStringEntry=r.prototype.GetStringEntry=function(a,b){var c=this.ptr;n.prepare();a&&"object"===typeof a&&(a=a.ptr);b=b&&"object"===typeof b?b.ptr:V(b);return X($b(c,a,b))};r.prototype.NumEntries=r.prototype.NumEntries=function(a){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);return ac(c,a)};r.prototype.GetEntryName=r.prototype.GetEntryName=function(a,b){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===
typeof b&&(b=b.ptr);return X(bc(c,a,b))};r.prototype.__destroy__=r.prototype.__destroy__=function(){cc(this.ptr)};G.prototype=Object.create(p.prototype);G.prototype.constructor=G;G.prototype.__class__=G;G.__cache__={};a.DracoInt16Array=G;G.prototype.GetValue=G.prototype.GetValue=function(a){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);return dc(c,a)};G.prototype.size=G.prototype.size=function(){return ec(this.ptr)};G.prototype.__destroy__=G.prototype.__destroy__=function(){fc(this.ptr)};H.prototype=
Object.create(p.prototype);H.prototype.constructor=H;H.prototype.__class__=H;H.__cache__={};a.DracoFloat32Array=H;H.prototype.GetValue=H.prototype.GetValue=function(a){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);return gc(c,a)};H.prototype.size=H.prototype.size=function(){return hc(this.ptr)};H.prototype.__destroy__=H.prototype.__destroy__=function(){ic(this.ptr)};O.prototype=Object.create(p.prototype);O.prototype.constructor=O;O.prototype.__class__=O;O.__cache__={};a.GeometryAttribute=O;O.prototype.__destroy__=
O.prototype.__destroy__=function(){jc(this.ptr)};K.prototype=Object.create(p.prototype);K.prototype.constructor=K;K.prototype.__class__=K;K.__cache__={};a.DecoderBuffer=K;K.prototype.Init=K.prototype.Init=function(a,b){var c=this.ptr;n.prepare();if("object"==typeof a&&"object"===typeof a){var e=n.alloc(a,T);n.copy(a,T,e);a=e}b&&"object"===typeof b&&(b=b.ptr);kc(c,a,b)};K.prototype.__destroy__=K.prototype.__destroy__=function(){lc(this.ptr)};g.prototype=Object.create(p.prototype);g.prototype.constructor=
g;g.prototype.__class__=g;g.__cache__={};a.Decoder=g;g.prototype.GetEncodedGeometryType=g.prototype.GetEncodedGeometryType=function(a){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);return mc(c,a)};g.prototype.DecodeBufferToPointCloud=g.prototype.DecodeBufferToPointCloud=function(a,b){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);return N(nc(c,a,b),x)};g.prototype.DecodeBufferToMesh=g.prototype.DecodeBufferToMesh=function(a,b){var c=this.ptr;a&&"object"===typeof a&&
(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);return N(oc(c,a,b),x)};g.prototype.GetAttributeId=g.prototype.GetAttributeId=function(a,b){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);return pc(c,a,b)};g.prototype.GetAttributeIdByName=g.prototype.GetAttributeIdByName=function(a,b){var c=this.ptr;n.prepare();a&&"object"===typeof a&&(a=a.ptr);b=b&&"object"===typeof b?b.ptr:V(b);return qc(c,a,b)};g.prototype.GetAttributeIdByMetadataEntry=g.prototype.GetAttributeIdByMetadataEntry=
function(a,b,d){var c=this.ptr;n.prepare();a&&"object"===typeof a&&(a=a.ptr);b=b&&"object"===typeof b?b.ptr:V(b);d=d&&"object"===typeof d?d.ptr:V(d);return rc(c,a,b,d)};g.prototype.GetAttribute=g.prototype.GetAttribute=function(a,b){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);return N(sc(c,a,b),q)};g.prototype.GetAttributeByUniqueId=g.prototype.GetAttributeByUniqueId=function(a,b){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);
return N(tc(c,a,b),q)};g.prototype.GetMetadata=g.prototype.GetMetadata=function(a){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);return N(uc(c,a),L)};g.prototype.GetAttributeMetadata=g.prototype.GetAttributeMetadata=function(a,b){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);return N(vc(c,a,b),L)};g.prototype.GetFaceFromMesh=g.prototype.GetFaceFromMesh=function(a,b,d){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);d&&"object"===
typeof d&&(d=d.ptr);return!!wc(c,a,b,d)};g.prototype.GetTriangleStripsFromMesh=g.prototype.GetTriangleStripsFromMesh=function(a,b){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);return xc(c,a,b)};g.prototype.GetTrianglesUInt16Array=g.prototype.GetTrianglesUInt16Array=function(a,b,d){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!yc(c,a,b,d)};g.prototype.GetTrianglesUInt32Array=g.prototype.GetTrianglesUInt32Array=
function(a,b,d){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!zc(c,a,b,d)};g.prototype.GetAttributeFloat=g.prototype.GetAttributeFloat=function(a,b,d){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Ac(c,a,b,d)};g.prototype.GetAttributeFloatForAllPoints=g.prototype.GetAttributeFloatForAllPoints=function(a,b,d){var c=this.ptr;a&&"object"===typeof a&&
(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Bc(c,a,b,d)};g.prototype.GetAttributeIntForAllPoints=g.prototype.GetAttributeIntForAllPoints=function(a,b,d){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Cc(c,a,b,d)};g.prototype.GetAttributeInt8ForAllPoints=g.prototype.GetAttributeInt8ForAllPoints=function(a,b,d){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&
(b=b.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Dc(c,a,b,d)};g.prototype.GetAttributeUInt8ForAllPoints=g.prototype.GetAttributeUInt8ForAllPoints=function(a,b,d){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Ec(c,a,b,d)};g.prototype.GetAttributeInt16ForAllPoints=g.prototype.GetAttributeInt16ForAllPoints=function(a,b,d){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);d&&"object"===typeof d&&
(d=d.ptr);return!!Fc(c,a,b,d)};g.prototype.GetAttributeUInt16ForAllPoints=g.prototype.GetAttributeUInt16ForAllPoints=function(a,b,d){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Gc(c,a,b,d)};g.prototype.GetAttributeInt32ForAllPoints=g.prototype.GetAttributeInt32ForAllPoints=function(a,b,d){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Hc(c,
a,b,d)};g.prototype.GetAttributeUInt32ForAllPoints=g.prototype.GetAttributeUInt32ForAllPoints=function(a,b,d){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Ic(c,a,b,d)};g.prototype.GetAttributeDataArrayForAllPoints=g.prototype.GetAttributeDataArrayForAllPoints=function(a,b,d,e,f){var c=this.ptr;a&&"object"===typeof a&&(a=a.ptr);b&&"object"===typeof b&&(b=b.ptr);d&&"object"===typeof d&&(d=d.ptr);e&&"object"===typeof e&&
(e=e.ptr);f&&"object"===typeof f&&(f=f.ptr);return!!Jc(c,a,b,d,e,f)};g.prototype.SkipAttributeTransform=g.prototype.SkipAttributeTransform=function(a){var b=this.ptr;a&&"object"===typeof a&&(a=a.ptr);Kc(b,a)};g.prototype.__destroy__=g.prototype.__destroy__=function(){Lc(this.ptr)};y.prototype=Object.create(p.prototype);y.prototype.constructor=y;y.prototype.__class__=y;y.__cache__={};a.Mesh=y;y.prototype.num_faces=y.prototype.num_faces=function(){return Mc(this.ptr)};y.prototype.num_attributes=y.prototype.num_attributes=
function(){return Nc(this.ptr)};y.prototype.num_points=y.prototype.num_points=function(){return Oc(this.ptr)};y.prototype.__destroy__=y.prototype.__destroy__=function(){Pc(this.ptr)};Q.prototype=Object.create(p.prototype);Q.prototype.constructor=Q;Q.prototype.__class__=Q;Q.__cache__={};a.VoidPtr=Q;Q.prototype.__destroy__=Q.prototype.__destroy__=function(){Qc(this.ptr)};I.prototype=Object.create(p.prototype);I.prototype.constructor=I;I.prototype.__class__=I;I.__cache__={};a.DracoInt32Array=I;I.prototype.GetValue=
I.prototype.GetValue=function(a){var b=this.ptr;a&&"object"===typeof a&&(a=a.ptr);return Rc(b,a)};I.prototype.size=I.prototype.size=function(){return Sc(this.ptr)};I.prototype.__destroy__=I.prototype.__destroy__=function(){Tc(this.ptr)};L.prototype=Object.create(p.prototype);L.prototype.constructor=L;L.prototype.__class__=L;L.__cache__={};a.Metadata=L;L.prototype.__destroy__=L.prototype.__destroy__=function(){Uc(this.ptr)};(function(){function c(){a.OK=Vc();a.DRACO_ERROR=Wc();a.IO_ERROR=Xc();a.INVALID_PARAMETER=
Yc();a.UNSUPPORTED_VERSION=Zc();a.UNKNOWN_VERSION=$c();a.DT_INVALID=ad();a.DT_INT8=bd();a.DT_UINT8=cd();a.DT_INT16=dd();a.DT_UINT16=ed();a.DT_INT32=fd();a.DT_UINT32=gd();a.DT_INT64=hd();a.DT_UINT64=id();a.DT_FLOAT32=jd();a.DT_FLOAT64=kd();a.DT_BOOL=ld();a.DT_TYPES_COUNT=md();a.INVALID_GEOMETRY_TYPE=nd();a.POINT_CLOUD=od();a.TRIANGULAR_MESH=pd();a.ATTRIBUTE_INVALID_TRANSFORM=qd();a.ATTRIBUTE_NO_TRANSFORM=rd();a.ATTRIBUTE_QUANTIZATION_TRANSFORM=sd();a.ATTRIBUTE_OCTAHEDRON_TRANSFORM=td();a.INVALID=ud();
a.POSITION=vd();a.NORMAL=wd();a.COLOR=xd();a.TEX_COORD=yd();a.GENERIC=zd()}Ba?c():Da.unshift(c)})();if("function"===typeof a.onModuleParsed)a.onModuleParsed();return m}}();"object"===typeof exports&&"object"===typeof module?module.exports=DracoDecoderModule:"function"===typeof define&&define.amd?define([],function(){return DracoDecoderModule}):"object"===typeof exports&&(exports.DracoDecoderModule=DracoDecoderModule);

49
src/App.vue Normal file
View File

@@ -0,0 +1,49 @@
<template>
<div
class="app-root"
:class="{
'with-dev-sidebar': showDevSidebar,
'sidebar-expanded': showDevSidebar && !appStore.devSidebarCollapsed,
}"
>
<DevSidebar v-if="showDevSidebar" v-model:collapsed="appStore.devSidebarCollapsed" />
<div class="app-content">
<AssistantFabs />
<RouterView />
</div>
</div>
</template>
<script setup>
import { RouterView } from "vue-router";
import AssistantFabs from "./components/assistant-fabs/index.vue";
import DevSidebar from "./components/dev/DevSidebar.vue";
import { useAppStore } from "./stores/app.js";
const appStore = useAppStore();
const showDevSidebar = import.meta.env.DEV;
</script>
<style>
.app-root {
width: 100%;
height: 100%;
}
.app-content {
position: relative;
width: 100%;
height: 100%;
transition: margin-left 0.22s ease, width 0.22s ease;
}
.app-root.with-dev-sidebar .app-content {
margin-left: 44px;
width: calc(100% - 44px);
}
.app-root.sidebar-expanded .app-content {
margin-left: 220px;
width: calc(100% - 220px);
}
</style>

View File

@@ -0,0 +1,69 @@
:root {
/* 文字色 */
--bim-text: rgba(245, 252, 255, 0.92);
--bim-muted: rgba(223, 241, 246, 0.72);
/* 面板背景 */
--bim-panel: rgba(18, 29, 35, 0.9);
--bim-panel-soft: rgba(16, 25, 31, 0.8);
/* 强调色 */
--bim-accent: #08c7bc;
--bim-accent2: #20e2d5;
--bim-accent-hex: #53d6ce;
/* 边框/线条 */
--bim-line: rgba(42, 190, 182, 0.34);
--bim-line-soft: rgba(42, 190, 182, 0.2);
/* 进度条 */
--bim-bar-track: rgba(255, 255, 255, 0.12);
--bim-bar-fill: linear-gradient(90deg, #0eb7ff, #1ce0c5);
/* 字体 */
--bim-font-cn: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
--bim-font-num: "DIN Alternate", "Bahnschrift", "Segoe UI", "Arial Narrow", sans-serif;
/* 玻璃态通用 */
--bim-glass-bg:
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box;
--bim-glass-border: linear-gradient(135deg, rgba(83, 214, 206, 0.42), rgba(83, 214, 206, 0.14), rgba(0, 0, 0, 0)) border-box;
--bim-glass-shadow:
0 32px 96px rgba(0, 0, 0, 0.48),
0 0 28px rgba(83, 214, 206, 0.12),
0 0 0 1px rgba(83, 214, 206, 0.12) inset,
0 0 0 2px rgba(0, 0, 0, 0.1) inset;
/* 警告/橙色 */
--bim-warn: rgba(212, 136, 6, 0.95);
}
* {
box-sizing: border-box;
}
html, body, #app {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
body {
font-family: var(--bim-font-cn);
background: #0b1216;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

View File

@@ -0,0 +1,180 @@
<template>
<div class="assistant-fabs-layer">
<button class="ai-fab" type="button" aria-haspopup="dialog" title="AI模型智能助手" @click="toggleAi"
>
<span class="sr-only">AI模型智能助手</span>
<svg class="ai-fab-icon" viewBox="0 0 64 64" aria-hidden="true"
>
<defs>
<linearGradient id="aiFabGradGlobal" x1="0" y1="0" x2="1" y2="1"
>
<stop offset="0" stop-color="#156cff" />
<stop offset="1" stop-color="#2cc8ff" />
</linearGradient>
</defs>
<path d="M32 8c1 0 1.8.8 1.8 1.8v4.1h6.5c7.4 0 13.4 6 13.4 13.4V40c0 7.4-6 13.4-13.4 13.4H23.7C16.3 53.4 10.3 47.4 10.3 40V27.3c0-7.4 6-13.4 13.4-13.4h6.5V9.8C30.2 8.8 31 8 32 8Zm8.3 13.6H23.7c-3.2 0-5.7 2.6-5.7 5.7V40c0 3.2 2.6 5.7 5.7 5.7h16.6c3.2 0 5.7-2.6 5.7-5.7V27.3c0-3.2-2.6-5.7-5.7-5.7Z" fill="url(#aiFabGradGlobal)" opacity="0.95" />
<path d="M19.7 31.1h24.6c1.2 0 2.1 1 2.1 2.1v6.9c0 1.2-1 2.1-2.1 2.1H19.7c-1.2 0-2.1-1-2.1-2.1v-6.9c0-1.2 1-2.1 2.1-2.1Z" fill="rgba(255,255,255,0.88)" />
<path d="M25.4 35.7a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4Z" fill="#0b1b3a" opacity="0.85" />
<path d="M38.6 35.7a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4Z" fill="#0b1b3a" opacity="0.85" />
<path d="M23.8 26.8h16.4" stroke="rgba(255,255,255,0.78)" stroke-width="3.2" stroke-linecap="round" />
<path d="M20 17.8c2.8-2.8 6.7-4.6 12-4.6s9.2 1.8 12 4.6" stroke="rgba(44,200,255,0.78)" stroke-width="2.8" stroke-linecap="round" fill="none" opacity="0.8" />
<path d="M32 6.2v5.8" stroke="rgba(44,200,255,0.78)" stroke-width="2.8" stroke-linecap="round" />
<circle cx="32" cy="5.2" r="2.2" fill="rgba(44,200,255,0.92)" />
</svg>
</button>
<button class="issue-fab" type="button" aria-haspopup="dialog" title="设计问题智能管理" @click="toggleIssue"
>
<span class="sr-only">设计问题智能管理</span>
<svg class="issue-fab-icon" viewBox="0 0 64 64" aria-hidden="true"
>
<defs>
<linearGradient id="issueFabGradGlobal" x1="0" y1="0" x2="1" y2="1"
>
<stop offset="0" stop-color="#ffcc4a" />
<stop offset="1" stop-color="#8a5bff" />
</linearGradient>
</defs>
<path d="M32 8c12.9 0 23.4 10.5 23.4 23.4S44.9 54.8 32 54.8 8.6 44.3 8.6 31.4 19.1 8 32 8Z" fill="url(#issueFabGradGlobal)" opacity="0.96" />
<path d="M32 18.6c-5.2 0-9.4 3-9.4 8.1 0 .9.7 1.6 1.6 1.6.9 0 1.6-.7 1.6-1.6 0-3.1 2.6-4.9 6.2-4.9 3.3 0 5.7 1.6 5.7 4.2 0 2.1-1.3 3.3-3.4 4.6l-1.1.7c-2.8 1.8-4.6 3.7-4.6 7.1v.2c0 .9.7 1.6 1.6 1.6.9 0 1.6-.7 1.6-1.6v-.1c0-2.2 1.1-3.2 3.4-4.7l1.1-.7c2.8-1.8 5-3.8 5-7.4 0-4.6-4.1-7.7-8.9-7.7Z" fill="rgba(255,255,255,0.92)" />
<circle cx="32" cy="45.2" r="2.6" fill="rgba(255,255,255,0.92)" />
<path d="M44.9 20.6c2.6 2.4 4.2 5.8 4.2 9.7 0 7.4-6 13.4-13.4 13.4" fill="none" stroke="rgba(255,255,255,0.55)" stroke-width="3" stroke-linecap="round" />
</svg>
</button>
<AiAssistantModal :open="aiOpen" @close="aiOpen = false" @toast="showToast" />
<IssueManagerModal :open="issueOpen" @close="issueOpen = false" @toast="showToast" />
<div class="toast" v-show="toastOpen">{{ toastText }}</div>
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from "vue";
import AiAssistantModal from "../assistant/AiAssistantModal.vue";
import IssueManagerModal from "../assistant/IssueManagerModal.vue";
const aiOpen = ref(false);
const issueOpen = ref(false);
const toastOpen = ref(false);
const toastText = ref("");
let toastTimer = null;
function toggleAi() {
aiOpen.value = !aiOpen.value;
if (aiOpen.value) issueOpen.value = false;
}
function toggleIssue() {
issueOpen.value = !issueOpen.value;
if (issueOpen.value) aiOpen.value = false;
}
function showToast(text) {
toastText.value = String(text || "");
toastOpen.value = !!toastText.value;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastOpen.value = false;
}, 1400);
}
function onKeydown(e) {
if (e.key !== "Escape") return;
aiOpen.value = false;
issueOpen.value = false;
}
onMounted(() => {
window.addEventListener("keydown", onKeydown);
});
onBeforeUnmount(() => {
if (toastTimer) clearTimeout(toastTimer);
window.removeEventListener("keydown", onKeydown);
});
</script>
<style scoped>
.assistant-fabs-layer {
position: absolute;
inset: 0;
z-index: 140;
pointer-events: none;
}
.assistant-fabs-layer :is(button, .toast) {
pointer-events: auto;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.ai-fab,
.issue-fab {
position: absolute;
top: 30px;
width: 54px;
height: 54px;
border-radius: 18px;
border: 1px solid transparent;
background:
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box,
linear-gradient(135deg, rgba(83, 214, 206, 0.42), rgba(83, 214, 206, 0.14), rgba(0, 0, 0, 0)) border-box;
box-shadow:
0 22px 60px rgba(0, 0, 0, 0.35),
0 0 26px rgba(83, 214, 206, 0.1),
0 0 0 1px rgba(83, 214, 206, 0.14) inset,
0 0 0 2px rgba(0, 0, 0, 0.06) inset;
display: grid;
place-items: center;
cursor: pointer;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.ai-fab { left: 30px; }
.issue-fab { left: 100px; }
.ai-fab-icon,
.issue-fab-icon {
width: 38px;
height: 38px;
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.22));
}
.ai-fab:hover,
.issue-fab:hover {
transform: translateY(-1px);
box-shadow:
0 26px 70px rgba(0, 0, 0, 0.38),
0 0 0 1px rgba(255, 255, 255, 0.07) inset;
}
.ai-fab:active,
.issue-fab:active { transform: translateY(0); }
.toast {
position: absolute;
left: 50%;
top: 74px;
transform: translateX(-50%);
z-index: 150;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(18, 26, 31, 0.94);
color: rgba(255, 255, 255, 0.86);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.32);
}
</style>

View File

@@ -0,0 +1,537 @@
<template>
<div v-show="open" class="ai-overlay">
<section
ref="modalRef"
class="ai-modal"
role="dialog"
aria-modal="true"
aria-label="AI模型智能助手"
:style="style"
>
<header ref="headerRef" class="ai-modal-header" @pointerdown="onHeaderPointerDown">
<div class="ai-modal-title">AI模型智能助手</div>
<button class="ai-close" type="button" aria-label="关闭" @click="$emit('close')">×</button>
</header>
<div class="ai-modal-body">
<div v-show="screen === 'form'" class="ai-screen">
<div class="ai-field">
<div class="ai-label">选择模型</div>
<div class="ai-select-wrap">
<select class="input ai-select" aria-label="选择模型" v-model="model">
<option value="bridge">桥梁专业</option>
<option value="road">道路专业</option>
<option value="tunnel">隧道专业</option>
</select>
</div>
</div>
<div class="ai-field">
<div class="ai-label">查询类型</div>
<div class="ai-tabs" role="tablist" aria-label="查询类型">
<button
v-for="t in qtypes"
:key="t.key"
class="ai-tab"
type="button"
role="tab"
:aria-selected="qtype === t.key"
:class="{ 'is-on': qtype === t.key }"
@click="qtype = t.key"
>
{{ t.label }}
</button>
</div>
</div>
<div class="ai-search">
<div class="ai-searchbox">
<svg class="ai-search-icon" viewBox="0 0 24 24" aria-hidden="true"
>path d="M10.5 3.5a7 7 0 1 1 0 14 7 7 0 0 1 0-14Zm0 2a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm8.7 12.3 1.9 1.9a1 1 0 0 1-1.4 1.4l-1.9-1.9a1 1 0 0 1 1.4-1.4Z" />
</svg>
<input
ref="inputRef"
v-model="query"
class="ai-search-input"
placeholder="例如Z2-7桩基的混凝土工程量是多少"
autocomplete="off"
@keydown.enter.prevent="submit"
@keydown.esc.prevent="$emit('close')"
/>
</div>
<button class="ai-mic" type="button" title="语音输入" aria-label="语音输入" @click="$emit('toast', '语音输入:占位')"
>
<svg viewBox="0 0 24 24" aria-hidden="true"
>path d="M12 14.2c1.6 0 2.8-1.3 2.8-2.8V6.2C14.8 4.6 13.6 3.4 12 3.4S9.2 4.6 9.2 6.2v5.2c0 1.5 1.2 2.8 2.8 2.8Zm6-2.8c0-.6.4-1 1-1s1 .4 1 1c0 3.7-2.8 6.7-6.4 7.1v2h1.8c.6 0 1 .4 1 1s-.4 1-1 1H8.6c-.6 0-1-.4-1-1s.4-1 1-1h1.8v-2C6.8 18.1 4 15.1 4 11.4c0-.6.4-1 1-1s1 .4 1 1c0 3.3 2.7 6 6 6s6-2.7 6-6Z" />
</svg>
</button>
<button class="ai-inline-search" type="button" aria-label="搜索" title="搜索" @click="submit"
>
<svg viewBox="0 0 24 24" aria-hidden="true"
>path d="M10.5 3.5a7 7 0 1 1 0 14 7 7 0 0 1 0-14Zm0 2a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm8.7 12.3 1.9 1.9a1 1 0 0 1-1.4 1.4l-1.9-1.9a1 1 0 0 1 1.4-1.4Z" />
</svg>
</button>
</div>
<div class="ai-history">
<div class="ai-history-head">
<svg class="ai-history-icon" viewBox="0 0 24 24" aria-hidden="true"
>path d="M12 4a8 8 0 1 1-7.7 10h2.2a6 6 0 1 0 .3-3.3l1.5 1.5H3V6.5l1.7 1.7A8 8 0 0 1 12 4Zm-.2 4.1c.6 0 1 .4 1 1v3.4l2.4 1.4c.5.3.7.9.4 1.4-.3.5-.9.7-1.4.4l-2.9-1.7a1 1 0 0 1-.5-.9V9.1c0-.6.4-1 1-1Z" />
</svg>
<div class="ai-label">历史搜索</div>
</div>
<div class="ai-chips">
<button v-for="h in history" :key="h" class="ai-chip" type="button" @click="applyHistory(h)">{{ h }}</button>
</div>
</div>
<div class="ai-desc">
<div class="ai-label">功能说明</div>
<ul class="ai-desc-list">
<li>支持选择专业模型进行精准查询</li>
<li>可查询构件工程量材料属性等信息</li>
<li>支持挂接外部文档和施工方案</li>
<li>基于模型查询挂接文档中的知识内容</li>
<li>自然语言理解智能定位模型构件</li>
</ul>
</div>
</div>
<div v-show="screen === 'result'" class="ai-screen">
<div class="ai-result-head">
<div class="ai-result-title">搜索结果</div>
<button class="btn btn-ghost btn-sm" type="button" @click="screen = 'form'">返回</button>
</div>
<div class="ai-result-summary">{{ resultSummary }}</div>
<div class="ai-result-card">
<div v-for="row in resultRows" :key="row.key" class="ai-kv">
<span>{{ row.key }}</span>
<div v-html="row.value"></div>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { nextTick, ref, watch } from "vue";
import { useDraggableModal } from "../../composables/useDraggableModal.js";
const props = defineProps({
open: Boolean,
});
const emit = defineEmits(["close", "toast"]);
const qtypes = [
{ key: "general", label: "常规查询" },
{ key: "quantity", label: "工程量查询" },
{ key: "props", label: "构件属性" },
{ key: "knowledge", label: "工程知识" },
];
const history = ["Z2-7桩基", "Z1左墩柱", "桥面"];
const model = ref("bridge");
const qtype = ref("quantity");
const query = ref("");
const screen = ref("form");
const resultSummary = ref("");
const resultRows = ref([]);
const inputRef = ref(null);
const { modalRef, headerRef, style, onHeaderPointerDown } = useDraggableModal({ x: 190, y: 140 });
watch(
() => props.open,
(val) => {
if (val) {
screen.value = "form";
nextTick(() => inputRef.value?.focus?.());
}
}
);
const modelLabels = { bridge: "桥梁专业", road: "道路专业", tunnel: "隧道专业" };
const qtypeLabels = { general: "常规查询", quantity: "工程量查询", props: "构件属性", knowledge: "工程知识" };
function renderResults() {
const now = new Date();
const time = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
resultSummary.value = `模型:${modelLabels[model.value]} · 类型:${qtypeLabels[qtype.value]} · 时间:${time}`;
resultRows.value = [
{ key: "你的问题", value: query.value },
{ key: "解析结果", value: "已理解自然语言意图(占位),可进一步解析楼层/构件类型/专业范围。" },
{
key: "可执行动作",
value: ["• 定位构件 / 显隐构件(占位)", "• 视角切换(占位)", "• 进度填报 / 工程量问询(占位)"].join("<br/>"),
},
];
}
function submit() {
const q = String(query.value || "").trim();
if (!q) {
emit("toast", "请输入查询内容");
return;
}
renderResults();
screen.value = "result";
}
function applyHistory(h) {
query.value = h;
submit();
}
</script>
<style scoped>
.ai-overlay {
position: absolute;
inset: 0;
z-index: 90;
pointer-events: none;
}
.ai-modal {
pointer-events: auto;
position: fixed;
left: 190px;
top: 140px;
width: min(820px, calc(100vw - 60px));
max-height: min(820px, calc(100vh - 60px));
transform: scale(1);
transform-origin: left top;
border-radius: 18px;
border: 1px solid transparent;
background:
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box,
linear-gradient(135deg, rgba(83, 214, 206, 0.42), rgba(83, 214, 206, 0.14), rgba(0, 0, 0, 0)) border-box;
box-shadow:
0 32px 96px rgba(0, 0, 0, 0.48),
0 0 28px rgba(83, 214, 206, 0.12),
0 0 0 1px rgba(83, 214, 206, 0.12) inset,
0 0 0 2px rgba(0, 0, 0, 0.1) inset;
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
backdrop-filter: blur(18px);
}
.ai-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background:
radial-gradient(520px 120px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 70%),
linear-gradient(180deg, rgba(18, 26, 31, 0.96), rgba(14, 20, 25, 0.86));
user-select: none;
cursor: grab;
}
.ai-modal-header:active {
cursor: grabbing;
}
.ai-modal-title {
font-size: 22px;
font-weight: 950;
letter-spacing: 0.6px;
background: linear-gradient(90deg, rgba(43, 191, 178, 0.98), rgba(85, 224, 212, 0.92));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 24px rgba(83, 214, 206, 0.12);
}
.ai-close {
appearance: none;
border: 0;
background: transparent;
width: 42px;
height: 42px;
border-radius: 12px;
color: rgba(255, 255, 255, 0.7);
font-size: 28px;
line-height: 1;
font-weight: 900;
cursor: pointer;
}
.ai-close:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.92);
}
.ai-modal-body {
padding: 20px;
overflow: auto;
}
.ai-field {
display: grid;
gap: 10px;
margin-bottom: 18px;
}
.ai-label {
font-weight: 900;
color: rgba(255, 255, 255, 0.66);
}
.ai-select {
width: 100%;
height: 52px;
font-size: 16px;
font-weight: 700;
padding: 0 56px 0 12px;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.86);
}
.ai-select-wrap {
position: relative;
}
.ai-select-wrap::after {
content: "▾";
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 34px;
height: 34px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.08);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.7);
font-weight: 900;
pointer-events: none;
}
.ai-tabs {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.ai-tab {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.72);
border-radius: 12px;
padding: 12px 16px;
min-width: 112px;
font-weight: 900;
cursor: pointer;
}
.ai-tab:hover {
border-color: rgba(83, 214, 206, 0.24);
box-shadow: 0 0 0 3px rgba(83, 214, 206, 0.1);
}
.ai-tab.is-on {
color: rgba(255, 255, 255, 0.94);
border-color: rgba(83, 214, 206, 0.26);
background: linear-gradient(180deg, rgba(43, 191, 178, 0.92), rgba(43, 191, 178, 0.72));
}
.ai-search {
display: grid;
grid-template-columns: 1fr 56px 56px;
gap: 14px;
align-items: stretch;
margin: 10px 0 14px;
}
.ai-searchbox {
position: relative;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
padding: 0 16px 0 48px;
}
.ai-search-icon {
position: absolute;
left: 16px;
width: 22px;
height: 22px;
fill: rgba(255, 255, 255, 0.5);
}
.ai-search-input {
border: 0;
outline: none;
width: 100%;
height: 56px;
background: transparent;
font-size: 16px;
font-weight: 800;
color: rgba(255, 255, 255, 0.86);
}
.ai-search-input::placeholder {
color: rgba(255, 255, 255, 0.38);
}
.ai-mic,
.ai-inline-search {
width: 56px;
height: 56px;
padding: 0;
border-radius: 18px;
border: 0;
background: linear-gradient(180deg, rgba(43, 191, 178, 0.92), rgba(43, 191, 178, 0.78));
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
}
.ai-mic svg,
.ai-inline-search svg {
width: 24px;
height: 24px;
fill: rgba(255, 255, 255, 0.92);
}
.ai-history {
margin: 6px 0 14px;
}
.ai-history-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.ai-history-icon {
width: 20px;
height: 20px;
fill: rgba(255, 255, 255, 0.5);
}
.ai-chips {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.ai-chip {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.14);
color: rgba(255, 255, 255, 0.82);
border-radius: 12px;
padding: 10px 14px;
font-weight: 900;
cursor: pointer;
}
.ai-chip:hover {
border-color: rgba(83, 214, 206, 0.22);
box-shadow: 0 0 0 3px rgba(83, 214, 206, 0.1);
}
.ai-desc {
margin-top: 16px;
padding: 16px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.16);
}
.ai-desc-list {
margin: 10px 0 0;
padding-left: 18px;
color: rgba(255, 255, 255, 0.7);
font-weight: 800;
line-height: 1.55;
}
.ai-result-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.ai-result-title {
font-size: 18px;
font-weight: 950;
color: rgba(255, 255, 255, 0.9);
}
.ai-result-summary {
color: rgba(255, 255, 255, 0.8);
font-weight: 800;
margin-bottom: 12px;
}
.ai-result-card {
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
padding: 14px;
display: grid;
gap: 10px;
}
.ai-kv {
display: grid;
grid-template-columns: 120px 1fr;
gap: 10px;
color: rgba(255, 255, 255, 0.86);
font-weight: 850;
}
.ai-kv span {
color: rgba(255, 255, 255, 0.62);
font-weight: 900;
}
.btn {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.86);
border-radius: 12px;
padding: 8px 10px;
cursor: pointer;
}
.btn-ghost {
background: rgba(255, 255, 255, 0.08);
}
.btn-sm {
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
@media (max-width: 980px) {
.ai-modal {
left: 60px;
top: 90px;
}
}
</style>

View File

@@ -0,0 +1,716 @@
<template>
<div v-show="open" class="issue-overlay">
<section
ref="modalRef"
class="issue-modal"
:class="{ 'is-detail': view === 'detail' }"
role="dialog"
aria-modal="true"
aria-label="设计问题智能管理"
:style="style"
>
<header ref="headerRef" class="issue-modal-header" @pointerdown="onHeaderPointerDown"
>
<div class="issue-modal-left"
>
<button v-show="view === 'detail'" class="issue-back" type="button" aria-label="返回" @click="backToList"
></button
>
<div class="issue-modal-title">{{ view === 'detail' ? '问题报告' : '设计问题智能管理' }}</div>
</div>
<button class="issue-close" type="button" aria-label="关闭" @click="$emit('close')">×</button>
</header>
<div class="issue-modal-body"
>
<div v-show="view === 'list'" class="issue-screen"
>
<section class="issue-card issue-card-form" aria-label="记录新问题"
>
<header class="issue-card-head"
>
<span class="issue-warn-dot" aria-hidden="true">!</span>
<div class="issue-card-title">记录新问题</div>
</header>
<div class="issue-field"
>
<div class="issue-label">选中的构件</div>
<div class="issue-pick"
>
<div class="issue-picked" :class="{ 'is-picking': awaitingPick }" @click="enterPickMode"
>
<span class="issue-picked-icon" aria-hidden="true"></span>
<span class="issue-picked-text">{{ selectedStructure ? selectedStructure.name : '未选择' }}</span>
</div>
<button class="issue-repick" type="button" @click="enterPickMode">重新选择</button>
</div>
<div v-show="awaitingPick" class="issue-pick-select"
>
<div class="issue-select-wrap"
>
<select class="issue-select" aria-label="下拉选择构件" v-model="pickId"
>
<option value="">在模型中点击选择或下拉选择</option>
<option v-for="s in structures" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div class="issue-pick-hint">提示也可直接在模型中点击构件进行选择</div>
</div>
</div>
<div class="issue-field"
>
<div class="issue-label">问题描述</div>
<textarea
class="issue-textarea"
placeholder="描述设计问题AI将自动补充构件名称、位置、专业等信息并整理为正式表述…"
v-model="descRaw"
/>
</div>
<button
class="issue-submit"
type="button"
:disabled="!canSubmit"
@click="submit"
>提交问题AI自动整理</button>
</section>
<section class="issue-list" aria-label="问题列表" v-show="issues.length > 0"
>
<header class="issue-list-head"
>
<div class="issue-list-title">问题列表{{ issues.length }}</div>
<div class="issue-list-sub">可定位 · 可追溯 · 可统计</div>
</header>
<div class="issue-list-body"
>
<div
class="issue-item"
v-for="it in issues"
:key="it.id"
@click="openDetail(it.id)"
>
<div class="issue-item-top"
>
<div class="issue-item-title"
>
<div class="issue-item-name">{{ it.structureName || it.structureId || '构件' }}</div>
<div class="issue-tag">{{ it.discipline || '结构专业' }}</div>
</div>
<div class="issue-chevron" aria-hidden="true"></div>
</div>
<div class="issue-item-meta"
>
<div class="issue-item-loc"
>
<span aria-hidden="true"></span>
<span>{{ it.location || '空间位置待确认' }}</span>
</div>
<div>{{ formatDateTimeCN(it.createdAt) }}</div>
</div>
<div class="issue-item-desc">{{ it.descRaw || '(无描述)' }}</div>
</div>
</div>
</section>
</div>
<div v-show="view === 'detail'" class="issue-screen"
>
<div v-if="activeIssue" class="issue-detail"
>
<div class="issue-detail-grid"
>
<div class="issue-kv"
><div class="issue-k">构件名称</div><div class="issue-v">{{ activeIssue.structureName || activeIssue.structureId || '构件' }}</div></div>
<div class="issue-kv"
><div class="issue-k">所属专业</div><div class="issue-v">{{ activeIssue.discipline || '结构专业' }}</div></div>
<div class="issue-kv"
><div class="issue-k">空间位置</div><div class="issue-v">{{ activeIssue.location || '空间位置待确认' }}</div></div>
<div class="issue-kv"
><div class="issue-k">问题状态</div><div class="issue-v">待整改占位</div></div>
</div>
<div class="issue-box"
>
<div class="issue-box-title">原始问题描述</div>
<div class="issue-box-content">{{ activeIssue.descRaw || '(无)' }}</div>
</div>
<div class="issue-box"
>
<div class="issue-box-title issue-ai-label">AI整理后的正式表述</div>
<div class="issue-ai-box">{{ activeIssue.descAi || 'AI整理占位' }}</div>
</div>
<div class="issue-views"
>
<div class="issue-box"
>
<div class="issue-box-title">模型视图三维视图</div>
<div class="issue-viewbox"
>3D 模型视图<br />{{ activeIssue.structureName || activeIssue.structureId || '构件' }}</div>
</div>
<div class="issue-box"
>
<div class="issue-box-title">二维视图</div>
<div class="issue-viewbox">2D 平面视图<br />{{ activeIssue.location || '空间位置待确认' }}</div>
</div>
</div>
<div class="issue-footer-time">创建时间{{ formatDateTimeCN(activeIssue.createdAt) }}</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { useDraggableModal } from "../../composables/useDraggableModal.js";
import { structures } from "../../constants/structures.js";
const props = defineProps({
open: Boolean,
});
const emit = defineEmits(["close", "toast", "save"]);
const STORAGE_KEY = "bim.issueManager.v1";
const view = ref("list");
const activeId = ref(null);
const issues = ref([]);
const pickId = ref("");
const awaitingPick = ref(false);
const descRaw = ref("");
const { modalRef, headerRef, style, onHeaderPointerDown, resetPosition } = useDraggableModal({ x: 0, y: 140 });
const selectedStructure = computed(() => structures.find((s) => s.id === pickId.value) || null);
const canSubmit = computed(() => !!selectedStructure.value && !!String(descRaw.value || "").trim());
const activeIssue = computed(() => issues.value.find((x) => x.id === activeId.value) || null);
watch(
() => props.open,
(val) => {
if (val) {
loadIssues();
view.value = "list";
activeId.value = null;
awaitingPick.value = false;
descRaw.value = "";
pickId.value = "";
resetPosition(window.innerWidth - 350, 140);
}
}
);
function loadIssues() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed?.issues)) issues.value = parsed.issues;
} catch {
// ignore
}
}
function saveIssues() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ issues: issues.value }));
} catch {
// ignore
}
}
function formatDateTimeCN(iso) {
const d = new Date(iso || Date.now());
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
function buildAiStatement({ discipline, structureName, location, descRaw }) {
const d = (discipline || "结构专业").trim();
const n = (structureName || "构件").trim();
const loc = (location || "空间位置待确认").trim();
const raw = (descRaw || "").trim();
const core = raw ? raw.replace(/[。;;]+$/g, "") : "存在设计问题";
return `${d}-${n}${n}(${loc})存在${core},实际情况与设计要求不一致,需核查并修正。`;
}
function enterPickMode() {
awaitingPick.value = true;
emit("toast", "选取中:请在模型里点击构件");
}
function submit() {
const s = selectedStructure.value;
const raw = String(descRaw.value || "").trim();
if (!s || !raw) return;
const issue = {
id: `ISS-${Date.now()}-${Math.floor(Math.random() * 1e6)}`,
createdAt: new Date().toISOString(),
structureId: s.id,
structureName: s.name,
discipline: s.discipline,
location: s.location,
descRaw: raw,
descAi: buildAiStatement({ discipline: s.discipline, structureName: s.name, location: s.location, descRaw: raw }),
};
issues.value.unshift(issue);
descRaw.value = "";
awaitingPick.value = false;
saveIssues();
emit("toast", "已提交问题AI整理占位");
}
function openDetail(id) {
activeId.value = id;
view.value = "detail";
}
function backToList() {
view.value = "list";
activeId.value = null;
}
</script>
<style scoped>
.issue-overlay {
position: absolute;
inset: 0;
z-index: 92;
pointer-events: none;
}
.issue-modal {
pointer-events: auto;
position: fixed;
right: 18px;
top: 140px;
width: min(820px, calc(100vw - 60px));
max-height: min(820px, calc(100vh - 60px));
transform-origin: right top;
border-radius: 18px;
border: 1px solid transparent;
background:
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box,
linear-gradient(135deg, rgba(83, 214, 206, 0.42), rgba(83, 214, 206, 0.14), rgba(0, 0, 0, 0)) border-box;
box-shadow:
0 32px 96px rgba(0, 0, 0, 0.48),
0 0 28px rgba(83, 214, 206, 0.12),
0 0 0 1px rgba(83, 214, 206, 0.12) inset,
0 0 0 2px rgba(0, 0, 0, 0.1) inset;
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
backdrop-filter: blur(18px);
}
.issue-modal.is-detail {
background:
radial-gradient(260px 160px at 18% 0%, rgba(212, 136, 6, 0.1), transparent 62%) padding-box,
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.1), transparent 62%) padding-box,
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box,
linear-gradient(135deg, rgba(212, 136, 6, 0.42), rgba(212, 136, 6, 0.14), rgba(83, 214, 206, 0.1), rgba(0, 0, 0, 0)) border-box;
}
.issue-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background:
radial-gradient(520px 120px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 70%),
linear-gradient(180deg, rgba(18, 26, 31, 0.96), rgba(14, 20, 25, 0.86));
user-select: none;
cursor: grab;
}
.issue-modal.is-detail .issue-modal-header {
border-bottom-color: rgba(212, 136, 6, 0.14);
background:
radial-gradient(520px 120px at 18% 0%, rgba(212, 136, 6, 0.12), transparent 70%),
radial-gradient(520px 120px at 40% 0%, rgba(83, 214, 206, 0.1), transparent 72%),
linear-gradient(180deg, rgba(18, 26, 31, 0.96), rgba(14, 20, 25, 0.86));
}
.issue-modal-header:active {
cursor: grabbing;
}
.issue-modal-left {
display: inline-flex;
align-items: center;
gap: 10px;
}
.issue-modal-title {
font-size: 22px;
font-weight: 950;
letter-spacing: 0.6px;
color: rgba(255, 255, 255, 0.92);
}
.issue-modal.is-detail .issue-modal-title {
background: linear-gradient(90deg, rgba(255, 190, 92, 0.98), rgba(255, 214, 122, 0.96));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.issue-back,
.issue-close {
appearance: none;
border: 0;
background: transparent;
width: 42px;
height: 42px;
border-radius: 12px;
color: rgba(255, 255, 255, 0.7);
font-size: 28px;
line-height: 1;
font-weight: 900;
cursor: pointer;
}
.issue-back {
font-size: 22px;
}
.issue-back:hover,
.issue-close:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.92);
}
.issue-modal-body {
padding: 20px;
overflow: auto;
}
.issue-card,
.issue-list,
.issue-box {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.14);
border-radius: 16px;
padding: 14px;
}
.issue-card-head,
.issue-list-head,
.issue-item-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.issue-card-title,
.issue-list-title,
.issue-item-name,
.issue-box-title,
.issue-v {
color: rgba(255, 255, 255, 0.9);
font-weight: 900;
}
.issue-warn-dot {
width: 22px;
height: 22px;
border-radius: 8px;
background: rgba(212, 136, 6, 0.18);
border: 1px solid rgba(212, 136, 6, 0.42);
color: rgba(212, 136, 6, 0.95);
display: grid;
place-items: center;
}
.issue-field {
display: grid;
gap: 10px;
margin-bottom: 18px;
}
.issue-label,
.issue-list-sub,
.issue-item-meta,
.issue-pick-hint {
color: rgba(255, 255, 255, 0.62);
font-weight: 900;
}
.issue-pick {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
}
.issue-pick-select {
margin-top: 10px;
display: grid;
gap: 8px;
}
.issue-picked {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 12px;
min-height: 44px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.86);
border-radius: 12px;
cursor: pointer;
}
.issue-picked.is-picking {
border-color: rgba(83, 214, 206, 0.34);
box-shadow: 0 0 0 3px rgba(83, 214, 206, 0.1);
}
.issue-picked-icon {
width: 28px;
height: 28px;
border-radius: 10px;
display: grid;
place-items: center;
background: rgba(212, 136, 6, 0.18);
border: 1px solid rgba(212, 136, 6, 0.28);
color: rgba(212, 136, 6, 0.95);
}
.issue-picked-text {
font-weight: 900;
color: rgba(255, 255, 255, 0.88);
}
.issue-repick {
appearance: none;
border: 0;
background: transparent;
color: rgba(212, 136, 6, 0.95);
font-weight: 900;
cursor: pointer;
padding: 8px 10px;
border-radius: 10px;
}
.issue-repick:hover {
background: rgba(212, 136, 6, 0.12);
}
.issue-select {
width: 100%;
height: 52px;
padding: 0 56px 0 12px;
font-size: 16px;
font-weight: 800;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.86);
border-radius: 12px;
}
.issue-select-wrap {
position: relative;
}
.issue-select-wrap::after {
content: "▾";
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 34px;
height: 34px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.08);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.7);
font-weight: 900;
pointer-events: none;
}
.issue-textarea {
width: 100%;
min-height: 88px;
resize: vertical;
padding: 12px;
font-size: 16px;
line-height: 1.55;
outline: none;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.86);
border-radius: 12px;
}
.issue-textarea:focus {
border-color: rgba(83, 214, 206, 0.3);
box-shadow: 0 0 0 4px rgba(83, 214, 206, 0.1);
}
.issue-submit {
width: 100%;
margin-top: 14px;
height: 52px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.72);
font-weight: 900;
letter-spacing: 0.6px;
cursor: not-allowed;
}
.issue-submit:not(:disabled) {
cursor: pointer;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.88);
box-shadow: 0 0 18px rgba(83, 214, 206, 0.12);
}
.issue-list-body {
display: grid;
gap: 10px;
margin-top: 10px;
}
.issue-item {
padding: 12px;
cursor: pointer;
display: grid;
gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
border-radius: 12px;
}
.issue-item:hover {
border-color: rgba(83, 214, 206, 0.22);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.22);
}
.issue-item-title {
display: inline-flex;
align-items: center;
gap: 10px;
}
.issue-chevron {
color: rgba(255, 255, 255, 0.36);
font-size: 20px;
font-weight: 900;
}
.issue-tag {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid rgba(83, 214, 206, 0.18);
background: rgba(43, 191, 178, 0.14);
color: rgba(83, 214, 206, 0.92);
font-size: 12px;
font-weight: 900;
}
.issue-item-meta {
display: flex;
align-items: center;
gap: 10px;
}
.issue-item-loc {
display: inline-flex;
align-items: center;
gap: 6px;
}
.issue-item-desc {
border-radius: 12px;
background: rgba(0, 0, 0, 0.14);
padding: 10px;
color: rgba(255, 255, 255, 0.74);
}
.issue-detail {
display: grid;
gap: 14px;
}
.issue-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.issue-k {
color: rgba(255, 255, 255, 0.62);
font-weight: 900;
font-size: 12px;
}
.issue-box-content {
padding: 12px;
line-height: 1.55;
color: rgba(255, 255, 255, 0.86);
}
.issue-ai-label {
color: rgba(212, 136, 6, 0.95);
}
.issue-ai-box {
padding: 12px;
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 248, 236, 0.16);
color: rgba(255, 255, 255, 0.88);
border-radius: 12px;
}
.issue-views {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.issue-viewbox {
min-height: 224px;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.56);
font-weight: 900;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.14);
border-radius: 12px;
}
.issue-footer-time {
text-align: right;
color: rgba(255, 255, 255, 0.46);
font-weight: 800;
font-size: 12px;
padding: 6px 2px 0;
}
@media (max-width: 820px) {
.issue-views,
.issue-detail-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<aside class="dev-sidebar" :class="{ 'is-collapsed': collapsed }">
<div class="dev-sidebar-inner">
<div class="dev-sidebar-head">
<span class="dev-sidebar-title" v-show="!collapsed">BIM Dev</span>
<button class="dev-sidebar-toggle" type="button" :title="collapsed ? '展开' : '收起'" @click="toggle">
{{ collapsed ? '' : '' }}
</button>
</div>
<nav class="dev-sidebar-nav" v-show="!collapsed">
<router-link
v-for="route in routes"
:key="route.path"
class="dev-sidebar-link"
:to="route.path"
:class="{ 'is-active': $route.path === route.path }"
>
<span class="dev-sidebar-dot"></span>
<span class="dev-sidebar-label">{{ route.label }}</span>
</router-link>
</nav>
</div>
</aside>
</template>
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
const props = defineProps({
collapsed: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:collapsed"]);
const route = useRoute();
const routes = [
{ path: "/home", label: "首页" },
{ path: "/project", label: "项目" },
{ path: "/subcontract", label: "分包" },
{ path: "/measurement", label: "计量" },
{ path: "/plan", label: "计划" },
{ path: "/progress", label: "进度" },
{ path: "/change", label: "变更" },
{ path: "/material", label: "物资" },
{ path: "/inspection", label: "质检" },
{ path: "/debug", label: "调试" },
];
const collapsed = computed({
get: () => props.collapsed,
set: (val) => emit("update:collapsed", val),
});
function toggle() {
collapsed.value = !collapsed.value;
}
</script>
<style scoped>
.dev-sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 220px;
z-index: 1000;
border-right: 1px solid rgba(83, 214, 206, 0.24);
background:
radial-gradient(260px 160px at 0% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
linear-gradient(180deg, rgba(14, 22, 26, 0.98), rgba(10, 16, 20, 0.96)) padding-box;
box-shadow: 8px 0 40px rgba(0, 0, 0, 0.35);
transition: width 0.22s ease;
}
.dev-sidebar.is-collapsed {
width: 44px;
}
.dev-sidebar-inner {
display: flex;
flex-direction: column;
height: 100%;
padding: 12px;
}
.dev-sidebar-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 14px;
}
.dev-sidebar-title {
font-size: 14px;
font-weight: 900;
color: rgba(207, 247, 242, 0.96);
white-space: nowrap;
}
.dev-sidebar-toggle {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
color: rgba(222, 238, 244, 0.9);
font-weight: 900;
cursor: pointer;
flex: 0 0 auto;
}
.dev-sidebar-nav {
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
}
.dev-sidebar-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 10px;
border-radius: 10px;
text-decoration: none;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.04);
color: rgba(222, 238, 244, 0.86);
transition: background 0.15s ease, border-color 0.15s ease;
}
.dev-sidebar-link:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(83, 214, 206, 0.2);
}
.dev-sidebar-link.is-active {
background: rgba(83, 214, 206, 0.18);
border-color: rgba(83, 214, 206, 0.35);
color: #fff;
}
.dev-sidebar-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(83, 214, 206, 0.7);
flex: 0 0 auto;
}
.dev-sidebar-link.is-active .dev-sidebar-dot {
background: rgba(83, 214, 206, 0.98);
box-shadow: 0 0 8px rgba(83, 214, 206, 0.6);
}
.dev-sidebar-label {
font-size: 13px;
font-weight: 800;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<section class="bottom-panel" :class="{ 'is-collapsed': collapsed }">
<header class="bottom-panel-header">
<slot name="header"></slot>
</header>
<button class="iconbtn bottom-panel-toggle" type="button" @click="toggle">{{ collapsed ? "" : "" }}</button>
<div v-show="!collapsed" class="bottom-panel-body">
<slot />
</div>
</section>
</template>
<script setup>
const props = defineProps({
collapsed: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:collapsed"]);
function toggle() {
emit("update:collapsed", !props.collapsed);
}
</script>
<style scoped>
.bottom-panel {
position: absolute;
left: 370px;
right: 16px;
bottom: 100px;
height: 390px;
border-radius: 18px;
border: 1px solid rgba(83, 214, 206, 0.24);
background:
radial-gradient(420px 180px at 14% 0%, rgba(83, 214, 206, 0.2), transparent 66%) padding-box,
radial-gradient(520px 220px at 82% 0%, rgba(40, 156, 228, 0.14), transparent 70%) padding-box,
linear-gradient(180deg, rgba(18, 33, 40, 0.94), rgba(14, 24, 31, 0.9)) padding-box;
box-shadow:
0 24px 70px rgba(0, 0, 0, 0.36),
0 0 0 1px rgba(83, 214, 206, 0.14) inset;
overflow: hidden;
z-index: 26;
}
.bottom-panel.is-collapsed {
height: 70px;
}
.bottom-panel-header {
padding: 12px 52px 12px 12px;
border-bottom: 1px solid rgba(83, 214, 206, 0.28);
background:
radial-gradient(380px 120px at 18% 0%, rgba(83, 214, 206, 0.28), transparent 70%),
linear-gradient(180deg, rgba(25, 137, 124, 0.86), rgba(16, 66, 82, 0.64));
}
.bottom-panel-toggle {
position: absolute;
right: 12px;
top: 12px;
}
.iconbtn {
width: 30px;
height: 30px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.42);
color: rgba(11, 27, 58, 0.86);
font-weight: 900;
cursor: pointer;
}
.bottom-panel-body {
padding: 0 12px 12px;
overflow: auto;
height: calc(100% - 66px);
}
@media (max-width: 1300px) {
.bottom-panel {
left: 330px;
}
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<div class="page-canvas">
<header class="page-topbar">
<div class="topbar-center">
<div class="model-title">{{ title }}</div>
</div>
<div v-if="$slots['topbar-right']" class="topbar-right">
<slot name="topbar-right"></slot>
</div>
</header>
<section class="model-stage">
<slot name="model">
<ModelPlaceholder />
</slot>
</section>
<slot />
</div>
</template>
<script setup>
import ModelPlaceholder from "../model-placeholder/index.vue";
defineProps({
title: {
type: String,
default: "XXX特大桥主体模型.rvt",
},
});
</script>
<style scoped>
.page-canvas {
position: relative;
width: 100%;
min-height: 100vh;
overflow: hidden;
border-radius: 24px;
border: 1px solid rgba(98, 191, 206, 0.34);
background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%);
box-shadow: inset 0 0 0 8px rgba(214, 230, 241, 0.55);
}
.page-topbar {
position: absolute;
inset: 0 0 auto;
height: 120px;
z-index: 30;
}
.topbar-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 16px;
display: grid;
justify-items: center;
gap: 10px;
}
.topbar-right {
position: absolute;
right: 24px;
top: 16px;
display: flex;
align-items: flex-start;
justify-content: flex-end;
}
.model-title {
font-size: 20px;
letter-spacing: 1px;
font-weight: 700;
color: rgba(255, 128, 52, 0.95);
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
}
.model-stage {
position: absolute;
inset: 0;
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<aside class="side-panel" :class="{ 'is-collapsed': collapsed }">
<header class="side-panel-header">
<div class="side-panel-title">{{ title }}</div>
<button class="iconbtn" type="button" @click="toggle">{{ collapsed ? "" : "" }}</button>
</header>
<div v-show="!collapsed" class="side-panel-body">
<slot />
</div>
</aside>
</template>
<script setup>
const props = defineProps({
title: {
type: String,
default: "",
},
collapsed: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:collapsed"]);
function toggle() {
emit("update:collapsed", !props.collapsed);
}
</script>
<style scoped>
.side-panel {
position: absolute;
left: 16px;
top: 175px;
width: 320px;
bottom: 100px;
border-radius: 18px;
border: 1px solid rgba(83, 214, 206, 0.24);
background:
radial-gradient(260px 140px at 8% 0%, rgba(63, 203, 191, 0.16), transparent 62%) padding-box,
linear-gradient(180deg, rgba(20, 31, 37, 0.92), rgba(15, 23, 29, 0.88)) padding-box;
box-shadow:
0 22px 60px rgba(0, 0, 0, 0.34),
0 0 0 1px rgba(83, 214, 206, 0.12) inset;
overflow: hidden;
z-index: 25;
}
.side-panel.is-collapsed {
width: 64px;
}
.side-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 10px 10px 12px;
border-bottom: 1px solid rgba(83, 214, 206, 0.16);
background:
radial-gradient(360px 100px at 10% 0%, rgba(83, 214, 206, 0.24), transparent 68%),
linear-gradient(180deg, rgba(28, 134, 122, 0.84), rgba(15, 60, 74, 0.62));
}
.side-panel-title {
font-size: 14px;
font-weight: 900;
color: rgba(207, 247, 242, 0.96);
white-space: nowrap;
}
.iconbtn {
width: 30px;
height: 30px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.42);
color: rgba(11, 27, 58, 0.86);
font-weight: 900;
cursor: pointer;
}
.side-panel-body {
height: calc(100% - 52px);
overflow: auto;
padding: 8px;
}
</style>

View File

@@ -0,0 +1,442 @@
<template>
<div class="model-viewer">
<div ref="containerRef" class="engine-container"></div>
<div class="engine-state" v-if="stateText">{{ stateText }}</div>
<button v-if="showCodeBtn" class="model-code-btn" type="button" :disabled="encoding" @click="onModelCodeClick">
{{ encoding ? "编码中..." : "模型编码" }}
</button>
<div v-if="showConfirm" class="code-confirm-mask">
<div class="code-confirm-panel">
<div class="code-confirm-title">模型编码</div>
<div class="code-confirm-body">
当前模型尚未进行模型编码是否立即开始编码
</div>
<div class="code-confirm-actions">
<button class="code-confirm-btn code-confirm-btn-secondary" type="button" @click="onConfirmCancel">
稍后
</button>
<button class="code-confirm-btn code-confirm-btn-primary" type="button" @click="onConfirmStart">
开始编码
</button>
</div>
</div>
</div>
<div v-if="encoding" class="encoding-overlay">
<div class="encoding-spinner"></div>
<div class="encoding-text">正在进行模型编码请稍候</div>
</div>
<div v-if="toastText" class="encoding-toast">{{ toastText }}</div>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import { BimEngine } from "iflow-engine";
const props = defineProps({
modelUrl: {
type: String,
default: "",
},
});
const containerRef = ref(null);
const loading = ref(true);
const errorText = ref("");
const encoding = ref(false);
const showCodeBtn = ref(false);
const showConfirm = ref(false);
const toastText = ref("");
let engine = null;
let disposed = false;
let resizeObserver = null;
let unsubEvents = [];
let toastTimer = null;
const resolvedModelUrl = computed(() => {
const fromProp = String(props.modelUrl || "").trim();
if (fromProp) return fromProp;
const fromEnv = String(import.meta.env.VITE_BIM_MODEL_URL || "").trim();
if (fromEnv) return fromEnv;
return "https://lyz-1259524260.cos.ap-guangzhou.myqcloud.com/iflow/models/417664a3-76c8-4d94-9344-1337246a5d4e";
});
const stateText = computed(() => {
if (errorText.value) return `模型加载失败:${errorText.value}`;
if (loading.value) return "模型加载中...";
return "";
});
function getEngineComponent() {
return engine?.engine?.getEngineComponent?.();
}
function bindEncodingEvents() {
if (!engine?.on) return;
unsubEvents = [
engine.on("encoding:start", () => {
encoding.value = true;
showConfirm.value = false;
}),
engine.on("encoding:complete", () => {
encoding.value = false;
showCodeBtn.value = false;
showToast("编码完成");
}),
engine.on("encoding:error", () => {
encoding.value = false;
}),
engine.on("engine:model-loading-completed", () => {
if (disposed) return;
handleModelLoadingCompleted();
}),
];
}
function unbindEncodingEvents() {
unsubEvents.forEach((fn) => {
if (typeof fn === "function") fn();
});
unsubEvents = [];
}
function handleModelLoadingCompleted() {
const comp = getEngineComponent();
if (!comp) return;
comp.readModelCodeFormStoge?.();
const hasCode = comp.hasModelCode?.();
console.log("[model-placeholder] hasModelCode:", hasCode);
if (hasCode) {
showCodeBtn.value = false;
} else {
showCodeBtn.value = true;
showConfirm.value = true;
}
}
function startEncoding() {
if (encoding.value) return;
const comp = getEngineComponent();
if (!comp) {
console.warn("engine component unavailable");
return;
}
comp.startOneClickEncoding?.();
}
function onModelCodeClick() {
startEncoding();
}
function onConfirmStart() {
startEncoding();
}
function onConfirmCancel() {
showConfirm.value = false;
}
function showToast(text) {
toastText.value = String(text || "");
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastText.value = "";
}, 2000);
}
function onDebugCheck() {
const comp = getEngineComponent();
if (!comp) {
console.warn("[debug] engine component unavailable");
return;
}
const hasCode = comp.hasModelCode?.();
console.log("[debug] hasModelCode:", hasCode);
window.alert(`hasModelCode: ${hasCode}`);
}
function onDebugRead() {
const comp = getEngineComponent();
if (!comp) {
console.warn("[debug] engine component unavailable");
return;
}
comp.readModelCodeFormStoge?.();
console.log("[debug] readModelCodeFormStoge called");
}
onMounted(async () => {
if (!containerRef.value) return;
disposed = false;
await nextTick();
let ready = false;
for (let i = 0; i < 30; i++) {
const rect = containerRef.value.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
ready = true;
break;
}
await new Promise((resolve) => requestAnimationFrame(resolve));
}
if (!ready || disposed) {
errorText.value = "container size is not ready";
loading.value = false;
return;
}
try {
engine = new BimEngine(containerRef.value, {
locale: "zh-CN",
theme: "light",
});
if (!engine?.engine) {
throw new Error("engine manager unavailable");
}
bindEncodingEvents();
await Promise.resolve(
engine.engine.initialize({
backgroundColor: 0x333333,
showViewCube: true,
})
);
if (disposed) return;
await Promise.resolve(
engine.engine.loadModel([resolvedModelUrl.value], {
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
})
);
engine.constructTreeBtn.setVisible(false);
if (disposed) return;
resizeObserver = new ResizeObserver(() => {
engine?.engine?.resize?.();
engine?.resize?.();
});
resizeObserver.observe(containerRef.value);
loading.value = false;
} catch (error) {
errorText.value = error instanceof Error ? error.message : "unknown error";
loading.value = false;
}
});
onBeforeUnmount(() => {
disposed = true;
if (toastTimer) clearTimeout(toastTimer);
unbindEncodingEvents();
resizeObserver?.disconnect?.();
resizeObserver = null;
try {
engine?.destroy?.();
} finally {
engine = null;
}
});
</script>
<style scoped>
.model-viewer {
position: absolute;
inset: 0;
}
.engine-container {
position: absolute;
inset: 0;
}
.engine-state {
position: absolute;
left: 50%;
top: 16px;
transform: translateX(-50%);
z-index: 5;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(20, 28, 34, 0.72);
color: rgba(236, 246, 250, 0.9);
font-size: 12px;
font-weight: 700;
padding: 6px 10px;
pointer-events: none;
}
.model-code-btn {
position: absolute;
left: 16px;
bottom: 16px;
z-index: 5;
border-radius: 10px;
border: 1px solid rgba(83, 214, 206, 0.35);
background: rgba(18, 29, 35, 0.85);
color: rgba(236, 246, 250, 0.95);
font-size: 12px;
font-weight: 700;
padding: 8px 14px;
cursor: pointer;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
transition: background 0.2s ease, transform 0.1s ease;
}
.model-code-btn:hover {
background: rgba(28, 45, 54, 0.92);
border-color: rgba(83, 214, 206, 0.55);
}
.model-code-btn:active {
transform: translateY(1px);
}
.model-code-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.code-confirm-mask {
position: absolute;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.code-confirm-panel {
width: min(360px, 86vw);
border-radius: 16px;
border: 1px solid rgba(83, 214, 206, 0.25);
background:
radial-gradient(260px 140px at 8% 0%, rgba(63, 203, 191, 0.12), transparent 62%) padding-box,
linear-gradient(180deg, rgba(20, 31, 37, 0.95), rgba(15, 23, 29, 0.92)) padding-box;
box-shadow:
0 32px 80px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(83, 214, 206, 0.1) inset;
padding: 20px;
color: rgba(236, 246, 250, 0.95);
}
.code-confirm-title {
font-size: 18px;
font-weight: 800;
margin-bottom: 10px;
}
.code-confirm-body {
font-size: 14px;
line-height: 1.6;
color: rgba(220, 238, 244, 0.85);
margin-bottom: 18px;
}
.code-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.code-confirm-btn {
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.2s ease, transform 0.1s ease;
}
.code-confirm-btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: rgba(236, 246, 250, 0.9);
border-color: rgba(255, 255, 255, 0.15);
}
.code-confirm-btn-secondary:hover {
background: rgba(255, 255, 255, 0.16);
}
.code-confirm-btn-primary {
background: rgba(83, 214, 206, 0.85);
color: rgba(11, 27, 58, 0.95);
border-color: rgba(83, 214, 206, 0.9);
}
.code-confirm-btn-primary:hover {
background: rgba(83, 214, 206, 0.95);
}
.code-confirm-btn:active {
transform: translateY(1px);
}
.encoding-overlay {
position: absolute;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
place-content: center;
gap: 14px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: rgba(236, 246, 250, 0.95);
pointer-events: auto;
}
.encoding-spinner {
width: 44px;
height: 44px;
border-radius: 50%;
border: 3px solid rgba(83, 214, 206, 0.2);
border-top-color: rgba(83, 214, 206, 0.95);
animation: encoding-spin 1s linear infinite;
}
@keyframes encoding-spin {
to {
transform: rotate(360deg);
}
}
.encoding-text {
font-size: 14px;
font-weight: 700;
}
.encoding-toast {
position: absolute;
left: 50%;
top: 56px;
transform: translateX(-50%);
z-index: 100;
border-radius: 10px;
border: 1px solid rgba(83, 214, 206, 0.35);
background: rgba(18, 29, 35, 0.92);
color: rgba(83, 214, 206, 0.98);
font-size: 13px;
font-weight: 800;
padding: 8px 16px;
pointer-events: none;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
</style>

View File

@@ -0,0 +1,96 @@
import { onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { clamp } from "../utils/format.js";
export function useDraggableModal(initialPosition = { x: 190, y: 140 }) {
const modalRef = ref(null);
const headerRef = ref(null);
const state = reactive({
dragging: false,
pointerId: null,
offsetX: 0,
offsetY: 0,
x: initialPosition.x,
y: initialPosition.y,
});
const style = reactive({
left: `${state.x}px`,
top: `${state.y}px`,
});
function clampToViewport() {
const modal = modalRef.value;
if (!modal) return;
const rect = modal.getBoundingClientRect();
const pad = 10;
const maxLeft = Math.max(pad, window.innerWidth - rect.width - pad);
const maxTop = Math.max(pad, window.innerHeight - rect.height - pad);
state.x = clamp(parseFloat(style.left), pad, maxLeft);
state.y = clamp(parseFloat(style.top), pad, maxTop);
style.left = `${state.x}px`;
style.top = `${state.y}px`;
}
function onHeaderPointerDown(e, closeSelector = ".close-btn") {
if (e.target.closest(closeSelector) || e.button !== 0) return;
const modal = modalRef.value;
if (!modal) return;
state.dragging = true;
state.pointerId = e.pointerId;
headerRef.value?.setPointerCapture?.(e.pointerId);
const rect = modal.getBoundingClientRect();
state.offsetX = e.clientX - rect.left;
state.offsetY = e.clientY - rect.top;
}
function onWindowPointerMove(e) {
if (!state.dragging) return;
if (state.pointerId != null && e.pointerId !== state.pointerId) return;
const modal = modalRef.value;
if (!modal) return;
const rect = modal.getBoundingClientRect();
const pad = 10;
const x = clamp(e.clientX - state.offsetX, pad, Math.max(pad, window.innerWidth - rect.width - pad));
const y = clamp(e.clientY - state.offsetY, pad, Math.max(pad, window.innerHeight - rect.height - pad));
style.left = `${x}px`;
style.top = `${y}px`;
}
function onWindowPointerUp(e) {
if (!state.dragging) return;
if (state.pointerId != null && e.pointerId !== state.pointerId) return;
state.dragging = false;
state.pointerId = null;
clampToViewport();
}
onMounted(() => {
window.addEventListener("pointermove", onWindowPointerMove);
window.addEventListener("pointerup", onWindowPointerUp);
window.addEventListener("pointercancel", onWindowPointerUp);
});
onBeforeUnmount(() => {
window.removeEventListener("pointermove", onWindowPointerMove);
window.removeEventListener("pointerup", onWindowPointerUp);
window.removeEventListener("pointercancel", onWindowPointerUp);
});
function resetPosition(x = initialPosition.x, y = initialPosition.y) {
state.x = x;
state.y = y;
style.left = `${x}px`;
style.top = `${y}px`;
}
return {
modalRef,
headerRef,
state,
style,
onHeaderPointerDown,
resetPosition,
clampToViewport,
};
}

59
src/constants/mock.js Normal file
View File

@@ -0,0 +1,59 @@
import { formatNumber } from "../utils/format.js";
export const projects = [
{ id: "qz-a2", name: "泉州市百崎通道 A2 合同段", section: "A2 合同段", scale: 1 },
{ id: "bridge-demo", name: "跨江大桥示范段", section: "示范段", scale: 0.82 },
{ id: "road-demo", name: "市政道路提升工程", section: "一期", scale: 0.64 },
];
export const measurementPeriods = ["2025-10", "2025-11", "2025-12", "2026-01"];
export function getDecomposeRows(structureIndex, projectScale = 1) {
return Array.from({ length: 12 }, (_, i) => {
const baseQty = (109.26 + i * 19.8 + structureIndex * 2.6) * projectScale;
const changedQty = baseQty * (i % 3 === 0 ? 1.08 : i % 3 === 1 ? 0.96 : 1.0);
const measuredQty = changedQty * (0.25 + (i % 5) * 0.15);
const doneQty = changedQty * (0.18 + (i % 4) * 0.2);
return {
code: `BOQ-${String(1001 + i)}`,
name: ["混凝土浇筑", "钢筋制安", "模板工程", "土方开挖", "路基填筑", "防水层"][i % 6],
unit: ["m³", "t", "㎡", "m³", "m³", "㎡"][i % 6],
decomposeQty: baseQty,
changedQty,
measuredQty,
measureRate: changedQty > 0 ? measuredQty / changedQty : 0,
doneQty,
doneRate: changedQty > 0 ? doneQty / changedQty : 0,
};
});
}
export function getMaterialRows(structureIndex, projectScale = 1) {
return [
{ code: "MAT-01", name: "钢筋", unit: "t", qty: formatNumber((12.3 + structureIndex * 0.6) * projectScale, 1) },
{ code: "MAT-02", name: "水泥", unit: "t", qty: formatNumber((80 + structureIndex * 1.8) * projectScale, 1) },
{ code: "MAT-03", name: "碎石", unit: "t", qty: formatNumber((320 + structureIndex * 2.5) * projectScale, 1) },
];
}
export function getPayRows(period, structureIndex, structureName) {
return Array.from({ length: 16 }, (_, i) => {
const unitPrice = 520 + i * 28;
const prevQty = (8 + i * 1.5 + structureIndex * 0.3) * (i % 3 === 0 ? 0.9 : 1.05);
const curQty = 2.4 + (i % 5) * 0.9 + structureIndex * 0.1;
const curAmt = curQty * unitPrice;
const cumQty = prevQty + curQty;
return {
no: i + 1,
period,
code: `BOQ-${String(3001 + i)}`,
name: `${["混凝土浇筑", "钢筋制安", "模板工程", "土方开挖", "路基填筑", "防水层"][i % 6]}${structureName}`,
unit: ["m³", "t", "㎡", "m³", "m³", "㎡"][i % 6],
unitPrice,
prevQty,
curQty,
curAmt,
cumQty,
};
});
}

View File

@@ -0,0 +1,44 @@
export const structures = [
{ id: "S-001", name: "主桥-0#墩" },
{ id: "S-002", name: "主桥-1#墩" },
{ id: "S-003", name: "主桥-2#墩" },
{ id: "S-004", name: "引桥-桩基" },
{ id: "S-005", name: "引桥-承台" },
{ id: "S-006", name: "引桥-盖梁" },
{ id: "S-007", name: "路基-填筑" },
{ id: "S-008", name: "路面-基层" },
{ id: "S-009", name: "路面-面层" },
];
export const elementTree = [
{
id: "E-G-bridge",
name: "桥梁工程",
children: [
{
id: "E-G-main-bridge",
name: "主桥",
children: ["S-001", "S-002", "S-003"].map((id) => ({
id,
name: structures.find((s) => s.id === id)?.name || id,
})),
},
{
id: "E-G-approach",
name: "引桥",
children: ["S-004", "S-005", "S-006"].map((id) => ({
id,
name: structures.find((s) => s.id === id)?.name || id,
})),
},
],
},
{
id: "E-G-road",
name: "道路工程",
children: ["S-007", "S-008", "S-009"].map((id) => ({
id,
name: structures.find((s) => s.id === id)?.name || id,
})),
},
];

7
src/entry.js Normal file
View File

@@ -0,0 +1,7 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import { pinia } from "./stores";
import "./assets/styles/vars.css";
createApp(App).use(pinia).use(router).mount("#app");

193
src/pages/change/index.vue Normal file
View File

@@ -0,0 +1,193 @@
<template>
<div class="change-page">
<div class="canvas">
<header class="topbar"><div class="topbar-center"><div class="model-title">XXX特大桥主体模型.rvt</div></div></header>
<section class="model-stage">
<ModelPlaceholder />
</section>
<aside class="sidepanel" :class="{ 'is-collapsed': sideCollapsed }">
<header class="sidepanel-header">
<div class="sidepanel-title">变更左侧区</div>
<button class="iconbtn" type="button" @click="sideCollapsed = !sideCollapsed">{{ sideCollapsed ? "" : "" }}</button>
</header>
<div class="sidepanel-body" v-show="!sideCollapsed">
<div class="tree">
<div class="tree-item" :class="{ 'is-active': row.leaf && row.id === selectedStructureId }" :style="{ paddingLeft: `${10 + row.level * 14}px` }" v-for="row in visibleTreeRows" :key="row.id" @click="onTreeRowClick(row)">
<button class="tree-caret" type="button" :class="{ 'is-leaf': row.leaf }" :disabled="row.leaf" @click.stop="toggleTreeExpand(row)">{{ row.leaf ? "" : row.open ? "" : "" }}</button>
<span class="tree-bullet"></span>
<span class="tree-text">{{ row.name }}</span>
</div>
</div>
</div>
</aside>
<section class="bottompanel" :class="{ 'is-collapsed': bottomCollapsed }">
<header class="bottompanel-header">
<div class="tabs">
<button class="tab" :class="{ 'is-on': activeTab === 'detail' }" type="button" @click="activeTab = 'detail'">变更明细</button>
<button class="tab" :class="{ 'is-on': activeTab === 'summary' }" type="button" @click="activeTab = 'summary'">模型汇总未选择</button>
</div>
</header>
<button class="iconbtn bottompanel-toggle" type="button" @click="bottomCollapsed = !bottomCollapsed">{{ bottomCollapsed ? "" : "" }}</button>
<div class="bottompanel-body" v-show="!bottomCollapsed">
<table class="table" v-if="activeTab === 'detail'">
<thead>
<tr>
<th>序号</th>
<th>变更编号</th>
<th>变更类型</th>
<th>清单编号</th>
<th>清单名称</th>
<th>单位</th>
<th>变更前数量</th>
<th>增减数量</th>
<th>变更后数量</th>
<th>影响金额</th>
<th>变更日期</th>
</tr>
</thead>
<tbody>
<tr v-for="r in changeRows" :key="r.no">
<td>{{ r.no }}</td>
<td>{{ r.changeCode }}</td>
<td>{{ r.changeType }}</td>
<td>{{ r.boqCode }}</td>
<td>{{ r.itemName }}</td>
<td>{{ r.unit }}</td>
<td>{{ formatNumber(r.beforeQty, 2) }}</td>
<td>{{ (r.deltaQty >= 0 ? "+" : "") + formatNumber(r.deltaQty, 2) }}</td>
<td>{{ formatNumber(r.afterQty, 2) }}</td>
<td>{{ formatMoney(r.impactAmount) }}</td>
<td>{{ r.changeDate }}</td>
</tr>
</tbody>
</table>
<div class="placeholder" v-else>模型汇总未选择占位</div>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { computed, ref } from "vue";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
const structures = [
{ id: "S-001", name: "主桥-0#墩" }, { id: "S-002", name: "主桥-1#墩" }, { id: "S-003", name: "主桥-2#墩" },
{ id: "S-004", name: "引桥-桩基" }, { id: "S-005", name: "引桥-承台" }, { id: "S-006", name: "引桥-盖梁" },
{ id: "S-007", name: "路基-填筑" }, { id: "S-008", name: "路面-基层" }, { id: "S-009", name: "路面-面层" },
];
const elementTree = [
{ id: "E-G-bridge", name: "桥梁工程", children: [
{ id: "E-G-main-bridge", name: "主桥", children: ["S-001", "S-002", "S-003"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
{ id: "E-G-approach", name: "引桥", children: ["S-004", "S-005", "S-006"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
] },
{ id: "E-G-road", name: "道路工程", children: ["S-007", "S-008", "S-009"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
];
const sideCollapsed = ref(false);
const bottomCollapsed = ref(false);
const selectedStructureId = ref("S-001");
const activeTab = ref("detail");
const expanded = ref(new Set(["E-G-bridge", "E-G-main-bridge", "E-G-approach", "E-G-road"]));
const selectedStructureName = computed(() => structures.find((s) => s.id === selectedStructureId.value)?.name || "");
const changeRows = computed(() =>
Array.from({ length: 16 }, (_, i) => {
const beforeQty = 178.2 + i * 20.79;
const deltaQty = (i % 2 === 0 ? 1 : -1) * (15.84 + i * 3.47);
const afterQty = beforeQty + deltaQty;
return {
no: i + 1,
changeCode: `CHG-${String(101 + i)}`,
changeType: ["设计变更", "现场签证", "工程洽商", "材料替代"][i % 4],
boqCode: `BOQ-${String(7001 + i)}`,
itemName: `${["混凝土浇筑", "钢筋制安", "模板工程", "土方开挖", "路基填筑", "防水层"][i % 6]}${selectedStructureName.value}`,
unit: ["m³", "t", "㎡", "m³", "m³", "㎡"][i % 6],
beforeQty,
deltaQty,
afterQty,
impactAmount: deltaQty * (520 + i * 48),
changeDate: "2026-03-17",
};
})
);
const visibleTreeRows = computed(() => {
const rows = [];
const walk = (node, level) => {
const leaf = !node.children || node.children.length === 0;
const open = expanded.value.has(node.id);
rows.push({ id: node.id, name: node.name, level, leaf, open });
if (!leaf && open) node.children.forEach((c) => walk(c, level + 1));
};
elementTree.forEach((n) => walk(n, 0));
return rows;
});
function toggleTreeExpand(row) { if (!row.leaf) (expanded.value.has(row.id) ? expanded.value.delete(row.id) : expanded.value.add(row.id)); }
function onTreeRowClick(row) { if (row.leaf) selectedStructureId.value = row.id; else toggleTreeExpand(row); }
function formatNumber(value, digits = 0) {
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function formatMoney(value) {
if (value == null || Number.isNaN(value)) return "--";
const abs = Math.abs(value);
const sign = value < 0 ? "-" : "";
if (abs >= 1e8) return `${sign}${(abs / 1e8).toFixed(2)}亿`;
if (abs >= 1e4) return `${sign}${(abs / 1e4).toFixed(2)}`;
return `${sign}${abs.toFixed(2)}`;
}
</script>
<style scoped>
.change-page { min-height: 100vh; font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif; }
.canvas { position: relative; min-height: 100vh; overflow: hidden; border-radius: 24px; border: 1px solid rgba(98,191,206,.34); background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%); box-shadow: inset 0 0 0 8px rgba(214,230,241,.55); }
.topbar { position: absolute; inset: 0 0 auto; height: 120px; z-index: 30; }
.topbar-center { position: absolute; left: 50%; transform: translateX(-50%); top: 16px; }
.model-title { font-size: 20px; letter-spacing: 1px; font-weight: 700; color: rgba(255,128,52,.95); text-shadow: 0 2px 10px rgba(0,0,0,.25); }
.model-stage { position: absolute; inset: 0; }
.sidepanel { position: absolute; left: 16px; top: 175px; width: 320px; bottom: 100px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(260px 140px at 8% 0%, rgba(63,203,191,.16), transparent 62%) padding-box, linear-gradient(180deg, rgba(20,31,37,.92), rgba(15,23,29,.88)) padding-box; box-shadow: 0 22px 60px rgba(0,0,0,.34), 0 0 0 1px rgba(83,214,206,.12) inset; overflow: hidden; z-index: 25; }
.sidepanel.is-collapsed { width: 64px; }
.sidepanel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 10px 10px 12px; border-bottom: 1px solid rgba(83,214,206,.16); background: radial-gradient(360px 100px at 10% 0%, rgba(83,214,206,.24), transparent 68%), linear-gradient(180deg, rgba(28,134,122,.84), rgba(15,60,74,.62)); }
.sidepanel-title { font-size: 14px; font-weight: 900; color: rgba(207,247,242,.96); }
.iconbtn { width: 30px; height: 30px; border-radius: 10px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.42); color: rgba(11,27,58,.86); font-weight: 900; cursor: pointer; }
.sidepanel-body { height: calc(100% - 52px); overflow: auto; padding: 8px; }
.tree { display: grid; gap: 8px; }
.tree-item { display: grid; grid-template-columns: 20px 10px 1fr; align-items: center; gap: 8px; min-height: 40px; border-radius: 14px; border: 1px solid rgba(154,186,198,.22); background: linear-gradient(180deg, rgba(44,58,68,.58), rgba(37,49,58,.54)); cursor: pointer; }
.tree-item:hover { border-color: rgba(83,214,206,.32); background: linear-gradient(180deg, rgba(51,67,78,.66), rgba(43,57,67,.62)); }
.tree-item.is-active { border-color: rgba(132,188,255,.3); background: linear-gradient(180deg, rgba(112,146,198,.34), rgba(95,128,180,.28)); }
.tree-caret { border: 0; background: transparent; color: rgba(203,230,236,.88); cursor: pointer; font-size: 14px; }
.tree-caret.is-leaf { cursor: default; }
.tree-bullet { width: 10px; height: 10px; border-radius: 50%; background: rgba(83,214,206,.85); }
.tree-text { font-size: 13px; font-weight: 800; color: rgba(222,238,244,.9); }
.bottompanel { position: absolute; left: 370px; right: 16px; bottom: 100px; height: 390px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(420px 180px at 14% 0%, rgba(83,214,206,.2), transparent 66%) padding-box, radial-gradient(520px 220px at 82% 0%, rgba(40,156,228,.14), transparent 70%) padding-box, linear-gradient(180deg, rgba(18,33,40,.94), rgba(14,24,31,.9)) padding-box; box-shadow: 0 24px 70px rgba(0,0,0,.36), 0 0 0 1px rgba(83,214,206,.14) inset; overflow: hidden; z-index: 26; }
.bottompanel-header { padding: 12px 52px 12px 12px; border-bottom: 1px solid rgba(83,214,206,.28); background: radial-gradient(380px 120px at 18% 0%, rgba(83,214,206,.28), transparent 70%), linear-gradient(180deg, rgba(25,137,124,.86), rgba(16,66,82,.64)); }
.tabs { display: flex; gap: 10px; }
.tab { border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.08); color: rgba(214,235,241,.9); border-radius: 999px; padding: 8px 16px; cursor: pointer; font-weight: 900; font-size: 12px; }
.tab.is-on { border-color: rgba(83,214,206,.32); background: rgba(83,214,206,.24); }
.bottompanel-toggle { position: absolute; right: 12px; top: 12px; }
.bottompanel.is-collapsed { height: 70px; }
.bottompanel-body { padding: 0 12px 12px; overflow: auto; height: calc(100% - 66px); }
.table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; }
.table th, .table td { text-align: left; padding: 12px 14px; border-bottom: 1px solid rgba(178,206,216,.16); }
.table th { color: rgba(219,240,246,.86); font-weight: 900; background: linear-gradient(90deg, rgba(12,38,52,.92), rgba(16,47,61,.86)); position: sticky; top: 0; z-index: 2; }
.table td { color: rgba(222,238,244,.86); background: linear-gradient(90deg, rgba(37,51,61,.42), rgba(31,45,54,.36)); }
.table tbody tr:hover td { background: linear-gradient(90deg, rgba(69,102,130,.36), rgba(56,86,113,.32)); }
.placeholder { margin: 12px 0 0; border: 1px solid rgba(255,255,255,.1); background: rgba(0,0,0,.14); border-radius: 14px; padding: 12px; color: rgba(222,238,244,.82); font-size: 13px; font-weight: 700; }
@media (max-width: 1300px) {
.sidepanel { width: 280px; }
.bottompanel { left: 330px; }
}
</style>

273
src/pages/debug/index.vue Normal file
View File

@@ -0,0 +1,273 @@
<template>
<div class="debug-page">
<PageCanvas title="调试面板">
<div class="debug-content"
>
<section class="debug-card"
>
<header class="debug-card-head"
>
<div class="debug-card-title">获取 AccessToken</div>
</header>
<div class="debug-form"
>
<div class="debug-field"
>
<label class="debug-label">API 基础地址</label>
<input v-model="form.baseUrl" class="debug-input" placeholder="https://example.com" />
</div>
<div class="debug-field"
>
<label class="debug-label">client_id</label>
<input v-model="form.clientId" class="debug-input" placeholder="client_id" />
</div>
<div class="debug-field"
>
<label class="debug-label">client_secret</label>
<input v-model="form.clientSecret" class="debug-input" placeholder="client_secret" />
</div>
<div class="debug-field"
>
<label class="debug-label">grant_type</label>
<input v-model="form.grantType" class="debug-input" placeholder="client_credentials" />
</div>
<div class="debug-actions"
>
<button class="debug-btn" type="button" :disabled="loading" @click="fetchToken"
>{{ loading ? "请求中…" : "获取 Token" }}</button>
</div>
</div>
<div v-if="resultText" class="debug-result"
>
<div class="debug-result-head"
>
<div class="debug-result-title">请求结果</div>
<button class="debug-btn debug-btn-sm" type="button" @click="copyResult"
>复制</button>
</div>
<pre class="debug-pre">{{ resultText }}</pre>
</div>
</section>
</div>
</PageCanvas>
</div>
</template>
<script setup>
import { reactive, ref } from "vue";
import PageCanvas from "../../components/layout/PageCanvas.vue";
const STORAGE_KEY = "bim.debug.tokenForm";
function loadForm() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {
// ignore
}
return null;
}
function saveForm(data) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch {
// ignore
}
}
const saved = loadForm();
const defaultBaseUrl = String(import.meta.env.VITE_API_BASE_URL || "").trim();
const form = reactive({
baseUrl: saved?.baseUrl || defaultBaseUrl || "",
clientId: saved?.clientId || "",
clientSecret: saved?.clientSecret || "",
grantType: saved?.grantType || "client_credentials",
});
const loading = ref(false);
const resultText = ref("");
async function fetchToken() {
if (!form.baseUrl) {
resultText.value = "请先填写 API 基础地址";
return;
}
saveForm({ baseUrl: form.baseUrl, clientId: form.clientId, clientSecret: form.clientSecret, grantType: form.grantType });
loading.value = true;
resultText.value = "";
try {
const url = new URL("/oauth2/token", form.baseUrl);
if (form.clientId) url.searchParams.set("client_id", form.clientId);
if (form.clientSecret) url.searchParams.set("client_secret", form.clientSecret);
if (form.grantType) url.searchParams.set("grant_type", form.grantType);
const res = await fetch(url.toString(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const data = await res.json().catch(() => ({}));
resultText.value = JSON.stringify({ status: res.status, statusText: res.statusText, data }, null, 2);
} catch (err) {
resultText.value = `请求失败:${err instanceof Error ? err.message : String(err)}`;
} finally {
loading.value = false;
}
}
function copyResult() {
navigator.clipboard?.writeText?.(resultText.value);
}
</script>
<style scoped>
.debug-page {
min-height: 100vh;
font-family: var(--bim-font-cn);
}
.debug-content {
position: absolute;
inset: 120px 24px 24px;
overflow: auto;
}
.debug-card {
max-width: 720px;
margin: 0 auto;
border-radius: 18px;
border: 1px solid rgba(83, 214, 206, 0.24);
background:
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box;
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.34), 0 0 0 1px rgba(83, 214, 206, 0.12) inset;
overflow: hidden;
}
.debug-card-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background:
radial-gradient(520px 120px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 70%),
linear-gradient(180deg, rgba(18, 26, 31, 0.96), rgba(14, 20, 25, 0.86));
}
.debug-card-title {
font-size: 18px;
font-weight: 950;
letter-spacing: 0.4px;
color: rgba(255, 255, 255, 0.92);
}
.debug-form {
padding: 20px;
display: grid;
gap: 14px;
}
.debug-field {
display: grid;
gap: 8px;
}
.debug-label {
font-size: 13px;
font-weight: 900;
color: rgba(255, 255, 255, 0.72);
}
.debug-input {
width: 100%;
height: 48px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
padding: 0 12px;
font-size: 15px;
font-weight: 700;
color: rgba(255, 255, 255, 0.9);
}
.debug-input::placeholder {
color: rgba(255, 255, 255, 0.35);
}
.debug-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 6px;
}
.debug-btn {
appearance: none;
border: 1px solid rgba(83, 214, 206, 0.35);
background: rgba(83, 214, 206, 0.14);
color: rgba(255, 255, 255, 0.92);
border-radius: 12px;
padding: 10px 18px;
font-size: 14px;
font-weight: 900;
cursor: pointer;
}
.debug-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.debug-btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.debug-result {
margin: 0 20px 20px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.22);
overflow: hidden;
}
.debug-result-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.debug-result-title {
font-size: 13px;
font-weight: 900;
color: rgba(255, 255, 255, 0.8);
}
.debug-pre {
margin: 0;
padding: 12px;
font-size: 13px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.86);
white-space: pre-wrap;
word-break: break-word;
max-height: 360px;
overflow: auto;
}
</style>

803
src/pages/home/index.vue Normal file
View File

@@ -0,0 +1,803 @@
<template>
<div class="home-page">
<div class="canvas" :class="{ 'is-left-closed': !leftOpen, 'is-right-closed': !rightOpen }">
<header class="topbar">
<div class="topbar-left"></div>
<div class="topbar-center">
<div class="model-title">XXX特大桥主体模型.rvt</div>
</div>
<div class="topbar-right">
<div class="status-chip">
<div class="status-chip-track">
<span class="countdown-label">距竣工</span>
</div>
<div class="countdown-value">
<span class="countdown-days">{{ countdownDays }}</span>
<span class="countdown-days-unit"></span>
<span class="countdown-time">{{ countdownTime }}</span>
</div>
</div>
</div>
</header>
<section class="model-stage">
<div class="model-shell"></div>
<ModelPlaceholder />
</section>
<button class="cards-toggle cards-toggle-left" type="button" aria-label="展开/收起左侧卡片" @click="leftOpen = !leftOpen">{{ leftOpen ? "" : "" }}</button>
<button class="cards-toggle cards-toggle-right" type="button" aria-label="展开/收起右侧卡片" @click="rightOpen = !rightOpen">{{ rightOpen ? "" : "" }}</button>
<aside class="cards cards-left" v-show="leftOpen">
<section class="card">
<header class="card-header"><div class="card-title">合同与利润</div></header>
<div class="card-body">
<div class="metrics">
<div class="metric"><div class="metric-icon" data-icon="percent"></div><div><div class="metric-value">{{ formatPercent(mock.budgetProfitRate) }}</div><div class="metric-label">施工预算利润率</div></div></div>
<div class="metric"><div class="metric-icon" data-icon="currency"></div><div><div class="metric-value">{{ formatMoney(mock.currentContractAmount) }}</div><div class="metric-label">当前合同额</div></div></div>
<div class="metric"><div class="metric-icon" data-icon="currency"></div><div><div class="metric-value">{{ formatMoney(mock.bidContractAmount) }}</div><div class="metric-label">中标合同额</div></div></div>
<div class="metric"><div class="metric-icon" data-icon="percent"></div><div><div class="metric-value">{{ formatPercent(mock.postBidBudgetProfitRate) }}</div><div class="metric-label">标后预算利润率</div></div></div>
</div>
</div>
</section>
<section class="card">
<header class="card-header"><div class="card-title">进度管理</div><div class="seg"><button class="segbtn" :class="{ 'is-on': progressValueGrain === 'month' }" @click="progressValueGrain = 'month'"></button><button class="segbtn" :class="{ 'is-on': progressValueGrain === 'quarter' }" @click="progressValueGrain = 'quarter'"></button><button class="segbtn" :class="{ 'is-on': progressValueGrain === 'year' }" @click="progressValueGrain = 'year'"></button></div></header>
<div class="card-body">
<div class="metrics">
<div class="metric"><div class="metric-icon" data-icon="calendar"></div><div><div class="metric-value">{{ formatMoney(progressValueData.planValue) }}</div><div class="metric-label">计划产值</div></div></div>
<div class="metric"><div class="metric-icon" data-icon="check"></div><div><div class="metric-value">{{ formatMoney(progressValueData.actualValue) }}</div><div class="metric-label">实际产值</div></div></div>
</div>
<div class="progressline">
<div class="progressline-top"><div class="progressline-label">产值完成率</div><div class="progressline-value">{{ formatPercent(valueRate) }}</div></div>
<div class="bar"><div class="bar-fill" :style="{ width: `${Math.max(0, Math.min(1, valueRate)) * 100}%` }"></div></div>
</div>
</div>
</section>
<section class="card">
<header class="card-header"><div class="card-title">支出合同管理</div></header>
<div class="card-body">
<div class="expense-metrics">
<div class="metric-row"><div class="metric-icon" data-icon="percent"></div><div><div class="metric-value">{{ formatPercent(mock.settlementRate) }}</div><div class="metric-label">结算比率</div></div></div>
<div class="metric-row"><div class="metric-icon" data-icon="currency"></div><div><div class="metric-value">{{ formatMoney(mock.supplementAgreementAmount) }}</div><div class="metric-label">补充协议金额</div></div></div>
</div>
<div class="expense-breakdown">
<div class="ring">
<svg viewBox="0 0 120 120" class="ring-svg"><circle class="ring-bg" cx="60" cy="60" r="46"></circle><g class="ring-rot"><circle v-for="seg in ringSegments" :key="seg.name" class="ring-seg" cx="60" cy="60" r="46" :style="seg.style"></circle></g></svg>
<div class="ring-center"><div class="ring-center-title">费用构成</div><div class="ring-center-sub">占比</div></div>
</div>
<div class="ring-legend"><div class="ring-item" v-for="row in expenseBreakdown" :key="row.name"><span class="ring-swatch" :style="{ background: row.color }"></span><span>{{ row.name }}</span><span>{{ row.pct }}%</span></div></div>
</div>
</div>
</section>
</aside>
<aside class="cards cards-right" v-show="rightOpen">
<section class="card">
<header class="card-header"><div class="card-title">营收产值</div></header>
<div class="card-body">
<div class="metrics">
<div class="metric"><div class="metric-icon" data-icon="currency"></div><div><div class="metric-value">{{ formatMoney(mock.changeClaimAmount) }}</div><div class="metric-label">变更索赔额</div></div></div>
<div class="metric"><div class="metric-icon" data-icon="percent"></div><div><div class="metric-value">{{ formatPercent(mock.changeApprovalRate) }}</div><div class="metric-label">变更批复率</div></div></div>
<div class="metric"><div class="metric-icon" data-icon="delta"></div><div><div class="metric-value">{{ formatPercent(mock.revenueValueRatioDiffRate) }}</div><div class="metric-label">营收产值比差异率</div></div></div>
<div class="metric"><div class="metric-icon" data-icon="percent"></div><div><div class="metric-value">{{ formatPercent(mock.revenueValueRatio) }}</div><div class="metric-label">营收产值比</div></div></div>
<div class="metric"><div class="metric-icon" data-icon="target"></div><div><div class="metric-value">{{ formatPercent(mock.dataCompleteness) }}</div><div class="metric-label">数据完整性</div></div></div>
<div class="metric"><div class="metric-icon" data-icon="percent"></div><div><div class="metric-value">{{ formatPercent(mock.profitDiffRate) }}</div><div class="metric-label">财务利润差异率</div></div></div>
</div>
</div>
</section>
<section class="card">
<header class="card-header"><div class="card-title">进度管理</div><div class="seg"><button class="segbtn" :class="{ 'is-on': progressTimeGrain === 'month' }" @click="progressTimeGrain = 'month'"></button><button class="segbtn" :class="{ 'is-on': progressTimeGrain === 'quarter' }" @click="progressTimeGrain = 'quarter'"></button><button class="segbtn" :class="{ 'is-on': progressTimeGrain === 'year' }" @click="progressTimeGrain = 'year'"></button></div></header>
<div class="card-body">
<div class="metrics">
<div class="metric"><div class="metric-icon" data-icon="sum"></div><div><div class="metric-value">{{ formatMoney(progressTimeData.cumValue) }}</div><div class="metric-label">开累产值</div></div></div>
<div class="metric"><div class="metric-icon" data-icon="time"></div><div><div class="metric-value">{{ progressTimeData.elapsedDays.toLocaleString("zh-CN") }}</div><div class="metric-label">已消耗工期</div></div></div>
</div>
<div class="progressline">
<div class="progressline-top"><div class="progressline-label">产值完成率</div><div class="progressline-value">{{ formatPercent(cumRate) }}</div></div>
<div class="bar"><div class="bar-fill" :style="{ width: `${Math.max(0, Math.min(1, cumRate)) * 100}%` }"></div></div>
</div>
</div>
</section>
<section class="card">
<header class="card-header"><div class="card-title">物资管理</div><div class="seg"><button class="segbtn" :class="{ 'is-on': materialsGrain === 'month' }" @click="materialsGrain = 'month'"></button><button class="segbtn" :class="{ 'is-on': materialsGrain === 'quarter' }" @click="materialsGrain = 'quarter'"></button><button class="segbtn" :class="{ 'is-on': materialsGrain === 'year' }" @click="materialsGrain = 'year'"></button></div></header>
<div class="card-body">
<div class="barchart">
<div class="barrow" v-for="row in materialsRows" :key="row.key" :data-kind="row.key"><div class="barname">{{ row.name }}</div><div class="bartrack"><div class="barval" :style="{ width: `${(row.value / materialsMax) * 100}%` }"></div></div><div class="barlabel">{{ row.value.toLocaleString("zh-CN") }}</div></div>
</div>
</div>
</section>
</aside>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
const mock = {
finishDate: "2027-12-30",
budgetProfitRate: 0.153,
currentContractAmount: 162500000,
bidContractAmount: 148800000,
postBidBudgetProfitRate: 0.128,
planValue: 6380000,
actualValue: 5710000,
cumValue: 98200000,
elapsedDays: 476,
settlementRate: 0.62,
supplementAgreementAmount: 3600000,
changeClaimAmount: 2850000,
changeApprovalRate: 0.74,
revenueValueRatioDiffRate: -0.013,
revenueValueRatio: 0.986,
dataCompleteness: 0.912,
profitDiffRate: 0.027,
materials: { received: 2350, issued: 2112, consumed: 1843, settled: 1520, stock: 538 },
};
const expenseBreakdown = [
{ name: "设备租赁", pct: 0.2, color: "#6e7d96" },
{ name: "物资购销", pct: 44.3, color: "#156cff" },
{ name: "施工协作分包", pct: 43.3, color: "#2cc8ff" },
{ name: "拌合", pct: 1.5, color: "#2f8f2f" },
{ name: "临设分包", pct: 0.6, color: "#d48806" },
{ name: "其他", pct: 9.9, color: "#6b5cff" },
];
const countdownDays = ref("--");
const countdownTime = ref("--:--");
const leftOpen = ref(true);
const rightOpen = ref(true);
const progressValueGrain = ref("month");
const progressTimeGrain = ref("month");
const materialsGrain = ref("month");
let timer = null;
const progressValueMap = {
month: { planValue: 6380000, actualValue: 5710000 },
quarter: { planValue: 18900000, actualValue: 17260000 },
year: { planValue: 72400000, actualValue: 67800000 },
};
const progressTimeMap = {
month: { cumValue: 98200000, elapsedDays: 476 },
quarter: { cumValue: 101600000, elapsedDays: 492 },
year: { cumValue: 114300000, elapsedDays: 538 },
};
const materialsMap = {
month: { received: 2350, issued: 2112, consumed: 1843, settled: 1520, stock: 538 },
quarter: { received: 7190, issued: 6810, consumed: 5995, settled: 5240, stock: 1084 },
year: { received: 29100, issued: 27540, consumed: 24120, settled: 20800, stock: 3840 },
};
const progressValueData = computed(() => progressValueMap[progressValueGrain.value]);
const progressTimeData = computed(() => progressTimeMap[progressTimeGrain.value]);
const materialsData = computed(() => materialsMap[materialsGrain.value]);
const valueRate = computed(() => (progressValueData.value.planValue > 0 ? progressValueData.value.actualValue / progressValueData.value.planValue : 0));
const cumRate = computed(() => (mock.currentContractAmount > 0 ? progressTimeData.value.cumValue / mock.currentContractAmount : 0));
const materialsRows = computed(() => [
{ key: "received", name: "收料", value: materialsData.value.received },
{ key: "issued", name: "领料", value: materialsData.value.issued },
{ key: "consumed", name: "消耗", value: materialsData.value.consumed },
{ key: "settled", name: "结算", value: materialsData.value.settled },
{ key: "stock", name: "库存", value: materialsData.value.stock },
]);
const materialsMax = computed(() => Math.max(...materialsRows.value.map((row) => row.value), 1));
const ringSegments = computed(() => {
const r = 46;
const C = 2 * Math.PI * r;
const total = expenseBreakdown.reduce((sum, row) => sum + row.pct, 0);
let offset = 0;
return expenseBreakdown.map((row) => {
const ratio = row.pct / total;
const len = C * ratio;
const seg = {
name: row.name,
style: {
stroke: row.color,
strokeDasharray: `${len.toFixed(1)} ${(C - len).toFixed(1)}`,
strokeDashoffset: `${(-offset).toFixed(1)}`,
},
};
offset += len;
return seg;
});
});
function clamp(n, min, max) {
return Math.min(max, Math.max(min, n));
}
function formatMoney(value) {
const abs = Math.abs(value);
if (abs >= 100000000) return `${(value / 100000000).toFixed(2)}亿`;
if (abs >= 10000) return `${(value / 10000).toFixed(2)}`;
return `${value.toFixed(2)}`;
}
function formatPercent(value) {
return `${(value * 100).toFixed(1)}%`;
}
function updateCountdown() {
const end = new Date(`${mock.finishDate}T00:00:00`);
const now = new Date();
const diffMs = Math.max(0, end.getTime() - now.getTime());
const totalMinutes = Math.floor(diffMs / 60000);
const days = Math.floor(totalMinutes / (24 * 60));
const hours = Math.floor((totalMinutes - days * 24 * 60) / 60);
const minutes = totalMinutes % 60;
countdownDays.value = String(days);
countdownTime.value = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
}
onMounted(() => {
updateCountdown();
timer = setInterval(updateCountdown, 30000);
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
</script>
<style scoped>
.home-page {
--text: rgba(245, 252, 255, 0.92);
--muted: rgba(223, 241, 246, 0.72);
--panel: rgba(18, 29, 35, 0.9);
--panel-soft: rgba(16, 25, 31, 0.8);
--accent: #08c7bc;
--accent2: #20e2d5;
--line: rgba(42, 190, 182, 0.34);
--line-soft: rgba(42, 190, 182, 0.2);
--bar-track: rgba(255, 255, 255, 0.12);
--bar-fill: linear-gradient(90deg, #0eb7ff, #1ce0c5);
--font-cn: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
--font-num: "DIN Alternate", "Bahnschrift", "Segoe UI", "Arial Narrow", sans-serif;
font-family: var(--font-cn);
}
* {
box-sizing: border-box;
}
.canvas {
position: relative;
width: 100%;
min-height: 100vh;
overflow: hidden;
border-radius: 24px;
border: 1px solid rgba(98, 191, 206, 0.34);
background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%);
box-shadow: inset 0 0 0 8px rgba(214, 230, 241, 0.55);
}
.topbar {
position: absolute;
inset: 0 0 auto;
height: 92px;
padding: 18px 20px 0;
z-index: 30;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: flex-start;
}
.topbar-left,
.topbar-center,
.topbar-right {
display: flex;
align-items: flex-start;
}
.topbar-left {
gap: 10px;
}
.topbar-center {
justify-content: center;
padding-top: 8px;
}
.topbar-right {
justify-content: flex-end;
padding-right: 6px;
margin-right: 100px;
}
.model-title {
font-size: 26px;
letter-spacing: 1px;
font-weight: 700;
color: rgba(255, 128, 52, 0.95);
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
}
.status-chip {
position: relative;
min-width: 220px;
display: grid;
gap: 4px;
padding: 9px 14px 10px;
border-radius: 16px;
border: 1px solid rgba(103, 204, 216, 0.32);
background: linear-gradient(180deg, rgba(16, 45, 57, 0.9), rgba(14, 31, 42, 0.86));
box-shadow: 0 18px 36px rgba(4, 20, 31, 0.32);
overflow: hidden;
}
.status-chip-track {
height: 18px;
border-radius: 10px;
margin-bottom:10px;
}
.countdown-label {
font-size: 12px;
color: rgba(220, 238, 244, 0.78);
font-weight: 800;
line-height: 1;
}
.countdown-value {
display: inline-flex;
align-items: baseline;
gap: 5px;
font-weight: 900;
font-family: var(--font-num);
padding-left: 2px;
}
.countdown-days {
font-size: 22px;
color: #f2fbff;
line-height: 1;
}
.countdown-days-unit {
font-size: 12px;
color: rgba(203, 229, 236, 0.85);
}
.countdown-time {
font-size: 18px;
color: var(--accent2);
line-height: 1;
text-shadow: 0 0 18px rgba(32, 226, 213, 0.26);
}
.model-stage {
position: absolute;
inset: 0;
}
.model-shell {
position: absolute;
inset: 0;
background: radial-gradient(900px 600px at 50% 50%, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0));
}
.cards-toggle {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 21;
width: 38px;
height: 38px;
border-radius: 12px;
border: 1px solid rgba(97, 203, 213, 0.3);
background: linear-gradient(180deg, rgba(17, 45, 56, 0.95), rgba(13, 29, 39, 0.9));
color: rgba(202, 236, 244, 0.92);
cursor: pointer;
}
.cards-toggle-left {
left: 360px;
}
.cards-toggle-right {
right: 360px;
}
.canvas.is-left-closed .cards-toggle-left {
left: 24px;
}
.canvas.is-right-closed .cards-toggle-right {
right: 24px;
}
.cards {
position: absolute;
top: 110px;
width: 320px;
z-index: 20;
display: flex;
flex-direction: column;
gap: 12px;
}
.cards-left {
left: 24px;
}
.cards-right {
right: 24px;
}
.card {
border-radius: 14px;
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(13, 30, 39, 0.95), rgba(28, 43, 52, 0.9));
box-shadow: 0 16px 30px rgba(6, 16, 24, 0.35), inset 0 0 0 1px rgba(26, 121, 121, 0.16);
overflow: hidden;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid var(--line-soft);
background: linear-gradient(180deg, rgba(4, 128, 123, 0.86), rgba(5, 93, 98, 0.8));
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #d4f7ff;
letter-spacing: 0.5px;
font-family: var(--font-cn);
}
.card-body {
padding: 10px 12px 10px;
}
.seg {
display: inline-flex;
border-radius: 999px;
padding: 3px;
background: rgba(0, 44, 54, 0.8);
border: 1px solid rgba(98, 200, 211, 0.2);
}
.segbtn {
border: 0;
background: transparent;
color: rgba(210, 236, 241, 0.8);
width: 28px;
height: 28px;
padding: 0;
border-radius: 999px;
font-weight: 600;
font-size: 12px;
font-family: var(--font-cn);
}
.segbtn.is-on {
background: rgba(0, 170, 232, 0.86);
color: #d8f8ff;
box-shadow: 0 0 0 1px rgba(65, 233, 229, 0.3) inset;
}
.metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.metric,
.metric-row {
display: flex;
gap: 10px;
align-items: center;
padding: 6px;
border-radius: 14px;
border: 1px solid rgba(76, 181, 194, 0.18);
background: linear-gradient(180deg, rgba(31, 46, 55, 0.86), rgba(24, 38, 47, 0.86));
}
.metric-row {
margin-top: 10px;
}
.metric-icon {
width: 30px;
height: 30px;
border-radius: 999px;
display: grid;
place-items: center;
border: 1px solid rgba(19, 201, 194, 0.55);
background: rgba(4, 70, 81, 0.8);
color: #00d5d0;
flex: 0 0 auto;
}
.metric-icon[data-icon] {
font-size: 0;
}
.metric-icon[data-icon]::before {
content: "";
width: 16px;
height: 16px;
background: currentColor;
-webkit-mask: var(--metric-icon-mask) no-repeat center / contain;
mask: var(--metric-icon-mask) no-repeat center / contain;
}
.metric-icon[data-icon="percent"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Ccircle%20cx='7'%20cy='7'%20r='2.2'%20fill='black'/%3E%3Ccircle%20cx='17'%20cy='17'%20r='2.2'%20fill='black'/%3E%3Cpath%20d='M7%2017L17%207'%20stroke='black'%20stroke-width='2.4'%20stroke-linecap='round'/%3E%3C/svg%3E"); }
.metric-icon[data-icon="currency"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20fill='black'%20d='M8%203l4%206%204-6h2l-5%207h4v2h-5v2h5v2h-5v5h-2v-5H7v-2h5v-2H7v-2h4L6%203h2z'/%3E%3C/svg%3E"); }
.metric-icon[data-icon="calendar"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20fill='black'%20d='M7%202h2v2h6V2h2v2h2a2%202%200%200%201%202%202v14a3%203%200%200%201-3%203H6a3%203%200%200%201-3-3V6a2%202%200%200%201%202-2h2V2zm14%208H5v10a1%201%200%200%200%201%201h14a1%201%200%200%200%201-1V10zM6%206a1%201%200%200%200-1%201v1h16V7a1%201%200%200%200-1-1H6z'/%3E%3C/svg%3E"); }
.metric-icon[data-icon="check"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20d='M20%206L9%2017l-5-5'%20fill='none'%20stroke='black'%20stroke-width='2.8'%20stroke-linecap='round'%20stroke-linejoin='round'/%3E%3C/svg%3E"); }
.metric-icon[data-icon="delta"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20fill='black'%20d='M12%204l8%2016H4l8-16z'/%3E%3C/svg%3E"); }
.metric-icon[data-icon="target"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20fill='black'%20fill-rule='evenodd'%20d='M12%204a8%208%200%201%200%200%2016a8%208%200%200%200%200-16zm0%205a3%203%200%201%200%200%206a3%203%200%200%200%200-6z'/%3E%3C/svg%3E"); }
.metric-icon[data-icon="sum"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20d='M18%205H8l5%207-5%207h10'%20fill='none'%20stroke='black'%20stroke-width='2.6'%20stroke-linecap='round'%20stroke-linejoin='round'/%3E%3C/svg%3E"); }
.metric-icon[data-icon="time"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20d='M10%202h4v2h-4V2zm2%205a7%207%200%201%200%200%2014a7%207%200%200%200%200-14zm0%203v4l3%202'%20fill='none'%20stroke='black'%20stroke-width='2.2'%20stroke-linecap='round'%20stroke-linejoin='round'/%3E%3C/svg%3E"); }
.metric-value {
font-weight: 700;
letter-spacing: 0.2px;
color: var(--text);
font-size: 16px;
line-height: 1.05;
font-family: var(--font-num);
}
.metric-label {
font-size: 11px;
color: var(--muted);
margin-top: 4px;
font-family: var(--font-cn);
}
.progressline {
margin-top: 12px;
padding: 10px;
border-radius: 14px;
border: 1px solid rgba(84, 197, 208, 0.2);
background: linear-gradient(180deg, rgba(27, 39, 47, 0.86), rgba(22, 34, 43, 0.86));
}
.progressline-top {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 10px;
margin-bottom: 8px;
}
.progressline-label {
font-size: 11px;
color: var(--muted);
font-family: var(--font-cn);
}
.progressline-value {
font-weight: 700;
color: var(--text);
font-size: 16px;
font-family: var(--font-num);
}
.bar {
height: 12px;
border-radius: 999px;
background: var(--bar-track);
overflow: hidden;
border: 1px solid rgba(104, 209, 220, 0.16);
}
.bar-fill,
.barval {
height: 100%;
width: 0%;
background: var(--bar-fill);
box-shadow: 0 0 18px rgba(27, 229, 206, 0.2);
}
.expense-metrics {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
margin-bottom: 12px;
}
.expense-breakdown {
display: grid;
grid-template-columns: 140px 1fr;
gap: 12px;
align-items: center;
}
.ring {
display: grid;
place-items: center;
position: relative;
}
.ring-svg {
width: 140px;
height: 140px;
}
.ring-bg {
fill: none;
stroke: rgba(108, 209, 219, 0.18);
stroke-width: 12;
}
.ring-rot {
transform: rotate(-90deg);
transform-origin: 60px 60px;
}
.ring-seg {
fill: none;
stroke-width: 12;
stroke-linecap: round;
stroke-dasharray: 0 999;
filter: drop-shadow(0 0 8px rgba(21, 108, 255, 0.2));
}
.ring-center {
position: absolute;
text-align: center;
}
.ring-center-title {
font-weight: 700;
letter-spacing: 0.4px;
font-size: 12px;
color: var(--text);
font-family: var(--font-cn);
}
.ring-center-sub {
margin-top: 2px;
font-size: 11px;
color: var(--muted);
font-family: var(--font-cn);
}
.ring-legend {
display: grid;
gap: 8px;
}
.ring-item {
display: grid;
grid-template-columns: 10px 1fr auto;
gap: 8px;
align-items: center;
font-size: 9px;
color: rgba(213, 237, 243, 0.86);
font-family: var(--font-cn);
}
.ring-swatch {
width: 10px;
height: 10px;
border-radius: 3px;
border: 1px solid rgba(238, 250, 252, 0.16);
}
.barchart {
display: grid;
gap: 10px;
}
.barrow {
display: grid;
grid-template-columns: 64px 1fr auto;
align-items: center;
gap: 10px;
}
.barname {
font-size: 11px;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-cn);
}
.barname::before {
content: "";
width: 8px;
height: 8px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: #1a87ff;
}
.bartrack {
height: 10px;
border-radius: 999px;
background: var(--bar-track);
border: 1px solid rgba(113, 211, 221, 0.15);
overflow: hidden;
}
.barlabel {
font-weight: 700;
color: var(--text);
font-size: 10px;
font-family: var(--font-num);
}
.barrow[data-kind="received"] .barname::before,
.barrow[data-kind="received"] .barval {
background: linear-gradient(90deg, #1d87ff, #2280ea);
}
.barrow[data-kind="issued"] .barname::before,
.barrow[data-kind="issued"] .barval {
background: linear-gradient(90deg, #0bb8ff, #17a9ea);
}
.barrow[data-kind="consumed"] .barname::before,
.barrow[data-kind="consumed"] .barval {
background: linear-gradient(90deg, #15b964, #1ca56c);
}
.barrow[data-kind="settled"] .barname::before,
.barrow[data-kind="settled"] .barval {
background: linear-gradient(90deg, #d88b06, #c67806);
}
.barrow[data-kind="stock"] .barname::before,
.barrow[data-kind="stock"] .barval {
background: linear-gradient(90deg, #6f7e98, #5f708a);
}
@media (max-width: 1300px) {
.cards {
position: static;
width: auto;
}
.cards-toggle {
display: none;
}
.canvas {
overflow-y: auto;
padding: 16px;
border-radius: 0;
}
.topbar {
position: static;
height: auto;
padding: 0 0 14px;
}
.model-stage {
position: relative;
height: 460px;
border-radius: 18px;
overflow: hidden;
}
.cards-left,
.cards-right {
left: auto;
right: auto;
margin-top: 16px;
}
}
</style>

View File

@@ -0,0 +1,362 @@
<template>
<div class="inspection-page">
<div class="canvas">
<header class="topbar"><div class="topbar-center"><div class="model-title">XXX特大桥主体模型.rvt</div></div></header>
<section class="model-stage">
<ModelPlaceholder />
</section>
<aside class="sidepanel inspect-panel-left" :class="{ 'is-collapsed': leftCollapsed }">
<header class="sidepanel-header">
<div class="sidepanel-title">智能巡检分析</div>
<button class="iconbtn" type="button" @click="leftCollapsed = !leftCollapsed">{{ leftCollapsed ? "" : "" }}</button>
</header>
<div class="sidepanel-body" v-show="!leftCollapsed">
<section class="inspect-upload-card">
<div class="inspect-upload-head">
<div class="inspect-upload-head-title">全景照片上传</div>
</div>
<button class="inspect-drop" type="button" @click="panoramaInputRef?.click()">
<div class="inspect-drop-title">上传全景照片</div>
<div class="inspect-drop-sub">AI 将对全景照片进行整体场景分析</div>
</button>
<input ref="panoramaInputRef" type="file" accept="image/*" hidden @change="onPanoramaPick" />
<div class="inspect-file-row" v-show="panoramaFileName">
<div class="inspect-file-name">{{ panoramaFileName }}</div>
<button class="inspect-file-remove" type="button" @click="panoramaFileName = ''">×</button>
</div>
</section>
<section class="inspect-upload-card is-media">
<div class="inspect-upload-head">
<div class="inspect-upload-head-title">照片/视频上传</div>
</div>
<button class="inspect-drop" type="button" @click="mediaInputRef?.click()">
<div class="inspect-drop-title">点击上传照片或视频</div>
<div class="inspect-drop-sub">支持 JPGPNGMP4 格式</div>
</button>
<input ref="mediaInputRef" type="file" accept="image/*,video/*" multiple hidden @change="onMediaPick" />
<div class="inspect-file-list" v-show="mediaFiles.length > 0">
<div class="inspect-file-row" v-for="f in mediaFiles" :key="f.name + f.size">
<div class="inspect-file-name">{{ f.name }}</div>
<button class="inspect-file-remove" type="button" @click="removeMediaFile(f)">×</button>
</div>
</div>
<button class="btn btn-accent inspect-analyze-btn" type="button" :disabled="mediaFiles.length === 0" @click="analyzeMedia">开始照片/视频 AI 分析</button>
</section>
<section class="inspect-block">
<div class="inspect-block-title">风险分级统计</div>
<div class="inspect-risk-grid">
<div class="inspect-risk-card is-high"><div class="inspect-risk-k">高风险</div><div class="inspect-risk-v">{{ riskCount.high }}</div></div>
<div class="inspect-risk-card is-mid"><div class="inspect-risk-k">中风险</div><div class="inspect-risk-v">{{ riskCount.mid }}</div></div>
<div class="inspect-risk-card is-low"><div class="inspect-risk-k">低风险</div><div class="inspect-risk-v">{{ riskCount.low }}</div></div>
</div>
</section>
</div>
</aside>
<aside class="sidepanel inspect-panel-right">
<header class="sidepanel-header">
<div class="sidepanel-title inspect-list-title">巡检问题清单</div>
<div class="inspect-list-badge">{{ issues.length }} 个问题</div>
</header>
<div class="sidepanel-body inspect-list-body">
<template v-for="group in groupedIssues" :key="group.key">
<div class="inspect-group-title" :class="`is-${group.key}`">{{ group.label }}</div>
<article class="inspect-item" :class="`is-${it.severity}`" v-for="it in group.items" :key="it.id" @click="openDetail(it)">
<div class="inspect-item-top">
<span class="inspect-pill" :class="`is-${it.severity}`">{{ severityLabel(it.severity) }}</span>
<span class="inspect-level">{{ it.levelText }}</span>
</div>
<div class="inspect-item-title">{{ it.title }}</div>
<div class="inspect-item-desc">{{ it.desc }}</div>
<div class="inspect-item-meta">
<div class="inspect-item-loc">{{ it.location }}</div>
<div class="inspect-item-status" :class="`is-${it.status}`">{{ statusLabel(it.status) }}</div>
</div>
</article>
</template>
</div>
</aside>
<div class="inspect-detail-overlay" v-show="detailOpen" @click.self="closeDetail">
<section class="inspect-detail-modal">
<header class="inspect-detail-header">
<div class="inspect-detail-chips">
<span class="inspect-pill" :class="`is-${activeIssue?.severity || 'high'}`">{{ severityLabel(activeIssue?.severity) }}</span>
<span class="inspect-detail-status-chip">{{ statusLabel(activeIssue?.status) }}</span>
</div>
<button class="inspect-detail-close" type="button" @click="closeDetail">×</button>
</header>
<div class="inspect-detail-body" v-if="activeIssue">
<div class="inspect-detail-title">{{ activeIssue.title }}</div>
<section class="inspect-detail-card is-ai">
<div class="inspect-detail-card-title">AI 分析结果</div>
<div class="inspect-detail-card-text">{{ activeIssue.aiText }}</div>
</section>
<div class="inspect-detail-grid">
<section class="inspect-detail-mini"><div class="inspect-detail-mini-label">关联构件</div><div class="inspect-detail-mini-value">{{ activeIssue.component }}</div></section>
<section class="inspect-detail-mini"><div class="inspect-detail-mini-label">位置信息</div><div class="inspect-detail-mini-value">{{ activeIssue.location }}</div></section>
</div>
<section class="inspect-detail-task" v-show="activeIssue.owner || activeIssue.due">
<div class="inspect-detail-task-title">整改任务信息</div>
<div class="inspect-detail-task-grid">
<div class="inspect-detail-task-item"><div class="inspect-detail-task-k">责任人</div><div class="inspect-detail-task-v">{{ activeIssue.owner || "--" }}</div></div>
<div class="inspect-detail-task-item"><div class="inspect-detail-task-k">完成期限</div><div class="inspect-detail-task-v">{{ activeIssue.due || "--" }}</div></div>
<div class="inspect-detail-task-item"><div class="inspect-detail-task-k">当前状态</div><div class="inspect-detail-task-v">{{ statusLabel(activeIssue.status) }}</div></div>
</div>
</section>
</div>
<footer class="inspect-detail-footer">
<button class="btn inspect-detail-btn" type="button" @click="openAssign" v-show="activeIssue && activeIssue.status === 'unassigned'">指派整改任务</button>
<button class="btn inspect-detail-btn" type="button" @click="toast('已在模型中定位(占位)')">在模型中定位</button>
<button class="btn inspect-detail-btn" type="button" @click="toast('导出报告(占位)')">导出报告</button>
</footer>
</section>
</div>
<div class="inspect-assign-overlay" v-show="assignOpen" @click.self="assignOpen = false">
<section class="inspect-assign-modal">
<header class="inspect-assign-header">
<div class="inspect-assign-title">指派整改任务</div>
<button class="inspect-assign-close" type="button" @click="assignOpen = false">×</button>
</header>
<div class="inspect-assign-body" v-if="activeIssue">
<section class="inspect-assign-summary">
<div class="inspect-assign-summary-title">{{ activeIssue.title }}</div>
<div class="inspect-assign-summary-meta">{{ activeIssue.component }}</div>
</section>
<div class="inspect-assign-field">
<div class="inspect-assign-label">指派类型</div>
<div class="inspect-assign-radio">
<button class="inspect-assign-radio-btn" :class="{ 'is-on': assignType === 'person' }" type="button" @click="assignType='person'">个人</button>
<button class="inspect-assign-radio-btn" :class="{ 'is-on': assignType === 'team' }" type="button" @click="assignType='team'">班组</button>
</div>
</div>
<div class="inspect-assign-field">
<div class="inspect-assign-label">{{ assignType === 'person' ? '选择责任人' : '选择责任班组' }}</div>
<div class="inspect-assign-select-wrap">
<select class="input inspect-assign-select" v-model="assignOwner">
<option value="">请选择</option>
<option v-for="p in assignCandidates" :key="p" :value="p">{{ p }}</option>
</select>
</div>
</div>
<div class="inspect-assign-field"><div class="inspect-assign-label">设置完成期限</div><input class="input inspect-assign-input" type="date" v-model="assignDue" /></div>
<div class="inspect-assign-field"><div class="inspect-assign-label">整改要求与备注</div><textarea class="inspect-assign-textarea" v-model="assignNote" placeholder="输入整改要求与备注…"></textarea></div>
</div>
<footer class="inspect-assign-footer">
<button class="btn btn-ghost inspect-assign-btn" type="button" @click="assignOpen = false">取消</button>
<button class="btn btn-accent inspect-assign-btn" type="button" :disabled="!canAssign" @click="confirmAssign">确认指派</button>
</footer>
</section>
</div>
<div class="toast" v-show="toastOpen">{{ toastText }}</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from "vue";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
const leftCollapsed = ref(false);
const panoramaFileName = ref("");
const mediaFiles = ref([]);
const detailOpen = ref(false);
const assignOpen = ref(false);
const activeIssueId = ref("");
const assignType = ref("person");
const assignOwner = ref("");
const assignDue = ref("");
const assignNote = ref("");
const toastOpen = ref(false);
const toastText = ref("");
const panoramaInputRef = ref(null);
const mediaInputRef = ref(null);
const issues = ref([
{ id: "I-001", severity: "high", levelText: "高风险", title: "临边防护缺失", desc: "桥面边缘未设置连续防护栏杆,存在坠落风险。", component: "主桥-0#墩", location: "主桥桥面北侧", status: "unassigned", aiText: "AI识别到临边防护缺失建议立即设置双道栏杆并加挂安全网。", owner: "", due: "" },
{ id: "I-002", severity: "mid", levelText: "中风险", title: "材料堆放不规范", desc: "钢筋堆放区未按分区标识,通道占用。", component: "引桥-承台", location: "引桥材料堆场", status: "assigned", aiText: "AI识别材料堆放不规范建议按区域标线重新布置并清理通道。", owner: "张工", due: "2026-03-25" },
{ id: "I-003", severity: "low", levelText: "低风险", title: "警示牌破损", desc: "个别警示牌褪色破损,辨识度下降。", component: "路面-基层", location: "东侧便道入口", status: "fixing", aiText: "AI识别警示标识清晰度不足建议统一更换为反光警示牌。", owner: "安监班组", due: "2026-03-21" },
]);
const groupedIssues = computed(() => {
const groups = [
{ key: "high", label: "高风险问题", items: [] },
{ key: "mid", label: "中风险问题", items: [] },
{ key: "low", label: "低风险问题", items: [] },
];
issues.value.forEach((it) => groups.find((g) => g.key === it.severity)?.items.push(it));
return groups.filter((g) => g.items.length > 0);
});
const riskCount = computed(() => ({
high: issues.value.filter((i) => i.severity === "high").length,
mid: issues.value.filter((i) => i.severity === "mid").length,
low: issues.value.filter((i) => i.severity === "low").length,
}));
const activeIssue = computed(() => issues.value.find((i) => i.id === activeIssueId.value) || null);
const assignCandidates = computed(() => (assignType.value === "person" ? ["张工", "李工", "王工"] : ["土建班组", "钢筋班组", "机电班组"]));
const canAssign = computed(() => !!assignOwner.value && !!assignDue.value);
function onPanoramaPick(e) {
const file = e.target.files?.[0];
panoramaFileName.value = file ? file.name : "";
}
function onMediaPick(e) {
const files = Array.from(e.target.files || []);
mediaFiles.value = files;
}
function removeMediaFile(file) {
mediaFiles.value = mediaFiles.value.filter((f) => f !== file);
}
function analyzeMedia() {
toast("AI 分析完成,已生成巡检问题清单");
}
function openDetail(issue) {
activeIssueId.value = issue.id;
detailOpen.value = true;
}
function closeDetail() {
detailOpen.value = false;
}
function openAssign() {
assignType.value = "person";
assignOwner.value = "";
assignDue.value = "";
assignNote.value = "";
assignOpen.value = true;
}
function confirmAssign() {
const idx = issues.value.findIndex((i) => i.id === activeIssueId.value);
if (idx < 0) return;
issues.value[idx] = {
...issues.value[idx],
status: "assigned",
owner: assignOwner.value,
due: assignDue.value,
};
assignOpen.value = false;
toast("已指派整改任务");
}
let timer = null;
function toast(text) {
toastText.value = text;
toastOpen.value = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => (toastOpen.value = false), 1500);
}
function severityLabel(sev) {
return sev === "mid" ? "中风险" : sev === "low" ? "低风险" : "高风险";
}
function statusLabel(status) {
return status === "assigned" ? "已指派" : status === "fixing" ? "整改中" : "未指派";
}
</script>
<style scoped>
.inspection-page { min-height: 100vh; font-family: "Microsoft YaHei", "PingFang SC", sans-serif; }
.canvas { position: relative; min-height: 100vh; overflow: hidden; border-radius: 24px; border: 1px solid rgba(98,191,206,.34); background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%); box-shadow: inset 0 0 0 8px rgba(214,230,241,.55); }
.topbar { position: absolute; inset: 0 0 auto; height: 120px; z-index: 30; }
.topbar-center { position: absolute; left: 50%; transform: translateX(-50%); top: 16px; }
.model-title { font-size: 20px; letter-spacing: 1px; font-weight: 700; color: rgba(255,128,52,.95); text-shadow: 0 2px 10px rgba(0,0,0,.25); }
.model-stage { position: absolute; inset: 0; }
.sidepanel { border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(260px 140px at 8% 0%, rgba(63,203,191,.16), transparent 62%) padding-box, linear-gradient(180deg, rgba(20,31,37,.92), rgba(15,23,29,.88)) padding-box; box-shadow: 0 22px 60px rgba(0,0,0,.34), 0 0 0 1px rgba(83,214,206,.12) inset; overflow: hidden; z-index: 25; position: absolute; top: 150px; bottom: 100px; }
.inspect-panel-left { left: 16px; width: 340px; }
.inspect-panel-right { right: 16px; width: 360px; }
.sidepanel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 10px 10px 12px; border-bottom: 1px solid rgba(83,214,206,.16); background: radial-gradient(360px 100px at 10% 0%, rgba(83,214,206,.24), transparent 68%), linear-gradient(180deg, rgba(28,134,122,.84), rgba(15,60,74,.62)); }
.sidepanel-title { font-size: 14px; font-weight: 900; color: rgba(207,247,242,.96); }
.iconbtn { width: 30px; height: 30px; border-radius: 10px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.42); color: rgba(11,27,58,.86); font-weight: 900; cursor: pointer; }
.sidepanel-body { height: calc(100% - 52px); overflow: auto; padding: 10px; }
.inspect-upload-card, .inspect-block { border: 1px solid rgba(255,255,255,.1); background: rgba(0,0,0,.14); border-radius: 14px; padding: 10px; margin-bottom: 10px; }
.inspect-upload-head-title, .inspect-block-title { color: rgba(238,247,251,.92); font-size: 13px; font-weight: 900; margin-bottom: 8px; }
.inspect-drop { width: 100%; border: 1px dashed rgba(83,214,206,.4); border-radius: 12px; background: rgba(0,0,0,.16); padding: 12px; color: rgba(220,238,244,.88); text-align: left; cursor: pointer; }
.inspect-drop-title { font-size: 13px; font-weight: 900; }
.inspect-drop-sub { font-size: 12px; opacity: .75; margin-top: 4px; }
.inspect-file-row { margin-top: 8px; display: flex; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid rgba(255,255,255,.1); border-radius: 10px; background: rgba(255,255,255,.04); padding: 6px 8px; }
.inspect-file-name { color: rgba(222,238,244,.86); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.inspect-file-remove { border: 0; background: transparent; color: rgba(255,255,255,.72); font-size: 18px; cursor: pointer; }
.inspect-file-list { display: grid; gap: 6px; margin-top: 8px; }
.inspect-analyze-btn { width: 100%; margin-top: 8px; }
.btn { appearance: none; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.08); color: rgba(255,255,255,.88); border-radius: 12px; padding: 8px 10px; cursor: pointer; }
.btn-ghost { background: rgba(255,255,255,.04); }
.btn-accent { border-color: rgba(83,214,206,.24); background: rgba(43,191,178,.26); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.inspect-risk-grid { display: grid; grid-template-columns: 1fr; gap: 8px; }
.inspect-risk-card { border-radius: 12px; padding: 8px 10px; border: 1px solid rgba(255,255,255,.1); background: rgba(0,0,0,.16); display: flex; align-items: center; justify-content: space-between; }
.inspect-risk-card.is-high { border-color: rgba(217,54,62,.35); }
.inspect-risk-card.is-mid { border-color: rgba(212,136,6,.35); }
.inspect-risk-card.is-low { border-color: rgba(47,143,47,.35); }
.inspect-risk-k { color: rgba(220,236,242,.82); font-size: 12px; }
.inspect-risk-v { color: rgba(255,255,255,.92); font-weight: 900; font-size: 16px; }
.inspect-list-title { display: flex; align-items: center; gap: 8px; }
.inspect-list-badge { font-size: 12px; font-weight: 800; color: rgba(255,255,255,.9); border: 1px solid rgba(255,255,255,.14); border-radius: 999px; padding: 4px 8px; background: rgba(0,0,0,.18); }
.inspect-list-body { display: grid; gap: 8px; }
.inspect-group-title { margin: 2px 2px 0; font-size: 12px; font-weight: 900; color: rgba(230,243,247,.85); }
.inspect-item { border: 1px solid rgba(255,255,255,.1); background: rgba(0,0,0,.16); border-radius: 14px; padding: 10px; cursor: pointer; }
.inspect-item:hover { border-color: rgba(83,214,206,.26); }
.inspect-item-top { display: flex; align-items: center; justify-content: space-between; }
.inspect-pill { border-radius: 999px; padding: 3px 8px; font-size: 11px; font-weight: 900; border: 1px solid transparent; }
.inspect-pill.is-high { color: rgba(255,178,182,.95); border-color: rgba(217,54,62,.35); background: rgba(217,54,62,.18); }
.inspect-pill.is-mid { color: rgba(255,214,142,.95); border-color: rgba(212,136,6,.35); background: rgba(212,136,6,.18); }
.inspect-pill.is-low { color: rgba(176,244,176,.95); border-color: rgba(47,143,47,.35); background: rgba(47,143,47,.18); }
.inspect-level { color: rgba(214,235,241,.8); font-size: 11px; }
.inspect-item-title { margin-top: 6px; color: rgba(244,252,255,.92); font-size: 13px; font-weight: 900; }
.inspect-item-desc { margin-top: 6px; color: rgba(214,233,239,.8); font-size: 12px; line-height: 1.45; }
.inspect-item-meta { margin-top: 8px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.inspect-item-loc { color: rgba(201,223,231,.72); font-size: 11px; }
.inspect-item-status { font-size: 11px; font-weight: 900; border-radius: 999px; padding: 3px 8px; border: 1px solid rgba(255,255,255,.1); }
.inspect-item-status.is-unassigned { color: rgba(255,214,142,.95); border-color: rgba(212,136,6,.35); background: rgba(212,136,6,.18); }
.inspect-item-status.is-assigned { color: rgba(176,244,176,.95); border-color: rgba(47,143,47,.35); background: rgba(47,143,47,.18); }
.inspect-item-status.is-fixing { color: rgba(176,218,255,.95); border-color: rgba(21,108,255,.35); background: rgba(21,108,255,.18); }
.inspect-detail-overlay, .inspect-assign-overlay { position: absolute; inset: 0; z-index: 90; display: grid; place-items: center; background: rgba(13,36,74,.08); backdrop-filter: blur(3px); }
.inspect-detail-modal, .inspect-assign-modal { width: min(720px, calc(100% - 32px)); border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(260px 160px at 18% 0%, rgba(83,214,206,.14), transparent 62%) padding-box, linear-gradient(180deg, rgba(18,26,31,.94), rgba(14,20,25,.86)) padding-box; box-shadow: 0 30px 80px rgba(0,0,0,.3); }
.inspect-detail-header, .inspect-assign-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border-bottom: 1px solid rgba(83,214,206,.2); }
.inspect-detail-close, .inspect-assign-close { border: 0; background: transparent; color: rgba(255,255,255,.8); font-size: 26px; cursor: pointer; }
.inspect-detail-body, .inspect-assign-body { padding: 14px; max-height: 58vh; overflow: auto; }
.inspect-detail-title { color: rgba(244,252,255,.92); font-size: 18px; font-weight: 900; margin-bottom: 12px; }
.inspect-detail-card, .inspect-detail-mini, .inspect-detail-task, .inspect-assign-summary { border: 1px solid rgba(255,255,255,.1); background: rgba(0,0,0,.16); border-radius: 12px; padding: 10px; }
.inspect-detail-card-title, .inspect-detail-task-title, .inspect-detail-mini-label, .inspect-assign-label, .inspect-assign-title { color: rgba(230,243,247,.84); font-size: 12px; font-weight: 900; }
.inspect-detail-card-text, .inspect-detail-mini-value, .inspect-detail-task-v { color: rgba(244,252,255,.9); font-size: 13px; margin-top: 6px; }
.inspect-detail-grid, .inspect-detail-task-grid { margin-top: 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.inspect-detail-task { margin-top: 10px; }
.inspect-detail-footer, .inspect-assign-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 14px; border-top: 1px solid rgba(83,214,206,.2); }
.inspect-assign-field { margin-top: 10px; }
.inspect-assign-radio { margin-top: 6px; display: inline-flex; gap: 8px; }
.inspect-assign-radio-btn { border: 1px solid rgba(255,255,255,.14); background: rgba(0,0,0,.14); color: rgba(255,255,255,.84); border-radius: 999px; padding: 6px 10px; cursor: pointer; }
.inspect-assign-radio-btn.is-on { border-color: rgba(83,214,206,.28); background: rgba(43,191,178,.24); }
.inspect-assign-select-wrap { margin-top: 6px; }
.input { width: 100%; border-radius: 10px; border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.08); color: rgba(255,255,255,.9); padding: 8px 10px; }
.inspect-assign-textarea { margin-top: 6px; width: 100%; min-height: 86px; border-radius: 10px; border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.08); color: rgba(255,255,255,.9); padding: 8px 10px; resize: vertical; }
.toast { position: absolute; left: 50%; top: 74px; transform: translateX(-50%); z-index: 100; border-radius: 12px; padding: 8px 12px; border: 1px solid rgba(255,255,255,.14); background: rgba(18,26,31,.92); color: rgba(238,246,250,.9); font-size: 12px; font-weight: 800; }
@media (max-width: 1300px) {
.inspect-panel-left { width: 300px; }
.inspect-panel-right { width: 320px; }
}
</style>

View File

@@ -0,0 +1,205 @@
<template>
<div class="material-page">
<div class="canvas">
<header class="topbar"><div class="topbar-center"><div class="model-title">XXX特大桥主体模型.rvt</div></div></header>
<section class="model-stage">
<ModelPlaceholder />
</section>
<section class="module-topbar">
<div class="field">
<span class="field-label">构件用料偏差率</span>
<label class="chip chip-status"><input type="checkbox" :checked="deviationFilters.all" @change="toggleDeviationFilter('all', $event.target.checked)" /> 全部</label>
<label class="chip chip-status chip-blue"><input type="checkbox" :checked="deviationFilters.lt_50" @change="toggleDeviationFilter('lt_50', $event.target.checked)" /> -50% 以下</label>
<label class="chip chip-status chip-lightblue"><input type="checkbox" :checked="deviationFilters.m25_50" @change="toggleDeviationFilter('m25_50', $event.target.checked)" /> -25%~-50%</label>
<label class="chip chip-status chip-green"><input type="checkbox" :checked="deviationFilters.p0_25" @change="toggleDeviationFilter('p0_25', $event.target.checked)" /> 0%~25%</label>
<label class="chip chip-status chip-yellow"><input type="checkbox" :checked="deviationFilters.p25_50" @change="toggleDeviationFilter('p25_50', $event.target.checked)" /> 25%~50%</label>
<label class="chip chip-status chip-yellow2"><input type="checkbox" :checked="deviationFilters.gt50" @change="toggleDeviationFilter('gt50', $event.target.checked)" /> 50% 以上</label>
</div>
</section>
<aside class="sidepanel" :class="{ 'is-collapsed': sideCollapsed }">
<header class="sidepanel-header">
<div class="sidepanel-title">工程部位</div>
<button class="iconbtn" type="button" @click="sideCollapsed = !sideCollapsed">{{ sideCollapsed ? "" : "" }}</button>
</header>
<div class="sidepanel-body" v-show="!sideCollapsed">
<div class="tree">
<div class="tree-item" :class="{ 'is-active': row.leaf && row.id === selectedStructureId }" :style="{ paddingLeft: `${10 + row.level * 14}px` }" v-for="row in visibleTreeRows" :key="row.id" @click="onTreeRowClick(row)">
<button class="tree-caret" type="button" :class="{ 'is-leaf': row.leaf }" :disabled="row.leaf" @click.stop="toggleTreeExpand(row)">{{ row.leaf ? "" : row.open ? "" : "" }}</button>
<span class="tree-bullet"></span>
<span class="tree-text">{{ row.name }}</span>
</div>
</div>
</div>
</aside>
<section class="bottompanel" :class="{ 'is-collapsed': bottomCollapsed }">
<header class="bottompanel-header"><div class="tabs"><button class="tab is-on" type="button">材料用料分析</button></div></header>
<button class="iconbtn bottompanel-toggle" type="button" @click="bottomCollapsed = !bottomCollapsed">{{ bottomCollapsed ? "" : "" }}</button>
<div class="bottompanel-body" v-show="!bottomCollapsed">
<table class="table">
<thead>
<tr>
<th>序号</th><th>材料编号</th><th>材料名称</th><th>规格</th><th>单位</th><th>变更后数量</th><th>预计总量</th><th>当前完成量</th><th>实际消耗量</th><th>量差</th>
</tr>
</thead>
<tbody>
<tr v-for="r in materialRows" :key="r.no">
<td>{{ r.no }}</td><td>{{ r.code }}</td><td>{{ r.name }}</td><td>{{ r.spec }}</td><td>{{ r.unit }}</td><td>{{ formatNumber(r.changedQty,2) }}</td><td>{{ formatNumber(r.expectedTotal,2) }}</td><td>{{ formatNumber(r.curDone,2) }}</td><td>{{ formatNumber(r.actualConsume,2) }}</td><td>{{ (r.diff >= 0 ? "+" : "") + formatNumber(r.diff,2) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
const structures = [
{ id: "S-001", name: "主桥-0#墩" }, { id: "S-002", name: "主桥-1#墩" }, { id: "S-003", name: "主桥-2#墩" },
{ id: "S-004", name: "引桥-桩基" }, { id: "S-005", name: "引桥-承台" }, { id: "S-006", name: "引桥-盖梁" },
{ id: "S-007", name: "路基-填筑" }, { id: "S-008", name: "路面-基层" }, { id: "S-009", name: "路面-面层" },
];
const elementTree = [
{ id: "E-G-bridge", name: "桥梁工程", children: [
{ id: "E-G-main-bridge", name: "主桥", children: ["S-001", "S-002", "S-003"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
{ id: "E-G-approach", name: "引桥", children: ["S-004", "S-005", "S-006"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
] },
{ id: "E-G-road", name: "道路工程", children: ["S-007", "S-008", "S-009"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
];
const sideCollapsed = ref(false);
const bottomCollapsed = ref(false);
const selectedStructureId = ref("S-001");
const expanded = ref(new Set(["E-G-bridge", "E-G-main-bridge", "E-G-approach", "E-G-road"]));
const deviationFilters = reactive({ all: true, lt_50: false, m25_50: false, p0_25: false, p25_50: false, gt50: false });
const selectedStructureName = computed(() => structures.find((s) => s.id === selectedStructureId.value)?.name || "");
const visibleTreeRows = computed(() => {
const rows = [];
const walk = (node, level) => {
const leaf = !node.children || node.children.length === 0;
const open = expanded.value.has(node.id);
rows.push({ id: node.id, name: node.name, level, leaf, open });
if (!leaf && open) node.children.forEach((c) => walk(c, level + 1));
};
elementTree.forEach((n) => walk(n, 0));
return rows;
});
const materialRows = computed(() =>
Array.from({ length: 16 }, (_, i) => {
const changedQty = 120 + i * 18;
const expectedTotal = changedQty * (0.95 + (i % 3) * 0.04);
const curDone = expectedTotal * (0.25 + (i % 5) * 0.13);
const actualConsume = expectedTotal * (0.22 + (i % 6) * 0.14);
const diff = actualConsume - curDone;
return {
no: i + 1,
code: `MAT-${String(6001 + i)}`,
name: `${["钢筋", "水泥", "碎石", "砂", "沥青", "外加剂"][i % 6]}${selectedStructureName.value}`,
spec: ["HRB400", "P.O 42.5", "5-20mm", "中砂", "70#", "减水剂"][i % 6],
unit: ["t", "t", "t", "t", "t", "kg"][i % 6],
changedQty,
expectedTotal,
curDone,
actualConsume,
diff,
};
})
);
function formatNumber(value, digits = 0) {
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function applyAllExclusive(key, checked) {
if (key === "all") {
deviationFilters.all = checked;
if (checked) {
deviationFilters.lt_50 = false;
deviationFilters.m25_50 = false;
deviationFilters.p0_25 = false;
deviationFilters.p25_50 = false;
deviationFilters.gt50 = false;
}
return;
}
deviationFilters[key] = checked;
if (checked) deviationFilters.all = false;
if (!deviationFilters.lt_50 && !deviationFilters.m25_50 && !deviationFilters.p0_25 && !deviationFilters.p25_50 && !deviationFilters.gt50) deviationFilters.all = true;
}
function toggleDeviationFilter(key, checked) { applyAllExclusive(key, checked); }
function toggleTreeExpand(row) { if (!row.leaf) (expanded.value.has(row.id) ? expanded.value.delete(row.id) : expanded.value.add(row.id)); }
function onTreeRowClick(row) { if (row.leaf) selectedStructureId.value = row.id; else toggleTreeExpand(row); }
</script>
<style scoped>
.material-page { min-height: 100vh; font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif; }
.canvas { position: relative; min-height: 100vh; overflow: hidden; border-radius: 24px; border: 1px solid rgba(98,191,206,.34); background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%); box-shadow: inset 0 0 0 8px rgba(214,230,241,.55); }
.topbar { position: absolute; inset: 0 0 auto; height: 120px; z-index: 30; }
.topbar-center { position: absolute; left: 50%; transform: translateX(-50%); top: 16px; }
.model-title { font-size: 20px; letter-spacing: 1px; font-weight: 700; color: rgba(255,128,52,.95); text-shadow: 0 2px 10px rgba(0,0,0,.25); }
.model-stage { position: absolute; inset: 0; }
.module-topbar { position: absolute; left: 50%; top: 20px; transform: translateX(-50%); width: min(1060px, calc(100% - 180px)); z-index: 99; }
.field { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; border-radius: 18px; border: 1px solid rgba(83,214,206,.26); background: radial-gradient(420px 120px at 18% 0%, rgba(83,214,206,.22), transparent 70%), linear-gradient(180deg, rgba(18,32,40,.94), rgba(15,25,32,.88)); box-shadow: 0 20px 56px rgba(0,0,0,.28), 0 0 0 1px rgba(83,214,206,.12) inset; padding: 10px 14px; }
.field-label { font-weight: 900; color: rgba(244,252,255,.92); font-size: 14px; white-space: nowrap; }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 999px; border: 1px solid rgba(255,255,255,.12); background: rgba(0,0,0,.2); color: rgba(255,255,255,.88); font-weight: 800; font-size: 13px; white-space: nowrap; }
.chip input { width: 13px; height: 13px; margin: 0; accent-color: rgba(64,224,208,.92); }
.chip-status::before { content: ""; width: 10px; height: 10px; border-radius: 3px; border: 1px solid rgba(255,255,255,.2); background: transparent; }
.chip-status.chip-blue::before { border-color: rgba(21,108,255,.4); background: rgba(21,108,255,.22); }
.chip-status.chip-lightblue::before { border-color: rgba(114,188,255,.5); background: rgba(114,188,255,.24); }
.chip-status.chip-green::before { border-color: rgba(47,143,47,.4); background: rgba(47,143,47,.22); }
.chip-status.chip-yellow::before { border-color: rgba(212,136,6,.4); background: rgba(212,136,6,.22); }
.chip-status.chip-yellow2::before { border-color: rgba(255,186,66,.45); background: rgba(255,186,66,.22); }
.sidepanel { position: absolute; left: 16px; top: 175px; width: 320px; bottom: 100px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(260px 140px at 8% 0%, rgba(63,203,191,.16), transparent 62%) padding-box, linear-gradient(180deg, rgba(20,31,37,.92), rgba(15,23,29,.88)) padding-box; box-shadow: 0 22px 60px rgba(0,0,0,.34), 0 0 0 1px rgba(83,214,206,.12) inset; overflow: hidden; z-index: 25; }
.sidepanel.is-collapsed { width: 64px; }
.sidepanel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 10px 10px 12px; border-bottom: 1px solid rgba(83,214,206,.16); background: radial-gradient(360px 100px at 10% 0%, rgba(83,214,206,.24), transparent 68%), linear-gradient(180deg, rgba(28,134,122,.84), rgba(15,60,74,.62)); }
.sidepanel-title { font-size: 14px; font-weight: 900; color: rgba(207,247,242,.96); }
.iconbtn { width: 30px; height: 30px; border-radius: 10px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.42); color: rgba(11,27,58,.86); font-weight: 900; cursor: pointer; }
.sidepanel-body { height: calc(100% - 52px); overflow: auto; padding: 8px; }
.tree { display: grid; gap: 8px; }
.tree-item { display: grid; grid-template-columns: 20px 10px 1fr; align-items: center; gap: 8px; min-height: 40px; border-radius: 14px; border: 1px solid rgba(154,186,198,.22); background: linear-gradient(180deg, rgba(44,58,68,.58), rgba(37,49,58,.54)); cursor: pointer; }
.tree-item:hover { border-color: rgba(83,214,206,.32); background: linear-gradient(180deg, rgba(51,67,78,.66), rgba(43,57,67,.62)); }
.tree-item.is-active { border-color: rgba(132,188,255,.3); background: linear-gradient(180deg, rgba(112,146,198,.34), rgba(95,128,180,.28)); }
.tree-caret { border: 0; background: transparent; color: rgba(203,230,236,.88); cursor: pointer; font-size: 14px; }
.tree-caret.is-leaf { cursor: default; }
.tree-bullet { width: 10px; height: 10px; border-radius: 50%; background: rgba(83,214,206,.85); }
.tree-text { font-size: 13px; font-weight: 800; color: rgba(222,238,244,.9); }
.bottompanel { position: absolute; left: 370px; right: 16px; bottom: 100px; height: 390px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(420px 180px at 14% 0%, rgba(83,214,206,.2), transparent 66%) padding-box, radial-gradient(520px 220px at 82% 0%, rgba(40,156,228,.14), transparent 70%) padding-box, linear-gradient(180deg, rgba(18,33,40,.94), rgba(14,24,31,.9)) padding-box; box-shadow: 0 24px 70px rgba(0,0,0,.36), 0 0 0 1px rgba(83,214,206,.14) inset; overflow: hidden; z-index: 26; }
.bottompanel-header { padding: 12px 52px 12px 12px; border-bottom: 1px solid rgba(83,214,206,.28); background: radial-gradient(380px 120px at 18% 0%, rgba(83,214,206,.28), transparent 70%), linear-gradient(180deg, rgba(25,137,124,.86), rgba(16,66,82,.64)); }
.bottompanel-toggle { position: absolute; right: 12px; top: 12px; }
.tabs { display: flex; gap: 10px; }
.tab { border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.08); color: rgba(214,235,241,.9); border-radius: 999px; padding: 8px 16px; cursor: pointer; font-weight: 900; font-size: 12px; }
.tab.is-on { border-color: rgba(83,214,206,.32); background: rgba(83,214,206,.24); }
.bottompanel.is-collapsed { height: 70px; }
.bottompanel-body { padding: 0 12px 12px; overflow: auto; height: calc(100% - 66px); }
.table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; }
.table th, .table td { text-align: left; padding: 12px 14px; border-bottom: 1px solid rgba(178,206,216,.16); }
.table th { color: rgba(219,240,246,.86); font-weight: 900; background: linear-gradient(90deg, rgba(12,38,52,.92), rgba(16,47,61,.86)); position: sticky; top: 0; z-index: 2; }
.table td { color: rgba(222,238,244,.86); background: linear-gradient(90deg, rgba(37,51,61,.42), rgba(31,45,54,.36)); }
.table tbody tr:hover td { background: linear-gradient(90deg, rgba(69,102,130,.36), rgba(56,86,113,.32)); }
@media (max-width: 1300px) {
.module-topbar { width: calc(100% - 32px); }
.field { flex-wrap: wrap; }
.sidepanel { width: 280px; }
.bottompanel { left: 330px; }
}
</style>

View File

@@ -0,0 +1,515 @@
<template>
<div class="measurement-page">
<PageCanvas>
<section class="module-topbar">
<div class="field">
<span class="field-label">计量状态</span>
<label class="chip chip-status chip-yellow">
<input type="checkbox" v-model="statusFilters.month_done" />
月计量结算完成
</label>
<label class="chip chip-status chip-green">
<input type="checkbox" v-model="statusFilters.cum_done" />
累计计量结算完成结构
</label>
<label class="chip chip-status chip-red">
<input type="checkbox" v-model="statusFilters.value_not_settled" />
有产值未完成计量结算构件
</label>
</div>
</section>
<SidePanel v-model:collapsed="sideCollapsed" title="计量|左侧区">
<section class="placeholder">
<div class="placeholder-top">
<div class="placeholder-title">计量期次<b>{{ periodId }}</b></div>
<button class="btn btn-sm" type="button" @click="openPeriodModal">切换期次</button>
</div>
</section>
<div class="tree">
<div
class="tree-item"
:class="{ 'is-active': row.leaf && row.id === selectedStructureId }"
:style="{ paddingLeft: `${10 + row.level * 14}px` }"
v-for="row in visibleTreeRows"
:key="row.id"
@click="onTreeRowClick(row)"
>
<button
class="tree-caret"
type="button"
:class="{ 'is-leaf': row.leaf }"
:disabled="row.leaf"
@click.stop="toggleTreeExpand(row)"
>
{{ row.leaf ? "•" : row.open ? "▾" : "▸" }}
</button>
<span class="tree-bullet"></span>
<span class="tree-text">{{ row.name }}</span>
</div>
</div>
</SidePanel>
<BottomPanel v-model:collapsed="bottomCollapsed">
<template #header>
<div class="tabs">
<button class="tab" :class="{ 'is-on': activeTab === 'pay' }" type="button" @click="activeTab = 'pay'">计量支付</button>
<button class="tab" :class="{ 'is-on': activeTab === 'boq' }" type="button" @click="activeTab = 'boq'">分解清单</button>
<button class="tab" :class="{ 'is-on': activeTab === 'materials' }" type="button" @click="activeTab = 'materials'">项目主材</button>
</div>
</template>
<table class="table" v-if="activeTab === 'pay'">
<thead>
<tr>
<th>序号</th>
<th>期次</th>
<th>清单编号</th>
<th>清单名称</th>
<th>单位</th>
<th>单价</th>
<th>上期末计量数量</th>
<th>本期计量数量</th>
<th>本期计量金额</th>
<th>开累计量数量</th>
</tr>
</thead>
<tbody>
<tr v-for="r in payRows" :key="r.no">
<td>{{ r.no }}</td>
<td>{{ r.period }}</td>
<td>{{ r.code }}</td>
<td>{{ r.name }}</td>
<td>{{ r.unit }}</td>
<td>{{ formatYuan(r.unitPrice) }}</td>
<td>{{ formatNumber(r.prevQty, 2) }}</td>
<td>{{ formatNumber(r.curQty, 2) }}</td>
<td>{{ formatMoney(r.curAmt) }}</td>
<td>{{ formatNumber(r.cumQty, 2) }}</td>
</tr>
</tbody>
</table>
<table class="table" v-else-if="activeTab === 'boq'">
<thead>
<tr><th>编码</th><th>细目内容</th><th>单位</th><th>数量</th><th>金额</th></tr>
</thead>
<tbody>
<tr v-for="r in boqRows" :key="r.code">
<td>{{ r.code }}</td>
<td>{{ r.item }}</td>
<td>{{ r.unit }}</td>
<td>{{ r.qty }}</td>
<td>{{ formatMoney(r.amount) }}</td>
</tr>
</tbody>
</table>
<table class="table" v-else>
<thead>
<tr><th>材料编码</th><th>名称</th><th>单位</th><th>数量</th></tr>
</thead>
<tbody>
<tr v-for="r in materialRows" :key="r.code">
<td>{{ r.code }}</td>
<td>{{ r.name }}</td>
<td>{{ r.unit }}</td>
<td>{{ r.qty }}</td>
</tr>
</tbody>
</table>
</BottomPanel>
<div class="modal-mask" v-show="periodModalOpen" @click.self="periodModalOpen = false">
<div class="period-modal">
<div class="period-modal-head">
<div class="period-modal-title">切换计量期次</div>
<button class="iconbtn" type="button" @click="periodModalOpen = false"></button>
</div>
<div class="period-list">
<div
class="tree-item"
:class="{ 'is-active': p === periodDraft }"
v-for="p in measurementPeriods"
:key="p"
@click="periodDraft = p"
>
<span class="tree-bullet"></span>
<span class="tree-text">{{ p }}</span>
</div>
</div>
<div class="period-actions">
<button class="btn btn-ghost" type="button" @click="periodModalOpen = false">取消</button>
<button class="btn" type="button" @click="confirmPeriod">确认</button>
</div>
</div>
</div>
</PageCanvas>
</div>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import PageCanvas from "../../components/layout/PageCanvas.vue";
import SidePanel from "../../components/layout/SidePanel.vue";
import BottomPanel from "../../components/layout/BottomPanel.vue";
import { structures, elementTree } from "../../constants/structures.js";
import { measurementPeriods, getPayRows, getMaterialRows } from "../../constants/mock.js";
import { formatNumber, formatPercent, formatMoney, formatYuan } from "../../utils/format.js";
const periodId = ref("2025-12");
const periodModalOpen = ref(false);
const periodDraft = ref(periodId.value);
const sideCollapsed = ref(false);
const bottomCollapsed = ref(false);
const selectedStructureId = ref("S-001");
const activeTab = ref("pay");
const expanded = ref(new Set(["E-G-bridge", "E-G-main-bridge", "E-G-approach", "E-G-road"]));
const statusFilters = reactive({
month_done: true,
cum_done: true,
value_not_settled: true,
});
const structureIndex = computed(() => Math.max(0, structures.findIndex((s) => s.id === selectedStructureId.value)));
const selectedStructureName = computed(() => structures.find((s) => s.id === selectedStructureId.value)?.name || "");
const visibleTreeRows = computed(() => {
const rows = [];
const walk = (node, level) => {
const leaf = !node.children || node.children.length === 0;
const open = expanded.value.has(node.id);
rows.push({ id: node.id, name: node.name, level, leaf, open });
if (!leaf && open) node.children.forEach((c) => walk(c, level + 1));
};
elementTree.forEach((n) => walk(n, 0));
return rows;
});
const payRows = computed(() => getPayRows(periodId.value, structureIndex.value, selectedStructureName.value));
const boqRows = [
{ code: "BOQ-001", item: "混凝土浇筑", unit: "m³", qty: 120.5, amount: 385000 },
{ code: "BOQ-002", item: "钢筋制安", unit: "t", qty: 18.2, amount: 246000 },
{ code: "BOQ-003", item: "模板工程", unit: "㎡", qty: 560, amount: 112000 },
];
const materialRows = computed(() => getMaterialRows(structureIndex.value));
function toggleTreeExpand(row) {
if (row.leaf) return;
if (expanded.value.has(row.id)) expanded.value.delete(row.id);
else expanded.value.add(row.id);
}
function onTreeRowClick(row) {
if (row.leaf) {
selectedStructureId.value = row.id;
return;
}
toggleTreeExpand(row);
}
function openPeriodModal() {
periodDraft.value = periodId.value;
periodModalOpen.value = true;
}
function confirmPeriod() {
periodId.value = periodDraft.value;
periodModalOpen.value = false;
}
</script>
<style scoped>
.measurement-page {
min-height: 100vh;
font-family: var(--bim-font-cn);
}
.module-topbar {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 20px;
max-width: calc(100% - 160px);
z-index: 99;
}
.field {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: nowrap;
border-radius: 18px;
border: 1px solid rgba(83, 214, 206, 0.26);
background:
radial-gradient(420px 120px at 18% 0%, rgba(83, 214, 206, 0.22), transparent 70%),
linear-gradient(180deg, rgba(18, 32, 40, 0.94), rgba(15, 25, 32, 0.88));
box-shadow:
0 20px 56px rgba(0, 0, 0, 0.28),
0 0 0 1px rgba(83, 214, 206, 0.12) inset;
padding: 10px 14px;
}
.field-label {
font-weight: 900;
color: rgba(244, 252, 255, 0.92);
font-size: 14px;
white-space: nowrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.2);
color: rgba(255, 255, 255, 0.88);
font-weight: 800;
font-size: 13px;
white-space: nowrap;
}
.chip input {
width: 13px;
height: 13px;
margin: 0;
accent-color: rgba(64, 224, 208, 0.92);
}
.chip-status::before {
content: "";
width: 10px;
height: 10px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: transparent;
}
.chip-status.chip-yellow::before { border-color: rgba(212, 136, 6, 0.4); background: rgba(212, 136, 6, 0.22); }
.chip-status.chip-green::before { border-color: rgba(47, 143, 47, 0.4); background: rgba(47, 143, 47, 0.22); }
.chip-status.chip-red::before { border-color: rgba(217, 54, 62, 0.4); background: rgba(217, 54, 62, 0.22); }
.placeholder {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.14);
border-radius: 14px;
padding: 10px;
margin-bottom: 10px;
}
.placeholder-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.placeholder-title {
color: rgba(222, 238, 244, 0.9);
font-size: 13px;
font-weight: 900;
}
.btn {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.88);
border-radius: 12px;
padding: 8px 10px;
cursor: pointer;
}
.btn-sm {
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.btn-ghost {
background: rgba(255, 255, 255, 0.04);
}
.tree {
display: grid;
gap: 8px;
}
.tree-item {
display: grid;
grid-template-columns: 20px 10px 1fr;
align-items: center;
gap: 8px;
min-height: 40px;
border-radius: 14px;
border: 1px solid rgba(154, 186, 198, 0.22);
background: linear-gradient(180deg, rgba(44, 58, 68, 0.58), rgba(37, 49, 58, 0.54));
cursor: pointer;
}
.tree-item:hover {
border-color: rgba(83, 214, 206, 0.32);
background: linear-gradient(180deg, rgba(51, 67, 78, 0.66), rgba(43, 57, 67, 0.62));
}
.tree-item.is-active {
border-color: rgba(132, 188, 255, 0.3);
background: linear-gradient(180deg, rgba(112, 146, 198, 0.34), rgba(95, 128, 180, 0.28));
}
.tree-caret {
border: 0;
background: transparent;
color: rgba(203, 230, 236, 0.88);
cursor: pointer;
font-size: 14px;
}
.tree-caret.is-leaf {
cursor: default;
}
.tree-bullet {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(83, 214, 206, 0.85);
}
.tree-text {
font-size: 13px;
font-weight: 800;
color: rgba(222, 238, 244, 0.9);
}
.tabs {
display: flex;
gap: 10px;
}
.tab {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
color: rgba(214, 235, 241, 0.9);
border-radius: 999px;
padding: 8px 16px;
cursor: pointer;
font-weight: 900;
font-size: 12px;
}
.tab.is-on {
border-color: rgba(83, 214, 206, 0.32);
background: rgba(83, 214, 206, 0.24);
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 14px;
}
.table th,
.table td {
text-align: left;
padding: 12px 14px;
border-bottom: 1px solid rgba(178, 206, 216, 0.16);
}
.table th {
color: rgba(219, 240, 246, 0.86);
font-weight: 900;
background: linear-gradient(90deg, rgba(12, 38, 52, 0.92), rgba(16, 47, 61, 0.86));
position: sticky;
top: 0;
z-index: 2;
}
.table td {
color: rgba(222, 238, 244, 0.86);
background: linear-gradient(90deg, rgba(37, 51, 61, 0.42), rgba(31, 45, 54, 0.36));
}
.table tbody tr:hover td {
background: linear-gradient(90deg, rgba(69, 102, 130, 0.36), rgba(56, 86, 113, 0.32));
}
.modal-mask {
position: absolute;
inset: 0;
z-index: 90;
display: grid;
place-items: center;
background: rgba(13, 36, 74, 0.08);
backdrop-filter: blur(3px);
}
.period-modal {
width: min(520px, calc(100% - 32px));
border-radius: 18px;
border: 1px solid rgba(83, 214, 206, 0.24);
background:
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.3);
padding: 14px;
}
.period-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.period-modal-title {
font-weight: 900;
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
.period-list {
margin-top: 12px;
border-top: 1px solid rgba(83, 214, 206, 0.2);
padding-top: 12px;
display: grid;
gap: 8px;
}
.period-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 12px;
}
.iconbtn {
width: 30px;
height: 30px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.42);
color: rgba(11, 27, 58, 0.86);
font-weight: 900;
cursor: pointer;
}
@media (max-width: 1300px) {
.module-topbar {
left: 50%;
max-width: calc(100% - 32px);
transform: translateX(-50%);
}
}
</style>

236
src/pages/plan/index.vue Normal file
View File

@@ -0,0 +1,236 @@
<template>
<div class="plan-page">
<div class="canvas">
<header class="topbar"><div class="topbar-center"><div class="model-title">XXX特大桥主体模型.rvt</div></div></header>
<section class="model-stage">
<ModelPlaceholder />
</section>
<section class="module-topbar">
<div class="field">
<span class="field-label">计划状态</span>
<label class="chip chip-status"><input type="checkbox" :checked="statusFilters.all" @change="togglePlanFilter('all', $event.target.checked)" /> 全部</label>
<label class="chip chip-status chip-red"><input type="checkbox" :checked="statusFilters.not_done" @change="togglePlanFilter('not_done', $event.target.checked)" /> 计划未完成</label>
<label class="chip chip-status chip-blue"><input type="checkbox" :checked="statusFilters.done" @change="togglePlanFilter('done', $event.target.checked)" /> 计划完成</label>
<label class="chip chip-status chip-green"><input type="checkbox" :checked="statusFilters.out_of_plan" @change="togglePlanFilter('out_of_plan', $event.target.checked)" /> 计划外完成</label>
</div>
</section>
<aside class="sidepanel" :class="{ 'is-collapsed': sideCollapsed }">
<header class="sidepanel-header">
<div class="sidepanel-title">计划左侧区</div>
<button class="iconbtn" type="button" @click="sideCollapsed = !sideCollapsed">{{ sideCollapsed ? "" : "" }}</button>
</header>
<div class="sidepanel-body" v-show="!sideCollapsed">
<section class="placeholder">
<div class="placeholder-top">
<div class="placeholder-title">计划期次<b>{{ periodId }}</b></div>
<button class="btn btn-sm" type="button" @click="openPeriodModal">切换期次</button>
</div>
</section>
<div class="tree">
<div class="tree-item" :class="{ 'is-active': row.leaf && row.id === selectedStructureId }" :style="{ paddingLeft: `${10 + row.level * 14}px` }" v-for="row in visibleTreeRows" :key="row.id" @click="onTreeRowClick(row)">
<button class="tree-caret" type="button" :class="{ 'is-leaf': row.leaf }" :disabled="row.leaf" @click.stop="toggleTreeExpand(row)">{{ row.leaf ? "" : row.open ? "" : "" }}</button>
<span class="tree-bullet"></span>
<span class="tree-text">{{ row.name }}</span>
</div>
</div>
</div>
</aside>
<section class="bottompanel" :class="{ 'is-collapsed': bottomCollapsed }">
<header class="bottompanel-header"><div class="tabs"><button class="tab is-on" type="button">计划进度</button></div></header>
<button class="iconbtn bottompanel-toggle" type="button" @click="bottomCollapsed = !bottomCollapsed">{{ bottomCollapsed ? "" : "" }}</button>
<div class="bottompanel-body" v-show="!bottomCollapsed">
<table class="table">
<thead>
<tr>
<th>序号</th><th>期次</th><th>清单编号</th><th>清单名称</th><th>单位</th><th>单价</th><th>数量</th><th>本期末计划完成数量</th><th>本期末实际完成数量</th>
</tr>
</thead>
<tbody>
<tr v-for="r in planRows" :key="r.no">
<td>{{ r.no }}</td><td>{{ r.period }}</td><td>{{ r.code }}</td><td>{{ r.name }}</td><td>{{ r.unit }}</td><td>{{ formatYuan(r.unitPrice) }}</td><td>{{ formatNumber(r.qty,2) }}</td><td>{{ formatNumber(r.planDone,2) }}</td><td>{{ formatNumber(r.actualDone,2) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<div class="modal-mask" v-show="periodModalOpen" @click.self="periodModalOpen = false">
<div class="period-modal">
<div class="period-modal-head"><div class="period-modal-title">切换计划期次</div><button class="iconbtn" type="button" @click="periodModalOpen = false"></button></div>
<div class="period-list">
<div class="tree-item" :class="{ 'is-active': p === periodDraft }" v-for="p in planPeriods" :key="p" @click="periodDraft = p"><span class="tree-bullet"></span><span class="tree-text">{{ p }}</span></div>
</div>
<div class="period-actions"><button class="btn btn-ghost" type="button" @click="periodModalOpen = false">取消</button><button class="btn" type="button" @click="confirmPeriod">确认</button></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
const structures = [
{ id: "S-001", name: "主桥-0#墩" }, { id: "S-002", name: "主桥-1#墩" }, { id: "S-003", name: "主桥-2#墩" },
{ id: "S-004", name: "引桥-桩基" }, { id: "S-005", name: "引桥-承台" }, { id: "S-006", name: "引桥-盖梁" },
{ id: "S-007", name: "路基-填筑" }, { id: "S-008", name: "路面-基层" }, { id: "S-009", name: "路面-面层" },
];
const elementTree = [
{ id: "E-G-bridge", name: "桥梁工程", children: [
{ id: "E-G-main-bridge", name: "主桥", children: ["S-001", "S-002", "S-003"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
{ id: "E-G-approach", name: "引桥", children: ["S-004", "S-005", "S-006"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
] },
{ id: "E-G-road", name: "道路工程", children: ["S-007", "S-008", "S-009"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
];
const planPeriods = ["2025-12", "2026-Q1", "2026-Q2"];
const periodId = ref("2025-12");
const periodModalOpen = ref(false);
const periodDraft = ref(periodId.value);
const sideCollapsed = ref(false);
const bottomCollapsed = ref(false);
const selectedStructureId = ref("S-001");
const expanded = ref(new Set(["E-G-bridge", "E-G-main-bridge", "E-G-approach", "E-G-road"]));
const statusFilters = reactive({ all: true, not_done: false, done: false, out_of_plan: false });
const structureIndex = computed(() => Math.max(0, structures.findIndex((s) => s.id === selectedStructureId.value)));
const selectedStructureName = computed(() => structures.find((s) => s.id === selectedStructureId.value)?.name || "");
const visibleTreeRows = computed(() => {
const rows = [];
const walk = (node, level) => {
const leaf = !node.children || node.children.length === 0;
const open = expanded.value.has(node.id);
rows.push({ id: node.id, name: node.name, level, leaf, open });
if (!leaf && open) node.children.forEach((c) => walk(c, level + 1));
};
elementTree.forEach((n) => walk(n, 0));
return rows;
});
const planRows = computed(() => {
const idx = structureIndex.value;
return Array.from({ length: 16 }, (_, i) => {
const unitPrice = 480 + i * 22;
const qty = 18 + i * 2.5 + idx * 0.5;
const planDone = qty * (0.35 + (i % 5) * 0.12);
const actualDone = qty * (0.22 + (i % 4) * 0.16);
return {
no: i + 1,
period: periodId.value,
code: `BOQ-${String(4001 + i)}`,
name: `${["混凝土浇筑", "钢筋制安", "模板工程", "土方开挖", "路基填筑", "防水层"][i % 6]}${selectedStructureName.value}`,
unit: ["m³", "t", "㎡", "m³", "m³", "㎡"][i % 6],
unitPrice,
qty,
planDone,
actualDone,
};
});
});
function formatYuan(value) { return `${Number(value).toFixed(2)}`; }
function formatNumber(value, digits = 0) { return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: digits, maximumFractionDigits: digits }); }
function applyAllExclusive(key, checked) {
if (key === "all") {
statusFilters.all = checked;
if (checked) {
statusFilters.not_done = false;
statusFilters.done = false;
statusFilters.out_of_plan = false;
}
return;
}
statusFilters[key] = checked;
if (checked) statusFilters.all = false;
if (!statusFilters.not_done && !statusFilters.done && !statusFilters.out_of_plan) statusFilters.all = true;
}
function togglePlanFilter(key, checked) { applyAllExclusive(key, checked); }
function toggleTreeExpand(row) { if (!row.leaf) (expanded.value.has(row.id) ? expanded.value.delete(row.id) : expanded.value.add(row.id)); }
function onTreeRowClick(row) { if (row.leaf) selectedStructureId.value = row.id; else toggleTreeExpand(row); }
function openPeriodModal() { periodDraft.value = periodId.value; periodModalOpen.value = true; }
function confirmPeriod() { periodId.value = periodDraft.value; periodModalOpen.value = false; }
</script>
<style scoped>
.plan-page { min-height: 100vh; font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif; }
.canvas { position: relative; min-height: 100vh; overflow: hidden; border-radius: 24px; border: 1px solid rgba(98,191,206,.34); background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%); box-shadow: inset 0 0 0 8px rgba(214,230,241,.55); }
.topbar { position: absolute; inset: 0 0 auto; height: 120px; z-index: 30; }
.topbar-center { position: absolute; left: 50%; transform: translateX(-50%); top: 16px; }
.model-title { font-size: 20px; letter-spacing: 1px; font-weight: 700; color: rgba(255,128,52,.95); text-shadow: 0 2px 10px rgba(0,0,0,.25); }
.model-stage { position: absolute; inset: 0; }
.module-topbar { position: absolute; left: 50%; top: 20px; transform: translateX(-50%); max-width: calc(100% - 160px); z-index: 99; }
.field { display: flex; align-items: center; gap: 10px; flex-wrap: nowrap; border-radius: 18px; border: 1px solid rgba(83,214,206,.26); background: radial-gradient(420px 120px at 18% 0%, rgba(83,214,206,.22), transparent 70%), linear-gradient(180deg, rgba(18,32,40,.94), rgba(15,25,32,.88)); box-shadow: 0 20px 56px rgba(0,0,0,.28), 0 0 0 1px rgba(83,214,206,.12) inset; padding: 10px 14px; }
.field-label { font-weight: 900; color: rgba(244,252,255,.92); font-size: 14px; white-space: nowrap; }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 999px; border: 1px solid rgba(255,255,255,.12); background: rgba(0,0,0,.2); color: rgba(255,255,255,.88); font-weight: 800; font-size: 13px; white-space: nowrap; }
.chip input { width: 13px; height: 13px; margin: 0; accent-color: rgba(64,224,208,.92); }
.chip-status::before { content: ""; width: 10px; height: 10px; border-radius: 3px; border: 1px solid rgba(255,255,255,.2); background: transparent; }
.chip-status.chip-red::before { border-color: rgba(217,54,62,.4); background: rgba(217,54,62,.22); }
.chip-status.chip-blue::before { border-color: rgba(21,108,255,.4); background: rgba(21,108,255,.22); }
.chip-status.chip-green::before { border-color: rgba(47,143,47,.4); background: rgba(47,143,47,.22); }
.sidepanel { position: absolute; left: 16px; top: 175px; width: 320px; bottom: 100px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(260px 140px at 8% 0%, rgba(63,203,191,.16), transparent 62%) padding-box, linear-gradient(180deg, rgba(20,31,37,.92), rgba(15,23,29,.88)) padding-box; box-shadow: 0 22px 60px rgba(0,0,0,.34), 0 0 0 1px rgba(83,214,206,.12) inset; overflow: hidden; z-index: 25; }
.sidepanel.is-collapsed { width: 64px; }
.sidepanel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 10px 10px 12px; border-bottom: 1px solid rgba(83,214,206,.16); background: radial-gradient(360px 100px at 10% 0%, rgba(83,214,206,.24), transparent 68%), linear-gradient(180deg, rgba(28,134,122,.84), rgba(15,60,74,.62)); }
.sidepanel-title { font-size: 14px; font-weight: 900; color: rgba(207,247,242,.96); }
.iconbtn { width: 30px; height: 30px; border-radius: 10px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.42); color: rgba(11,27,58,.86); font-weight: 900; cursor: pointer; }
.sidepanel-body { height: calc(100% - 52px); overflow: auto; padding: 8px; }
.placeholder { border: 1px solid rgba(255,255,255,.1); background: rgba(0,0,0,.14); border-radius: 14px; padding: 10px; margin-bottom: 10px; }
.placeholder-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.placeholder-title { color: rgba(222,238,244,.9); font-size: 13px; font-weight: 900; }
.btn { appearance: none; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.08); color: rgba(255,255,255,.88); border-radius: 12px; padding: 8px 10px; cursor: pointer; }
.btn-sm { padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 800; }
.btn-ghost { background: rgba(255,255,255,.04); }
.tree { display: grid; gap: 8px; }
.tree-item { display: grid; grid-template-columns: 20px 10px 1fr; align-items: center; gap: 8px; min-height: 40px; border-radius: 14px; border: 1px solid rgba(154,186,198,.22); background: linear-gradient(180deg, rgba(44,58,68,.58), rgba(37,49,58,.54)); cursor: pointer; }
.tree-item:hover { border-color: rgba(83,214,206,.32); background: linear-gradient(180deg, rgba(51,67,78,.66), rgba(43,57,67,.62)); }
.tree-item.is-active { border-color: rgba(132,188,255,.3); background: linear-gradient(180deg, rgba(112,146,198,.34), rgba(95,128,180,.28)); }
.tree-caret { border: 0; background: transparent; color: rgba(203,230,236,.88); cursor: pointer; font-size: 14px; }
.tree-caret.is-leaf { cursor: default; }
.tree-bullet { width: 10px; height: 10px; border-radius: 50%; background: rgba(83,214,206,.85); }
.tree-text { font-size: 13px; font-weight: 800; color: rgba(222,238,244,.9); }
.bottompanel { position: absolute; left: 370px; right: 16px; bottom: 100px; height: 390px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(420px 180px at 14% 0%, rgba(83,214,206,.2), transparent 66%) padding-box, radial-gradient(520px 220px at 82% 0%, rgba(40,156,228,.14), transparent 70%) padding-box, linear-gradient(180deg, rgba(18,33,40,.94), rgba(14,24,31,.9)) padding-box; box-shadow: 0 24px 70px rgba(0,0,0,.36), 0 0 0 1px rgba(83,214,206,.14) inset; overflow: hidden; z-index: 26; }
.bottompanel-header { padding: 12px 52px 12px 12px; border-bottom: 1px solid rgba(83,214,206,.28); background: radial-gradient(380px 120px at 18% 0%, rgba(83,214,206,.28), transparent 70%), linear-gradient(180deg, rgba(25,137,124,.86), rgba(16,66,82,.64)); }
.bottompanel-toggle { position: absolute; right: 12px; top: 12px; }
.tabs { display: flex; gap: 10px; }
.tab { border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.08); color: rgba(214,235,241,.9); border-radius: 999px; padding: 8px 16px; cursor: pointer; font-weight: 900; font-size: 12px; }
.tab.is-on { border-color: rgba(83,214,206,.32); background: rgba(83,214,206,.24); }
.bottompanel.is-collapsed { height: 70px; }
.bottompanel-body { padding: 0 12px 12px; overflow: auto; height: calc(100% - 66px); }
.table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; }
.table th, .table td { text-align: left; padding: 12px 14px; border-bottom: 1px solid rgba(178,206,216,.16); }
.table th { color: rgba(219,240,246,.86); font-weight: 900; background: linear-gradient(90deg, rgba(12,38,52,.92), rgba(16,47,61,.86)); position: sticky; top: 0; z-index: 2; }
.table td { color: rgba(222,238,244,.86); background: linear-gradient(90deg, rgba(37,51,61,.42), rgba(31,45,54,.36)); }
.table tbody tr:hover td { background: linear-gradient(90deg, rgba(69,102,130,.36), rgba(56,86,113,.32)); }
.modal-mask { position: absolute; inset: 0; z-index: 90; display: grid; place-items: center; background: rgba(13,36,74,.08); backdrop-filter: blur(3px); }
.period-modal { width: min(520px, calc(100% - 32px)); border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(260px 160px at 18% 0%, rgba(83,214,206,.14), transparent 62%) padding-box, linear-gradient(180deg, rgba(18,26,31,.94), rgba(14,20,25,.86)) padding-box; box-shadow: 0 30px 80px rgba(0,0,0,.3); padding: 14px; }
.period-modal-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.period-modal-title { font-weight: 900; color: rgba(255,255,255,.9); font-size: 14px; }
.period-list { margin-top: 12px; border-top: 1px solid rgba(83,214,206,.2); padding-top: 12px; display: grid; gap: 8px; }
.period-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 12px; }
@media (max-width: 1300px) {
.module-topbar { left: 50%; max-width: calc(100% - 32px); transform: translateX(-50%); }
.sidepanel { width: 280px; }
.bottompanel { left: 330px; }
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<div class="progress-page">
<div class="canvas">
<header class="topbar"><div class="topbar-center"><div class="model-title">XXX特大桥主体模型.rvt</div></div></header>
<section class="model-stage">
<ModelPlaceholder />
</section>
<section class="module-topbar">
<div class="module-topbar-panel">
<div class="field field-time">
<select class="select" v-model="timeStatMode"><option value="cutoff">日期截止统计</option></select>
<input class="date-input" type="date" v-model="cutoffDateDraft" />
<button class="btn btn-sm btn-accent" type="button" @click="applyCutoffDate">查询</button>
<div class="kpi kpi-progress">整体完成百分比<span class="kpi-number">{{ formatPercentValue(overallPercent, 2) }}</span></div>
</div>
<div class="field field-percent">
<span class="field-label">进度百分比</span>
<label class="chip chip-status"><input type="checkbox" :checked="percentFilters.all" @change="togglePercentFilter('all', $event.target.checked)" /> 全部</label>
<label class="chip chip-status chip-gray"><input type="checkbox" :checked="percentFilters.p0" @change="togglePercentFilter('p0', $event.target.checked)" /> 0%</label>
<label class="chip chip-status chip-red"><input type="checkbox" :checked="percentFilters.p0_50" @change="togglePercentFilter('p0_50', $event.target.checked)" /> 0%-50%</label>
<label class="chip chip-status chip-blue"><input type="checkbox" :checked="percentFilters.p50_100" @change="togglePercentFilter('p50_100', $event.target.checked)" /> 50%-100%</label>
<label class="chip chip-status chip-green"><input type="checkbox" :checked="percentFilters.p100" @change="togglePercentFilter('p100', $event.target.checked)" /> 100%</label>
</div>
</div>
</section>
<aside class="sidepanel" :class="{ 'is-collapsed': sideCollapsed }">
<header class="sidepanel-header">
<div class="sidepanel-title">工程部位</div>
<button class="iconbtn" type="button" @click="sideCollapsed = !sideCollapsed">{{ sideCollapsed ? "" : "" }}</button>
</header>
<div class="sidepanel-body" v-show="!sideCollapsed">
<div class="tree">
<div class="tree-item" :class="{ 'is-active': row.leaf && row.id === selectedStructureId }" :style="{ paddingLeft: `${10 + row.level * 14}px` }" v-for="row in visibleTreeRows" :key="row.id" @click="onTreeRowClick(row)">
<button class="tree-caret" type="button" :class="{ 'is-leaf': row.leaf }" :disabled="row.leaf" @click.stop="toggleTreeExpand(row)">{{ row.leaf ? "" : row.open ? "" : "" }}</button>
<span class="tree-bullet"></span>
<span class="tree-text">{{ row.name }}</span>
</div>
</div>
</div>
</aside>
<section class="bottompanel" :class="{ 'is-collapsed': bottomCollapsed }">
<header class="bottompanel-header"><div class="tabs"><button class="tab is-on" type="button">项目日进度明细</button></div></header>
<button class="iconbtn bottompanel-toggle" type="button" @click="bottomCollapsed = !bottomCollapsed">{{ bottomCollapsed ? "" : "" }}</button>
<div class="bottompanel-body" v-show="!bottomCollapsed">
<table class="table">
<thead>
<tr>
<th>序号</th><th>全名称</th><th>清单编号</th><th>清单名称</th><th>单位</th><th>合同数量</th><th>合同金额</th><th>填报日期</th><th>完成数量</th><th>完成金额</th><th>累计完成数量</th>
</tr>
</thead>
<tbody>
<tr v-for="r in progressRows" :key="r.no">
<td>{{ r.no }}</td><td>{{ r.fullName }}</td><td>{{ r.code }}</td><td>{{ r.name }}</td><td>{{ r.unit }}</td><td>{{ formatNumber(r.contractQty,2) }}</td><td>{{ formatMoney(r.contractAmt) }}</td><td>{{ r.reportDate }}</td><td>{{ formatNumber(r.doneQty,2) }}</td><td>{{ formatMoney(r.doneAmt) }}</td><td>{{ formatNumber(r.cumDoneQty,2) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
const structures = [
{ id: "S-001", name: "主桥-0#墩" }, { id: "S-002", name: "主桥-1#墩" }, { id: "S-003", name: "主桥-2#墩" },
{ id: "S-004", name: "引桥-桩基" }, { id: "S-005", name: "引桥-承台" }, { id: "S-006", name: "引桥-盖梁" },
{ id: "S-007", name: "路基-填筑" }, { id: "S-008", name: "路面-基层" }, { id: "S-009", name: "路面-面层" },
];
const elementTree = [
{ id: "E-G-bridge", name: "桥梁工程", children: [
{ id: "E-G-main-bridge", name: "主桥", children: ["S-001", "S-002", "S-003"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
{ id: "E-G-approach", name: "引桥", children: ["S-004", "S-005", "S-006"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
] },
{ id: "E-G-road", name: "道路工程", children: ["S-007", "S-008", "S-009"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
];
const sideCollapsed = ref(false);
const bottomCollapsed = ref(false);
const selectedStructureId = ref("S-001");
const expanded = ref(new Set(["E-G-bridge", "E-G-main-bridge", "E-G-approach", "E-G-road"]));
const timeStatMode = ref("cutoff");
const baselineDate = ref(todayISODate());
const baselineOverallPercent = ref(61.54);
const cutoffDate = ref(todayISODate());
const cutoffDateDraft = ref(cutoffDate.value);
const percentFilters = reactive({ all: true, p0: false, p0_50: false, p50_100: false, p100: false });
const visibleTreeRows = computed(() => {
const rows = [];
const walk = (node, level) => {
const leaf = !node.children || node.children.length === 0;
const open = expanded.value.has(node.id);
rows.push({ id: node.id, name: node.name, level, leaf, open });
if (!leaf && open) node.children.forEach((c) => walk(c, level + 1));
};
elementTree.forEach((n) => walk(n, 0));
return rows;
});
const selectedStructureName = computed(() => structures.find((s) => s.id === selectedStructureId.value)?.name || "");
const overallPercent = computed(() => {
const deltaDays = daysBetweenISO(baselineDate.value, cutoffDate.value);
return clamp(baselineOverallPercent.value + deltaDays * 0.06, 0, 100);
});
const progressRows = computed(() => {
const reportDate = cutoffDate.value;
return Array.from({ length: 16 }, (_, i) => {
const contractQty = 30 + i * 2.2;
const unitPrice = 860 + i * 30;
const contractAmt = contractQty * unitPrice;
const doneQty = contractQty * (0.03 + (i % 6) * 0.06);
const doneAmt = doneQty * unitPrice;
const cumDoneQty = contractQty * (0.18 + (i % 5) * 0.14);
return {
no: i + 1,
fullName: selectedStructureName.value || structures[i % structures.length].name,
code: `BOQ-${String(5001 + i)}`,
name: ["混凝土浇筑", "钢筋制安", "模板工程", "土方开挖", "路基填筑", "防水层"][i % 6],
unit: ["m³", "t", "㎡", "m³", "m³", "㎡"][i % 6],
contractQty,
contractAmt,
reportDate,
doneQty,
doneAmt,
cumDoneQty,
};
});
});
function todayISODate() {
return new Date().toISOString().slice(0, 10);
}
function daysBetweenISO(a, b) {
const da = new Date(`${a}T00:00:00`);
const db = new Date(`${b}T00:00:00`);
return Math.round((db.getTime() - da.getTime()) / 86400000);
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function formatPercentValue(value, digits = 2) {
return `${Number(value).toFixed(digits)}%`;
}
function formatMoney(value) {
if (value == null || Number.isNaN(value)) return "--";
const abs = Math.abs(value);
const sign = value < 0 ? "-" : "";
if (abs >= 1e8) return `${sign}${(abs / 1e8).toFixed(2)}亿`;
if (abs >= 1e4) return `${sign}${(abs / 1e4).toFixed(2)}`;
return `${sign}${abs.toFixed(2)}`;
}
function formatNumber(value, digits = 0) {
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function applyCutoffDate() {
cutoffDate.value = cutoffDateDraft.value || cutoffDate.value || todayISODate();
}
function applyAllExclusive(key, checked) {
if (key === "all") {
percentFilters.all = checked;
if (checked) {
percentFilters.p0 = false;
percentFilters.p0_50 = false;
percentFilters.p50_100 = false;
percentFilters.p100 = false;
}
return;
}
percentFilters[key] = checked;
if (checked) percentFilters.all = false;
if (!percentFilters.p0 && !percentFilters.p0_50 && !percentFilters.p50_100 && !percentFilters.p100) percentFilters.all = true;
}
function togglePercentFilter(key, checked) { applyAllExclusive(key, checked); }
function toggleTreeExpand(row) { if (!row.leaf) (expanded.value.has(row.id) ? expanded.value.delete(row.id) : expanded.value.add(row.id)); }
function onTreeRowClick(row) { if (row.leaf) selectedStructureId.value = row.id; else toggleTreeExpand(row); }
</script>
<style scoped>
.progress-page { min-height: 100vh; font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif; }
.canvas { position: relative; min-height: 100vh; overflow: hidden; border-radius: 24px; border: 1px solid rgba(98,191,206,.34); background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%); box-shadow: inset 0 0 0 8px rgba(214,230,241,.55); }
.topbar { position: absolute; inset: 0 0 auto; height: 120px; z-index: 30; }
.topbar-center { position: absolute; left: 50%; transform: translateX(-50%); top: 16px; }
.model-title { font-size: 20px; letter-spacing: 1px; font-weight: 700; color: rgba(255,128,52,.95); text-shadow: 0 2px 10px rgba(0,0,0,.25); }
.model-stage { position: absolute; inset: 0; }
.module-topbar { position: absolute; left: 50%; top: 20px; transform: translateX(-50%); width: min(800px, calc(100% - 180px)); z-index: 99; }
.module-topbar-panel { border-radius: 18px; border: 1px solid rgba(83,214,206,.26); background: radial-gradient(420px 120px at 18% 0%, rgba(83,214,206,.22), transparent 70%), linear-gradient(180deg, rgba(18,32,40,.94), rgba(15,25,32,.88)); box-shadow: 0 20px 56px rgba(0,0,0,.28), 0 0 0 1px rgba(83,214,206,.12) inset; padding: 10px 14px; display: grid; gap: 10px; }
.field { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.field-time { flex-wrap: nowrap; }
.field-percent { flex-wrap: nowrap; }
.select, .date-input { height: 34px; border-radius: 10px; border: 1px solid rgba(255,255,255,.14); background: rgba(0,0,0,.18); color: rgba(255,255,255,.9); padding: 0 10px; font-size: 13px; font-weight: 700; }
.date-input { width: 150px; }
.btn { appearance: none; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.08); color: rgba(255,255,255,.88); border-radius: 12px; padding: 8px 10px; cursor: pointer; }
.btn-sm { padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 800; }
.btn-accent { border-color: rgba(83,214,206,.22); background: rgba(43,191,178,.22); }
.kpi { margin-left: auto; font-size: 13px; font-weight: 800; color: rgba(236,248,251,.86); white-space: nowrap; }
.kpi-number { font-weight: 900; color: rgba(83,214,206,.95); margin-left: 4px; }
.field-label { font-weight: 900; color: rgba(244,252,255,.92); font-size: 14px; white-space: nowrap; }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 999px; border: 1px solid rgba(255,255,255,.12); background: rgba(0,0,0,.2); color: rgba(255,255,255,.88); font-weight: 800; font-size: 13px; white-space: nowrap; }
.chip input { width: 13px; height: 13px; margin: 0; accent-color: rgba(64,224,208,.92); }
.chip-status::before { content: ""; width: 10px; height: 10px; border-radius: 3px; border: 1px solid rgba(255,255,255,.2); background: transparent; }
.chip-status.chip-gray::before { border-color: rgba(110,125,150,.45); background: rgba(110,125,150,.24); }
.chip-status.chip-red::before { border-color: rgba(217,54,62,.4); background: rgba(217,54,62,.22); }
.chip-status.chip-blue::before { border-color: rgba(21,108,255,.4); background: rgba(21,108,255,.22); }
.chip-status.chip-green::before { border-color: rgba(47,143,47,.4); background: rgba(47,143,47,.22); }
.sidepanel { position: absolute; left: 16px; top: 175px; width: 320px; bottom: 100px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(260px 140px at 8% 0%, rgba(63,203,191,.16), transparent 62%) padding-box, linear-gradient(180deg, rgba(20,31,37,.92), rgba(15,23,29,.88)) padding-box; box-shadow: 0 22px 60px rgba(0,0,0,.34), 0 0 0 1px rgba(83,214,206,.12) inset; overflow: hidden; z-index: 25; }
.sidepanel.is-collapsed { width: 64px; }
.sidepanel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 10px 10px 12px; border-bottom: 1px solid rgba(83,214,206,.16); background: radial-gradient(360px 100px at 10% 0%, rgba(83,214,206,.24), transparent 68%), linear-gradient(180deg, rgba(28,134,122,.84), rgba(15,60,74,.62)); }
.sidepanel-title { font-size: 14px; font-weight: 900; color: rgba(207,247,242,.96); }
.iconbtn { width: 30px; height: 30px; border-radius: 10px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.42); color: rgba(11,27,58,.86); font-weight: 900; cursor: pointer; }
.sidepanel-body { height: calc(100% - 52px); overflow: auto; padding: 8px; }
.tree { display: grid; gap: 8px; }
.tree-item { display: grid; grid-template-columns: 20px 10px 1fr; align-items: center; gap: 8px; min-height: 40px; border-radius: 14px; border: 1px solid rgba(154,186,198,.22); background: linear-gradient(180deg, rgba(44,58,68,.58), rgba(37,49,58,.54)); cursor: pointer; }
.tree-item:hover { border-color: rgba(83,214,206,.32); background: linear-gradient(180deg, rgba(51,67,78,.66), rgba(43,57,67,.62)); }
.tree-item.is-active { border-color: rgba(132,188,255,.3); background: linear-gradient(180deg, rgba(112,146,198,.34), rgba(95,128,180,.28)); }
.tree-caret { border: 0; background: transparent; color: rgba(203,230,236,.88); cursor: pointer; font-size: 14px; }
.tree-caret.is-leaf { cursor: default; }
.tree-bullet { width: 10px; height: 10px; border-radius: 50%; background: rgba(83,214,206,.85); }
.tree-text { font-size: 13px; font-weight: 800; color: rgba(222,238,244,.9); }
.bottompanel { position: absolute; left: 370px; right: 16px; bottom: 100px; height: 390px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(420px 180px at 14% 0%, rgba(83,214,206,.2), transparent 66%) padding-box, radial-gradient(520px 220px at 82% 0%, rgba(40,156,228,.14), transparent 70%) padding-box, linear-gradient(180deg, rgba(18,33,40,.94), rgba(14,24,31,.9)) padding-box; box-shadow: 0 24px 70px rgba(0,0,0,.36), 0 0 0 1px rgba(83,214,206,.14) inset; overflow: hidden; z-index: 26; }
.bottompanel-header { padding: 12px 52px 12px 12px; border-bottom: 1px solid rgba(83,214,206,.28); background: radial-gradient(380px 120px at 18% 0%, rgba(83,214,206,.28), transparent 70%), linear-gradient(180deg, rgba(25,137,124,.86), rgba(16,66,82,.64)); }
.bottompanel-toggle { position: absolute; right: 12px; top: 12px; }
.tabs { display: flex; gap: 10px; }
.tab { border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.08); color: rgba(214,235,241,.9); border-radius: 999px; padding: 8px 16px; cursor: pointer; font-weight: 900; font-size: 12px; }
.tab.is-on { border-color: rgba(83,214,206,.32); background: rgba(83,214,206,.24); }
.bottompanel.is-collapsed { height: 70px; }
.bottompanel-body { padding: 0 12px 12px; overflow: auto; height: calc(100% - 66px); }
.table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; }
.table th, .table td { text-align: left; padding: 12px 14px; border-bottom: 1px solid rgba(178,206,216,.16); }
.table th { color: rgba(219,240,246,.86); font-weight: 900; background: linear-gradient(90deg, rgba(12,38,52,.92), rgba(16,47,61,.86)); position: sticky; top: 0; z-index: 2; }
.table td { color: rgba(222,238,244,.86); background: linear-gradient(90deg, rgba(37,51,61,.42), rgba(31,45,54,.36)); }
.table tbody tr:hover td { background: linear-gradient(90deg, rgba(69,102,130,.36), rgba(56,86,113,.32)); }
@media (max-width: 1300px) {
.module-topbar { width: calc(100% - 32px); }
.field-time, .field-percent { flex-wrap: wrap; }
.kpi { margin-left: 0; }
.sidepanel { width: 280px; }
.bottompanel { left: 330px; }
}
</style>

250
src/pages/project/index.vue Normal file
View File

@@ -0,0 +1,250 @@
<template>
<div class="project-page">
<PageCanvas>
<SidePanel v-model:collapsed="sideCollapsed" title="项目|左侧区">
<div class="tree">
<div
class="tree-item"
:class="{ 'is-active': row.leaf && row.id === selectedStructureId }"
:style="{ paddingLeft: `${10 + row.level * 14}px` }"
v-for="row in visibleTreeRows"
:key="row.id"
@click="onTreeRowClick(row)"
>
<button
class="tree-caret"
type="button"
:class="{ 'is-leaf': row.leaf }"
:disabled="row.leaf"
@click.stop="toggleTreeExpand(row)"
>
{{ row.leaf ? "•" : row.open ? "▾" : "▸" }}
</button>
<span class="tree-bullet"></span>
<span class="tree-text">{{ row.name }}</span>
</div>
</div>
</SidePanel>
<BottomPanel v-model:collapsed="bottomCollapsed">
<template #header>
<div class="tabs">
<button class="tab" :class="{ 'is-on': activeTab === 'boq_decompose' }" type="button" @click="activeTab = 'boq_decompose'">分解清单</button>
<button class="tab" :class="{ 'is-on': activeTab === 'materials' }" type="button" @click="activeTab = 'materials'">项目主材</button>
<button class="tab" type="button" disabled>模型汇总未选择</button>
</div>
</template>
<table class="table" v-if="activeTab === 'boq_decompose'">
<thead>
<tr>
<th>清单编号</th>
<th>清单名称</th>
<th>单位</th>
<th>分解数量</th>
<th>变更后数量</th>
<th>计量数量</th>
<th>计量完成比例</th>
<th>已完成数量</th>
<th>完成比</th>
</tr>
</thead>
<tbody>
<tr v-for="r in decomposeRows" :key="r.code">
<td>{{ r.code }}</td>
<td>{{ r.name }}</td>
<td>{{ r.unit }}</td>
<td>{{ formatNumber(r.decomposeQty, 2) }}</td>
<td>{{ formatNumber(r.changedQty, 2) }}</td>
<td>{{ formatNumber(r.measuredQty, 2) }}</td>
<td>{{ formatPercent(r.measureRate) }}</td>
<td>{{ formatNumber(r.doneQty, 2) }}</td>
<td>{{ formatPercent(r.doneRate) }}</td>
</tr>
</tbody>
</table>
<table class="table" v-else>
<thead>
<tr><th>材料编码</th><th>名称</th><th>单位</th><th>数量</th></tr>
</thead>
<tbody>
<tr v-for="r in materialRows" :key="r.code">
<td>{{ r.code }}</td>
<td>{{ r.name }}</td>
<td>{{ r.unit }}</td>
<td>{{ r.qty }}</td>
</tr>
</tbody>
</table>
</BottomPanel>
</PageCanvas>
</div>
</template>
<script setup>
import { computed, ref } from "vue";
import PageCanvas from "../../components/layout/PageCanvas.vue";
import SidePanel from "../../components/layout/SidePanel.vue";
import BottomPanel from "../../components/layout/BottomPanel.vue";
import { structures, elementTree } from "../../constants/structures.js";
import { projects, getDecomposeRows, getMaterialRows } from "../../constants/mock.js";
import { formatNumber, formatPercent } from "../../utils/format.js";
const currentProjectId = ref("qz-a2");
const selectedStructureId = ref("S-001");
const sideCollapsed = ref(false);
const bottomCollapsed = ref(false);
const activeTab = ref("boq_decompose");
const expanded = ref(new Set(["E-G-bridge", "E-G-main-bridge", "E-G-approach", "E-G-road"]));
const currentProject = computed(() => projects.find((p) => p.id === currentProjectId.value) || projects[0]);
const structureIndex = computed(() => Math.max(0, structures.findIndex((s) => s.id === selectedStructureId.value)));
const visibleTreeRows = computed(() => {
const rows = [];
const walk = (node, level) => {
const leaf = !node.children || node.children.length === 0;
const open = expanded.value.has(node.id);
rows.push({ id: node.id, name: node.name, level, leaf, open });
if (!leaf && open) node.children.forEach((c) => walk(c, level + 1));
};
elementTree.forEach((n) => walk(n, 0));
return rows;
});
const decomposeRows = computed(() => getDecomposeRows(structureIndex.value, currentProject.value.scale));
const materialRows = computed(() => getMaterialRows(structureIndex.value, currentProject.value.scale));
function toggleTreeExpand(row) {
if (row.leaf) return;
if (expanded.value.has(row.id)) expanded.value.delete(row.id);
else expanded.value.add(row.id);
}
function onTreeRowClick(row) {
if (row.leaf) {
selectedStructureId.value = row.id;
return;
}
toggleTreeExpand(row);
}
</script>
<style scoped>
.project-page {
min-height: 98vh;
font-family: var(--bim-font-cn);
}
.tree {
display: grid;
gap: 8px;
}
.tree-item {
display: grid;
grid-template-columns: 20px 10px 1fr;
align-items: center;
gap: 8px;
min-height: 44px;
border-radius: 14px;
border: 1px solid rgba(154, 186, 198, 0.22);
background: linear-gradient(180deg, rgba(44, 58, 68, 0.58), rgba(37, 49, 58, 0.54));
cursor: pointer;
}
.tree-item:hover {
border-color: rgba(83, 214, 206, 0.32);
background: linear-gradient(180deg, rgba(51, 67, 78, 0.66), rgba(43, 57, 67, 0.62));
}
.tree-item.is-active {
border-color: rgba(132, 188, 255, 0.3);
background: linear-gradient(180deg, rgba(112, 146, 198, 0.34), rgba(95, 128, 180, 0.28));
}
.tree-caret {
border: 0;
background: transparent;
color: rgba(203, 230, 236, 0.88);
cursor: pointer;
font-size: 14px;
}
.tree-caret.is-leaf {
cursor: default;
}
.tree-bullet {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(83, 214, 206, 0.85);
}
.tree-text {
font-size: 13px;
font-weight: 800;
color: rgba(222, 238, 244, 0.9);
}
.tabs {
display: flex;
gap: 10px;
}
.tab {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
color: rgba(214, 235, 241, 0.9);
border-radius: 999px;
padding: 8px 16px;
cursor: pointer;
font-weight: 900;
font-size: 12px;
}
.tab.is-on {
border-color: rgba(83, 214, 206, 0.32);
background: rgba(83, 214, 206, 0.24);
}
.tab:disabled {
opacity: 0.7;
cursor: default;
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 14px;
}
.table th,
.table td {
text-align: left;
padding: 12px 14px;
border-bottom: 1px solid rgba(178, 206, 216, 0.16);
}
.table th {
color: rgba(219, 240, 246, 0.86);
font-weight: 900;
background: linear-gradient(90deg, rgba(12, 38, 52, 0.92), rgba(16, 47, 61, 0.86));
position: sticky;
top: 0;
z-index: 2;
}
.table td {
color: rgba(222, 238, 244, 0.86);
background: linear-gradient(90deg, rgba(37, 51, 61, 0.42), rgba(31, 45, 54, 0.36));
}
.table tbody tr:hover td {
background: linear-gradient(90deg, rgba(69, 102, 130, 0.36), rgba(56, 86, 113, 0.32));
}
</style>

View File

@@ -0,0 +1,651 @@
<template>
<div class="subcontract-page">
<div class="canvas">
<header class="topbar">
<div class="topbar-center">
<div class="model-title">XXX特大桥主体模型.rvt</div>
</div>
</header>
<section class="model-stage">
<ModelPlaceholder />
</section>
<aside class="sidepanel" :class="{ 'is-collapsed': sideCollapsed }">
<header class="sidepanel-header">
<div class="sidepanel-title">分包左侧区</div>
<button class="iconbtn" type="button" @click="sideCollapsed = !sideCollapsed">{{ sideCollapsed ? "" : "" }}</button>
</header>
<div class="sidepanel-body" v-show="!sideCollapsed">
<section class="placeholder">
<div class="placeholder-top">
<div class="placeholder-title">合同分包构件树</div>
<button class="btn btn-sm" type="button" @click="openContractModal">选择合同</button>
</div>
<div class="placeholder-sub">当前合同<b>{{ selectedContractId }}</b></div>
</section>
<div class="tree">
<div class="tree-item" :class="{ 'is-active': row.leaf && row.id === selectedStructureId }" :style="{ paddingLeft: `${10 + row.level * 14}px` }" v-for="row in visibleTreeRows" :key="row.id" @click="onTreeRowClick(row)">
<button class="tree-caret" type="button" :class="{ 'is-leaf': row.leaf }" :disabled="row.leaf" @click.stop="toggleTreeExpand(row)">{{ row.leaf ? "" : row.open ? "" : "" }}</button>
<span class="tree-bullet"></span>
<span class="tree-text">{{ row.name }}</span>
</div>
</div>
</div>
</aside>
<section class="bottompanel" :class="{ 'is-collapsed': bottomCollapsed }">
<header class="bottompanel-header">
<div class="tabs">
<button class="tab" :class="{ 'is-on': activeTab === 'sub_boq' }" type="button" @click="activeTab = 'sub_boq'">分包清单</button>
<button class="tab" :class="{ 'is-on': activeTab === 'sub_measure' }" type="button" @click="activeTab = 'sub_measure'">分包计量</button>
<button class="tab" :class="{ 'is-on': activeTab === 'boq' }" type="button" @click="activeTab = 'boq'">分解清单</button>
<button class="tab" :class="{ 'is-on': activeTab === 'materials' }" type="button" @click="activeTab = 'materials'">项目主材</button>
</div>
</header>
<button class="iconbtn bottompanel-toggle" type="button" @click="bottomCollapsed = !bottomCollapsed">{{ bottomCollapsed ? "" : "" }}</button>
<div class="bottompanel-body" v-show="!bottomCollapsed">
<table class="table" v-if="activeTab === 'sub_boq'">
<thead>
<tr>
<th>清单编号</th>
<th>清单名称</th>
<th>单位</th>
<th>分包内容</th>
<th>分包单位</th>
<th>含税单价</th>
<th>合同数量</th>
<th>合同金额</th>
<th>累计产值</th>
</tr>
</thead>
<tbody>
<tr v-for="r in subcontractRows" :key="r.code">
<td>{{ r.code }}</td>
<td>{{ r.name }}</td>
<td>{{ r.unit }}</td>
<td>{{ r.content }}</td>
<td>{{ r.vendor }}</td>
<td>{{ formatYuan(r.unitPrice) }}</td>
<td>{{ formatNumber(r.qty, 2) }}</td>
<td>{{ formatMoney(r.amount) }}</td>
<td>{{ formatMoney(r.cumValue) }}</td>
</tr>
</tbody>
</table>
<div class="placeholder" v-else>
<div class="placeholder-sub">当前合同{{ selectedContractId }}选中结构{{ selectedStructureName || "未选择" }}占位</div>
</div>
</div>
</section>
<div class="modal-mask" v-show="contractModalOpen" @click.self="closeContractModal">
<div class="contract-modal">
<div class="contract-modal-head">
<div class="contract-modal-title">协作合同选择</div>
<button class="iconbtn" type="button" @click="closeContractModal"></button>
</div>
<div class="contract-filters">
<input class="input" placeholder="合同编号" v-model="contractFilterNo" />
<input class="input" placeholder="队伍名称" v-model="contractFilterTeam" />
<input class="input" placeholder="签订日期YYYY-MM-DD" v-model="contractFilterDate" />
</div>
<div class="contract-list">
<table class="table">
<thead>
<tr>
<th>合同编号</th>
<th>合同名称</th>
<th>协作队伍名称</th>
<th>合同额</th>
<th>合同签订日期</th>
</tr>
</thead>
<tbody>
<tr v-for="c in filteredContracts" :key="c.id" :class="{ 'is-active': c.id === contractDraftId }" @click="contractDraftId = c.id">
<td>{{ c.no }}</td>
<td>{{ c.name }}</td>
<td>{{ c.team }}</td>
<td>{{ formatMoney(c.amount) }}</td>
<td>{{ c.date }}</td>
</tr>
</tbody>
</table>
</div>
<div class="contract-actions">
<button class="btn btn-ghost" type="button" @click="closeContractModal">取消</button>
<button class="btn" type="button" @click="confirmContract">确认</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from "vue";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
const structures = [
{ id: "S-001", name: "主桥-0#墩" },
{ id: "S-002", name: "主桥-1#墩" },
{ id: "S-003", name: "主桥-2#墩" },
{ id: "S-004", name: "引桥-桩基" },
{ id: "S-005", name: "引桥-承台" },
{ id: "S-006", name: "引桥-盖梁" },
{ id: "S-007", name: "路基-填筑" },
{ id: "S-008", name: "路面-基层" },
{ id: "S-009", name: "路面-面层" },
];
const contracts = [
{ id: "sc-001", no: "SC-2025-001", name: "桥梁下部结构劳务分包", team: "一分包队伍", amount: 8600000, date: "2025-03-12" },
{ id: "sc-002", no: "SC-2025-002", name: "引桥上部结构劳务分包", team: "二分包队伍", amount: 6250000, date: "2025-04-08" },
{ id: "sc-003", no: "SC-2025-003", name: "路基路面专业分包", team: "三分包队伍", amount: 9800000, date: "2025-05-20" },
];
const elementTree = [
{
id: "E-G-bridge",
name: "桥梁工程",
children: [
{ id: "E-G-main-bridge", name: "主桥", children: ["S-001", "S-002", "S-003"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
{ id: "E-G-approach", name: "引桥", children: ["S-004", "S-005", "S-006"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
],
},
{ id: "E-G-road", name: "道路工程", children: ["S-007", "S-008", "S-009"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
];
const selectedContractId = ref("sc-001");
const selectedStructureId = ref("S-001");
const sideCollapsed = ref(false);
const bottomCollapsed = ref(false);
const activeTab = ref("sub_boq");
const expanded = ref(new Set(["E-G-bridge", "E-G-main-bridge", "E-G-approach", "E-G-road"]));
const contractModalOpen = ref(false);
const contractDraftId = ref(selectedContractId.value);
const contractFilterNo = ref("");
const contractFilterTeam = ref("");
const contractFilterDate = ref("");
const selectedStructureName = computed(() => structures.find((s) => s.id === selectedStructureId.value)?.name || "");
const selectedVendor = computed(() =>
selectedContractId.value === "sc-001" ? "一分包队伍" : selectedContractId.value === "sc-002" ? "二分包队伍" : "三分包队伍"
);
const visibleTreeRows = computed(() => {
const rows = [];
const walk = (node, level) => {
const leaf = !node.children || node.children.length === 0;
const open = expanded.value.has(node.id);
rows.push({ id: node.id, name: node.name, level, leaf, open });
if (!leaf && open) node.children.forEach((c) => walk(c, level + 1));
};
elementTree.forEach((n) => walk(n, 0));
return rows;
});
const filteredContracts = computed(() => {
const qNo = contractFilterNo.value.trim();
const qTeam = contractFilterTeam.value.trim();
const qDate = contractFilterDate.value.trim();
return contracts
.filter((c) => (qNo ? c.no.includes(qNo) : true))
.filter((c) => (qTeam ? c.team.includes(qTeam) : true))
.filter((c) => (qDate ? c.date.includes(qDate) : true));
});
const subcontractRows = computed(() => {
const structure = selectedStructureName.value;
return Array.from({ length: 14 }, (_, i) => {
const qty = 20 + i * 3;
const unitPrice = 680 + i * 35;
const amount = qty * unitPrice;
const cumValue = amount * (0.2 + (i % 5) * 0.12);
return {
code: `SUB-${String(2001 + i)}`,
name: `${["钢筋制安", "混凝土浇筑", "模板工程", "支架搭设", "土方外运", "路基填筑"][i % 6]}${structure ? `${structure}` : ""}`,
unit: ["t", "m³", "㎡", "m³", "m³", "m³"][i % 6],
content: ["劳务分包", "专业分包", "机械配合", "材料辅材"][i % 4],
vendor: selectedVendor.value,
unitPrice,
qty,
amount,
cumValue,
};
});
});
function formatMoney(value) {
if (value == null || Number.isNaN(value)) return "--";
const abs = Math.abs(value);
const sign = value < 0 ? "-" : "";
if (abs >= 1e8) return `${sign}${(abs / 1e8).toFixed(2)}亿`;
if (abs >= 1e4) return `${sign}${(abs / 1e4).toFixed(2)}`;
return `${sign}${abs.toFixed(2)}`;
}
function formatYuan(value) {
if (value == null || Number.isNaN(value)) return "--";
return `${Number(value).toFixed(2)}`;
}
function formatNumber(value, digits = 0) {
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function toggleTreeExpand(row) {
if (row.leaf) return;
if (expanded.value.has(row.id)) expanded.value.delete(row.id);
else expanded.value.add(row.id);
}
function onTreeRowClick(row) {
if (row.leaf) {
selectedStructureId.value = row.id;
return;
}
toggleTreeExpand(row);
}
function closeContractModal() {
contractModalOpen.value = false;
}
function openContractModal() {
contractDraftId.value = selectedContractId.value;
contractModalOpen.value = true;
}
function confirmContract() {
selectedContractId.value = contractDraftId.value;
contractModalOpen.value = false;
}
</script>
<style scoped>
.subcontract-page {
min-height: 100vh;
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
}
.canvas {
position: relative;
min-height: 100vh;
overflow: hidden;
border-radius: 24px;
border: 1px solid rgba(98, 191, 206, 0.34);
background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%);
box-shadow: inset 0 0 0 8px rgba(214, 230, 241, 0.55);
}
.topbar {
position: absolute;
inset: 0 0 auto;
height: 120px;
z-index: 30;
}
.topbar-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 16px;
display: grid;
justify-items: center;
}
.model-title {
font-size: 20px;
letter-spacing: 1px;
font-weight: 700;
color: rgba(255, 128, 52, 0.95);
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
}
.model-stage {
position: absolute;
inset: 0;
}
.sidepanel {
position: absolute;
left: 16px;
top: 175px;
width: 320px;
bottom: 100px;
border-radius: 18px;
border: 1px solid rgba(83, 214, 206, 0.24);
background:
radial-gradient(260px 140px at 8% 0%, rgba(63, 203, 191, 0.16), transparent 62%) padding-box,
linear-gradient(180deg, rgba(20, 31, 37, 0.92), rgba(15, 23, 29, 0.88)) padding-box;
box-shadow:
0 22px 60px rgba(0, 0, 0, 0.34),
0 0 0 1px rgba(83, 214, 206, 0.12) inset;
overflow: hidden;
z-index: 25;
}
.sidepanel.is-collapsed {
width: 64px;
}
.sidepanel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 10px 10px 12px;
border-bottom: 1px solid rgba(83, 214, 206, 0.16);
background:
radial-gradient(360px 100px at 10% 0%, rgba(83, 214, 206, 0.24), transparent 68%),
linear-gradient(180deg, rgba(28, 134, 122, 0.84), rgba(15, 60, 74, 0.62));
}
.sidepanel-title {
font-size: 14px;
font-weight: 900;
color: rgba(207, 247, 242, 0.96);
}
.iconbtn {
width: 30px;
height: 30px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.42);
color: rgba(11, 27, 58, 0.86);
font-weight: 900;
cursor: pointer;
}
.sidepanel-body {
height: calc(100% - 52px);
overflow: auto;
padding: 8px;
}
.placeholder {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.14);
border-radius: 14px;
padding: 10px;
margin-bottom: 10px;
}
.placeholder-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.placeholder-title {
color: rgba(222, 238, 244, 0.9);
font-size: 13px;
font-weight: 900;
}
.placeholder-sub {
margin-top: 8px;
color: rgba(222, 238, 244, 0.74);
font-size: 12px;
line-height: 1.5;
}
.btn {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.88);
border-radius: 12px;
padding: 8px 10px;
cursor: pointer;
}
.btn-sm {
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.btn-ghost {
background: rgba(255, 255, 255, 0.04);
}
.tree {
display: grid;
gap: 8px;
}
.tree-item {
display: grid;
grid-template-columns: 20px 10px 1fr;
align-items: center;
gap: 8px;
min-height: 40px;
border-radius: 14px;
border: 1px solid rgba(154, 186, 198, 0.22);
background: linear-gradient(180deg, rgba(44, 58, 68, 0.58), rgba(37, 49, 58, 0.54));
cursor: pointer;
}
.tree-item:hover {
border-color: rgba(83, 214, 206, 0.32);
background: linear-gradient(180deg, rgba(51, 67, 78, 0.66), rgba(43, 57, 67, 0.62));
}
.tree-item.is-active {
border-color: rgba(132, 188, 255, 0.3);
background: linear-gradient(180deg, rgba(112, 146, 198, 0.34), rgba(95, 128, 180, 0.28));
}
.tree-caret {
border: 0;
background: transparent;
color: rgba(203, 230, 236, 0.88);
cursor: pointer;
font-size: 14px;
}
.tree-caret.is-leaf {
cursor: default;
}
.tree-bullet {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(83, 214, 206, 0.85);
}
.tree-text {
font-size: 13px;
font-weight: 800;
color: rgba(222, 238, 244, 0.9);
}
.bottompanel {
position: absolute;
left: 370px;
right: 16px;
bottom: 100px;
height: 390px;
border-radius: 18px;
border: 1px solid rgba(83, 214, 206, 0.24);
background:
radial-gradient(420px 180px at 14% 0%, rgba(83, 214, 206, 0.2), transparent 66%) padding-box,
radial-gradient(520px 220px at 82% 0%, rgba(40, 156, 228, 0.14), transparent 70%) padding-box,
linear-gradient(180deg, rgba(18, 33, 40, 0.94), rgba(14, 24, 31, 0.9)) padding-box;
box-shadow:
0 24px 70px rgba(0, 0, 0, 0.36),
0 0 0 1px rgba(83, 214, 206, 0.14) inset;
overflow: hidden;
z-index: 26;
}
.bottompanel-header {
padding: 12px 52px 12px 12px;
border-bottom: 1px solid rgba(83, 214, 206, 0.28);
background:
radial-gradient(380px 120px at 18% 0%, rgba(83, 214, 206, 0.28), transparent 70%),
linear-gradient(180deg, rgba(25, 137, 124, 0.86), rgba(16, 66, 82, 0.64));
}
.bottompanel-toggle {
position: absolute;
right: 12px;
top: 12px;
}
.tabs {
display: flex;
gap: 10px;
}
.tab {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
color: rgba(214, 235, 241, 0.9);
border-radius: 999px;
padding: 8px 16px;
cursor: pointer;
font-weight: 900;
font-size: 12px;
}
.tab.is-on {
border-color: rgba(83, 214, 206, 0.32);
background: rgba(83, 214, 206, 0.24);
}
.bottompanel.is-collapsed {
height: 70px;
}
.bottompanel-body {
padding: 0 12px 12px;
overflow: auto;
height: calc(100% - 66px);
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 14px;
}
.table th,
.table td {
text-align: left;
padding: 12px 14px;
border-bottom: 1px solid rgba(178, 206, 216, 0.16);
}
.table th {
color: rgba(219, 240, 246, 0.86);
font-weight: 900;
background: linear-gradient(90deg, rgba(12, 38, 52, 0.92), rgba(16, 47, 61, 0.86));
position: sticky;
top: 0;
z-index: 2;
}
.table td {
color: rgba(222, 238, 244, 0.86);
background: linear-gradient(90deg, rgba(37, 51, 61, 0.42), rgba(31, 45, 54, 0.36));
}
.table tbody tr:hover td {
background: linear-gradient(90deg, rgba(69, 102, 130, 0.36), rgba(56, 86, 113, 0.32));
}
.table tbody tr.is-active td {
background: linear-gradient(90deg, rgba(60, 110, 132, 0.46), rgba(54, 98, 120, 0.42));
}
.modal-mask {
position: absolute;
inset: 0;
z-index: 90;
display: grid;
place-items: center;
background: rgba(13, 36, 74, 0.08);
backdrop-filter: blur(3px);
}
.contract-modal {
width: min(640px, calc(100% - 32px));
border-radius: 18px;
border: 1px solid rgba(83, 214, 206, 0.24);
background:
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.3);
padding: 14px;
}
.contract-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.contract-modal-title {
font-weight: 900;
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
.contract-filters {
display: flex;
gap: 10px;
margin-top: 12px;
flex-wrap: wrap;
}
.contract-filters .input {
flex: 1;
min-width: 160px;
padding: 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.88);
outline: none;
}
.contract-list {
margin-top: 12px;
border-top: 1px solid rgba(83, 214, 206, 0.2);
padding-top: 12px;
max-height: 340px;
overflow: auto;
}
.contract-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 12px;
}
@media (max-width: 1300px) {
.sidepanel {
width: 280px;
}
.bottompanel {
left: 330px;
}
}
</style>

34
src/router/index.js Normal file
View File

@@ -0,0 +1,34 @@
import { createRouter, createWebHistory } from "vue-router";
import HomePage from "../pages/home/index.vue";
import ProjectPage from "../pages/project/index.vue";
import SubcontractPage from "../pages/subcontract/index.vue";
import MeasurementPage from "../pages/measurement/index.vue";
import PlanPage from "../pages/plan/index.vue";
import ProgressPage from "../pages/progress/index.vue";
import ChangePage from "../pages/change/index.vue";
import MaterialPage from "../pages/material/index.vue";
import InspectionPage from "../pages/inspection/index.vue";
import DebugPage from "../pages/debug/index.vue";
const routes = [
{ path: "/", redirect: "/home" },
{ path: "/home", component: HomePage },
{ path: "/project", component: ProjectPage },
{ path: "/subcontract", component: SubcontractPage },
{ path: "/measurement", component: MeasurementPage },
{ path: "/plan", component: PlanPage },
{ path: "/progress", component: ProgressPage },
{ path: "/change", component: ChangePage },
{ path: "/material", component: MaterialPage },
{ path: "/inspection", component: InspectionPage },
{ path: "/debug", component: DebugPage },
{ path: "/:pathMatch(.*)*", redirect: "/home" },
];
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;

15
src/stores/app.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineStore } from "pinia";
export const useAppStore = defineStore("app", {
state: () => ({
devSidebarCollapsed: false,
}),
actions: {
toggleDevSidebar() {
this.devSidebarCollapsed = !this.devSidebarCollapsed;
},
setDevSidebarCollapsed(val) {
this.devSidebarCollapsed = !!val;
},
},
});

3
src/stores/index.js Normal file
View File

@@ -0,0 +1,3 @@
import { createPinia } from "pinia";
export const pinia = createPinia();

28
src/utils/format.js Normal file
View File

@@ -0,0 +1,28 @@
export function formatMoney(value) {
if (value == null || Number.isNaN(value)) return "--";
const abs = Math.abs(value);
const sign = value < 0 ? "-" : "";
if (abs >= 1e8) return `${sign}${(abs / 1e8).toFixed(2)}亿`;
if (abs >= 1e4) return `${sign}${(abs / 1e4).toFixed(2)}`;
return `${sign}${abs.toFixed(2)}`;
}
export function formatYuan(value) {
if (value == null || Number.isNaN(value)) return "--";
return `${Number(value).toFixed(2)}`;
}
export function formatNumber(value, digits = 0) {
return Number(value).toLocaleString("zh-CN", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
export function formatPercent(value) {
return `${(value * 100).toFixed(1)}%`;
}
export function clamp(n, min, max) {
return Math.min(max, Math.max(min, n));
}

6
vite.config.js Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
});