Initial commit

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

49
src/App.vue Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

7
src/entry.js Normal file
View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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