Initial commit
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
22
README.md
Normal 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`
|
||||
663
docs/引擎组件快速开始.md
Normal 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
@@ -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
21
package.json
Normal 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
1
public/assets/iflow-engine-base.css
Normal 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}
|
||||
1
public/assets/svg/rotate-orbit.svg
Normal 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 |
BIN
public/assets/viewcube/cn_back.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/assets/viewcube/cn_bottom.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/assets/viewcube/cn_front.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/assets/viewcube/cn_left.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/assets/viewcube/cn_right.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/assets/viewcube/cn_top.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/assets/viewcube/home.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
739
public/draco/DRACOLoader.js
Normal 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 };
|
||||
52
public/draco/draco_decoder.js
Normal file
BIN
public/draco/draco_decoder.wasm
Normal file
33
public/draco/draco_encoder.js
Normal file
104
public/draco/draco_wasm_wrapper.js
Normal 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
@@ -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>
|
||||
69
src/assets/styles/vars.css
Normal 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;
|
||||
}
|
||||
180
src/components/assistant-fabs/index.vue
Normal 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>
|
||||
537
src/components/assistant/AiAssistantModal.vue
Normal 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>
|
||||
716
src/components/assistant/IssueManagerModal.vue
Normal 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>
|
||||
168
src/components/dev/DevSidebar.vue
Normal 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>
|
||||
88
src/components/layout/BottomPanel.vue
Normal 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>
|
||||
83
src/components/layout/PageCanvas.vue
Normal 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>
|
||||
89
src/components/layout/SidePanel.vue
Normal 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>
|
||||
442
src/components/model-placeholder/index.vue
Normal 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>
|
||||
96
src/composables/useDraggableModal.js
Normal 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
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
44
src/constants/structures.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
362
src/pages/inspection/index.vue
Normal 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">支持 JPG、PNG、MP4 格式</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>
|
||||
205
src/pages/material/index.vue
Normal 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>
|
||||
515
src/pages/measurement/index.vue
Normal 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
@@ -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>
|
||||
269
src/pages/progress/index.vue
Normal 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
@@ -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>
|
||||
651
src/pages/subcontract/index.vue
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
export const pinia = createPinia();
|
||||
28
src/utils/format.js
Normal 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
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
});
|
||||