Initial commit
This commit is contained in:
49
src/App.vue
Normal file
49
src/App.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div
|
||||
class="app-root"
|
||||
:class="{
|
||||
'with-dev-sidebar': showDevSidebar,
|
||||
'sidebar-expanded': showDevSidebar && !appStore.devSidebarCollapsed,
|
||||
}"
|
||||
>
|
||||
<DevSidebar v-if="showDevSidebar" v-model:collapsed="appStore.devSidebarCollapsed" />
|
||||
<div class="app-content">
|
||||
<AssistantFabs />
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterView } from "vue-router";
|
||||
import AssistantFabs from "./components/assistant-fabs/index.vue";
|
||||
import DevSidebar from "./components/dev/DevSidebar.vue";
|
||||
import { useAppStore } from "./stores/app.js";
|
||||
|
||||
const appStore = useAppStore();
|
||||
const showDevSidebar = import.meta.env.DEV;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.app-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: margin-left 0.22s ease, width 0.22s ease;
|
||||
}
|
||||
|
||||
.app-root.with-dev-sidebar .app-content {
|
||||
margin-left: 44px;
|
||||
width: calc(100% - 44px);
|
||||
}
|
||||
|
||||
.app-root.sidebar-expanded .app-content {
|
||||
margin-left: 220px;
|
||||
width: calc(100% - 220px);
|
||||
}
|
||||
</style>
|
||||
69
src/assets/styles/vars.css
Normal file
69
src/assets/styles/vars.css
Normal file
@@ -0,0 +1,69 @@
|
||||
:root {
|
||||
/* 文字色 */
|
||||
--bim-text: rgba(245, 252, 255, 0.92);
|
||||
--bim-muted: rgba(223, 241, 246, 0.72);
|
||||
|
||||
/* 面板背景 */
|
||||
--bim-panel: rgba(18, 29, 35, 0.9);
|
||||
--bim-panel-soft: rgba(16, 25, 31, 0.8);
|
||||
|
||||
/* 强调色 */
|
||||
--bim-accent: #08c7bc;
|
||||
--bim-accent2: #20e2d5;
|
||||
--bim-accent-hex: #53d6ce;
|
||||
|
||||
/* 边框/线条 */
|
||||
--bim-line: rgba(42, 190, 182, 0.34);
|
||||
--bim-line-soft: rgba(42, 190, 182, 0.2);
|
||||
|
||||
/* 进度条 */
|
||||
--bim-bar-track: rgba(255, 255, 255, 0.12);
|
||||
--bim-bar-fill: linear-gradient(90deg, #0eb7ff, #1ce0c5);
|
||||
|
||||
/* 字体 */
|
||||
--bim-font-cn: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
|
||||
--bim-font-num: "DIN Alternate", "Bahnschrift", "Segoe UI", "Arial Narrow", sans-serif;
|
||||
|
||||
/* 玻璃态通用 */
|
||||
--bim-glass-bg:
|
||||
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
|
||||
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box;
|
||||
--bim-glass-border: linear-gradient(135deg, rgba(83, 214, 206, 0.42), rgba(83, 214, 206, 0.14), rgba(0, 0, 0, 0)) border-box;
|
||||
--bim-glass-shadow:
|
||||
0 32px 96px rgba(0, 0, 0, 0.48),
|
||||
0 0 28px rgba(83, 214, 206, 0.12),
|
||||
0 0 0 1px rgba(83, 214, 206, 0.12) inset,
|
||||
0 0 0 2px rgba(0, 0, 0, 0.1) inset;
|
||||
|
||||
/* 警告/橙色 */
|
||||
--bim-warn: rgba(212, 136, 6, 0.95);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--bim-font-cn);
|
||||
background: #0b1216;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
180
src/components/assistant-fabs/index.vue
Normal file
180
src/components/assistant-fabs/index.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="assistant-fabs-layer">
|
||||
<button class="ai-fab" type="button" aria-haspopup="dialog" title="AI模型智能助手" @click="toggleAi"
|
||||
>
|
||||
<span class="sr-only">AI模型智能助手</span>
|
||||
<svg class="ai-fab-icon" viewBox="0 0 64 64" aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="aiFabGradGlobal" x1="0" y1="0" x2="1" y2="1"
|
||||
>
|
||||
<stop offset="0" stop-color="#156cff" />
|
||||
<stop offset="1" stop-color="#2cc8ff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M32 8c1 0 1.8.8 1.8 1.8v4.1h6.5c7.4 0 13.4 6 13.4 13.4V40c0 7.4-6 13.4-13.4 13.4H23.7C16.3 53.4 10.3 47.4 10.3 40V27.3c0-7.4 6-13.4 13.4-13.4h6.5V9.8C30.2 8.8 31 8 32 8Zm8.3 13.6H23.7c-3.2 0-5.7 2.6-5.7 5.7V40c0 3.2 2.6 5.7 5.7 5.7h16.6c3.2 0 5.7-2.6 5.7-5.7V27.3c0-3.2-2.6-5.7-5.7-5.7Z" fill="url(#aiFabGradGlobal)" opacity="0.95" />
|
||||
<path d="M19.7 31.1h24.6c1.2 0 2.1 1 2.1 2.1v6.9c0 1.2-1 2.1-2.1 2.1H19.7c-1.2 0-2.1-1-2.1-2.1v-6.9c0-1.2 1-2.1 2.1-2.1Z" fill="rgba(255,255,255,0.88)" />
|
||||
<path d="M25.4 35.7a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4Z" fill="#0b1b3a" opacity="0.85" />
|
||||
<path d="M38.6 35.7a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4Z" fill="#0b1b3a" opacity="0.85" />
|
||||
<path d="M23.8 26.8h16.4" stroke="rgba(255,255,255,0.78)" stroke-width="3.2" stroke-linecap="round" />
|
||||
<path d="M20 17.8c2.8-2.8 6.7-4.6 12-4.6s9.2 1.8 12 4.6" stroke="rgba(44,200,255,0.78)" stroke-width="2.8" stroke-linecap="round" fill="none" opacity="0.8" />
|
||||
<path d="M32 6.2v5.8" stroke="rgba(44,200,255,0.78)" stroke-width="2.8" stroke-linecap="round" />
|
||||
<circle cx="32" cy="5.2" r="2.2" fill="rgba(44,200,255,0.92)" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="issue-fab" type="button" aria-haspopup="dialog" title="设计问题智能管理" @click="toggleIssue"
|
||||
>
|
||||
<span class="sr-only">设计问题智能管理</span>
|
||||
<svg class="issue-fab-icon" viewBox="0 0 64 64" aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="issueFabGradGlobal" x1="0" y1="0" x2="1" y2="1"
|
||||
>
|
||||
<stop offset="0" stop-color="#ffcc4a" />
|
||||
<stop offset="1" stop-color="#8a5bff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M32 8c12.9 0 23.4 10.5 23.4 23.4S44.9 54.8 32 54.8 8.6 44.3 8.6 31.4 19.1 8 32 8Z" fill="url(#issueFabGradGlobal)" opacity="0.96" />
|
||||
<path d="M32 18.6c-5.2 0-9.4 3-9.4 8.1 0 .9.7 1.6 1.6 1.6.9 0 1.6-.7 1.6-1.6 0-3.1 2.6-4.9 6.2-4.9 3.3 0 5.7 1.6 5.7 4.2 0 2.1-1.3 3.3-3.4 4.6l-1.1.7c-2.8 1.8-4.6 3.7-4.6 7.1v.2c0 .9.7 1.6 1.6 1.6.9 0 1.6-.7 1.6-1.6v-.1c0-2.2 1.1-3.2 3.4-4.7l1.1-.7c2.8-1.8 5-3.8 5-7.4 0-4.6-4.1-7.7-8.9-7.7Z" fill="rgba(255,255,255,0.92)" />
|
||||
<circle cx="32" cy="45.2" r="2.6" fill="rgba(255,255,255,0.92)" />
|
||||
<path d="M44.9 20.6c2.6 2.4 4.2 5.8 4.2 9.7 0 7.4-6 13.4-13.4 13.4" fill="none" stroke="rgba(255,255,255,0.55)" stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<AiAssistantModal :open="aiOpen" @close="aiOpen = false" @toast="showToast" />
|
||||
<IssueManagerModal :open="issueOpen" @close="issueOpen = false" @toast="showToast" />
|
||||
|
||||
<div class="toast" v-show="toastOpen">{{ toastText }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import AiAssistantModal from "../assistant/AiAssistantModal.vue";
|
||||
import IssueManagerModal from "../assistant/IssueManagerModal.vue";
|
||||
|
||||
const aiOpen = ref(false);
|
||||
const issueOpen = ref(false);
|
||||
const toastOpen = ref(false);
|
||||
const toastText = ref("");
|
||||
let toastTimer = null;
|
||||
|
||||
function toggleAi() {
|
||||
aiOpen.value = !aiOpen.value;
|
||||
if (aiOpen.value) issueOpen.value = false;
|
||||
}
|
||||
|
||||
function toggleIssue() {
|
||||
issueOpen.value = !issueOpen.value;
|
||||
if (issueOpen.value) aiOpen.value = false;
|
||||
}
|
||||
|
||||
function showToast(text) {
|
||||
toastText.value = String(text || "");
|
||||
toastOpen.value = !!toastText.value;
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => {
|
||||
toastOpen.value = false;
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key !== "Escape") return;
|
||||
aiOpen.value = false;
|
||||
issueOpen.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.assistant-fabs-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 140;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.assistant-fabs-layer :is(button, .toast) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.ai-fab,
|
||||
.issue-fab {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid transparent;
|
||||
background:
|
||||
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
|
||||
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box,
|
||||
linear-gradient(135deg, rgba(83, 214, 206, 0.42), rgba(83, 214, 206, 0.14), rgba(0, 0, 0, 0)) border-box;
|
||||
box-shadow:
|
||||
0 22px 60px rgba(0, 0, 0, 0.35),
|
||||
0 0 26px rgba(83, 214, 206, 0.1),
|
||||
0 0 0 1px rgba(83, 214, 206, 0.14) inset,
|
||||
0 0 0 2px rgba(0, 0, 0, 0.06) inset;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.ai-fab { left: 30px; }
|
||||
.issue-fab { left: 100px; }
|
||||
|
||||
.ai-fab-icon,
|
||||
.issue-fab-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.22));
|
||||
}
|
||||
|
||||
.ai-fab:hover,
|
||||
.issue-fab:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 26px 70px rgba(0, 0, 0, 0.38),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.07) inset;
|
||||
}
|
||||
|
||||
.ai-fab:active,
|
||||
.issue-fab:active { transform: translateY(0); }
|
||||
|
||||
.toast {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 74px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 150;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(18, 26, 31, 0.94);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
</style>
|
||||
537
src/components/assistant/AiAssistantModal.vue
Normal file
537
src/components/assistant/AiAssistantModal.vue
Normal file
@@ -0,0 +1,537 @@
|
||||
<template>
|
||||
<div v-show="open" class="ai-overlay">
|
||||
<section
|
||||
ref="modalRef"
|
||||
class="ai-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="AI模型智能助手"
|
||||
:style="style"
|
||||
>
|
||||
<header ref="headerRef" class="ai-modal-header" @pointerdown="onHeaderPointerDown">
|
||||
<div class="ai-modal-title">AI模型智能助手</div>
|
||||
<button class="ai-close" type="button" aria-label="关闭" @click="$emit('close')">×</button>
|
||||
</header>
|
||||
|
||||
<div class="ai-modal-body">
|
||||
<div v-show="screen === 'form'" class="ai-screen">
|
||||
<div class="ai-field">
|
||||
<div class="ai-label">选择模型</div>
|
||||
<div class="ai-select-wrap">
|
||||
<select class="input ai-select" aria-label="选择模型" v-model="model">
|
||||
<option value="bridge">桥梁专业</option>
|
||||
<option value="road">道路专业</option>
|
||||
<option value="tunnel">隧道专业</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ai-field">
|
||||
<div class="ai-label">查询类型</div>
|
||||
<div class="ai-tabs" role="tablist" aria-label="查询类型">
|
||||
<button
|
||||
v-for="t in qtypes"
|
||||
:key="t.key"
|
||||
class="ai-tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="qtype === t.key"
|
||||
:class="{ 'is-on': qtype === t.key }"
|
||||
@click="qtype = t.key"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ai-search">
|
||||
<div class="ai-searchbox">
|
||||
<svg class="ai-search-icon" viewBox="0 0 24 24" aria-hidden="true"
|
||||
>path d="M10.5 3.5a7 7 0 1 1 0 14 7 7 0 0 1 0-14Zm0 2a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm8.7 12.3 1.9 1.9a1 1 0 0 1-1.4 1.4l-1.9-1.9a1 1 0 0 1 1.4-1.4Z" />
|
||||
</svg>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="query"
|
||||
class="ai-search-input"
|
||||
placeholder="例如:Z2-7桩基的混凝土工程量是多少?"
|
||||
autocomplete="off"
|
||||
@keydown.enter.prevent="submit"
|
||||
@keydown.esc.prevent="$emit('close')"
|
||||
/>
|
||||
</div>
|
||||
<button class="ai-mic" type="button" title="语音输入" aria-label="语音输入" @click="$emit('toast', '语音输入:占位')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"
|
||||
>path d="M12 14.2c1.6 0 2.8-1.3 2.8-2.8V6.2C14.8 4.6 13.6 3.4 12 3.4S9.2 4.6 9.2 6.2v5.2c0 1.5 1.2 2.8 2.8 2.8Zm6-2.8c0-.6.4-1 1-1s1 .4 1 1c0 3.7-2.8 6.7-6.4 7.1v2h1.8c.6 0 1 .4 1 1s-.4 1-1 1H8.6c-.6 0-1-.4-1-1s.4-1 1-1h1.8v-2C6.8 18.1 4 15.1 4 11.4c0-.6.4-1 1-1s1 .4 1 1c0 3.3 2.7 6 6 6s6-2.7 6-6Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="ai-inline-search" type="button" aria-label="搜索" title="搜索" @click="submit"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"
|
||||
>path d="M10.5 3.5a7 7 0 1 1 0 14 7 7 0 0 1 0-14Zm0 2a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm8.7 12.3 1.9 1.9a1 1 0 0 1-1.4 1.4l-1.9-1.9a1 1 0 0 1 1.4-1.4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ai-history">
|
||||
<div class="ai-history-head">
|
||||
<svg class="ai-history-icon" viewBox="0 0 24 24" aria-hidden="true"
|
||||
>path d="M12 4a8 8 0 1 1-7.7 10h2.2a6 6 0 1 0 .3-3.3l1.5 1.5H3V6.5l1.7 1.7A8 8 0 0 1 12 4Zm-.2 4.1c.6 0 1 .4 1 1v3.4l2.4 1.4c.5.3.7.9.4 1.4-.3.5-.9.7-1.4.4l-2.9-1.7a1 1 0 0 1-.5-.9V9.1c0-.6.4-1 1-1Z" />
|
||||
</svg>
|
||||
<div class="ai-label">历史搜索</div>
|
||||
</div>
|
||||
<div class="ai-chips">
|
||||
<button v-for="h in history" :key="h" class="ai-chip" type="button" @click="applyHistory(h)">{{ h }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ai-desc">
|
||||
<div class="ai-label">功能说明</div>
|
||||
<ul class="ai-desc-list">
|
||||
<li>支持选择专业模型进行精准查询</li>
|
||||
<li>可查询构件工程量、材料属性等信息</li>
|
||||
<li>支持挂接外部文档和施工方案</li>
|
||||
<li>基于模型查询挂接文档中的知识内容</li>
|
||||
<li>自然语言理解,智能定位模型构件</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="screen === 'result'" class="ai-screen">
|
||||
<div class="ai-result-head">
|
||||
<div class="ai-result-title">搜索结果</div>
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="screen = 'form'">返回</button>
|
||||
</div>
|
||||
<div class="ai-result-summary">{{ resultSummary }}</div>
|
||||
<div class="ai-result-card">
|
||||
<div v-for="row in resultRows" :key="row.key" class="ai-kv">
|
||||
<span>{{ row.key }}</span>
|
||||
<div v-html="row.value"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, ref, watch } from "vue";
|
||||
import { useDraggableModal } from "../../composables/useDraggableModal.js";
|
||||
|
||||
const props = defineProps({
|
||||
open: Boolean,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "toast"]);
|
||||
|
||||
const qtypes = [
|
||||
{ key: "general", label: "常规查询" },
|
||||
{ key: "quantity", label: "工程量查询" },
|
||||
{ key: "props", label: "构件属性" },
|
||||
{ key: "knowledge", label: "工程知识" },
|
||||
];
|
||||
|
||||
const history = ["Z2-7桩基", "Z1左墩柱", "桥面"];
|
||||
|
||||
const model = ref("bridge");
|
||||
const qtype = ref("quantity");
|
||||
const query = ref("");
|
||||
const screen = ref("form");
|
||||
const resultSummary = ref("");
|
||||
const resultRows = ref([]);
|
||||
const inputRef = ref(null);
|
||||
|
||||
const { modalRef, headerRef, style, onHeaderPointerDown } = useDraggableModal({ x: 190, y: 140 });
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
if (val) {
|
||||
screen.value = "form";
|
||||
nextTick(() => inputRef.value?.focus?.());
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const modelLabels = { bridge: "桥梁专业", road: "道路专业", tunnel: "隧道专业" };
|
||||
const qtypeLabels = { general: "常规查询", quantity: "工程量查询", props: "构件属性", knowledge: "工程知识" };
|
||||
|
||||
function renderResults() {
|
||||
const now = new Date();
|
||||
const time = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
resultSummary.value = `模型:${modelLabels[model.value]} · 类型:${qtypeLabels[qtype.value]} · 时间:${time}`;
|
||||
resultRows.value = [
|
||||
{ key: "你的问题", value: query.value },
|
||||
{ key: "解析结果", value: "已理解自然语言意图(占位),可进一步解析楼层/构件类型/专业范围。" },
|
||||
{
|
||||
key: "可执行动作",
|
||||
value: ["• 定位构件 / 显隐构件(占位)", "• 视角切换(占位)", "• 进度填报 / 工程量问询(占位)"].join("<br/>"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const q = String(query.value || "").trim();
|
||||
if (!q) {
|
||||
emit("toast", "请输入查询内容");
|
||||
return;
|
||||
}
|
||||
renderResults();
|
||||
screen.value = "result";
|
||||
}
|
||||
|
||||
function applyHistory(h) {
|
||||
query.value = h;
|
||||
submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ai-modal {
|
||||
pointer-events: auto;
|
||||
position: fixed;
|
||||
left: 190px;
|
||||
top: 140px;
|
||||
width: min(820px, calc(100vw - 60px));
|
||||
max-height: min(820px, calc(100vh - 60px));
|
||||
transform: scale(1);
|
||||
transform-origin: left top;
|
||||
border-radius: 18px;
|
||||
border: 1px solid transparent;
|
||||
background:
|
||||
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
|
||||
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box,
|
||||
linear-gradient(135deg, rgba(83, 214, 206, 0.42), rgba(83, 214, 206, 0.14), rgba(0, 0, 0, 0)) border-box;
|
||||
box-shadow:
|
||||
0 32px 96px rgba(0, 0, 0, 0.48),
|
||||
0 0 28px rgba(83, 214, 206, 0.12),
|
||||
0 0 0 1px rgba(83, 214, 206, 0.12) inset,
|
||||
0 0 0 2px rgba(0, 0, 0, 0.1) inset;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.ai-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background:
|
||||
radial-gradient(520px 120px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 70%),
|
||||
linear-gradient(180deg, rgba(18, 26, 31, 0.96), rgba(14, 20, 25, 0.86));
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.ai-modal-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.ai-modal-title {
|
||||
font-size: 22px;
|
||||
font-weight: 950;
|
||||
letter-spacing: 0.6px;
|
||||
background: linear-gradient(90deg, rgba(43, 191, 178, 0.98), rgba(85, 224, 212, 0.92));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-shadow: 0 0 24px rgba(83, 214, 206, 0.12);
|
||||
}
|
||||
|
||||
.ai-close {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ai-close:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.ai-modal-body {
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ai-field {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.ai-label {
|
||||
font-weight: 900;
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
}
|
||||
|
||||
.ai-select {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
padding: 0 56px 0 12px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.ai-select-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ai-select-wrap::after {
|
||||
content: "▾";
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 900;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ai-tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ai-tab {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
min-width: 112px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ai-tab:hover {
|
||||
border-color: rgba(83, 214, 206, 0.24);
|
||||
box-shadow: 0 0 0 3px rgba(83, 214, 206, 0.1);
|
||||
}
|
||||
|
||||
.ai-tab.is-on {
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
border-color: rgba(83, 214, 206, 0.26);
|
||||
background: linear-gradient(180deg, rgba(43, 191, 178, 0.92), rgba(43, 191, 178, 0.72));
|
||||
}
|
||||
|
||||
.ai-search {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 56px 56px;
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
margin: 10px 0 14px;
|
||||
}
|
||||
|
||||
.ai-searchbox {
|
||||
position: relative;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px 0 48px;
|
||||
}
|
||||
|
||||
.ai-search-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
fill: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.ai-search-input {
|
||||
border: 0;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
background: transparent;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.ai-search-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
}
|
||||
|
||||
.ai-mic,
|
||||
.ai-inline-search {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
padding: 0;
|
||||
border-radius: 18px;
|
||||
border: 0;
|
||||
background: linear-gradient(180deg, rgba(43, 191, 178, 0.92), rgba(43, 191, 178, 0.78));
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.ai-mic svg,
|
||||
.ai-inline-search svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.ai-history {
|
||||
margin: 6px 0 14px;
|
||||
}
|
||||
|
||||
.ai-history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ai-history-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.ai-chips {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ai-chip {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.14);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ai-chip:hover {
|
||||
border-color: rgba(83, 214, 206, 0.22);
|
||||
box-shadow: 0 0 0 3px rgba(83, 214, 206, 0.1);
|
||||
}
|
||||
|
||||
.ai-desc {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.ai-desc-list {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 18px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 800;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.ai-result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ai-result-title {
|
||||
font-size: 18px;
|
||||
font-weight: 950;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.ai-result-summary {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-weight: 800;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ai-result-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ai-kv {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 10px;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.ai-kv span {
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.ai-modal {
|
||||
left: 60px;
|
||||
top: 90px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
716
src/components/assistant/IssueManagerModal.vue
Normal file
716
src/components/assistant/IssueManagerModal.vue
Normal file
@@ -0,0 +1,716 @@
|
||||
<template>
|
||||
<div v-show="open" class="issue-overlay">
|
||||
<section
|
||||
ref="modalRef"
|
||||
class="issue-modal"
|
||||
:class="{ 'is-detail': view === 'detail' }"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="设计问题智能管理"
|
||||
:style="style"
|
||||
>
|
||||
<header ref="headerRef" class="issue-modal-header" @pointerdown="onHeaderPointerDown"
|
||||
>
|
||||
<div class="issue-modal-left"
|
||||
>
|
||||
<button v-show="view === 'detail'" class="issue-back" type="button" aria-label="返回" @click="backToList"
|
||||
>←</button
|
||||
>
|
||||
<div class="issue-modal-title">{{ view === 'detail' ? '问题报告' : '设计问题智能管理' }}</div>
|
||||
</div>
|
||||
<button class="issue-close" type="button" aria-label="关闭" @click="$emit('close')">×</button>
|
||||
</header>
|
||||
|
||||
<div class="issue-modal-body"
|
||||
>
|
||||
<div v-show="view === 'list'" class="issue-screen"
|
||||
>
|
||||
<section class="issue-card issue-card-form" aria-label="记录新问题"
|
||||
>
|
||||
<header class="issue-card-head"
|
||||
>
|
||||
<span class="issue-warn-dot" aria-hidden="true">!</span>
|
||||
<div class="issue-card-title">记录新问题</div>
|
||||
</header>
|
||||
|
||||
<div class="issue-field"
|
||||
>
|
||||
<div class="issue-label">选中的构件</div>
|
||||
<div class="issue-pick"
|
||||
>
|
||||
<div class="issue-picked" :class="{ 'is-picking': awaitingPick }" @click="enterPickMode"
|
||||
>
|
||||
<span class="issue-picked-icon" aria-hidden="true">⌖</span>
|
||||
<span class="issue-picked-text">{{ selectedStructure ? selectedStructure.name : '未选择' }}</span>
|
||||
</div>
|
||||
<button class="issue-repick" type="button" @click="enterPickMode">重新选择</button>
|
||||
</div>
|
||||
<div v-show="awaitingPick" class="issue-pick-select"
|
||||
>
|
||||
<div class="issue-select-wrap"
|
||||
>
|
||||
<select class="issue-select" aria-label="下拉选择构件" v-model="pickId"
|
||||
>
|
||||
<option value="">(在模型中点击选择,或下拉选择)</option>
|
||||
<option v-for="s in structures" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="issue-pick-hint">提示:也可直接在模型中点击构件进行选择</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="issue-field"
|
||||
>
|
||||
<div class="issue-label">问题描述</div>
|
||||
<textarea
|
||||
class="issue-textarea"
|
||||
placeholder="描述设计问题,AI将自动补充构件名称、位置、专业等信息并整理为正式表述…"
|
||||
v-model="descRaw"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="issue-submit"
|
||||
type="button"
|
||||
:disabled="!canSubmit"
|
||||
@click="submit"
|
||||
>提交问题(AI自动整理)</button>
|
||||
</section>
|
||||
|
||||
<section class="issue-list" aria-label="问题列表" v-show="issues.length > 0"
|
||||
>
|
||||
<header class="issue-list-head"
|
||||
>
|
||||
<div class="issue-list-title">问题列表({{ issues.length }})</div>
|
||||
<div class="issue-list-sub">可定位 · 可追溯 · 可统计</div>
|
||||
</header>
|
||||
<div class="issue-list-body"
|
||||
>
|
||||
<div
|
||||
class="issue-item"
|
||||
v-for="it in issues"
|
||||
:key="it.id"
|
||||
@click="openDetail(it.id)"
|
||||
|
||||
>
|
||||
<div class="issue-item-top"
|
||||
>
|
||||
<div class="issue-item-title"
|
||||
>
|
||||
<div class="issue-item-name">{{ it.structureName || it.structureId || '构件' }}</div>
|
||||
<div class="issue-tag">{{ it.discipline || '结构专业' }}</div>
|
||||
</div>
|
||||
<div class="issue-chevron" aria-hidden="true">›</div>
|
||||
</div>
|
||||
<div class="issue-item-meta"
|
||||
>
|
||||
<div class="issue-item-loc"
|
||||
>
|
||||
<span aria-hidden="true">⌁</span>
|
||||
<span>{{ it.location || '空间位置待确认' }}</span>
|
||||
</div>
|
||||
<div>{{ formatDateTimeCN(it.createdAt) }}</div>
|
||||
</div>
|
||||
<div class="issue-item-desc">{{ it.descRaw || '(无描述)' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-show="view === 'detail'" class="issue-screen"
|
||||
>
|
||||
<div v-if="activeIssue" class="issue-detail"
|
||||
>
|
||||
<div class="issue-detail-grid"
|
||||
>
|
||||
<div class="issue-kv"
|
||||
><div class="issue-k">构件名称</div><div class="issue-v">{{ activeIssue.structureName || activeIssue.structureId || '构件' }}</div></div>
|
||||
<div class="issue-kv"
|
||||
><div class="issue-k">所属专业</div><div class="issue-v">{{ activeIssue.discipline || '结构专业' }}</div></div>
|
||||
<div class="issue-kv"
|
||||
><div class="issue-k">空间位置</div><div class="issue-v">{{ activeIssue.location || '空间位置待确认' }}</div></div>
|
||||
<div class="issue-kv"
|
||||
><div class="issue-k">问题状态</div><div class="issue-v">待整改(占位)</div></div>
|
||||
</div>
|
||||
|
||||
<div class="issue-box"
|
||||
>
|
||||
<div class="issue-box-title">原始问题描述</div>
|
||||
<div class="issue-box-content">{{ activeIssue.descRaw || '(无)' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="issue-box"
|
||||
>
|
||||
<div class="issue-box-title issue-ai-label">AI整理后的正式表述</div>
|
||||
<div class="issue-ai-box">{{ activeIssue.descAi || '(AI整理占位)' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="issue-views"
|
||||
>
|
||||
<div class="issue-box"
|
||||
>
|
||||
<div class="issue-box-title">模型视图|三维视图</div>
|
||||
<div class="issue-viewbox"
|
||||
>3D 模型视图<br />{{ activeIssue.structureName || activeIssue.structureId || '构件' }}</div>
|
||||
</div>
|
||||
<div class="issue-box"
|
||||
>
|
||||
<div class="issue-box-title">二维视图</div>
|
||||
<div class="issue-viewbox">2D 平面视图<br />{{ activeIssue.location || '空间位置待确认' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="issue-footer-time">创建时间:{{ formatDateTimeCN(activeIssue.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useDraggableModal } from "../../composables/useDraggableModal.js";
|
||||
import { structures } from "../../constants/structures.js";
|
||||
|
||||
const props = defineProps({
|
||||
open: Boolean,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "toast", "save"]);
|
||||
|
||||
const STORAGE_KEY = "bim.issueManager.v1";
|
||||
|
||||
const view = ref("list");
|
||||
const activeId = ref(null);
|
||||
const issues = ref([]);
|
||||
const pickId = ref("");
|
||||
const awaitingPick = ref(false);
|
||||
const descRaw = ref("");
|
||||
|
||||
const { modalRef, headerRef, style, onHeaderPointerDown, resetPosition } = useDraggableModal({ x: 0, y: 140 });
|
||||
|
||||
const selectedStructure = computed(() => structures.find((s) => s.id === pickId.value) || null);
|
||||
const canSubmit = computed(() => !!selectedStructure.value && !!String(descRaw.value || "").trim());
|
||||
const activeIssue = computed(() => issues.value.find((x) => x.id === activeId.value) || null);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
if (val) {
|
||||
loadIssues();
|
||||
view.value = "list";
|
||||
activeId.value = null;
|
||||
awaitingPick.value = false;
|
||||
descRaw.value = "";
|
||||
pickId.value = "";
|
||||
resetPosition(window.innerWidth - 350, 140);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function loadIssues() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed?.issues)) issues.value = parsed.issues;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function saveIssues() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ issues: issues.value }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTimeCN(iso) {
|
||||
const d = new Date(iso || Date.now());
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function buildAiStatement({ discipline, structureName, location, descRaw }) {
|
||||
const d = (discipline || "结构专业").trim();
|
||||
const n = (structureName || "构件").trim();
|
||||
const loc = (location || "空间位置待确认").trim();
|
||||
const raw = (descRaw || "").trim();
|
||||
const core = raw ? raw.replace(/[。;;]+$/g, "") : "存在设计问题";
|
||||
return `【${d}-${n}】${n}(${loc})存在${core},实际情况与设计要求不一致,需核查并修正。`;
|
||||
}
|
||||
|
||||
function enterPickMode() {
|
||||
awaitingPick.value = true;
|
||||
emit("toast", "选取中:请在模型里点击构件");
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const s = selectedStructure.value;
|
||||
const raw = String(descRaw.value || "").trim();
|
||||
if (!s || !raw) return;
|
||||
const issue = {
|
||||
id: `ISS-${Date.now()}-${Math.floor(Math.random() * 1e6)}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
structureId: s.id,
|
||||
structureName: s.name,
|
||||
discipline: s.discipline,
|
||||
location: s.location,
|
||||
descRaw: raw,
|
||||
descAi: buildAiStatement({ discipline: s.discipline, structureName: s.name, location: s.location, descRaw: raw }),
|
||||
};
|
||||
issues.value.unshift(issue);
|
||||
descRaw.value = "";
|
||||
awaitingPick.value = false;
|
||||
saveIssues();
|
||||
emit("toast", "已提交问题(AI整理占位)");
|
||||
}
|
||||
|
||||
function openDetail(id) {
|
||||
activeId.value = id;
|
||||
view.value = "detail";
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
view.value = "list";
|
||||
activeId.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.issue-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 92;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.issue-modal {
|
||||
pointer-events: auto;
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
top: 140px;
|
||||
width: min(820px, calc(100vw - 60px));
|
||||
max-height: min(820px, calc(100vh - 60px));
|
||||
transform-origin: right top;
|
||||
border-radius: 18px;
|
||||
border: 1px solid transparent;
|
||||
background:
|
||||
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
|
||||
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box,
|
||||
linear-gradient(135deg, rgba(83, 214, 206, 0.42), rgba(83, 214, 206, 0.14), rgba(0, 0, 0, 0)) border-box;
|
||||
box-shadow:
|
||||
0 32px 96px rgba(0, 0, 0, 0.48),
|
||||
0 0 28px rgba(83, 214, 206, 0.12),
|
||||
0 0 0 1px rgba(83, 214, 206, 0.12) inset,
|
||||
0 0 0 2px rgba(0, 0, 0, 0.1) inset;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.issue-modal.is-detail {
|
||||
background:
|
||||
radial-gradient(260px 160px at 18% 0%, rgba(212, 136, 6, 0.1), transparent 62%) padding-box,
|
||||
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.1), transparent 62%) padding-box,
|
||||
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box,
|
||||
linear-gradient(135deg, rgba(212, 136, 6, 0.42), rgba(212, 136, 6, 0.14), rgba(83, 214, 206, 0.1), rgba(0, 0, 0, 0)) border-box;
|
||||
}
|
||||
|
||||
.issue-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background:
|
||||
radial-gradient(520px 120px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 70%),
|
||||
linear-gradient(180deg, rgba(18, 26, 31, 0.96), rgba(14, 20, 25, 0.86));
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.issue-modal.is-detail .issue-modal-header {
|
||||
border-bottom-color: rgba(212, 136, 6, 0.14);
|
||||
background:
|
||||
radial-gradient(520px 120px at 18% 0%, rgba(212, 136, 6, 0.12), transparent 70%),
|
||||
radial-gradient(520px 120px at 40% 0%, rgba(83, 214, 206, 0.1), transparent 72%),
|
||||
linear-gradient(180deg, rgba(18, 26, 31, 0.96), rgba(14, 20, 25, 0.86));
|
||||
}
|
||||
|
||||
.issue-modal-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.issue-modal-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.issue-modal-title {
|
||||
font-size: 22px;
|
||||
font-weight: 950;
|
||||
letter-spacing: 0.6px;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.issue-modal.is-detail .issue-modal-title {
|
||||
background: linear-gradient(90deg, rgba(255, 190, 92, 0.98), rgba(255, 214, 122, 0.96));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.issue-back,
|
||||
.issue-close {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.issue-back {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.issue-back:hover,
|
||||
.issue-close:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.issue-modal-body {
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.issue-card,
|
||||
.issue-list,
|
||||
.issue-box {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.14);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.issue-card-head,
|
||||
.issue-list-head,
|
||||
.issue-item-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.issue-card-title,
|
||||
.issue-list-title,
|
||||
.issue-item-name,
|
||||
.issue-box-title,
|
||||
.issue-v {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.issue-warn-dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 8px;
|
||||
background: rgba(212, 136, 6, 0.18);
|
||||
border: 1px solid rgba(212, 136, 6, 0.42);
|
||||
color: rgba(212, 136, 6, 0.95);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.issue-field {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.issue-label,
|
||||
.issue-list-sub,
|
||||
.issue-item-meta,
|
||||
.issue-pick-hint {
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.issue-pick {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.issue-pick-select {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.issue-picked {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
min-height: 44px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.issue-picked.is-picking {
|
||||
border-color: rgba(83, 214, 206, 0.34);
|
||||
box-shadow: 0 0 0 3px rgba(83, 214, 206, 0.1);
|
||||
}
|
||||
|
||||
.issue-picked-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(212, 136, 6, 0.18);
|
||||
border: 1px solid rgba(212, 136, 6, 0.28);
|
||||
color: rgba(212, 136, 6, 0.95);
|
||||
}
|
||||
|
||||
.issue-picked-text {
|
||||
font-weight: 900;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.issue-repick {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(212, 136, 6, 0.95);
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.issue-repick:hover {
|
||||
background: rgba(212, 136, 6, 0.12);
|
||||
}
|
||||
|
||||
.issue-select {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
padding: 0 56px 0 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.issue-select-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.issue-select-wrap::after {
|
||||
content: "▾";
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 900;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.issue-textarea {
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
outline: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.issue-textarea:focus {
|
||||
border-color: rgba(83, 214, 206, 0.3);
|
||||
box-shadow: 0 0 0 4px rgba(83, 214, 206, 0.1);
|
||||
}
|
||||
|
||||
.issue-submit {
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.6px;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.issue-submit:not(:disabled) {
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 0 18px rgba(83, 214, 206, 0.12);
|
||||
}
|
||||
|
||||
.issue-list-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.issue-item {
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.issue-item:hover {
|
||||
border-color: rgba(83, 214, 206, 0.22);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.issue-item-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.issue-chevron {
|
||||
color: rgba(255, 255, 255, 0.36);
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.issue-tag {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(83, 214, 206, 0.18);
|
||||
background: rgba(43, 191, 178, 0.14);
|
||||
color: rgba(83, 214, 206, 0.92);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.issue-item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.issue-item-loc {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.issue-item-desc {
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.14);
|
||||
padding: 10px;
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
|
||||
.issue-detail {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.issue-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.issue-k {
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-weight: 900;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.issue-box-content {
|
||||
padding: 12px;
|
||||
line-height: 1.55;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.issue-ai-label {
|
||||
color: rgba(212, 136, 6, 0.95);
|
||||
}
|
||||
|
||||
.issue-ai-box {
|
||||
padding: 12px;
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 248, 236, 0.16);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.issue-views {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.issue-viewbox {
|
||||
min-height: 224px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-weight: 900;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.14);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.issue-footer-time {
|
||||
text-align: right;
|
||||
color: rgba(255, 255, 255, 0.46);
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
padding: 6px 2px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.issue-views,
|
||||
.issue-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
168
src/components/dev/DevSidebar.vue
Normal file
168
src/components/dev/DevSidebar.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<aside class="dev-sidebar" :class="{ 'is-collapsed': collapsed }">
|
||||
<div class="dev-sidebar-inner">
|
||||
<div class="dev-sidebar-head">
|
||||
<span class="dev-sidebar-title" v-show="!collapsed">BIM Dev</span>
|
||||
<button class="dev-sidebar-toggle" type="button" :title="collapsed ? '展开' : '收起'" @click="toggle">
|
||||
{{ collapsed ? '▸' : '◂' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="dev-sidebar-nav" v-show="!collapsed">
|
||||
<router-link
|
||||
v-for="route in routes"
|
||||
:key="route.path"
|
||||
class="dev-sidebar-link"
|
||||
:to="route.path"
|
||||
:class="{ 'is-active': $route.path === route.path }"
|
||||
>
|
||||
<span class="dev-sidebar-dot"></span>
|
||||
<span class="dev-sidebar-label">{{ route.label }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const props = defineProps({
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:collapsed"]);
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const routes = [
|
||||
{ path: "/home", label: "首页" },
|
||||
{ path: "/project", label: "项目" },
|
||||
{ path: "/subcontract", label: "分包" },
|
||||
{ path: "/measurement", label: "计量" },
|
||||
{ path: "/plan", label: "计划" },
|
||||
{ path: "/progress", label: "进度" },
|
||||
{ path: "/change", label: "变更" },
|
||||
{ path: "/material", label: "物资" },
|
||||
{ path: "/inspection", label: "质检" },
|
||||
{ path: "/debug", label: "调试" },
|
||||
];
|
||||
|
||||
const collapsed = computed({
|
||||
get: () => props.collapsed,
|
||||
set: (val) => emit("update:collapsed", val),
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dev-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 220px;
|
||||
z-index: 1000;
|
||||
border-right: 1px solid rgba(83, 214, 206, 0.24);
|
||||
background:
|
||||
radial-gradient(260px 160px at 0% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
|
||||
linear-gradient(180deg, rgba(14, 22, 26, 0.98), rgba(10, 16, 20, 0.96)) padding-box;
|
||||
box-shadow: 8px 0 40px rgba(0, 0, 0, 0.35);
|
||||
transition: width 0.22s ease;
|
||||
}
|
||||
|
||||
.dev-sidebar.is-collapsed {
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
.dev-sidebar-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dev-sidebar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.dev-sidebar-title {
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
color: rgba(207, 247, 242, 0.96);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dev-sidebar-toggle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(222, 238, 244, 0.9);
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dev-sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dev-sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 10px;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(222, 238, 244, 0.86);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dev-sidebar-link:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(83, 214, 206, 0.2);
|
||||
}
|
||||
|
||||
.dev-sidebar-link.is-active {
|
||||
background: rgba(83, 214, 206, 0.18);
|
||||
border-color: rgba(83, 214, 206, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dev-sidebar-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(83, 214, 206, 0.7);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dev-sidebar-link.is-active .dev-sidebar-dot {
|
||||
background: rgba(83, 214, 206, 0.98);
|
||||
box-shadow: 0 0 8px rgba(83, 214, 206, 0.6);
|
||||
}
|
||||
|
||||
.dev-sidebar-label {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
88
src/components/layout/BottomPanel.vue
Normal file
88
src/components/layout/BottomPanel.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<section class="bottom-panel" :class="{ 'is-collapsed': collapsed }">
|
||||
<header class="bottom-panel-header">
|
||||
<slot name="header"></slot>
|
||||
</header>
|
||||
<button class="iconbtn bottom-panel-toggle" type="button" @click="toggle">{{ collapsed ? "▴" : "▾" }}</button>
|
||||
<div v-show="!collapsed" class="bottom-panel-body">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:collapsed"]);
|
||||
|
||||
function toggle() {
|
||||
emit("update:collapsed", !props.collapsed);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bottom-panel {
|
||||
position: absolute;
|
||||
left: 370px;
|
||||
right: 16px;
|
||||
bottom: 100px;
|
||||
height: 390px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(83, 214, 206, 0.24);
|
||||
background:
|
||||
radial-gradient(420px 180px at 14% 0%, rgba(83, 214, 206, 0.2), transparent 66%) padding-box,
|
||||
radial-gradient(520px 220px at 82% 0%, rgba(40, 156, 228, 0.14), transparent 70%) padding-box,
|
||||
linear-gradient(180deg, rgba(18, 33, 40, 0.94), rgba(14, 24, 31, 0.9)) padding-box;
|
||||
box-shadow:
|
||||
0 24px 70px rgba(0, 0, 0, 0.36),
|
||||
0 0 0 1px rgba(83, 214, 206, 0.14) inset;
|
||||
overflow: hidden;
|
||||
z-index: 26;
|
||||
}
|
||||
|
||||
.bottom-panel.is-collapsed {
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.bottom-panel-header {
|
||||
padding: 12px 52px 12px 12px;
|
||||
border-bottom: 1px solid rgba(83, 214, 206, 0.28);
|
||||
background:
|
||||
radial-gradient(380px 120px at 18% 0%, rgba(83, 214, 206, 0.28), transparent 70%),
|
||||
linear-gradient(180deg, rgba(25, 137, 124, 0.86), rgba(16, 66, 82, 0.64));
|
||||
}
|
||||
|
||||
.bottom-panel-toggle {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.iconbtn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
color: rgba(11, 27, 58, 0.86);
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bottom-panel-body {
|
||||
padding: 0 12px 12px;
|
||||
overflow: auto;
|
||||
height: calc(100% - 66px);
|
||||
}
|
||||
|
||||
@media (max-width: 1300px) {
|
||||
.bottom-panel {
|
||||
left: 330px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
83
src/components/layout/PageCanvas.vue
Normal file
83
src/components/layout/PageCanvas.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="page-canvas">
|
||||
<header class="page-topbar">
|
||||
<div class="topbar-center">
|
||||
<div class="model-title">{{ title }}</div>
|
||||
</div>
|
||||
<div v-if="$slots['topbar-right']" class="topbar-right">
|
||||
<slot name="topbar-right"></slot>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="model-stage">
|
||||
<slot name="model">
|
||||
<ModelPlaceholder />
|
||||
</slot>
|
||||
</section>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ModelPlaceholder from "../model-placeholder/index.vue";
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: "XXX特大桥主体模型.rvt",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(98, 191, 206, 0.34);
|
||||
background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%);
|
||||
box-shadow: inset 0 0 0 8px rgba(214, 230, 241, 0.55);
|
||||
}
|
||||
|
||||
.page-topbar {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: 120px;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.topbar-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 16px;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
top: 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.model-title {
|
||||
font-size: 20px;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 128, 52, 0.95);
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.model-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
</style>
|
||||
89
src/components/layout/SidePanel.vue
Normal file
89
src/components/layout/SidePanel.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<aside class="side-panel" :class="{ 'is-collapsed': collapsed }">
|
||||
<header class="side-panel-header">
|
||||
<div class="side-panel-title">{{ title }}</div>
|
||||
<button class="iconbtn" type="button" @click="toggle">{{ collapsed ? "▸" : "▾" }}</button>
|
||||
</header>
|
||||
<div v-show="!collapsed" class="side-panel-body">
|
||||
<slot />
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:collapsed"]);
|
||||
|
||||
function toggle() {
|
||||
emit("update:collapsed", !props.collapsed);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.side-panel {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 175px;
|
||||
width: 320px;
|
||||
bottom: 100px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(83, 214, 206, 0.24);
|
||||
background:
|
||||
radial-gradient(260px 140px at 8% 0%, rgba(63, 203, 191, 0.16), transparent 62%) padding-box,
|
||||
linear-gradient(180deg, rgba(20, 31, 37, 0.92), rgba(15, 23, 29, 0.88)) padding-box;
|
||||
box-shadow:
|
||||
0 22px 60px rgba(0, 0, 0, 0.34),
|
||||
0 0 0 1px rgba(83, 214, 206, 0.12) inset;
|
||||
overflow: hidden;
|
||||
z-index: 25;
|
||||
}
|
||||
|
||||
.side-panel.is-collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.side-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 10px 10px 12px;
|
||||
border-bottom: 1px solid rgba(83, 214, 206, 0.16);
|
||||
background:
|
||||
radial-gradient(360px 100px at 10% 0%, rgba(83, 214, 206, 0.24), transparent 68%),
|
||||
linear-gradient(180deg, rgba(28, 134, 122, 0.84), rgba(15, 60, 74, 0.62));
|
||||
}
|
||||
|
||||
.side-panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
color: rgba(207, 247, 242, 0.96);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.iconbtn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
color: rgba(11, 27, 58, 0.86);
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.side-panel-body {
|
||||
height: calc(100% - 52px);
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
442
src/components/model-placeholder/index.vue
Normal file
442
src/components/model-placeholder/index.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<div class="model-viewer">
|
||||
<div ref="containerRef" class="engine-container"></div>
|
||||
<div class="engine-state" v-if="stateText">{{ stateText }}</div>
|
||||
|
||||
<button v-if="showCodeBtn" class="model-code-btn" type="button" :disabled="encoding" @click="onModelCodeClick">
|
||||
{{ encoding ? "编码中..." : "模型编码" }}
|
||||
</button>
|
||||
|
||||
<div v-if="showConfirm" class="code-confirm-mask">
|
||||
<div class="code-confirm-panel">
|
||||
<div class="code-confirm-title">模型编码</div>
|
||||
<div class="code-confirm-body">
|
||||
当前模型尚未进行模型编码,是否立即开始编码?
|
||||
</div>
|
||||
<div class="code-confirm-actions">
|
||||
<button class="code-confirm-btn code-confirm-btn-secondary" type="button" @click="onConfirmCancel">
|
||||
稍后
|
||||
</button>
|
||||
<button class="code-confirm-btn code-confirm-btn-primary" type="button" @click="onConfirmStart">
|
||||
开始编码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="encoding" class="encoding-overlay">
|
||||
<div class="encoding-spinner"></div>
|
||||
<div class="encoding-text">正在进行模型编码,请稍候…</div>
|
||||
</div>
|
||||
|
||||
<div v-if="toastText" class="encoding-toast">{{ toastText }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { BimEngine } from "iflow-engine";
|
||||
|
||||
const props = defineProps({
|
||||
modelUrl: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const containerRef = ref(null);
|
||||
const loading = ref(true);
|
||||
const errorText = ref("");
|
||||
const encoding = ref(false);
|
||||
const showCodeBtn = ref(false);
|
||||
const showConfirm = ref(false);
|
||||
const toastText = ref("");
|
||||
let engine = null;
|
||||
let disposed = false;
|
||||
let resizeObserver = null;
|
||||
let unsubEvents = [];
|
||||
let toastTimer = null;
|
||||
|
||||
const resolvedModelUrl = computed(() => {
|
||||
const fromProp = String(props.modelUrl || "").trim();
|
||||
if (fromProp) return fromProp;
|
||||
const fromEnv = String(import.meta.env.VITE_BIM_MODEL_URL || "").trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
return "https://lyz-1259524260.cos.ap-guangzhou.myqcloud.com/iflow/models/417664a3-76c8-4d94-9344-1337246a5d4e";
|
||||
});
|
||||
|
||||
const stateText = computed(() => {
|
||||
if (errorText.value) return `模型加载失败:${errorText.value}`;
|
||||
if (loading.value) return "模型加载中...";
|
||||
return "";
|
||||
});
|
||||
|
||||
function getEngineComponent() {
|
||||
return engine?.engine?.getEngineComponent?.();
|
||||
}
|
||||
|
||||
function bindEncodingEvents() {
|
||||
if (!engine?.on) return;
|
||||
unsubEvents = [
|
||||
engine.on("encoding:start", () => {
|
||||
encoding.value = true;
|
||||
showConfirm.value = false;
|
||||
}),
|
||||
engine.on("encoding:complete", () => {
|
||||
encoding.value = false;
|
||||
showCodeBtn.value = false;
|
||||
showToast("编码完成");
|
||||
}),
|
||||
engine.on("encoding:error", () => {
|
||||
encoding.value = false;
|
||||
}),
|
||||
engine.on("engine:model-loading-completed", () => {
|
||||
if (disposed) return;
|
||||
handleModelLoadingCompleted();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function unbindEncodingEvents() {
|
||||
unsubEvents.forEach((fn) => {
|
||||
if (typeof fn === "function") fn();
|
||||
});
|
||||
unsubEvents = [];
|
||||
}
|
||||
|
||||
function handleModelLoadingCompleted() {
|
||||
const comp = getEngineComponent();
|
||||
if (!comp) return;
|
||||
comp.readModelCodeFormStoge?.();
|
||||
const hasCode = comp.hasModelCode?.();
|
||||
console.log("[model-placeholder] hasModelCode:", hasCode);
|
||||
if (hasCode) {
|
||||
showCodeBtn.value = false;
|
||||
} else {
|
||||
showCodeBtn.value = true;
|
||||
showConfirm.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function startEncoding() {
|
||||
if (encoding.value) return;
|
||||
const comp = getEngineComponent();
|
||||
if (!comp) {
|
||||
console.warn("engine component unavailable");
|
||||
return;
|
||||
}
|
||||
comp.startOneClickEncoding?.();
|
||||
}
|
||||
|
||||
function onModelCodeClick() {
|
||||
startEncoding();
|
||||
}
|
||||
|
||||
function onConfirmStart() {
|
||||
startEncoding();
|
||||
}
|
||||
|
||||
function onConfirmCancel() {
|
||||
showConfirm.value = false;
|
||||
}
|
||||
|
||||
function showToast(text) {
|
||||
toastText.value = String(text || "");
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => {
|
||||
toastText.value = "";
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function onDebugCheck() {
|
||||
const comp = getEngineComponent();
|
||||
if (!comp) {
|
||||
console.warn("[debug] engine component unavailable");
|
||||
return;
|
||||
}
|
||||
const hasCode = comp.hasModelCode?.();
|
||||
console.log("[debug] hasModelCode:", hasCode);
|
||||
window.alert(`hasModelCode: ${hasCode}`);
|
||||
}
|
||||
|
||||
function onDebugRead() {
|
||||
const comp = getEngineComponent();
|
||||
if (!comp) {
|
||||
console.warn("[debug] engine component unavailable");
|
||||
return;
|
||||
}
|
||||
comp.readModelCodeFormStoge?.();
|
||||
console.log("[debug] readModelCodeFormStoge called");
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!containerRef.value) return;
|
||||
disposed = false;
|
||||
|
||||
await nextTick();
|
||||
|
||||
let ready = false;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const rect = containerRef.value.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
|
||||
if (!ready || disposed) {
|
||||
errorText.value = "container size is not ready";
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
engine = new BimEngine(containerRef.value, {
|
||||
locale: "zh-CN",
|
||||
theme: "light",
|
||||
});
|
||||
|
||||
if (!engine?.engine) {
|
||||
throw new Error("engine manager unavailable");
|
||||
}
|
||||
|
||||
bindEncodingEvents();
|
||||
|
||||
await Promise.resolve(
|
||||
engine.engine.initialize({
|
||||
backgroundColor: 0x333333,
|
||||
showViewCube: true,
|
||||
})
|
||||
);
|
||||
|
||||
if (disposed) return;
|
||||
|
||||
await Promise.resolve(
|
||||
engine.engine.loadModel([resolvedModelUrl.value], {
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
})
|
||||
);
|
||||
engine.constructTreeBtn.setVisible(false);
|
||||
if (disposed) return;
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
engine?.engine?.resize?.();
|
||||
engine?.resize?.();
|
||||
});
|
||||
resizeObserver.observe(containerRef.value);
|
||||
loading.value = false;
|
||||
} catch (error) {
|
||||
errorText.value = error instanceof Error ? error.message : "unknown error";
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
unbindEncodingEvents();
|
||||
resizeObserver?.disconnect?.();
|
||||
resizeObserver = null;
|
||||
try {
|
||||
engine?.destroy?.();
|
||||
} finally {
|
||||
engine = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.model-viewer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.engine-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.engine-state {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 16px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 5;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(20, 28, 34, 0.72);
|
||||
color: rgba(236, 246, 250, 0.9);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 6px 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.model-code-btn {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 5;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(83, 214, 206, 0.35);
|
||||
background: rgba(18, 29, 35, 0.85);
|
||||
color: rgba(236, 246, 250, 0.95);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
||||
transition: background 0.2s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.model-code-btn:hover {
|
||||
background: rgba(28, 45, 54, 0.92);
|
||||
border-color: rgba(83, 214, 206, 0.55);
|
||||
}
|
||||
|
||||
.model-code-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.model-code-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.code-confirm-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.code-confirm-panel {
|
||||
width: min(360px, 86vw);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(83, 214, 206, 0.25);
|
||||
background:
|
||||
radial-gradient(260px 140px at 8% 0%, rgba(63, 203, 191, 0.12), transparent 62%) padding-box,
|
||||
linear-gradient(180deg, rgba(20, 31, 37, 0.95), rgba(15, 23, 29, 0.92)) padding-box;
|
||||
box-shadow:
|
||||
0 32px 80px rgba(0, 0, 0, 0.45),
|
||||
0 0 0 1px rgba(83, 214, 206, 0.1) inset;
|
||||
padding: 20px;
|
||||
color: rgba(236, 246, 250, 0.95);
|
||||
}
|
||||
|
||||
.code-confirm-title {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.code-confirm-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: rgba(220, 238, 244, 0.85);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.code-confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.code-confirm-btn {
|
||||
border-radius: 10px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.2s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.code-confirm-btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(236, 246, 250, 0.9);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.code-confirm-btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.code-confirm-btn-primary {
|
||||
background: rgba(83, 214, 206, 0.85);
|
||||
color: rgba(11, 27, 58, 0.95);
|
||||
border-color: rgba(83, 214, 206, 0.9);
|
||||
}
|
||||
|
||||
.code-confirm-btn-primary:hover {
|
||||
background: rgba(83, 214, 206, 0.95);
|
||||
}
|
||||
|
||||
.code-confirm-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.encoding-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
gap: 14px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
color: rgba(236, 246, 250, 0.95);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.encoding-spinner {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(83, 214, 206, 0.2);
|
||||
border-top-color: rgba(83, 214, 206, 0.95);
|
||||
animation: encoding-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes encoding-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.encoding-text {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.encoding-toast {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 56px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(83, 214, 206, 0.35);
|
||||
background: rgba(18, 29, 35, 0.92);
|
||||
color: rgba(83, 214, 206, 0.98);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
padding: 8px 16px;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
</style>
|
||||
96
src/composables/useDraggableModal.js
Normal file
96
src/composables/useDraggableModal.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { clamp } from "../utils/format.js";
|
||||
|
||||
export function useDraggableModal(initialPosition = { x: 190, y: 140 }) {
|
||||
const modalRef = ref(null);
|
||||
const headerRef = ref(null);
|
||||
|
||||
const state = reactive({
|
||||
dragging: false,
|
||||
pointerId: null,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
x: initialPosition.x,
|
||||
y: initialPosition.y,
|
||||
});
|
||||
|
||||
const style = reactive({
|
||||
left: `${state.x}px`,
|
||||
top: `${state.y}px`,
|
||||
});
|
||||
|
||||
function clampToViewport() {
|
||||
const modal = modalRef.value;
|
||||
if (!modal) return;
|
||||
const rect = modal.getBoundingClientRect();
|
||||
const pad = 10;
|
||||
const maxLeft = Math.max(pad, window.innerWidth - rect.width - pad);
|
||||
const maxTop = Math.max(pad, window.innerHeight - rect.height - pad);
|
||||
state.x = clamp(parseFloat(style.left), pad, maxLeft);
|
||||
state.y = clamp(parseFloat(style.top), pad, maxTop);
|
||||
style.left = `${state.x}px`;
|
||||
style.top = `${state.y}px`;
|
||||
}
|
||||
|
||||
function onHeaderPointerDown(e, closeSelector = ".close-btn") {
|
||||
if (e.target.closest(closeSelector) || e.button !== 0) return;
|
||||
const modal = modalRef.value;
|
||||
if (!modal) return;
|
||||
state.dragging = true;
|
||||
state.pointerId = e.pointerId;
|
||||
headerRef.value?.setPointerCapture?.(e.pointerId);
|
||||
const rect = modal.getBoundingClientRect();
|
||||
state.offsetX = e.clientX - rect.left;
|
||||
state.offsetY = e.clientY - rect.top;
|
||||
}
|
||||
|
||||
function onWindowPointerMove(e) {
|
||||
if (!state.dragging) return;
|
||||
if (state.pointerId != null && e.pointerId !== state.pointerId) return;
|
||||
const modal = modalRef.value;
|
||||
if (!modal) return;
|
||||
const rect = modal.getBoundingClientRect();
|
||||
const pad = 10;
|
||||
const x = clamp(e.clientX - state.offsetX, pad, Math.max(pad, window.innerWidth - rect.width - pad));
|
||||
const y = clamp(e.clientY - state.offsetY, pad, Math.max(pad, window.innerHeight - rect.height - pad));
|
||||
style.left = `${x}px`;
|
||||
style.top = `${y}px`;
|
||||
}
|
||||
|
||||
function onWindowPointerUp(e) {
|
||||
if (!state.dragging) return;
|
||||
if (state.pointerId != null && e.pointerId !== state.pointerId) return;
|
||||
state.dragging = false;
|
||||
state.pointerId = null;
|
||||
clampToViewport();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("pointermove", onWindowPointerMove);
|
||||
window.addEventListener("pointerup", onWindowPointerUp);
|
||||
window.addEventListener("pointercancel", onWindowPointerUp);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("pointermove", onWindowPointerMove);
|
||||
window.removeEventListener("pointerup", onWindowPointerUp);
|
||||
window.removeEventListener("pointercancel", onWindowPointerUp);
|
||||
});
|
||||
|
||||
function resetPosition(x = initialPosition.x, y = initialPosition.y) {
|
||||
state.x = x;
|
||||
state.y = y;
|
||||
style.left = `${x}px`;
|
||||
style.top = `${y}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
modalRef,
|
||||
headerRef,
|
||||
state,
|
||||
style,
|
||||
onHeaderPointerDown,
|
||||
resetPosition,
|
||||
clampToViewport,
|
||||
};
|
||||
}
|
||||
59
src/constants/mock.js
Normal file
59
src/constants/mock.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { formatNumber } from "../utils/format.js";
|
||||
|
||||
export const projects = [
|
||||
{ id: "qz-a2", name: "泉州市百崎通道 A2 合同段", section: "A2 合同段", scale: 1 },
|
||||
{ id: "bridge-demo", name: "跨江大桥示范段", section: "示范段", scale: 0.82 },
|
||||
{ id: "road-demo", name: "市政道路提升工程", section: "一期", scale: 0.64 },
|
||||
];
|
||||
|
||||
export const measurementPeriods = ["2025-10", "2025-11", "2025-12", "2026-01"];
|
||||
|
||||
export function getDecomposeRows(structureIndex, projectScale = 1) {
|
||||
return Array.from({ length: 12 }, (_, i) => {
|
||||
const baseQty = (109.26 + i * 19.8 + structureIndex * 2.6) * projectScale;
|
||||
const changedQty = baseQty * (i % 3 === 0 ? 1.08 : i % 3 === 1 ? 0.96 : 1.0);
|
||||
const measuredQty = changedQty * (0.25 + (i % 5) * 0.15);
|
||||
const doneQty = changedQty * (0.18 + (i % 4) * 0.2);
|
||||
return {
|
||||
code: `BOQ-${String(1001 + i)}`,
|
||||
name: ["混凝土浇筑", "钢筋制安", "模板工程", "土方开挖", "路基填筑", "防水层"][i % 6],
|
||||
unit: ["m³", "t", "㎡", "m³", "m³", "㎡"][i % 6],
|
||||
decomposeQty: baseQty,
|
||||
changedQty,
|
||||
measuredQty,
|
||||
measureRate: changedQty > 0 ? measuredQty / changedQty : 0,
|
||||
doneQty,
|
||||
doneRate: changedQty > 0 ? doneQty / changedQty : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getMaterialRows(structureIndex, projectScale = 1) {
|
||||
return [
|
||||
{ code: "MAT-01", name: "钢筋", unit: "t", qty: formatNumber((12.3 + structureIndex * 0.6) * projectScale, 1) },
|
||||
{ code: "MAT-02", name: "水泥", unit: "t", qty: formatNumber((80 + structureIndex * 1.8) * projectScale, 1) },
|
||||
{ code: "MAT-03", name: "碎石", unit: "t", qty: formatNumber((320 + structureIndex * 2.5) * projectScale, 1) },
|
||||
];
|
||||
}
|
||||
|
||||
export function getPayRows(period, structureIndex, structureName) {
|
||||
return Array.from({ length: 16 }, (_, i) => {
|
||||
const unitPrice = 520 + i * 28;
|
||||
const prevQty = (8 + i * 1.5 + structureIndex * 0.3) * (i % 3 === 0 ? 0.9 : 1.05);
|
||||
const curQty = 2.4 + (i % 5) * 0.9 + structureIndex * 0.1;
|
||||
const curAmt = curQty * unitPrice;
|
||||
const cumQty = prevQty + curQty;
|
||||
return {
|
||||
no: i + 1,
|
||||
period,
|
||||
code: `BOQ-${String(3001 + i)}`,
|
||||
name: `${["混凝土浇筑", "钢筋制安", "模板工程", "土方开挖", "路基填筑", "防水层"][i % 6]}(${structureName})`,
|
||||
unit: ["m³", "t", "㎡", "m³", "m³", "㎡"][i % 6],
|
||||
unitPrice,
|
||||
prevQty,
|
||||
curQty,
|
||||
curAmt,
|
||||
cumQty,
|
||||
};
|
||||
});
|
||||
}
|
||||
44
src/constants/structures.js
Normal file
44
src/constants/structures.js
Normal file
@@ -0,0 +1,44 @@
|
||||
export const structures = [
|
||||
{ id: "S-001", name: "主桥-0#墩" },
|
||||
{ id: "S-002", name: "主桥-1#墩" },
|
||||
{ id: "S-003", name: "主桥-2#墩" },
|
||||
{ id: "S-004", name: "引桥-桩基" },
|
||||
{ id: "S-005", name: "引桥-承台" },
|
||||
{ id: "S-006", name: "引桥-盖梁" },
|
||||
{ id: "S-007", name: "路基-填筑" },
|
||||
{ id: "S-008", name: "路面-基层" },
|
||||
{ id: "S-009", name: "路面-面层" },
|
||||
];
|
||||
|
||||
export const elementTree = [
|
||||
{
|
||||
id: "E-G-bridge",
|
||||
name: "桥梁工程",
|
||||
children: [
|
||||
{
|
||||
id: "E-G-main-bridge",
|
||||
name: "主桥",
|
||||
children: ["S-001", "S-002", "S-003"].map((id) => ({
|
||||
id,
|
||||
name: structures.find((s) => s.id === id)?.name || id,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "E-G-approach",
|
||||
name: "引桥",
|
||||
children: ["S-004", "S-005", "S-006"].map((id) => ({
|
||||
id,
|
||||
name: structures.find((s) => s.id === id)?.name || id,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "E-G-road",
|
||||
name: "道路工程",
|
||||
children: ["S-007", "S-008", "S-009"].map((id) => ({
|
||||
id,
|
||||
name: structures.find((s) => s.id === id)?.name || id,
|
||||
})),
|
||||
},
|
||||
];
|
||||
7
src/entry.js
Normal file
7
src/entry.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { pinia } from "./stores";
|
||||
import "./assets/styles/vars.css";
|
||||
|
||||
createApp(App).use(pinia).use(router).mount("#app");
|
||||
193
src/pages/change/index.vue
Normal file
193
src/pages/change/index.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="change-page">
|
||||
<div class="canvas">
|
||||
<header class="topbar"><div class="topbar-center"><div class="model-title">XXX特大桥主体模型.rvt</div></div></header>
|
||||
|
||||
<section class="model-stage">
|
||||
<ModelPlaceholder />
|
||||
</section>
|
||||
|
||||
<aside class="sidepanel" :class="{ 'is-collapsed': sideCollapsed }">
|
||||
<header class="sidepanel-header">
|
||||
<div class="sidepanel-title">变更|左侧区</div>
|
||||
<button class="iconbtn" type="button" @click="sideCollapsed = !sideCollapsed">{{ sideCollapsed ? "▸" : "▾" }}</button>
|
||||
</header>
|
||||
<div class="sidepanel-body" v-show="!sideCollapsed">
|
||||
<div class="tree">
|
||||
<div class="tree-item" :class="{ 'is-active': row.leaf && row.id === selectedStructureId }" :style="{ paddingLeft: `${10 + row.level * 14}px` }" v-for="row in visibleTreeRows" :key="row.id" @click="onTreeRowClick(row)">
|
||||
<button class="tree-caret" type="button" :class="{ 'is-leaf': row.leaf }" :disabled="row.leaf" @click.stop="toggleTreeExpand(row)">{{ row.leaf ? "•" : row.open ? "▾" : "▸" }}</button>
|
||||
<span class="tree-bullet"></span>
|
||||
<span class="tree-text">{{ row.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="bottompanel" :class="{ 'is-collapsed': bottomCollapsed }">
|
||||
<header class="bottompanel-header">
|
||||
<div class="tabs">
|
||||
<button class="tab" :class="{ 'is-on': activeTab === 'detail' }" type="button" @click="activeTab = 'detail'">变更明细</button>
|
||||
<button class="tab" :class="{ 'is-on': activeTab === 'summary' }" type="button" @click="activeTab = 'summary'">模型汇总(未选择)</button>
|
||||
</div>
|
||||
</header>
|
||||
<button class="iconbtn bottompanel-toggle" type="button" @click="bottomCollapsed = !bottomCollapsed">{{ bottomCollapsed ? "▴" : "▾" }}</button>
|
||||
<div class="bottompanel-body" v-show="!bottomCollapsed">
|
||||
<table class="table" v-if="activeTab === 'detail'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>序号</th>
|
||||
<th>变更编号</th>
|
||||
<th>变更类型</th>
|
||||
<th>清单编号</th>
|
||||
<th>清单名称</th>
|
||||
<th>单位</th>
|
||||
<th>变更前数量</th>
|
||||
<th>增减数量</th>
|
||||
<th>变更后数量</th>
|
||||
<th>影响金额</th>
|
||||
<th>变更日期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in changeRows" :key="r.no">
|
||||
<td>{{ r.no }}</td>
|
||||
<td>{{ r.changeCode }}</td>
|
||||
<td>{{ r.changeType }}</td>
|
||||
<td>{{ r.boqCode }}</td>
|
||||
<td>{{ r.itemName }}</td>
|
||||
<td>{{ r.unit }}</td>
|
||||
<td>{{ formatNumber(r.beforeQty, 2) }}</td>
|
||||
<td>{{ (r.deltaQty >= 0 ? "+" : "") + formatNumber(r.deltaQty, 2) }}</td>
|
||||
<td>{{ formatNumber(r.afterQty, 2) }}</td>
|
||||
<td>{{ formatMoney(r.impactAmount) }}</td>
|
||||
<td>{{ r.changeDate }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="placeholder" v-else>模型汇总(未选择)占位。</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
|
||||
|
||||
const structures = [
|
||||
{ id: "S-001", name: "主桥-0#墩" }, { id: "S-002", name: "主桥-1#墩" }, { id: "S-003", name: "主桥-2#墩" },
|
||||
{ id: "S-004", name: "引桥-桩基" }, { id: "S-005", name: "引桥-承台" }, { id: "S-006", name: "引桥-盖梁" },
|
||||
{ id: "S-007", name: "路基-填筑" }, { id: "S-008", name: "路面-基层" }, { id: "S-009", name: "路面-面层" },
|
||||
];
|
||||
|
||||
const elementTree = [
|
||||
{ id: "E-G-bridge", name: "桥梁工程", children: [
|
||||
{ id: "E-G-main-bridge", name: "主桥", children: ["S-001", "S-002", "S-003"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
|
||||
{ id: "E-G-approach", name: "引桥", children: ["S-004", "S-005", "S-006"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
|
||||
] },
|
||||
{ id: "E-G-road", name: "道路工程", children: ["S-007", "S-008", "S-009"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
|
||||
];
|
||||
|
||||
const sideCollapsed = ref(false);
|
||||
const bottomCollapsed = ref(false);
|
||||
const selectedStructureId = ref("S-001");
|
||||
const activeTab = ref("detail");
|
||||
const expanded = ref(new Set(["E-G-bridge", "E-G-main-bridge", "E-G-approach", "E-G-road"]));
|
||||
|
||||
const selectedStructureName = computed(() => structures.find((s) => s.id === selectedStructureId.value)?.name || "");
|
||||
|
||||
const changeRows = computed(() =>
|
||||
Array.from({ length: 16 }, (_, i) => {
|
||||
const beforeQty = 178.2 + i * 20.79;
|
||||
const deltaQty = (i % 2 === 0 ? 1 : -1) * (15.84 + i * 3.47);
|
||||
const afterQty = beforeQty + deltaQty;
|
||||
return {
|
||||
no: i + 1,
|
||||
changeCode: `CHG-${String(101 + i)}`,
|
||||
changeType: ["设计变更", "现场签证", "工程洽商", "材料替代"][i % 4],
|
||||
boqCode: `BOQ-${String(7001 + i)}`,
|
||||
itemName: `${["混凝土浇筑", "钢筋制安", "模板工程", "土方开挖", "路基填筑", "防水层"][i % 6]}(${selectedStructureName.value})`,
|
||||
unit: ["m³", "t", "㎡", "m³", "m³", "㎡"][i % 6],
|
||||
beforeQty,
|
||||
deltaQty,
|
||||
afterQty,
|
||||
impactAmount: deltaQty * (520 + i * 48),
|
||||
changeDate: "2026-03-17",
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const visibleTreeRows = computed(() => {
|
||||
const rows = [];
|
||||
const walk = (node, level) => {
|
||||
const leaf = !node.children || node.children.length === 0;
|
||||
const open = expanded.value.has(node.id);
|
||||
rows.push({ id: node.id, name: node.name, level, leaf, open });
|
||||
if (!leaf && open) node.children.forEach((c) => walk(c, level + 1));
|
||||
};
|
||||
elementTree.forEach((n) => walk(n, 0));
|
||||
return rows;
|
||||
});
|
||||
|
||||
function toggleTreeExpand(row) { if (!row.leaf) (expanded.value.has(row.id) ? expanded.value.delete(row.id) : expanded.value.add(row.id)); }
|
||||
function onTreeRowClick(row) { if (row.leaf) selectedStructureId.value = row.id; else toggleTreeExpand(row); }
|
||||
|
||||
function formatNumber(value, digits = 0) {
|
||||
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function formatMoney(value) {
|
||||
if (value == null || Number.isNaN(value)) return "--";
|
||||
const abs = Math.abs(value);
|
||||
const sign = value < 0 ? "-" : "";
|
||||
if (abs >= 1e8) return `${sign}${(abs / 1e8).toFixed(2)}亿`;
|
||||
if (abs >= 1e4) return `${sign}${(abs / 1e4).toFixed(2)}万`;
|
||||
return `${sign}${abs.toFixed(2)}元`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.change-page { min-height: 100vh; font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif; }
|
||||
.canvas { position: relative; min-height: 100vh; overflow: hidden; border-radius: 24px; border: 1px solid rgba(98,191,206,.34); background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%); box-shadow: inset 0 0 0 8px rgba(214,230,241,.55); }
|
||||
.topbar { position: absolute; inset: 0 0 auto; height: 120px; z-index: 30; }
|
||||
.topbar-center { position: absolute; left: 50%; transform: translateX(-50%); top: 16px; }
|
||||
.model-title { font-size: 20px; letter-spacing: 1px; font-weight: 700; color: rgba(255,128,52,.95); text-shadow: 0 2px 10px rgba(0,0,0,.25); }
|
||||
.model-stage { position: absolute; inset: 0; }
|
||||
|
||||
.sidepanel { position: absolute; left: 16px; top: 175px; width: 320px; bottom: 100px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(260px 140px at 8% 0%, rgba(63,203,191,.16), transparent 62%) padding-box, linear-gradient(180deg, rgba(20,31,37,.92), rgba(15,23,29,.88)) padding-box; box-shadow: 0 22px 60px rgba(0,0,0,.34), 0 0 0 1px rgba(83,214,206,.12) inset; overflow: hidden; z-index: 25; }
|
||||
.sidepanel.is-collapsed { width: 64px; }
|
||||
.sidepanel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 10px 10px 12px; border-bottom: 1px solid rgba(83,214,206,.16); background: radial-gradient(360px 100px at 10% 0%, rgba(83,214,206,.24), transparent 68%), linear-gradient(180deg, rgba(28,134,122,.84), rgba(15,60,74,.62)); }
|
||||
.sidepanel-title { font-size: 14px; font-weight: 900; color: rgba(207,247,242,.96); }
|
||||
.iconbtn { width: 30px; height: 30px; border-radius: 10px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.42); color: rgba(11,27,58,.86); font-weight: 900; cursor: pointer; }
|
||||
.sidepanel-body { height: calc(100% - 52px); overflow: auto; padding: 8px; }
|
||||
|
||||
.tree { display: grid; gap: 8px; }
|
||||
.tree-item { display: grid; grid-template-columns: 20px 10px 1fr; align-items: center; gap: 8px; min-height: 40px; border-radius: 14px; border: 1px solid rgba(154,186,198,.22); background: linear-gradient(180deg, rgba(44,58,68,.58), rgba(37,49,58,.54)); cursor: pointer; }
|
||||
.tree-item:hover { border-color: rgba(83,214,206,.32); background: linear-gradient(180deg, rgba(51,67,78,.66), rgba(43,57,67,.62)); }
|
||||
.tree-item.is-active { border-color: rgba(132,188,255,.3); background: linear-gradient(180deg, rgba(112,146,198,.34), rgba(95,128,180,.28)); }
|
||||
.tree-caret { border: 0; background: transparent; color: rgba(203,230,236,.88); cursor: pointer; font-size: 14px; }
|
||||
.tree-caret.is-leaf { cursor: default; }
|
||||
.tree-bullet { width: 10px; height: 10px; border-radius: 50%; background: rgba(83,214,206,.85); }
|
||||
.tree-text { font-size: 13px; font-weight: 800; color: rgba(222,238,244,.9); }
|
||||
|
||||
.bottompanel { position: absolute; left: 370px; right: 16px; bottom: 100px; height: 390px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(420px 180px at 14% 0%, rgba(83,214,206,.2), transparent 66%) padding-box, radial-gradient(520px 220px at 82% 0%, rgba(40,156,228,.14), transparent 70%) padding-box, linear-gradient(180deg, rgba(18,33,40,.94), rgba(14,24,31,.9)) padding-box; box-shadow: 0 24px 70px rgba(0,0,0,.36), 0 0 0 1px rgba(83,214,206,.14) inset; overflow: hidden; z-index: 26; }
|
||||
.bottompanel-header { padding: 12px 52px 12px 12px; border-bottom: 1px solid rgba(83,214,206,.28); background: radial-gradient(380px 120px at 18% 0%, rgba(83,214,206,.28), transparent 70%), linear-gradient(180deg, rgba(25,137,124,.86), rgba(16,66,82,.64)); }
|
||||
.tabs { display: flex; gap: 10px; }
|
||||
.tab { border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.08); color: rgba(214,235,241,.9); border-radius: 999px; padding: 8px 16px; cursor: pointer; font-weight: 900; font-size: 12px; }
|
||||
.tab.is-on { border-color: rgba(83,214,206,.32); background: rgba(83,214,206,.24); }
|
||||
.bottompanel-toggle { position: absolute; right: 12px; top: 12px; }
|
||||
.bottompanel.is-collapsed { height: 70px; }
|
||||
.bottompanel-body { padding: 0 12px 12px; overflow: auto; height: calc(100% - 66px); }
|
||||
.table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; }
|
||||
.table th, .table td { text-align: left; padding: 12px 14px; border-bottom: 1px solid rgba(178,206,216,.16); }
|
||||
.table th { color: rgba(219,240,246,.86); font-weight: 900; background: linear-gradient(90deg, rgba(12,38,52,.92), rgba(16,47,61,.86)); position: sticky; top: 0; z-index: 2; }
|
||||
.table td { color: rgba(222,238,244,.86); background: linear-gradient(90deg, rgba(37,51,61,.42), rgba(31,45,54,.36)); }
|
||||
.table tbody tr:hover td { background: linear-gradient(90deg, rgba(69,102,130,.36), rgba(56,86,113,.32)); }
|
||||
.placeholder { margin: 12px 0 0; border: 1px solid rgba(255,255,255,.1); background: rgba(0,0,0,.14); border-radius: 14px; padding: 12px; color: rgba(222,238,244,.82); font-size: 13px; font-weight: 700; }
|
||||
|
||||
@media (max-width: 1300px) {
|
||||
.sidepanel { width: 280px; }
|
||||
.bottompanel { left: 330px; }
|
||||
}
|
||||
</style>
|
||||
273
src/pages/debug/index.vue
Normal file
273
src/pages/debug/index.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div class="debug-page">
|
||||
<PageCanvas title="调试面板">
|
||||
<div class="debug-content"
|
||||
>
|
||||
<section class="debug-card"
|
||||
>
|
||||
<header class="debug-card-head"
|
||||
>
|
||||
<div class="debug-card-title">获取 AccessToken</div>
|
||||
</header>
|
||||
|
||||
<div class="debug-form"
|
||||
>
|
||||
<div class="debug-field"
|
||||
>
|
||||
<label class="debug-label">API 基础地址</label>
|
||||
<input v-model="form.baseUrl" class="debug-input" placeholder="https://example.com" />
|
||||
</div>
|
||||
|
||||
<div class="debug-field"
|
||||
>
|
||||
<label class="debug-label">client_id</label>
|
||||
<input v-model="form.clientId" class="debug-input" placeholder="client_id" />
|
||||
</div>
|
||||
|
||||
<div class="debug-field"
|
||||
>
|
||||
<label class="debug-label">client_secret</label>
|
||||
<input v-model="form.clientSecret" class="debug-input" placeholder="client_secret" />
|
||||
</div>
|
||||
|
||||
<div class="debug-field"
|
||||
>
|
||||
<label class="debug-label">grant_type</label>
|
||||
<input v-model="form.grantType" class="debug-input" placeholder="client_credentials" />
|
||||
</div>
|
||||
|
||||
<div class="debug-actions"
|
||||
>
|
||||
<button class="debug-btn" type="button" :disabled="loading" @click="fetchToken"
|
||||
>{{ loading ? "请求中…" : "获取 Token" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="resultText" class="debug-result"
|
||||
>
|
||||
<div class="debug-result-head"
|
||||
>
|
||||
<div class="debug-result-title">请求结果</div>
|
||||
<button class="debug-btn debug-btn-sm" type="button" @click="copyResult"
|
||||
>复制</button>
|
||||
</div>
|
||||
<pre class="debug-pre">{{ resultText }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</PageCanvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from "vue";
|
||||
import PageCanvas from "../../components/layout/PageCanvas.vue";
|
||||
|
||||
const STORAGE_KEY = "bim.debug.tokenForm";
|
||||
|
||||
function loadForm() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveForm(data) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const saved = loadForm();
|
||||
const defaultBaseUrl = String(import.meta.env.VITE_API_BASE_URL || "").trim();
|
||||
|
||||
const form = reactive({
|
||||
baseUrl: saved?.baseUrl || defaultBaseUrl || "",
|
||||
clientId: saved?.clientId || "",
|
||||
clientSecret: saved?.clientSecret || "",
|
||||
grantType: saved?.grantType || "client_credentials",
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const resultText = ref("");
|
||||
|
||||
async function fetchToken() {
|
||||
if (!form.baseUrl) {
|
||||
resultText.value = "请先填写 API 基础地址";
|
||||
return;
|
||||
}
|
||||
saveForm({ baseUrl: form.baseUrl, clientId: form.clientId, clientSecret: form.clientSecret, grantType: form.grantType });
|
||||
|
||||
loading.value = true;
|
||||
resultText.value = "";
|
||||
|
||||
try {
|
||||
const url = new URL("/oauth2/token", form.baseUrl);
|
||||
if (form.clientId) url.searchParams.set("client_id", form.clientId);
|
||||
if (form.clientSecret) url.searchParams.set("client_secret", form.clientSecret);
|
||||
if (form.grantType) url.searchParams.set("grant_type", form.grantType);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
resultText.value = JSON.stringify({ status: res.status, statusText: res.statusText, data }, null, 2);
|
||||
} catch (err) {
|
||||
resultText.value = `请求失败:${err instanceof Error ? err.message : String(err)}`;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function copyResult() {
|
||||
navigator.clipboard?.writeText?.(resultText.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.debug-page {
|
||||
min-height: 100vh;
|
||||
font-family: var(--bim-font-cn);
|
||||
}
|
||||
|
||||
.debug-content {
|
||||
position: absolute;
|
||||
inset: 120px 24px 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.debug-card {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(83, 214, 206, 0.24);
|
||||
background:
|
||||
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
|
||||
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box;
|
||||
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.34), 0 0 0 1px rgba(83, 214, 206, 0.12) inset;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.debug-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background:
|
||||
radial-gradient(520px 120px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 70%),
|
||||
linear-gradient(180deg, rgba(18, 26, 31, 0.96), rgba(14, 20, 25, 0.86));
|
||||
}
|
||||
|
||||
.debug-card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 950;
|
||||
letter-spacing: 0.4px;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.debug-form {
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.debug-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.debug-label {
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.debug-input {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 0 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.debug-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.debug-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.debug-btn {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(83, 214, 206, 0.35);
|
||||
background: rgba(83, 214, 206, 0.14);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 12px;
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.debug-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.debug-btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.debug-result {
|
||||
margin: 0 20px 20px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.debug-result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.debug-result-title {
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.debug-pre {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
803
src/pages/home/index.vue
Normal file
803
src/pages/home/index.vue
Normal file
@@ -0,0 +1,803 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<div class="canvas" :class="{ 'is-left-closed': !leftOpen, 'is-right-closed': !rightOpen }">
|
||||
|
||||
<header class="topbar">
|
||||
<div class="topbar-left"></div>
|
||||
<div class="topbar-center">
|
||||
<div class="model-title">XXX特大桥主体模型.rvt</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="status-chip">
|
||||
<div class="status-chip-track">
|
||||
<span class="countdown-label">距竣工</span>
|
||||
</div>
|
||||
<div class="countdown-value">
|
||||
<span class="countdown-days">{{ countdownDays }}</span>
|
||||
<span class="countdown-days-unit">天</span>
|
||||
<span class="countdown-time">{{ countdownTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="model-stage">
|
||||
<div class="model-shell"></div>
|
||||
<ModelPlaceholder />
|
||||
</section>
|
||||
|
||||
<button class="cards-toggle cards-toggle-left" type="button" aria-label="展开/收起左侧卡片" @click="leftOpen = !leftOpen">{{ leftOpen ? "◀" : "▶" }}</button>
|
||||
<button class="cards-toggle cards-toggle-right" type="button" aria-label="展开/收起右侧卡片" @click="rightOpen = !rightOpen">{{ rightOpen ? "▶" : "◀" }}</button>
|
||||
|
||||
<aside class="cards cards-left" v-show="leftOpen">
|
||||
<section class="card">
|
||||
<header class="card-header"><div class="card-title">合同与利润</div></header>
|
||||
<div class="card-body">
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="metric-icon" data-icon="percent"></div><div><div class="metric-value">{{ formatPercent(mock.budgetProfitRate) }}</div><div class="metric-label">施工预算利润率</div></div></div>
|
||||
<div class="metric"><div class="metric-icon" data-icon="currency"></div><div><div class="metric-value">{{ formatMoney(mock.currentContractAmount) }}</div><div class="metric-label">当前合同额</div></div></div>
|
||||
<div class="metric"><div class="metric-icon" data-icon="currency"></div><div><div class="metric-value">{{ formatMoney(mock.bidContractAmount) }}</div><div class="metric-label">中标合同额</div></div></div>
|
||||
<div class="metric"><div class="metric-icon" data-icon="percent"></div><div><div class="metric-value">{{ formatPercent(mock.postBidBudgetProfitRate) }}</div><div class="metric-label">标后预算利润率</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<header class="card-header"><div class="card-title">进度管理</div><div class="seg"><button class="segbtn" :class="{ 'is-on': progressValueGrain === 'month' }" @click="progressValueGrain = 'month'">月</button><button class="segbtn" :class="{ 'is-on': progressValueGrain === 'quarter' }" @click="progressValueGrain = 'quarter'">季</button><button class="segbtn" :class="{ 'is-on': progressValueGrain === 'year' }" @click="progressValueGrain = 'year'">年</button></div></header>
|
||||
<div class="card-body">
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="metric-icon" data-icon="calendar"></div><div><div class="metric-value">{{ formatMoney(progressValueData.planValue) }}</div><div class="metric-label">计划产值</div></div></div>
|
||||
<div class="metric"><div class="metric-icon" data-icon="check"></div><div><div class="metric-value">{{ formatMoney(progressValueData.actualValue) }}</div><div class="metric-label">实际产值</div></div></div>
|
||||
</div>
|
||||
<div class="progressline">
|
||||
<div class="progressline-top"><div class="progressline-label">产值完成率</div><div class="progressline-value">{{ formatPercent(valueRate) }}</div></div>
|
||||
<div class="bar"><div class="bar-fill" :style="{ width: `${Math.max(0, Math.min(1, valueRate)) * 100}%` }"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<header class="card-header"><div class="card-title">支出合同管理</div></header>
|
||||
<div class="card-body">
|
||||
<div class="expense-metrics">
|
||||
<div class="metric-row"><div class="metric-icon" data-icon="percent"></div><div><div class="metric-value">{{ formatPercent(mock.settlementRate) }}</div><div class="metric-label">结算比率</div></div></div>
|
||||
<div class="metric-row"><div class="metric-icon" data-icon="currency"></div><div><div class="metric-value">{{ formatMoney(mock.supplementAgreementAmount) }}</div><div class="metric-label">补充协议金额</div></div></div>
|
||||
</div>
|
||||
<div class="expense-breakdown">
|
||||
<div class="ring">
|
||||
<svg viewBox="0 0 120 120" class="ring-svg"><circle class="ring-bg" cx="60" cy="60" r="46"></circle><g class="ring-rot"><circle v-for="seg in ringSegments" :key="seg.name" class="ring-seg" cx="60" cy="60" r="46" :style="seg.style"></circle></g></svg>
|
||||
<div class="ring-center"><div class="ring-center-title">费用构成</div><div class="ring-center-sub">占比</div></div>
|
||||
</div>
|
||||
<div class="ring-legend"><div class="ring-item" v-for="row in expenseBreakdown" :key="row.name"><span class="ring-swatch" :style="{ background: row.color }"></span><span>{{ row.name }}</span><span>{{ row.pct }}%</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<aside class="cards cards-right" v-show="rightOpen">
|
||||
<section class="card">
|
||||
<header class="card-header"><div class="card-title">营收产值</div></header>
|
||||
<div class="card-body">
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="metric-icon" data-icon="currency"></div><div><div class="metric-value">{{ formatMoney(mock.changeClaimAmount) }}</div><div class="metric-label">变更索赔额</div></div></div>
|
||||
<div class="metric"><div class="metric-icon" data-icon="percent"></div><div><div class="metric-value">{{ formatPercent(mock.changeApprovalRate) }}</div><div class="metric-label">变更批复率</div></div></div>
|
||||
<div class="metric"><div class="metric-icon" data-icon="delta"></div><div><div class="metric-value">{{ formatPercent(mock.revenueValueRatioDiffRate) }}</div><div class="metric-label">营收产值比差异率</div></div></div>
|
||||
<div class="metric"><div class="metric-icon" data-icon="percent"></div><div><div class="metric-value">{{ formatPercent(mock.revenueValueRatio) }}</div><div class="metric-label">营收产值比</div></div></div>
|
||||
<div class="metric"><div class="metric-icon" data-icon="target"></div><div><div class="metric-value">{{ formatPercent(mock.dataCompleteness) }}</div><div class="metric-label">数据完整性</div></div></div>
|
||||
<div class="metric"><div class="metric-icon" data-icon="percent"></div><div><div class="metric-value">{{ formatPercent(mock.profitDiffRate) }}</div><div class="metric-label">财务利润差异率</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<header class="card-header"><div class="card-title">进度管理</div><div class="seg"><button class="segbtn" :class="{ 'is-on': progressTimeGrain === 'month' }" @click="progressTimeGrain = 'month'">月</button><button class="segbtn" :class="{ 'is-on': progressTimeGrain === 'quarter' }" @click="progressTimeGrain = 'quarter'">季</button><button class="segbtn" :class="{ 'is-on': progressTimeGrain === 'year' }" @click="progressTimeGrain = 'year'">年</button></div></header>
|
||||
<div class="card-body">
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="metric-icon" data-icon="sum"></div><div><div class="metric-value">{{ formatMoney(progressTimeData.cumValue) }}</div><div class="metric-label">开累产值</div></div></div>
|
||||
<div class="metric"><div class="metric-icon" data-icon="time"></div><div><div class="metric-value">{{ progressTimeData.elapsedDays.toLocaleString("zh-CN") }}天</div><div class="metric-label">已消耗工期</div></div></div>
|
||||
</div>
|
||||
<div class="progressline">
|
||||
<div class="progressline-top"><div class="progressline-label">产值完成率</div><div class="progressline-value">{{ formatPercent(cumRate) }}</div></div>
|
||||
<div class="bar"><div class="bar-fill" :style="{ width: `${Math.max(0, Math.min(1, cumRate)) * 100}%` }"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<header class="card-header"><div class="card-title">物资管理</div><div class="seg"><button class="segbtn" :class="{ 'is-on': materialsGrain === 'month' }" @click="materialsGrain = 'month'">月</button><button class="segbtn" :class="{ 'is-on': materialsGrain === 'quarter' }" @click="materialsGrain = 'quarter'">季</button><button class="segbtn" :class="{ 'is-on': materialsGrain === 'year' }" @click="materialsGrain = 'year'">年</button></div></header>
|
||||
<div class="card-body">
|
||||
<div class="barchart">
|
||||
<div class="barrow" v-for="row in materialsRows" :key="row.key" :data-kind="row.key"><div class="barname">{{ row.name }}</div><div class="bartrack"><div class="barval" :style="{ width: `${(row.value / materialsMax) * 100}%` }"></div></div><div class="barlabel">{{ row.value.toLocaleString("zh-CN") }}吨</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
|
||||
|
||||
const mock = {
|
||||
finishDate: "2027-12-30",
|
||||
budgetProfitRate: 0.153,
|
||||
currentContractAmount: 162500000,
|
||||
bidContractAmount: 148800000,
|
||||
postBidBudgetProfitRate: 0.128,
|
||||
planValue: 6380000,
|
||||
actualValue: 5710000,
|
||||
cumValue: 98200000,
|
||||
elapsedDays: 476,
|
||||
settlementRate: 0.62,
|
||||
supplementAgreementAmount: 3600000,
|
||||
changeClaimAmount: 2850000,
|
||||
changeApprovalRate: 0.74,
|
||||
revenueValueRatioDiffRate: -0.013,
|
||||
revenueValueRatio: 0.986,
|
||||
dataCompleteness: 0.912,
|
||||
profitDiffRate: 0.027,
|
||||
materials: { received: 2350, issued: 2112, consumed: 1843, settled: 1520, stock: 538 },
|
||||
};
|
||||
|
||||
const expenseBreakdown = [
|
||||
{ name: "设备租赁", pct: 0.2, color: "#6e7d96" },
|
||||
{ name: "物资购销", pct: 44.3, color: "#156cff" },
|
||||
{ name: "施工协作分包", pct: 43.3, color: "#2cc8ff" },
|
||||
{ name: "拌合", pct: 1.5, color: "#2f8f2f" },
|
||||
{ name: "临设分包", pct: 0.6, color: "#d48806" },
|
||||
{ name: "其他", pct: 9.9, color: "#6b5cff" },
|
||||
];
|
||||
|
||||
const countdownDays = ref("--");
|
||||
const countdownTime = ref("--:--");
|
||||
const leftOpen = ref(true);
|
||||
const rightOpen = ref(true);
|
||||
const progressValueGrain = ref("month");
|
||||
const progressTimeGrain = ref("month");
|
||||
const materialsGrain = ref("month");
|
||||
|
||||
let timer = null;
|
||||
|
||||
const progressValueMap = {
|
||||
month: { planValue: 6380000, actualValue: 5710000 },
|
||||
quarter: { planValue: 18900000, actualValue: 17260000 },
|
||||
year: { planValue: 72400000, actualValue: 67800000 },
|
||||
};
|
||||
|
||||
const progressTimeMap = {
|
||||
month: { cumValue: 98200000, elapsedDays: 476 },
|
||||
quarter: { cumValue: 101600000, elapsedDays: 492 },
|
||||
year: { cumValue: 114300000, elapsedDays: 538 },
|
||||
};
|
||||
|
||||
const materialsMap = {
|
||||
month: { received: 2350, issued: 2112, consumed: 1843, settled: 1520, stock: 538 },
|
||||
quarter: { received: 7190, issued: 6810, consumed: 5995, settled: 5240, stock: 1084 },
|
||||
year: { received: 29100, issued: 27540, consumed: 24120, settled: 20800, stock: 3840 },
|
||||
};
|
||||
|
||||
const progressValueData = computed(() => progressValueMap[progressValueGrain.value]);
|
||||
const progressTimeData = computed(() => progressTimeMap[progressTimeGrain.value]);
|
||||
const materialsData = computed(() => materialsMap[materialsGrain.value]);
|
||||
|
||||
const valueRate = computed(() => (progressValueData.value.planValue > 0 ? progressValueData.value.actualValue / progressValueData.value.planValue : 0));
|
||||
const cumRate = computed(() => (mock.currentContractAmount > 0 ? progressTimeData.value.cumValue / mock.currentContractAmount : 0));
|
||||
const materialsRows = computed(() => [
|
||||
{ key: "received", name: "收料", value: materialsData.value.received },
|
||||
{ key: "issued", name: "领料", value: materialsData.value.issued },
|
||||
{ key: "consumed", name: "消耗", value: materialsData.value.consumed },
|
||||
{ key: "settled", name: "结算", value: materialsData.value.settled },
|
||||
{ key: "stock", name: "库存", value: materialsData.value.stock },
|
||||
]);
|
||||
const materialsMax = computed(() => Math.max(...materialsRows.value.map((row) => row.value), 1));
|
||||
|
||||
const ringSegments = computed(() => {
|
||||
const r = 46;
|
||||
const C = 2 * Math.PI * r;
|
||||
const total = expenseBreakdown.reduce((sum, row) => sum + row.pct, 0);
|
||||
let offset = 0;
|
||||
return expenseBreakdown.map((row) => {
|
||||
const ratio = row.pct / total;
|
||||
const len = C * ratio;
|
||||
const seg = {
|
||||
name: row.name,
|
||||
style: {
|
||||
stroke: row.color,
|
||||
strokeDasharray: `${len.toFixed(1)} ${(C - len).toFixed(1)}`,
|
||||
strokeDashoffset: `${(-offset).toFixed(1)}`,
|
||||
},
|
||||
};
|
||||
offset += len;
|
||||
return seg;
|
||||
});
|
||||
});
|
||||
|
||||
function clamp(n, min, max) {
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
function formatMoney(value) {
|
||||
const abs = Math.abs(value);
|
||||
if (abs >= 100000000) return `${(value / 100000000).toFixed(2)}亿`;
|
||||
if (abs >= 10000) return `${(value / 10000).toFixed(2)}万`;
|
||||
return `${value.toFixed(2)}元`;
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function updateCountdown() {
|
||||
const end = new Date(`${mock.finishDate}T00:00:00`);
|
||||
const now = new Date();
|
||||
const diffMs = Math.max(0, end.getTime() - now.getTime());
|
||||
const totalMinutes = Math.floor(diffMs / 60000);
|
||||
const days = Math.floor(totalMinutes / (24 * 60));
|
||||
const hours = Math.floor((totalMinutes - days * 24 * 60) / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
countdownDays.value = String(days);
|
||||
countdownTime.value = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateCountdown();
|
||||
timer = setInterval(updateCountdown, 30000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-page {
|
||||
--text: rgba(245, 252, 255, 0.92);
|
||||
--muted: rgba(223, 241, 246, 0.72);
|
||||
--panel: rgba(18, 29, 35, 0.9);
|
||||
--panel-soft: rgba(16, 25, 31, 0.8);
|
||||
--accent: #08c7bc;
|
||||
--accent2: #20e2d5;
|
||||
--line: rgba(42, 190, 182, 0.34);
|
||||
--line-soft: rgba(42, 190, 182, 0.2);
|
||||
--bar-track: rgba(255, 255, 255, 0.12);
|
||||
--bar-fill: linear-gradient(90deg, #0eb7ff, #1ce0c5);
|
||||
--font-cn: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
|
||||
--font-num: "DIN Alternate", "Bahnschrift", "Segoe UI", "Arial Narrow", sans-serif;
|
||||
font-family: var(--font-cn);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(98, 191, 206, 0.34);
|
||||
background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%);
|
||||
box-shadow: inset 0 0 0 8px rgba(214, 230, 241, 0.55);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: 92px;
|
||||
padding: 18px 20px 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.topbar-left,
|
||||
.topbar-center,
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar-center {
|
||||
justify-content: center;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
justify-content: flex-end;
|
||||
padding-right: 6px;
|
||||
margin-right: 100px;
|
||||
}
|
||||
|
||||
.model-title {
|
||||
font-size: 26px;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 128, 52, 0.95);
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
position: relative;
|
||||
min-width: 220px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 9px 14px 10px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(103, 204, 216, 0.32);
|
||||
background: linear-gradient(180deg, rgba(16, 45, 57, 0.9), rgba(14, 31, 42, 0.86));
|
||||
box-shadow: 0 18px 36px rgba(4, 20, 31, 0.32);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-chip-track {
|
||||
height: 18px;
|
||||
border-radius: 10px;
|
||||
margin-bottom:10px;
|
||||
}
|
||||
|
||||
.countdown-label {
|
||||
font-size: 12px;
|
||||
color: rgba(220, 238, 244, 0.78);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.countdown-value {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
font-weight: 900;
|
||||
font-family: var(--font-num);
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.countdown-days {
|
||||
font-size: 22px;
|
||||
color: #f2fbff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.countdown-days-unit {
|
||||
font-size: 12px;
|
||||
color: rgba(203, 229, 236, 0.85);
|
||||
}
|
||||
|
||||
.countdown-time {
|
||||
font-size: 18px;
|
||||
color: var(--accent2);
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 18px rgba(32, 226, 213, 0.26);
|
||||
}
|
||||
|
||||
.model-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.model-shell {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(900px 600px at 50% 50%, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0));
|
||||
}
|
||||
|
||||
.cards-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 21;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(97, 203, 213, 0.3);
|
||||
background: linear-gradient(180deg, rgba(17, 45, 56, 0.95), rgba(13, 29, 39, 0.9));
|
||||
color: rgba(202, 236, 244, 0.92);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cards-toggle-left {
|
||||
left: 360px;
|
||||
}
|
||||
|
||||
.cards-toggle-right {
|
||||
right: 360px;
|
||||
}
|
||||
|
||||
.canvas.is-left-closed .cards-toggle-left {
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.canvas.is-right-closed .cards-toggle-right {
|
||||
right: 24px;
|
||||
}
|
||||
|
||||
.cards {
|
||||
position: absolute;
|
||||
top: 110px;
|
||||
width: 320px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cards-left {
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.cards-right {
|
||||
right: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(13, 30, 39, 0.95), rgba(28, 43, 52, 0.9));
|
||||
box-shadow: 0 16px 30px rgba(6, 16, 24, 0.35), inset 0 0 0 1px rgba(26, 121, 121, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: linear-gradient(180deg, rgba(4, 128, 123, 0.86), rgba(5, 93, 98, 0.8));
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #d4f7ff;
|
||||
letter-spacing: 0.5px;
|
||||
font-family: var(--font-cn);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 10px 12px 10px;
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
border-radius: 999px;
|
||||
padding: 3px;
|
||||
background: rgba(0, 44, 54, 0.8);
|
||||
border: 1px solid rgba(98, 200, 211, 0.2);
|
||||
}
|
||||
|
||||
.segbtn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(210, 236, 241, 0.8);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-cn);
|
||||
}
|
||||
|
||||
.segbtn.is-on {
|
||||
background: rgba(0, 170, 232, 0.86);
|
||||
color: #d8f8ff;
|
||||
box-shadow: 0 0 0 1px rgba(65, 233, 229, 0.3) inset;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric,
|
||||
.metric-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 6px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(76, 181, 194, 0.18);
|
||||
background: linear-gradient(180deg, rgba(31, 46, 55, 0.86), rgba(24, 38, 47, 0.86));
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(19, 201, 194, 0.55);
|
||||
background: rgba(4, 70, 81, 0.8);
|
||||
color: #00d5d0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.metric-icon[data-icon] {
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.metric-icon[data-icon]::before {
|
||||
content: "";
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: currentColor;
|
||||
-webkit-mask: var(--metric-icon-mask) no-repeat center / contain;
|
||||
mask: var(--metric-icon-mask) no-repeat center / contain;
|
||||
}
|
||||
|
||||
.metric-icon[data-icon="percent"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Ccircle%20cx='7'%20cy='7'%20r='2.2'%20fill='black'/%3E%3Ccircle%20cx='17'%20cy='17'%20r='2.2'%20fill='black'/%3E%3Cpath%20d='M7%2017L17%207'%20stroke='black'%20stroke-width='2.4'%20stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||
.metric-icon[data-icon="currency"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20fill='black'%20d='M8%203l4%206%204-6h2l-5%207h4v2h-5v2h5v2h-5v5h-2v-5H7v-2h5v-2H7v-2h4L6%203h2z'/%3E%3C/svg%3E"); }
|
||||
.metric-icon[data-icon="calendar"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20fill='black'%20d='M7%202h2v2h6V2h2v2h2a2%202%200%200%201%202%202v14a3%203%200%200%201-3%203H6a3%203%200%200%201-3-3V6a2%202%200%200%201%202-2h2V2zm14%208H5v10a1%201%200%200%200%201%201h14a1%201%200%200%200%201-1V10zM6%206a1%201%200%200%200-1%201v1h16V7a1%201%200%200%200-1-1H6z'/%3E%3C/svg%3E"); }
|
||||
.metric-icon[data-icon="check"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20d='M20%206L9%2017l-5-5'%20fill='none'%20stroke='black'%20stroke-width='2.8'%20stroke-linecap='round'%20stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.metric-icon[data-icon="delta"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20fill='black'%20d='M12%204l8%2016H4l8-16z'/%3E%3C/svg%3E"); }
|
||||
.metric-icon[data-icon="target"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20fill='black'%20fill-rule='evenodd'%20d='M12%204a8%208%200%201%200%200%2016a8%208%200%200%200%200-16zm0%205a3%203%200%201%200%200%206a3%203%200%200%200%200-6z'/%3E%3C/svg%3E"); }
|
||||
.metric-icon[data-icon="sum"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20d='M18%205H8l5%207-5%207h10'%20fill='none'%20stroke='black'%20stroke-width='2.6'%20stroke-linecap='round'%20stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.metric-icon[data-icon="time"] { --metric-icon-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20d='M10%202h4v2h-4V2zm2%205a7%207%200%201%200%200%2014a7%207%200%200%200%200-14zm0%203v4l3%202'%20fill='none'%20stroke='black'%20stroke-width='2.2'%20stroke-linecap='round'%20stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
|
||||
.metric-value {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
line-height: 1.05;
|
||||
font-family: var(--font-num);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-cn);
|
||||
}
|
||||
|
||||
.progressline {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(84, 197, 208, 0.2);
|
||||
background: linear-gradient(180deg, rgba(27, 39, 47, 0.86), rgba(22, 34, 43, 0.86));
|
||||
}
|
||||
|
||||
.progressline-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progressline-label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-cn);
|
||||
}
|
||||
|
||||
.progressline-value {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
font-family: var(--font-num);
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--bar-track);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(104, 209, 220, 0.16);
|
||||
}
|
||||
|
||||
.bar-fill,
|
||||
.barval {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--bar-fill);
|
||||
box-shadow: 0 0 18px rgba(27, 229, 206, 0.2);
|
||||
}
|
||||
|
||||
.expense-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.expense-breakdown {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ring {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ring-svg {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.ring-bg {
|
||||
fill: none;
|
||||
stroke: rgba(108, 209, 219, 0.18);
|
||||
stroke-width: 12;
|
||||
}
|
||||
|
||||
.ring-rot {
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: 60px 60px;
|
||||
}
|
||||
|
||||
.ring-seg {
|
||||
fill: none;
|
||||
stroke-width: 12;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 0 999;
|
||||
filter: drop-shadow(0 0 8px rgba(21, 108, 255, 0.2));
|
||||
}
|
||||
|
||||
.ring-center {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ring-center-title {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
font-family: var(--font-cn);
|
||||
}
|
||||
|
||||
.ring-center-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-cn);
|
||||
}
|
||||
|
||||
.ring-legend {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ring-item {
|
||||
display: grid;
|
||||
grid-template-columns: 10px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 9px;
|
||||
color: rgba(213, 237, 243, 0.86);
|
||||
font-family: var(--font-cn);
|
||||
}
|
||||
|
||||
.ring-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(238, 250, 252, 0.16);
|
||||
}
|
||||
|
||||
.barchart {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.barrow {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.barname {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-cn);
|
||||
}
|
||||
|
||||
.barname::before {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: #1a87ff;
|
||||
}
|
||||
|
||||
.bartrack {
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--bar-track);
|
||||
border: 1px solid rgba(113, 211, 221, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.barlabel {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-size: 10px;
|
||||
font-family: var(--font-num);
|
||||
}
|
||||
|
||||
.barrow[data-kind="received"] .barname::before,
|
||||
.barrow[data-kind="received"] .barval {
|
||||
background: linear-gradient(90deg, #1d87ff, #2280ea);
|
||||
}
|
||||
|
||||
.barrow[data-kind="issued"] .barname::before,
|
||||
.barrow[data-kind="issued"] .barval {
|
||||
background: linear-gradient(90deg, #0bb8ff, #17a9ea);
|
||||
}
|
||||
|
||||
.barrow[data-kind="consumed"] .barname::before,
|
||||
.barrow[data-kind="consumed"] .barval {
|
||||
background: linear-gradient(90deg, #15b964, #1ca56c);
|
||||
}
|
||||
|
||||
.barrow[data-kind="settled"] .barname::before,
|
||||
.barrow[data-kind="settled"] .barval {
|
||||
background: linear-gradient(90deg, #d88b06, #c67806);
|
||||
}
|
||||
|
||||
.barrow[data-kind="stock"] .barname::before,
|
||||
.barrow[data-kind="stock"] .barval {
|
||||
background: linear-gradient(90deg, #6f7e98, #5f708a);
|
||||
}
|
||||
|
||||
@media (max-width: 1300px) {
|
||||
.cards {
|
||||
position: static;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.cards-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: static;
|
||||
height: auto;
|
||||
padding: 0 0 14px;
|
||||
}
|
||||
|
||||
.model-stage {
|
||||
position: relative;
|
||||
height: 460px;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cards-left,
|
||||
.cards-right {
|
||||
left: auto;
|
||||
right: auto;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
362
src/pages/inspection/index.vue
Normal file
362
src/pages/inspection/index.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div class="inspection-page">
|
||||
<div class="canvas">
|
||||
<header class="topbar"><div class="topbar-center"><div class="model-title">XXX特大桥主体模型.rvt</div></div></header>
|
||||
|
||||
<section class="model-stage">
|
||||
<ModelPlaceholder />
|
||||
</section>
|
||||
|
||||
<aside class="sidepanel inspect-panel-left" :class="{ 'is-collapsed': leftCollapsed }">
|
||||
<header class="sidepanel-header">
|
||||
<div class="sidepanel-title">智能巡检分析</div>
|
||||
<button class="iconbtn" type="button" @click="leftCollapsed = !leftCollapsed">{{ leftCollapsed ? "▸" : "▾" }}</button>
|
||||
</header>
|
||||
<div class="sidepanel-body" v-show="!leftCollapsed">
|
||||
<section class="inspect-upload-card">
|
||||
<div class="inspect-upload-head">
|
||||
<div class="inspect-upload-head-title">全景照片上传</div>
|
||||
</div>
|
||||
<button class="inspect-drop" type="button" @click="panoramaInputRef?.click()">
|
||||
<div class="inspect-drop-title">上传全景照片</div>
|
||||
<div class="inspect-drop-sub">AI 将对全景照片进行整体场景分析</div>
|
||||
</button>
|
||||
<input ref="panoramaInputRef" type="file" accept="image/*" hidden @change="onPanoramaPick" />
|
||||
<div class="inspect-file-row" v-show="panoramaFileName">
|
||||
<div class="inspect-file-name">{{ panoramaFileName }}</div>
|
||||
<button class="inspect-file-remove" type="button" @click="panoramaFileName = ''">×</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="inspect-upload-card is-media">
|
||||
<div class="inspect-upload-head">
|
||||
<div class="inspect-upload-head-title">照片/视频上传</div>
|
||||
</div>
|
||||
<button class="inspect-drop" type="button" @click="mediaInputRef?.click()">
|
||||
<div class="inspect-drop-title">点击上传照片或视频</div>
|
||||
<div class="inspect-drop-sub">支持 JPG、PNG、MP4 格式</div>
|
||||
</button>
|
||||
<input ref="mediaInputRef" type="file" accept="image/*,video/*" multiple hidden @change="onMediaPick" />
|
||||
<div class="inspect-file-list" v-show="mediaFiles.length > 0">
|
||||
<div class="inspect-file-row" v-for="f in mediaFiles" :key="f.name + f.size">
|
||||
<div class="inspect-file-name">{{ f.name }}</div>
|
||||
<button class="inspect-file-remove" type="button" @click="removeMediaFile(f)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-accent inspect-analyze-btn" type="button" :disabled="mediaFiles.length === 0" @click="analyzeMedia">开始照片/视频 AI 分析</button>
|
||||
</section>
|
||||
|
||||
<section class="inspect-block">
|
||||
<div class="inspect-block-title">风险分级统计</div>
|
||||
<div class="inspect-risk-grid">
|
||||
<div class="inspect-risk-card is-high"><div class="inspect-risk-k">高风险</div><div class="inspect-risk-v">{{ riskCount.high }}</div></div>
|
||||
<div class="inspect-risk-card is-mid"><div class="inspect-risk-k">中风险</div><div class="inspect-risk-v">{{ riskCount.mid }}</div></div>
|
||||
<div class="inspect-risk-card is-low"><div class="inspect-risk-k">低风险</div><div class="inspect-risk-v">{{ riskCount.low }}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="sidepanel inspect-panel-right">
|
||||
<header class="sidepanel-header">
|
||||
<div class="sidepanel-title inspect-list-title">巡检问题清单</div>
|
||||
<div class="inspect-list-badge">{{ issues.length }} 个问题</div>
|
||||
</header>
|
||||
<div class="sidepanel-body inspect-list-body">
|
||||
<template v-for="group in groupedIssues" :key="group.key">
|
||||
<div class="inspect-group-title" :class="`is-${group.key}`">{{ group.label }}</div>
|
||||
<article class="inspect-item" :class="`is-${it.severity}`" v-for="it in group.items" :key="it.id" @click="openDetail(it)">
|
||||
<div class="inspect-item-top">
|
||||
<span class="inspect-pill" :class="`is-${it.severity}`">{{ severityLabel(it.severity) }}</span>
|
||||
<span class="inspect-level">{{ it.levelText }}</span>
|
||||
</div>
|
||||
<div class="inspect-item-title">{{ it.title }}</div>
|
||||
<div class="inspect-item-desc">{{ it.desc }}</div>
|
||||
<div class="inspect-item-meta">
|
||||
<div class="inspect-item-loc">{{ it.location }}</div>
|
||||
<div class="inspect-item-status" :class="`is-${it.status}`">{{ statusLabel(it.status) }}</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="inspect-detail-overlay" v-show="detailOpen" @click.self="closeDetail">
|
||||
<section class="inspect-detail-modal">
|
||||
<header class="inspect-detail-header">
|
||||
<div class="inspect-detail-chips">
|
||||
<span class="inspect-pill" :class="`is-${activeIssue?.severity || 'high'}`">{{ severityLabel(activeIssue?.severity) }}</span>
|
||||
<span class="inspect-detail-status-chip">{{ statusLabel(activeIssue?.status) }}</span>
|
||||
</div>
|
||||
<button class="inspect-detail-close" type="button" @click="closeDetail">×</button>
|
||||
</header>
|
||||
<div class="inspect-detail-body" v-if="activeIssue">
|
||||
<div class="inspect-detail-title">{{ activeIssue.title }}</div>
|
||||
<section class="inspect-detail-card is-ai">
|
||||
<div class="inspect-detail-card-title">AI 分析结果</div>
|
||||
<div class="inspect-detail-card-text">{{ activeIssue.aiText }}</div>
|
||||
</section>
|
||||
<div class="inspect-detail-grid">
|
||||
<section class="inspect-detail-mini"><div class="inspect-detail-mini-label">关联构件</div><div class="inspect-detail-mini-value">{{ activeIssue.component }}</div></section>
|
||||
<section class="inspect-detail-mini"><div class="inspect-detail-mini-label">位置信息</div><div class="inspect-detail-mini-value">{{ activeIssue.location }}</div></section>
|
||||
</div>
|
||||
<section class="inspect-detail-task" v-show="activeIssue.owner || activeIssue.due">
|
||||
<div class="inspect-detail-task-title">整改任务信息</div>
|
||||
<div class="inspect-detail-task-grid">
|
||||
<div class="inspect-detail-task-item"><div class="inspect-detail-task-k">责任人</div><div class="inspect-detail-task-v">{{ activeIssue.owner || "--" }}</div></div>
|
||||
<div class="inspect-detail-task-item"><div class="inspect-detail-task-k">完成期限</div><div class="inspect-detail-task-v">{{ activeIssue.due || "--" }}</div></div>
|
||||
<div class="inspect-detail-task-item"><div class="inspect-detail-task-k">当前状态</div><div class="inspect-detail-task-v">{{ statusLabel(activeIssue.status) }}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="inspect-detail-footer">
|
||||
<button class="btn inspect-detail-btn" type="button" @click="openAssign" v-show="activeIssue && activeIssue.status === 'unassigned'">指派整改任务</button>
|
||||
<button class="btn inspect-detail-btn" type="button" @click="toast('已在模型中定位(占位)')">在模型中定位</button>
|
||||
<button class="btn inspect-detail-btn" type="button" @click="toast('导出报告(占位)')">导出报告</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="inspect-assign-overlay" v-show="assignOpen" @click.self="assignOpen = false">
|
||||
<section class="inspect-assign-modal">
|
||||
<header class="inspect-assign-header">
|
||||
<div class="inspect-assign-title">指派整改任务</div>
|
||||
<button class="inspect-assign-close" type="button" @click="assignOpen = false">×</button>
|
||||
</header>
|
||||
<div class="inspect-assign-body" v-if="activeIssue">
|
||||
<section class="inspect-assign-summary">
|
||||
<div class="inspect-assign-summary-title">{{ activeIssue.title }}</div>
|
||||
<div class="inspect-assign-summary-meta">{{ activeIssue.component }}</div>
|
||||
</section>
|
||||
<div class="inspect-assign-field">
|
||||
<div class="inspect-assign-label">指派类型</div>
|
||||
<div class="inspect-assign-radio">
|
||||
<button class="inspect-assign-radio-btn" :class="{ 'is-on': assignType === 'person' }" type="button" @click="assignType='person'">个人</button>
|
||||
<button class="inspect-assign-radio-btn" :class="{ 'is-on': assignType === 'team' }" type="button" @click="assignType='team'">班组</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inspect-assign-field">
|
||||
<div class="inspect-assign-label">{{ assignType === 'person' ? '选择责任人' : '选择责任班组' }}</div>
|
||||
<div class="inspect-assign-select-wrap">
|
||||
<select class="input inspect-assign-select" v-model="assignOwner">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="p in assignCandidates" :key="p" :value="p">{{ p }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inspect-assign-field"><div class="inspect-assign-label">设置完成期限</div><input class="input inspect-assign-input" type="date" v-model="assignDue" /></div>
|
||||
<div class="inspect-assign-field"><div class="inspect-assign-label">整改要求与备注</div><textarea class="inspect-assign-textarea" v-model="assignNote" placeholder="输入整改要求与备注…"></textarea></div>
|
||||
</div>
|
||||
<footer class="inspect-assign-footer">
|
||||
<button class="btn btn-ghost inspect-assign-btn" type="button" @click="assignOpen = false">取消</button>
|
||||
<button class="btn btn-accent inspect-assign-btn" type="button" :disabled="!canAssign" @click="confirmAssign">确认指派</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="toast" v-show="toastOpen">{{ toastText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
|
||||
|
||||
const leftCollapsed = ref(false);
|
||||
const panoramaFileName = ref("");
|
||||
const mediaFiles = ref([]);
|
||||
const detailOpen = ref(false);
|
||||
const assignOpen = ref(false);
|
||||
const activeIssueId = ref("");
|
||||
const assignType = ref("person");
|
||||
const assignOwner = ref("");
|
||||
const assignDue = ref("");
|
||||
const assignNote = ref("");
|
||||
const toastOpen = ref(false);
|
||||
const toastText = ref("");
|
||||
|
||||
const panoramaInputRef = ref(null);
|
||||
const mediaInputRef = ref(null);
|
||||
|
||||
const issues = ref([
|
||||
{ id: "I-001", severity: "high", levelText: "高风险", title: "临边防护缺失", desc: "桥面边缘未设置连续防护栏杆,存在坠落风险。", component: "主桥-0#墩", location: "主桥桥面北侧", status: "unassigned", aiText: "AI识别到临边防护缺失,建议立即设置双道栏杆并加挂安全网。", owner: "", due: "" },
|
||||
{ id: "I-002", severity: "mid", levelText: "中风险", title: "材料堆放不规范", desc: "钢筋堆放区未按分区标识,通道占用。", component: "引桥-承台", location: "引桥材料堆场", status: "assigned", aiText: "AI识别材料堆放不规范,建议按区域标线重新布置并清理通道。", owner: "张工", due: "2026-03-25" },
|
||||
{ id: "I-003", severity: "low", levelText: "低风险", title: "警示牌破损", desc: "个别警示牌褪色破损,辨识度下降。", component: "路面-基层", location: "东侧便道入口", status: "fixing", aiText: "AI识别警示标识清晰度不足,建议统一更换为反光警示牌。", owner: "安监班组", due: "2026-03-21" },
|
||||
]);
|
||||
|
||||
const groupedIssues = computed(() => {
|
||||
const groups = [
|
||||
{ key: "high", label: "高风险问题", items: [] },
|
||||
{ key: "mid", label: "中风险问题", items: [] },
|
||||
{ key: "low", label: "低风险问题", items: [] },
|
||||
];
|
||||
issues.value.forEach((it) => groups.find((g) => g.key === it.severity)?.items.push(it));
|
||||
return groups.filter((g) => g.items.length > 0);
|
||||
});
|
||||
|
||||
const riskCount = computed(() => ({
|
||||
high: issues.value.filter((i) => i.severity === "high").length,
|
||||
mid: issues.value.filter((i) => i.severity === "mid").length,
|
||||
low: issues.value.filter((i) => i.severity === "low").length,
|
||||
}));
|
||||
|
||||
const activeIssue = computed(() => issues.value.find((i) => i.id === activeIssueId.value) || null);
|
||||
const assignCandidates = computed(() => (assignType.value === "person" ? ["张工", "李工", "王工"] : ["土建班组", "钢筋班组", "机电班组"]));
|
||||
const canAssign = computed(() => !!assignOwner.value && !!assignDue.value);
|
||||
|
||||
function onPanoramaPick(e) {
|
||||
const file = e.target.files?.[0];
|
||||
panoramaFileName.value = file ? file.name : "";
|
||||
}
|
||||
|
||||
function onMediaPick(e) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
mediaFiles.value = files;
|
||||
}
|
||||
|
||||
function removeMediaFile(file) {
|
||||
mediaFiles.value = mediaFiles.value.filter((f) => f !== file);
|
||||
}
|
||||
|
||||
function analyzeMedia() {
|
||||
toast("AI 分析完成,已生成巡检问题清单");
|
||||
}
|
||||
|
||||
function openDetail(issue) {
|
||||
activeIssueId.value = issue.id;
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detailOpen.value = false;
|
||||
}
|
||||
|
||||
function openAssign() {
|
||||
assignType.value = "person";
|
||||
assignOwner.value = "";
|
||||
assignDue.value = "";
|
||||
assignNote.value = "";
|
||||
assignOpen.value = true;
|
||||
}
|
||||
|
||||
function confirmAssign() {
|
||||
const idx = issues.value.findIndex((i) => i.id === activeIssueId.value);
|
||||
if (idx < 0) return;
|
||||
issues.value[idx] = {
|
||||
...issues.value[idx],
|
||||
status: "assigned",
|
||||
owner: assignOwner.value,
|
||||
due: assignDue.value,
|
||||
};
|
||||
assignOpen.value = false;
|
||||
toast("已指派整改任务");
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
function toast(text) {
|
||||
toastText.value = text;
|
||||
toastOpen.value = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => (toastOpen.value = false), 1500);
|
||||
}
|
||||
|
||||
function severityLabel(sev) {
|
||||
return sev === "mid" ? "中风险" : sev === "low" ? "低风险" : "高风险";
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
return status === "assigned" ? "已指派" : status === "fixing" ? "整改中" : "未指派";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.inspection-page { min-height: 100vh; font-family: "Microsoft YaHei", "PingFang SC", sans-serif; }
|
||||
.canvas { position: relative; min-height: 100vh; overflow: hidden; border-radius: 24px; border: 1px solid rgba(98,191,206,.34); background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%); box-shadow: inset 0 0 0 8px rgba(214,230,241,.55); }
|
||||
.topbar { position: absolute; inset: 0 0 auto; height: 120px; z-index: 30; }
|
||||
.topbar-center { position: absolute; left: 50%; transform: translateX(-50%); top: 16px; }
|
||||
.model-title { font-size: 20px; letter-spacing: 1px; font-weight: 700; color: rgba(255,128,52,.95); text-shadow: 0 2px 10px rgba(0,0,0,.25); }
|
||||
.model-stage { position: absolute; inset: 0; }
|
||||
|
||||
.sidepanel { border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(260px 140px at 8% 0%, rgba(63,203,191,.16), transparent 62%) padding-box, linear-gradient(180deg, rgba(20,31,37,.92), rgba(15,23,29,.88)) padding-box; box-shadow: 0 22px 60px rgba(0,0,0,.34), 0 0 0 1px rgba(83,214,206,.12) inset; overflow: hidden; z-index: 25; position: absolute; top: 150px; bottom: 100px; }
|
||||
.inspect-panel-left { left: 16px; width: 340px; }
|
||||
.inspect-panel-right { right: 16px; width: 360px; }
|
||||
.sidepanel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 10px 10px 12px; border-bottom: 1px solid rgba(83,214,206,.16); background: radial-gradient(360px 100px at 10% 0%, rgba(83,214,206,.24), transparent 68%), linear-gradient(180deg, rgba(28,134,122,.84), rgba(15,60,74,.62)); }
|
||||
.sidepanel-title { font-size: 14px; font-weight: 900; color: rgba(207,247,242,.96); }
|
||||
.iconbtn { width: 30px; height: 30px; border-radius: 10px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.42); color: rgba(11,27,58,.86); font-weight: 900; cursor: pointer; }
|
||||
.sidepanel-body { height: calc(100% - 52px); overflow: auto; padding: 10px; }
|
||||
|
||||
.inspect-upload-card, .inspect-block { border: 1px solid rgba(255,255,255,.1); background: rgba(0,0,0,.14); border-radius: 14px; padding: 10px; margin-bottom: 10px; }
|
||||
.inspect-upload-head-title, .inspect-block-title { color: rgba(238,247,251,.92); font-size: 13px; font-weight: 900; margin-bottom: 8px; }
|
||||
.inspect-drop { width: 100%; border: 1px dashed rgba(83,214,206,.4); border-radius: 12px; background: rgba(0,0,0,.16); padding: 12px; color: rgba(220,238,244,.88); text-align: left; cursor: pointer; }
|
||||
.inspect-drop-title { font-size: 13px; font-weight: 900; }
|
||||
.inspect-drop-sub { font-size: 12px; opacity: .75; margin-top: 4px; }
|
||||
.inspect-file-row { margin-top: 8px; display: flex; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid rgba(255,255,255,.1); border-radius: 10px; background: rgba(255,255,255,.04); padding: 6px 8px; }
|
||||
.inspect-file-name { color: rgba(222,238,244,.86); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.inspect-file-remove { border: 0; background: transparent; color: rgba(255,255,255,.72); font-size: 18px; cursor: pointer; }
|
||||
.inspect-file-list { display: grid; gap: 6px; margin-top: 8px; }
|
||||
.inspect-analyze-btn { width: 100%; margin-top: 8px; }
|
||||
|
||||
.btn { appearance: none; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.08); color: rgba(255,255,255,.88); border-radius: 12px; padding: 8px 10px; cursor: pointer; }
|
||||
.btn-ghost { background: rgba(255,255,255,.04); }
|
||||
.btn-accent { border-color: rgba(83,214,206,.24); background: rgba(43,191,178,.26); }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
.inspect-risk-grid { display: grid; grid-template-columns: 1fr; gap: 8px; }
|
||||
.inspect-risk-card { border-radius: 12px; padding: 8px 10px; border: 1px solid rgba(255,255,255,.1); background: rgba(0,0,0,.16); display: flex; align-items: center; justify-content: space-between; }
|
||||
.inspect-risk-card.is-high { border-color: rgba(217,54,62,.35); }
|
||||
.inspect-risk-card.is-mid { border-color: rgba(212,136,6,.35); }
|
||||
.inspect-risk-card.is-low { border-color: rgba(47,143,47,.35); }
|
||||
.inspect-risk-k { color: rgba(220,236,242,.82); font-size: 12px; }
|
||||
.inspect-risk-v { color: rgba(255,255,255,.92); font-weight: 900; font-size: 16px; }
|
||||
|
||||
.inspect-list-title { display: flex; align-items: center; gap: 8px; }
|
||||
.inspect-list-badge { font-size: 12px; font-weight: 800; color: rgba(255,255,255,.9); border: 1px solid rgba(255,255,255,.14); border-radius: 999px; padding: 4px 8px; background: rgba(0,0,0,.18); }
|
||||
.inspect-list-body { display: grid; gap: 8px; }
|
||||
.inspect-group-title { margin: 2px 2px 0; font-size: 12px; font-weight: 900; color: rgba(230,243,247,.85); }
|
||||
|
||||
.inspect-item { border: 1px solid rgba(255,255,255,.1); background: rgba(0,0,0,.16); border-radius: 14px; padding: 10px; cursor: pointer; }
|
||||
.inspect-item:hover { border-color: rgba(83,214,206,.26); }
|
||||
.inspect-item-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.inspect-pill { border-radius: 999px; padding: 3px 8px; font-size: 11px; font-weight: 900; border: 1px solid transparent; }
|
||||
.inspect-pill.is-high { color: rgba(255,178,182,.95); border-color: rgba(217,54,62,.35); background: rgba(217,54,62,.18); }
|
||||
.inspect-pill.is-mid { color: rgba(255,214,142,.95); border-color: rgba(212,136,6,.35); background: rgba(212,136,6,.18); }
|
||||
.inspect-pill.is-low { color: rgba(176,244,176,.95); border-color: rgba(47,143,47,.35); background: rgba(47,143,47,.18); }
|
||||
.inspect-level { color: rgba(214,235,241,.8); font-size: 11px; }
|
||||
.inspect-item-title { margin-top: 6px; color: rgba(244,252,255,.92); font-size: 13px; font-weight: 900; }
|
||||
.inspect-item-desc { margin-top: 6px; color: rgba(214,233,239,.8); font-size: 12px; line-height: 1.45; }
|
||||
.inspect-item-meta { margin-top: 8px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.inspect-item-loc { color: rgba(201,223,231,.72); font-size: 11px; }
|
||||
.inspect-item-status { font-size: 11px; font-weight: 900; border-radius: 999px; padding: 3px 8px; border: 1px solid rgba(255,255,255,.1); }
|
||||
.inspect-item-status.is-unassigned { color: rgba(255,214,142,.95); border-color: rgba(212,136,6,.35); background: rgba(212,136,6,.18); }
|
||||
.inspect-item-status.is-assigned { color: rgba(176,244,176,.95); border-color: rgba(47,143,47,.35); background: rgba(47,143,47,.18); }
|
||||
.inspect-item-status.is-fixing { color: rgba(176,218,255,.95); border-color: rgba(21,108,255,.35); background: rgba(21,108,255,.18); }
|
||||
|
||||
.inspect-detail-overlay, .inspect-assign-overlay { position: absolute; inset: 0; z-index: 90; display: grid; place-items: center; background: rgba(13,36,74,.08); backdrop-filter: blur(3px); }
|
||||
.inspect-detail-modal, .inspect-assign-modal { width: min(720px, calc(100% - 32px)); border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(260px 160px at 18% 0%, rgba(83,214,206,.14), transparent 62%) padding-box, linear-gradient(180deg, rgba(18,26,31,.94), rgba(14,20,25,.86)) padding-box; box-shadow: 0 30px 80px rgba(0,0,0,.3); }
|
||||
.inspect-detail-header, .inspect-assign-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border-bottom: 1px solid rgba(83,214,206,.2); }
|
||||
.inspect-detail-close, .inspect-assign-close { border: 0; background: transparent; color: rgba(255,255,255,.8); font-size: 26px; cursor: pointer; }
|
||||
.inspect-detail-body, .inspect-assign-body { padding: 14px; max-height: 58vh; overflow: auto; }
|
||||
.inspect-detail-title { color: rgba(244,252,255,.92); font-size: 18px; font-weight: 900; margin-bottom: 12px; }
|
||||
.inspect-detail-card, .inspect-detail-mini, .inspect-detail-task, .inspect-assign-summary { border: 1px solid rgba(255,255,255,.1); background: rgba(0,0,0,.16); border-radius: 12px; padding: 10px; }
|
||||
.inspect-detail-card-title, .inspect-detail-task-title, .inspect-detail-mini-label, .inspect-assign-label, .inspect-assign-title { color: rgba(230,243,247,.84); font-size: 12px; font-weight: 900; }
|
||||
.inspect-detail-card-text, .inspect-detail-mini-value, .inspect-detail-task-v { color: rgba(244,252,255,.9); font-size: 13px; margin-top: 6px; }
|
||||
.inspect-detail-grid, .inspect-detail-task-grid { margin-top: 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.inspect-detail-task { margin-top: 10px; }
|
||||
.inspect-detail-footer, .inspect-assign-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 14px; border-top: 1px solid rgba(83,214,206,.2); }
|
||||
|
||||
.inspect-assign-field { margin-top: 10px; }
|
||||
.inspect-assign-radio { margin-top: 6px; display: inline-flex; gap: 8px; }
|
||||
.inspect-assign-radio-btn { border: 1px solid rgba(255,255,255,.14); background: rgba(0,0,0,.14); color: rgba(255,255,255,.84); border-radius: 999px; padding: 6px 10px; cursor: pointer; }
|
||||
.inspect-assign-radio-btn.is-on { border-color: rgba(83,214,206,.28); background: rgba(43,191,178,.24); }
|
||||
.inspect-assign-select-wrap { margin-top: 6px; }
|
||||
.input { width: 100%; border-radius: 10px; border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.08); color: rgba(255,255,255,.9); padding: 8px 10px; }
|
||||
.inspect-assign-textarea { margin-top: 6px; width: 100%; min-height: 86px; border-radius: 10px; border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.08); color: rgba(255,255,255,.9); padding: 8px 10px; resize: vertical; }
|
||||
|
||||
.toast { position: absolute; left: 50%; top: 74px; transform: translateX(-50%); z-index: 100; border-radius: 12px; padding: 8px 12px; border: 1px solid rgba(255,255,255,.14); background: rgba(18,26,31,.92); color: rgba(238,246,250,.9); font-size: 12px; font-weight: 800; }
|
||||
|
||||
@media (max-width: 1300px) {
|
||||
.inspect-panel-left { width: 300px; }
|
||||
.inspect-panel-right { width: 320px; }
|
||||
}
|
||||
</style>
|
||||
205
src/pages/material/index.vue
Normal file
205
src/pages/material/index.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="material-page">
|
||||
<div class="canvas">
|
||||
<header class="topbar"><div class="topbar-center"><div class="model-title">XXX特大桥主体模型.rvt</div></div></header>
|
||||
|
||||
<section class="model-stage">
|
||||
<ModelPlaceholder />
|
||||
</section>
|
||||
|
||||
<section class="module-topbar">
|
||||
<div class="field">
|
||||
<span class="field-label">构件用料偏差率</span>
|
||||
<label class="chip chip-status"><input type="checkbox" :checked="deviationFilters.all" @change="toggleDeviationFilter('all', $event.target.checked)" /> 全部</label>
|
||||
<label class="chip chip-status chip-blue"><input type="checkbox" :checked="deviationFilters.lt_50" @change="toggleDeviationFilter('lt_50', $event.target.checked)" /> -50% 以下</label>
|
||||
<label class="chip chip-status chip-lightblue"><input type="checkbox" :checked="deviationFilters.m25_50" @change="toggleDeviationFilter('m25_50', $event.target.checked)" /> -25%~-50%</label>
|
||||
<label class="chip chip-status chip-green"><input type="checkbox" :checked="deviationFilters.p0_25" @change="toggleDeviationFilter('p0_25', $event.target.checked)" /> 0%~25%</label>
|
||||
<label class="chip chip-status chip-yellow"><input type="checkbox" :checked="deviationFilters.p25_50" @change="toggleDeviationFilter('p25_50', $event.target.checked)" /> 25%~50%</label>
|
||||
<label class="chip chip-status chip-yellow2"><input type="checkbox" :checked="deviationFilters.gt50" @change="toggleDeviationFilter('gt50', $event.target.checked)" /> 50% 以上</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="sidepanel" :class="{ 'is-collapsed': sideCollapsed }">
|
||||
<header class="sidepanel-header">
|
||||
<div class="sidepanel-title">工程部位</div>
|
||||
<button class="iconbtn" type="button" @click="sideCollapsed = !sideCollapsed">{{ sideCollapsed ? "▸" : "▾" }}</button>
|
||||
</header>
|
||||
<div class="sidepanel-body" v-show="!sideCollapsed">
|
||||
<div class="tree">
|
||||
<div class="tree-item" :class="{ 'is-active': row.leaf && row.id === selectedStructureId }" :style="{ paddingLeft: `${10 + row.level * 14}px` }" v-for="row in visibleTreeRows" :key="row.id" @click="onTreeRowClick(row)">
|
||||
<button class="tree-caret" type="button" :class="{ 'is-leaf': row.leaf }" :disabled="row.leaf" @click.stop="toggleTreeExpand(row)">{{ row.leaf ? "•" : row.open ? "▾" : "▸" }}</button>
|
||||
<span class="tree-bullet"></span>
|
||||
<span class="tree-text">{{ row.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="bottompanel" :class="{ 'is-collapsed': bottomCollapsed }">
|
||||
<header class="bottompanel-header"><div class="tabs"><button class="tab is-on" type="button">材料用料分析</button></div></header>
|
||||
<button class="iconbtn bottompanel-toggle" type="button" @click="bottomCollapsed = !bottomCollapsed">{{ bottomCollapsed ? "▴" : "▾" }}</button>
|
||||
<div class="bottompanel-body" v-show="!bottomCollapsed">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>序号</th><th>材料编号</th><th>材料名称</th><th>规格</th><th>单位</th><th>变更后数量</th><th>预计总量</th><th>当前完成量</th><th>实际消耗量</th><th>量差</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in materialRows" :key="r.no">
|
||||
<td>{{ r.no }}</td><td>{{ r.code }}</td><td>{{ r.name }}</td><td>{{ r.spec }}</td><td>{{ r.unit }}</td><td>{{ formatNumber(r.changedQty,2) }}</td><td>{{ formatNumber(r.expectedTotal,2) }}</td><td>{{ formatNumber(r.curDone,2) }}</td><td>{{ formatNumber(r.actualConsume,2) }}</td><td>{{ (r.diff >= 0 ? "+" : "") + formatNumber(r.diff,2) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
|
||||
|
||||
const structures = [
|
||||
{ id: "S-001", name: "主桥-0#墩" }, { id: "S-002", name: "主桥-1#墩" }, { id: "S-003", name: "主桥-2#墩" },
|
||||
{ id: "S-004", name: "引桥-桩基" }, { id: "S-005", name: "引桥-承台" }, { id: "S-006", name: "引桥-盖梁" },
|
||||
{ id: "S-007", name: "路基-填筑" }, { id: "S-008", name: "路面-基层" }, { id: "S-009", name: "路面-面层" },
|
||||
];
|
||||
|
||||
const elementTree = [
|
||||
{ id: "E-G-bridge", name: "桥梁工程", children: [
|
||||
{ id: "E-G-main-bridge", name: "主桥", children: ["S-001", "S-002", "S-003"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
|
||||
{ id: "E-G-approach", name: "引桥", children: ["S-004", "S-005", "S-006"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
|
||||
] },
|
||||
{ id: "E-G-road", name: "道路工程", children: ["S-007", "S-008", "S-009"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
|
||||
];
|
||||
|
||||
const sideCollapsed = ref(false);
|
||||
const bottomCollapsed = ref(false);
|
||||
const selectedStructureId = ref("S-001");
|
||||
const expanded = ref(new Set(["E-G-bridge", "E-G-main-bridge", "E-G-approach", "E-G-road"]));
|
||||
|
||||
const deviationFilters = reactive({ all: true, lt_50: false, m25_50: false, p0_25: false, p25_50: false, gt50: false });
|
||||
|
||||
const selectedStructureName = computed(() => structures.find((s) => s.id === selectedStructureId.value)?.name || "");
|
||||
|
||||
const visibleTreeRows = computed(() => {
|
||||
const rows = [];
|
||||
const walk = (node, level) => {
|
||||
const leaf = !node.children || node.children.length === 0;
|
||||
const open = expanded.value.has(node.id);
|
||||
rows.push({ id: node.id, name: node.name, level, leaf, open });
|
||||
if (!leaf && open) node.children.forEach((c) => walk(c, level + 1));
|
||||
};
|
||||
elementTree.forEach((n) => walk(n, 0));
|
||||
return rows;
|
||||
});
|
||||
|
||||
const materialRows = computed(() =>
|
||||
Array.from({ length: 16 }, (_, i) => {
|
||||
const changedQty = 120 + i * 18;
|
||||
const expectedTotal = changedQty * (0.95 + (i % 3) * 0.04);
|
||||
const curDone = expectedTotal * (0.25 + (i % 5) * 0.13);
|
||||
const actualConsume = expectedTotal * (0.22 + (i % 6) * 0.14);
|
||||
const diff = actualConsume - curDone;
|
||||
return {
|
||||
no: i + 1,
|
||||
code: `MAT-${String(6001 + i)}`,
|
||||
name: `${["钢筋", "水泥", "碎石", "砂", "沥青", "外加剂"][i % 6]}(${selectedStructureName.value})`,
|
||||
spec: ["HRB400", "P.O 42.5", "5-20mm", "中砂", "70#", "减水剂"][i % 6],
|
||||
unit: ["t", "t", "t", "t", "t", "kg"][i % 6],
|
||||
changedQty,
|
||||
expectedTotal,
|
||||
curDone,
|
||||
actualConsume,
|
||||
diff,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
function formatNumber(value, digits = 0) {
|
||||
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function applyAllExclusive(key, checked) {
|
||||
if (key === "all") {
|
||||
deviationFilters.all = checked;
|
||||
if (checked) {
|
||||
deviationFilters.lt_50 = false;
|
||||
deviationFilters.m25_50 = false;
|
||||
deviationFilters.p0_25 = false;
|
||||
deviationFilters.p25_50 = false;
|
||||
deviationFilters.gt50 = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
deviationFilters[key] = checked;
|
||||
if (checked) deviationFilters.all = false;
|
||||
if (!deviationFilters.lt_50 && !deviationFilters.m25_50 && !deviationFilters.p0_25 && !deviationFilters.p25_50 && !deviationFilters.gt50) deviationFilters.all = true;
|
||||
}
|
||||
|
||||
function toggleDeviationFilter(key, checked) { applyAllExclusive(key, checked); }
|
||||
|
||||
function toggleTreeExpand(row) { if (!row.leaf) (expanded.value.has(row.id) ? expanded.value.delete(row.id) : expanded.value.add(row.id)); }
|
||||
function onTreeRowClick(row) { if (row.leaf) selectedStructureId.value = row.id; else toggleTreeExpand(row); }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.material-page { min-height: 100vh; font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif; }
|
||||
.canvas { position: relative; min-height: 100vh; overflow: hidden; border-radius: 24px; border: 1px solid rgba(98,191,206,.34); background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%); box-shadow: inset 0 0 0 8px rgba(214,230,241,.55); }
|
||||
.topbar { position: absolute; inset: 0 0 auto; height: 120px; z-index: 30; }
|
||||
.topbar-center { position: absolute; left: 50%; transform: translateX(-50%); top: 16px; }
|
||||
.model-title { font-size: 20px; letter-spacing: 1px; font-weight: 700; color: rgba(255,128,52,.95); text-shadow: 0 2px 10px rgba(0,0,0,.25); }
|
||||
.model-stage { position: absolute; inset: 0; }
|
||||
|
||||
.module-topbar { position: absolute; left: 50%; top: 20px; transform: translateX(-50%); width: min(1060px, calc(100% - 180px)); z-index: 99; }
|
||||
.field { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; border-radius: 18px; border: 1px solid rgba(83,214,206,.26); background: radial-gradient(420px 120px at 18% 0%, rgba(83,214,206,.22), transparent 70%), linear-gradient(180deg, rgba(18,32,40,.94), rgba(15,25,32,.88)); box-shadow: 0 20px 56px rgba(0,0,0,.28), 0 0 0 1px rgba(83,214,206,.12) inset; padding: 10px 14px; }
|
||||
.field-label { font-weight: 900; color: rgba(244,252,255,.92); font-size: 14px; white-space: nowrap; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 999px; border: 1px solid rgba(255,255,255,.12); background: rgba(0,0,0,.2); color: rgba(255,255,255,.88); font-weight: 800; font-size: 13px; white-space: nowrap; }
|
||||
.chip input { width: 13px; height: 13px; margin: 0; accent-color: rgba(64,224,208,.92); }
|
||||
.chip-status::before { content: ""; width: 10px; height: 10px; border-radius: 3px; border: 1px solid rgba(255,255,255,.2); background: transparent; }
|
||||
.chip-status.chip-blue::before { border-color: rgba(21,108,255,.4); background: rgba(21,108,255,.22); }
|
||||
.chip-status.chip-lightblue::before { border-color: rgba(114,188,255,.5); background: rgba(114,188,255,.24); }
|
||||
.chip-status.chip-green::before { border-color: rgba(47,143,47,.4); background: rgba(47,143,47,.22); }
|
||||
.chip-status.chip-yellow::before { border-color: rgba(212,136,6,.4); background: rgba(212,136,6,.22); }
|
||||
.chip-status.chip-yellow2::before { border-color: rgba(255,186,66,.45); background: rgba(255,186,66,.22); }
|
||||
|
||||
.sidepanel { position: absolute; left: 16px; top: 175px; width: 320px; bottom: 100px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(260px 140px at 8% 0%, rgba(63,203,191,.16), transparent 62%) padding-box, linear-gradient(180deg, rgba(20,31,37,.92), rgba(15,23,29,.88)) padding-box; box-shadow: 0 22px 60px rgba(0,0,0,.34), 0 0 0 1px rgba(83,214,206,.12) inset; overflow: hidden; z-index: 25; }
|
||||
.sidepanel.is-collapsed { width: 64px; }
|
||||
.sidepanel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 10px 10px 12px; border-bottom: 1px solid rgba(83,214,206,.16); background: radial-gradient(360px 100px at 10% 0%, rgba(83,214,206,.24), transparent 68%), linear-gradient(180deg, rgba(28,134,122,.84), rgba(15,60,74,.62)); }
|
||||
.sidepanel-title { font-size: 14px; font-weight: 900; color: rgba(207,247,242,.96); }
|
||||
.iconbtn { width: 30px; height: 30px; border-radius: 10px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.42); color: rgba(11,27,58,.86); font-weight: 900; cursor: pointer; }
|
||||
.sidepanel-body { height: calc(100% - 52px); overflow: auto; padding: 8px; }
|
||||
|
||||
.tree { display: grid; gap: 8px; }
|
||||
.tree-item { display: grid; grid-template-columns: 20px 10px 1fr; align-items: center; gap: 8px; min-height: 40px; border-radius: 14px; border: 1px solid rgba(154,186,198,.22); background: linear-gradient(180deg, rgba(44,58,68,.58), rgba(37,49,58,.54)); cursor: pointer; }
|
||||
.tree-item:hover { border-color: rgba(83,214,206,.32); background: linear-gradient(180deg, rgba(51,67,78,.66), rgba(43,57,67,.62)); }
|
||||
.tree-item.is-active { border-color: rgba(132,188,255,.3); background: linear-gradient(180deg, rgba(112,146,198,.34), rgba(95,128,180,.28)); }
|
||||
.tree-caret { border: 0; background: transparent; color: rgba(203,230,236,.88); cursor: pointer; font-size: 14px; }
|
||||
.tree-caret.is-leaf { cursor: default; }
|
||||
.tree-bullet { width: 10px; height: 10px; border-radius: 50%; background: rgba(83,214,206,.85); }
|
||||
.tree-text { font-size: 13px; font-weight: 800; color: rgba(222,238,244,.9); }
|
||||
|
||||
.bottompanel { position: absolute; left: 370px; right: 16px; bottom: 100px; height: 390px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(420px 180px at 14% 0%, rgba(83,214,206,.2), transparent 66%) padding-box, radial-gradient(520px 220px at 82% 0%, rgba(40,156,228,.14), transparent 70%) padding-box, linear-gradient(180deg, rgba(18,33,40,.94), rgba(14,24,31,.9)) padding-box; box-shadow: 0 24px 70px rgba(0,0,0,.36), 0 0 0 1px rgba(83,214,206,.14) inset; overflow: hidden; z-index: 26; }
|
||||
.bottompanel-header { padding: 12px 52px 12px 12px; border-bottom: 1px solid rgba(83,214,206,.28); background: radial-gradient(380px 120px at 18% 0%, rgba(83,214,206,.28), transparent 70%), linear-gradient(180deg, rgba(25,137,124,.86), rgba(16,66,82,.64)); }
|
||||
.bottompanel-toggle { position: absolute; right: 12px; top: 12px; }
|
||||
.tabs { display: flex; gap: 10px; }
|
||||
.tab { border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.08); color: rgba(214,235,241,.9); border-radius: 999px; padding: 8px 16px; cursor: pointer; font-weight: 900; font-size: 12px; }
|
||||
.tab.is-on { border-color: rgba(83,214,206,.32); background: rgba(83,214,206,.24); }
|
||||
.bottompanel.is-collapsed { height: 70px; }
|
||||
.bottompanel-body { padding: 0 12px 12px; overflow: auto; height: calc(100% - 66px); }
|
||||
|
||||
.table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; }
|
||||
.table th, .table td { text-align: left; padding: 12px 14px; border-bottom: 1px solid rgba(178,206,216,.16); }
|
||||
.table th { color: rgba(219,240,246,.86); font-weight: 900; background: linear-gradient(90deg, rgba(12,38,52,.92), rgba(16,47,61,.86)); position: sticky; top: 0; z-index: 2; }
|
||||
.table td { color: rgba(222,238,244,.86); background: linear-gradient(90deg, rgba(37,51,61,.42), rgba(31,45,54,.36)); }
|
||||
.table tbody tr:hover td { background: linear-gradient(90deg, rgba(69,102,130,.36), rgba(56,86,113,.32)); }
|
||||
|
||||
@media (max-width: 1300px) {
|
||||
.module-topbar { width: calc(100% - 32px); }
|
||||
.field { flex-wrap: wrap; }
|
||||
.sidepanel { width: 280px; }
|
||||
.bottompanel { left: 330px; }
|
||||
}
|
||||
</style>
|
||||
515
src/pages/measurement/index.vue
Normal file
515
src/pages/measurement/index.vue
Normal file
@@ -0,0 +1,515 @@
|
||||
<template>
|
||||
<div class="measurement-page">
|
||||
<PageCanvas>
|
||||
<section class="module-topbar">
|
||||
<div class="field">
|
||||
<span class="field-label">计量状态</span>
|
||||
<label class="chip chip-status chip-yellow">
|
||||
<input type="checkbox" v-model="statusFilters.month_done" />
|
||||
月计量结算完成
|
||||
</label>
|
||||
<label class="chip chip-status chip-green">
|
||||
<input type="checkbox" v-model="statusFilters.cum_done" />
|
||||
累计计量结算完成结构
|
||||
</label>
|
||||
<label class="chip chip-status chip-red">
|
||||
<input type="checkbox" v-model="statusFilters.value_not_settled" />
|
||||
有产值未完成计量结算构件
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SidePanel v-model:collapsed="sideCollapsed" title="计量|左侧区">
|
||||
<section class="placeholder">
|
||||
<div class="placeholder-top">
|
||||
<div class="placeholder-title">计量期次:<b>{{ periodId }}</b></div>
|
||||
<button class="btn btn-sm" type="button" @click="openPeriodModal">切换期次</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="tree">
|
||||
<div
|
||||
class="tree-item"
|
||||
:class="{ 'is-active': row.leaf && row.id === selectedStructureId }"
|
||||
:style="{ paddingLeft: `${10 + row.level * 14}px` }"
|
||||
v-for="row in visibleTreeRows"
|
||||
:key="row.id"
|
||||
@click="onTreeRowClick(row)"
|
||||
>
|
||||
<button
|
||||
class="tree-caret"
|
||||
type="button"
|
||||
:class="{ 'is-leaf': row.leaf }"
|
||||
:disabled="row.leaf"
|
||||
@click.stop="toggleTreeExpand(row)"
|
||||
>
|
||||
{{ row.leaf ? "•" : row.open ? "▾" : "▸" }}
|
||||
</button>
|
||||
<span class="tree-bullet"></span>
|
||||
<span class="tree-text">{{ row.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidePanel>
|
||||
|
||||
<BottomPanel v-model:collapsed="bottomCollapsed">
|
||||
<template #header>
|
||||
<div class="tabs">
|
||||
<button class="tab" :class="{ 'is-on': activeTab === 'pay' }" type="button" @click="activeTab = 'pay'">计量支付</button>
|
||||
<button class="tab" :class="{ 'is-on': activeTab === 'boq' }" type="button" @click="activeTab = 'boq'">分解清单</button>
|
||||
<button class="tab" :class="{ 'is-on': activeTab === 'materials' }" type="button" @click="activeTab = 'materials'">项目主材</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<table class="table" v-if="activeTab === 'pay'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>序号</th>
|
||||
<th>期次</th>
|
||||
<th>清单编号</th>
|
||||
<th>清单名称</th>
|
||||
<th>单位</th>
|
||||
<th>单价</th>
|
||||
<th>上期末计量数量</th>
|
||||
<th>本期计量数量</th>
|
||||
<th>本期计量金额</th>
|
||||
<th>开累计量数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in payRows" :key="r.no">
|
||||
<td>{{ r.no }}</td>
|
||||
<td>{{ r.period }}</td>
|
||||
<td>{{ r.code }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td>{{ r.unit }}</td>
|
||||
<td>{{ formatYuan(r.unitPrice) }}</td>
|
||||
<td>{{ formatNumber(r.prevQty, 2) }}</td>
|
||||
<td>{{ formatNumber(r.curQty, 2) }}</td>
|
||||
<td>{{ formatMoney(r.curAmt) }}</td>
|
||||
<td>{{ formatNumber(r.cumQty, 2) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="table" v-else-if="activeTab === 'boq'">
|
||||
<thead>
|
||||
<tr><th>编码</th><th>细目内容</th><th>单位</th><th>数量</th><th>金额</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in boqRows" :key="r.code">
|
||||
<td>{{ r.code }}</td>
|
||||
<td>{{ r.item }}</td>
|
||||
<td>{{ r.unit }}</td>
|
||||
<td>{{ r.qty }}</td>
|
||||
<td>{{ formatMoney(r.amount) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="table" v-else>
|
||||
<thead>
|
||||
<tr><th>材料编码</th><th>名称</th><th>单位</th><th>数量</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in materialRows" :key="r.code">
|
||||
<td>{{ r.code }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td>{{ r.unit }}</td>
|
||||
<td>{{ r.qty }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</BottomPanel>
|
||||
|
||||
<div class="modal-mask" v-show="periodModalOpen" @click.self="periodModalOpen = false">
|
||||
<div class="period-modal">
|
||||
<div class="period-modal-head">
|
||||
<div class="period-modal-title">切换计量期次</div>
|
||||
<button class="iconbtn" type="button" @click="periodModalOpen = false">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="period-list">
|
||||
<div
|
||||
class="tree-item"
|
||||
:class="{ 'is-active': p === periodDraft }"
|
||||
v-for="p in measurementPeriods"
|
||||
:key="p"
|
||||
@click="periodDraft = p"
|
||||
>
|
||||
<span class="tree-bullet"></span>
|
||||
<span class="tree-text">{{ p }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="period-actions">
|
||||
<button class="btn btn-ghost" type="button" @click="periodModalOpen = false">取消</button>
|
||||
<button class="btn" type="button" @click="confirmPeriod">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageCanvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import PageCanvas from "../../components/layout/PageCanvas.vue";
|
||||
import SidePanel from "../../components/layout/SidePanel.vue";
|
||||
import BottomPanel from "../../components/layout/BottomPanel.vue";
|
||||
import { structures, elementTree } from "../../constants/structures.js";
|
||||
import { measurementPeriods, getPayRows, getMaterialRows } from "../../constants/mock.js";
|
||||
import { formatNumber, formatPercent, formatMoney, formatYuan } from "../../utils/format.js";
|
||||
|
||||
const periodId = ref("2025-12");
|
||||
const periodModalOpen = ref(false);
|
||||
const periodDraft = ref(periodId.value);
|
||||
|
||||
const sideCollapsed = ref(false);
|
||||
const bottomCollapsed = ref(false);
|
||||
const selectedStructureId = ref("S-001");
|
||||
const activeTab = ref("pay");
|
||||
const expanded = ref(new Set(["E-G-bridge", "E-G-main-bridge", "E-G-approach", "E-G-road"]));
|
||||
|
||||
const statusFilters = reactive({
|
||||
month_done: true,
|
||||
cum_done: true,
|
||||
value_not_settled: true,
|
||||
});
|
||||
|
||||
const structureIndex = computed(() => Math.max(0, structures.findIndex((s) => s.id === selectedStructureId.value)));
|
||||
const selectedStructureName = computed(() => structures.find((s) => s.id === selectedStructureId.value)?.name || "");
|
||||
|
||||
const visibleTreeRows = computed(() => {
|
||||
const rows = [];
|
||||
const walk = (node, level) => {
|
||||
const leaf = !node.children || node.children.length === 0;
|
||||
const open = expanded.value.has(node.id);
|
||||
rows.push({ id: node.id, name: node.name, level, leaf, open });
|
||||
if (!leaf && open) node.children.forEach((c) => walk(c, level + 1));
|
||||
};
|
||||
elementTree.forEach((n) => walk(n, 0));
|
||||
return rows;
|
||||
});
|
||||
|
||||
const payRows = computed(() => getPayRows(periodId.value, structureIndex.value, selectedStructureName.value));
|
||||
|
||||
const boqRows = [
|
||||
{ code: "BOQ-001", item: "混凝土浇筑", unit: "m³", qty: 120.5, amount: 385000 },
|
||||
{ code: "BOQ-002", item: "钢筋制安", unit: "t", qty: 18.2, amount: 246000 },
|
||||
{ code: "BOQ-003", item: "模板工程", unit: "㎡", qty: 560, amount: 112000 },
|
||||
];
|
||||
|
||||
const materialRows = computed(() => getMaterialRows(structureIndex.value));
|
||||
|
||||
function toggleTreeExpand(row) {
|
||||
if (row.leaf) return;
|
||||
if (expanded.value.has(row.id)) expanded.value.delete(row.id);
|
||||
else expanded.value.add(row.id);
|
||||
}
|
||||
|
||||
function onTreeRowClick(row) {
|
||||
if (row.leaf) {
|
||||
selectedStructureId.value = row.id;
|
||||
return;
|
||||
}
|
||||
toggleTreeExpand(row);
|
||||
}
|
||||
|
||||
function openPeriodModal() {
|
||||
periodDraft.value = periodId.value;
|
||||
periodModalOpen.value = true;
|
||||
}
|
||||
|
||||
function confirmPeriod() {
|
||||
periodId.value = periodDraft.value;
|
||||
periodModalOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.measurement-page {
|
||||
min-height: 100vh;
|
||||
font-family: var(--bim-font-cn);
|
||||
}
|
||||
|
||||
.module-topbar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 20px;
|
||||
max-width: calc(100% - 160px);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: nowrap;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(83, 214, 206, 0.26);
|
||||
background:
|
||||
radial-gradient(420px 120px at 18% 0%, rgba(83, 214, 206, 0.22), transparent 70%),
|
||||
linear-gradient(180deg, rgba(18, 32, 40, 0.94), rgba(15, 25, 32, 0.88));
|
||||
box-shadow:
|
||||
0 20px 56px rgba(0, 0, 0, 0.28),
|
||||
0 0 0 1px rgba(83, 214, 206, 0.12) inset;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-weight: 900;
|
||||
color: rgba(244, 252, 255, 0.92);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip input {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
margin: 0;
|
||||
accent-color: rgba(64, 224, 208, 0.92);
|
||||
}
|
||||
|
||||
.chip-status::before {
|
||||
content: "";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chip-status.chip-yellow::before { border-color: rgba(212, 136, 6, 0.4); background: rgba(212, 136, 6, 0.22); }
|
||||
.chip-status.chip-green::before { border-color: rgba(47, 143, 47, 0.4); background: rgba(47, 143, 47, 0.22); }
|
||||
.chip-status.chip-red::before { border-color: rgba(217, 54, 62, 0.4); background: rgba(217, 54, 62, 0.22); }
|
||||
|
||||
.placeholder {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.14);
|
||||
border-radius: 14px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.placeholder-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.placeholder-title {
|
||||
color: rgba(222, 238, 244, 0.9);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.tree {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 10px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(154, 186, 198, 0.22);
|
||||
background: linear-gradient(180deg, rgba(44, 58, 68, 0.58), rgba(37, 49, 58, 0.54));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tree-item:hover {
|
||||
border-color: rgba(83, 214, 206, 0.32);
|
||||
background: linear-gradient(180deg, rgba(51, 67, 78, 0.66), rgba(43, 57, 67, 0.62));
|
||||
}
|
||||
|
||||
.tree-item.is-active {
|
||||
border-color: rgba(132, 188, 255, 0.3);
|
||||
background: linear-gradient(180deg, rgba(112, 146, 198, 0.34), rgba(95, 128, 180, 0.28));
|
||||
}
|
||||
|
||||
.tree-caret {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(203, 230, 236, 0.88);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tree-caret.is-leaf {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tree-bullet {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: rgba(83, 214, 206, 0.85);
|
||||
}
|
||||
|
||||
.tree-text {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: rgba(222, 238, 244, 0.9);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(214, 235, 241, 0.9);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 900;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab.is-on {
|
||||
border-color: rgba(83, 214, 206, 0.32);
|
||||
background: rgba(83, 214, 206, 0.24);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(178, 206, 216, 0.16);
|
||||
}
|
||||
|
||||
.table th {
|
||||
color: rgba(219, 240, 246, 0.86);
|
||||
font-weight: 900;
|
||||
background: linear-gradient(90deg, rgba(12, 38, 52, 0.92), rgba(16, 47, 61, 0.86));
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.table td {
|
||||
color: rgba(222, 238, 244, 0.86);
|
||||
background: linear-gradient(90deg, rgba(37, 51, 61, 0.42), rgba(31, 45, 54, 0.36));
|
||||
}
|
||||
|
||||
.table tbody tr:hover td {
|
||||
background: linear-gradient(90deg, rgba(69, 102, 130, 0.36), rgba(56, 86, 113, 0.32));
|
||||
}
|
||||
|
||||
.modal-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(13, 36, 74, 0.08);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.period-modal {
|
||||
width: min(520px, calc(100% - 32px));
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(83, 214, 206, 0.24);
|
||||
background:
|
||||
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
|
||||
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.3);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.period-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.period-modal-title {
|
||||
font-weight: 900;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.period-list {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid rgba(83, 214, 206, 0.2);
|
||||
padding-top: 12px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.period-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.iconbtn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
color: rgba(11, 27, 58, 0.86);
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 1300px) {
|
||||
.module-topbar {
|
||||
left: 50%;
|
||||
max-width: calc(100% - 32px);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
236
src/pages/plan/index.vue
Normal file
236
src/pages/plan/index.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div class="plan-page">
|
||||
<div class="canvas">
|
||||
<header class="topbar"><div class="topbar-center"><div class="model-title">XXX特大桥主体模型.rvt</div></div></header>
|
||||
|
||||
<section class="model-stage">
|
||||
<ModelPlaceholder />
|
||||
</section>
|
||||
|
||||
<section class="module-topbar">
|
||||
<div class="field">
|
||||
<span class="field-label">计划状态</span>
|
||||
<label class="chip chip-status"><input type="checkbox" :checked="statusFilters.all" @change="togglePlanFilter('all', $event.target.checked)" /> 全部</label>
|
||||
<label class="chip chip-status chip-red"><input type="checkbox" :checked="statusFilters.not_done" @change="togglePlanFilter('not_done', $event.target.checked)" /> 计划未完成</label>
|
||||
<label class="chip chip-status chip-blue"><input type="checkbox" :checked="statusFilters.done" @change="togglePlanFilter('done', $event.target.checked)" /> 计划完成</label>
|
||||
<label class="chip chip-status chip-green"><input type="checkbox" :checked="statusFilters.out_of_plan" @change="togglePlanFilter('out_of_plan', $event.target.checked)" /> 计划外完成</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="sidepanel" :class="{ 'is-collapsed': sideCollapsed }">
|
||||
<header class="sidepanel-header">
|
||||
<div class="sidepanel-title">计划|左侧区</div>
|
||||
<button class="iconbtn" type="button" @click="sideCollapsed = !sideCollapsed">{{ sideCollapsed ? "▸" : "▾" }}</button>
|
||||
</header>
|
||||
<div class="sidepanel-body" v-show="!sideCollapsed">
|
||||
<section class="placeholder">
|
||||
<div class="placeholder-top">
|
||||
<div class="placeholder-title">计划期次:<b>{{ periodId }}</b></div>
|
||||
<button class="btn btn-sm" type="button" @click="openPeriodModal">切换期次</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="tree">
|
||||
<div class="tree-item" :class="{ 'is-active': row.leaf && row.id === selectedStructureId }" :style="{ paddingLeft: `${10 + row.level * 14}px` }" v-for="row in visibleTreeRows" :key="row.id" @click="onTreeRowClick(row)">
|
||||
<button class="tree-caret" type="button" :class="{ 'is-leaf': row.leaf }" :disabled="row.leaf" @click.stop="toggleTreeExpand(row)">{{ row.leaf ? "•" : row.open ? "▾" : "▸" }}</button>
|
||||
<span class="tree-bullet"></span>
|
||||
<span class="tree-text">{{ row.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="bottompanel" :class="{ 'is-collapsed': bottomCollapsed }">
|
||||
<header class="bottompanel-header"><div class="tabs"><button class="tab is-on" type="button">计划进度</button></div></header>
|
||||
<button class="iconbtn bottompanel-toggle" type="button" @click="bottomCollapsed = !bottomCollapsed">{{ bottomCollapsed ? "▴" : "▾" }}</button>
|
||||
<div class="bottompanel-body" v-show="!bottomCollapsed">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>序号</th><th>期次</th><th>清单编号</th><th>清单名称</th><th>单位</th><th>单价</th><th>数量</th><th>本期末计划完成数量</th><th>本期末实际完成数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in planRows" :key="r.no">
|
||||
<td>{{ r.no }}</td><td>{{ r.period }}</td><td>{{ r.code }}</td><td>{{ r.name }}</td><td>{{ r.unit }}</td><td>{{ formatYuan(r.unitPrice) }}</td><td>{{ formatNumber(r.qty,2) }}</td><td>{{ formatNumber(r.planDone,2) }}</td><td>{{ formatNumber(r.actualDone,2) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="modal-mask" v-show="periodModalOpen" @click.self="periodModalOpen = false">
|
||||
<div class="period-modal">
|
||||
<div class="period-modal-head"><div class="period-modal-title">切换计划期次</div><button class="iconbtn" type="button" @click="periodModalOpen = false">✕</button></div>
|
||||
<div class="period-list">
|
||||
<div class="tree-item" :class="{ 'is-active': p === periodDraft }" v-for="p in planPeriods" :key="p" @click="periodDraft = p"><span class="tree-bullet"></span><span class="tree-text">{{ p }}</span></div>
|
||||
</div>
|
||||
<div class="period-actions"><button class="btn btn-ghost" type="button" @click="periodModalOpen = false">取消</button><button class="btn" type="button" @click="confirmPeriod">确认</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
|
||||
|
||||
const structures = [
|
||||
{ id: "S-001", name: "主桥-0#墩" }, { id: "S-002", name: "主桥-1#墩" }, { id: "S-003", name: "主桥-2#墩" },
|
||||
{ id: "S-004", name: "引桥-桩基" }, { id: "S-005", name: "引桥-承台" }, { id: "S-006", name: "引桥-盖梁" },
|
||||
{ id: "S-007", name: "路基-填筑" }, { id: "S-008", name: "路面-基层" }, { id: "S-009", name: "路面-面层" },
|
||||
];
|
||||
|
||||
const elementTree = [
|
||||
{ id: "E-G-bridge", name: "桥梁工程", children: [
|
||||
{ id: "E-G-main-bridge", name: "主桥", children: ["S-001", "S-002", "S-003"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
|
||||
{ id: "E-G-approach", name: "引桥", children: ["S-004", "S-005", "S-006"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
|
||||
] },
|
||||
{ id: "E-G-road", name: "道路工程", children: ["S-007", "S-008", "S-009"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
|
||||
];
|
||||
|
||||
const planPeriods = ["2025-12", "2026-Q1", "2026-Q2"];
|
||||
const periodId = ref("2025-12");
|
||||
const periodModalOpen = ref(false);
|
||||
const periodDraft = ref(periodId.value);
|
||||
|
||||
const sideCollapsed = ref(false);
|
||||
const bottomCollapsed = ref(false);
|
||||
const selectedStructureId = ref("S-001");
|
||||
const expanded = ref(new Set(["E-G-bridge", "E-G-main-bridge", "E-G-approach", "E-G-road"]));
|
||||
const statusFilters = reactive({ all: true, not_done: false, done: false, out_of_plan: false });
|
||||
|
||||
const structureIndex = computed(() => Math.max(0, structures.findIndex((s) => s.id === selectedStructureId.value)));
|
||||
const selectedStructureName = computed(() => structures.find((s) => s.id === selectedStructureId.value)?.name || "");
|
||||
|
||||
const visibleTreeRows = computed(() => {
|
||||
const rows = [];
|
||||
const walk = (node, level) => {
|
||||
const leaf = !node.children || node.children.length === 0;
|
||||
const open = expanded.value.has(node.id);
|
||||
rows.push({ id: node.id, name: node.name, level, leaf, open });
|
||||
if (!leaf && open) node.children.forEach((c) => walk(c, level + 1));
|
||||
};
|
||||
elementTree.forEach((n) => walk(n, 0));
|
||||
return rows;
|
||||
});
|
||||
|
||||
const planRows = computed(() => {
|
||||
const idx = structureIndex.value;
|
||||
return Array.from({ length: 16 }, (_, i) => {
|
||||
const unitPrice = 480 + i * 22;
|
||||
const qty = 18 + i * 2.5 + idx * 0.5;
|
||||
const planDone = qty * (0.35 + (i % 5) * 0.12);
|
||||
const actualDone = qty * (0.22 + (i % 4) * 0.16);
|
||||
return {
|
||||
no: i + 1,
|
||||
period: periodId.value,
|
||||
code: `BOQ-${String(4001 + i)}`,
|
||||
name: `${["混凝土浇筑", "钢筋制安", "模板工程", "土方开挖", "路基填筑", "防水层"][i % 6]}(${selectedStructureName.value})`,
|
||||
unit: ["m³", "t", "㎡", "m³", "m³", "㎡"][i % 6],
|
||||
unitPrice,
|
||||
qty,
|
||||
planDone,
|
||||
actualDone,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
function formatYuan(value) { return `${Number(value).toFixed(2)}元`; }
|
||||
function formatNumber(value, digits = 0) { return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: digits, maximumFractionDigits: digits }); }
|
||||
|
||||
function applyAllExclusive(key, checked) {
|
||||
if (key === "all") {
|
||||
statusFilters.all = checked;
|
||||
if (checked) {
|
||||
statusFilters.not_done = false;
|
||||
statusFilters.done = false;
|
||||
statusFilters.out_of_plan = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
statusFilters[key] = checked;
|
||||
if (checked) statusFilters.all = false;
|
||||
if (!statusFilters.not_done && !statusFilters.done && !statusFilters.out_of_plan) statusFilters.all = true;
|
||||
}
|
||||
|
||||
function togglePlanFilter(key, checked) { applyAllExclusive(key, checked); }
|
||||
|
||||
function toggleTreeExpand(row) { if (!row.leaf) (expanded.value.has(row.id) ? expanded.value.delete(row.id) : expanded.value.add(row.id)); }
|
||||
function onTreeRowClick(row) { if (row.leaf) selectedStructureId.value = row.id; else toggleTreeExpand(row); }
|
||||
|
||||
function openPeriodModal() { periodDraft.value = periodId.value; periodModalOpen.value = true; }
|
||||
function confirmPeriod() { periodId.value = periodDraft.value; periodModalOpen.value = false; }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.plan-page { min-height: 100vh; font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif; }
|
||||
.canvas { position: relative; min-height: 100vh; overflow: hidden; border-radius: 24px; border: 1px solid rgba(98,191,206,.34); background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%); box-shadow: inset 0 0 0 8px rgba(214,230,241,.55); }
|
||||
.topbar { position: absolute; inset: 0 0 auto; height: 120px; z-index: 30; }
|
||||
.topbar-center { position: absolute; left: 50%; transform: translateX(-50%); top: 16px; }
|
||||
.model-title { font-size: 20px; letter-spacing: 1px; font-weight: 700; color: rgba(255,128,52,.95); text-shadow: 0 2px 10px rgba(0,0,0,.25); }
|
||||
.model-stage { position: absolute; inset: 0; }
|
||||
|
||||
.module-topbar { position: absolute; left: 50%; top: 20px; transform: translateX(-50%); max-width: calc(100% - 160px); z-index: 99; }
|
||||
.field { display: flex; align-items: center; gap: 10px; flex-wrap: nowrap; border-radius: 18px; border: 1px solid rgba(83,214,206,.26); background: radial-gradient(420px 120px at 18% 0%, rgba(83,214,206,.22), transparent 70%), linear-gradient(180deg, rgba(18,32,40,.94), rgba(15,25,32,.88)); box-shadow: 0 20px 56px rgba(0,0,0,.28), 0 0 0 1px rgba(83,214,206,.12) inset; padding: 10px 14px; }
|
||||
.field-label { font-weight: 900; color: rgba(244,252,255,.92); font-size: 14px; white-space: nowrap; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 999px; border: 1px solid rgba(255,255,255,.12); background: rgba(0,0,0,.2); color: rgba(255,255,255,.88); font-weight: 800; font-size: 13px; white-space: nowrap; }
|
||||
.chip input { width: 13px; height: 13px; margin: 0; accent-color: rgba(64,224,208,.92); }
|
||||
.chip-status::before { content: ""; width: 10px; height: 10px; border-radius: 3px; border: 1px solid rgba(255,255,255,.2); background: transparent; }
|
||||
.chip-status.chip-red::before { border-color: rgba(217,54,62,.4); background: rgba(217,54,62,.22); }
|
||||
.chip-status.chip-blue::before { border-color: rgba(21,108,255,.4); background: rgba(21,108,255,.22); }
|
||||
.chip-status.chip-green::before { border-color: rgba(47,143,47,.4); background: rgba(47,143,47,.22); }
|
||||
|
||||
.sidepanel { position: absolute; left: 16px; top: 175px; width: 320px; bottom: 100px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(260px 140px at 8% 0%, rgba(63,203,191,.16), transparent 62%) padding-box, linear-gradient(180deg, rgba(20,31,37,.92), rgba(15,23,29,.88)) padding-box; box-shadow: 0 22px 60px rgba(0,0,0,.34), 0 0 0 1px rgba(83,214,206,.12) inset; overflow: hidden; z-index: 25; }
|
||||
.sidepanel.is-collapsed { width: 64px; }
|
||||
.sidepanel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 10px 10px 12px; border-bottom: 1px solid rgba(83,214,206,.16); background: radial-gradient(360px 100px at 10% 0%, rgba(83,214,206,.24), transparent 68%), linear-gradient(180deg, rgba(28,134,122,.84), rgba(15,60,74,.62)); }
|
||||
.sidepanel-title { font-size: 14px; font-weight: 900; color: rgba(207,247,242,.96); }
|
||||
.iconbtn { width: 30px; height: 30px; border-radius: 10px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.42); color: rgba(11,27,58,.86); font-weight: 900; cursor: pointer; }
|
||||
.sidepanel-body { height: calc(100% - 52px); overflow: auto; padding: 8px; }
|
||||
|
||||
.placeholder { border: 1px solid rgba(255,255,255,.1); background: rgba(0,0,0,.14); border-radius: 14px; padding: 10px; margin-bottom: 10px; }
|
||||
.placeholder-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.placeholder-title { color: rgba(222,238,244,.9); font-size: 13px; font-weight: 900; }
|
||||
|
||||
.btn { appearance: none; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.08); color: rgba(255,255,255,.88); border-radius: 12px; padding: 8px 10px; cursor: pointer; }
|
||||
.btn-sm { padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 800; }
|
||||
.btn-ghost { background: rgba(255,255,255,.04); }
|
||||
|
||||
.tree { display: grid; gap: 8px; }
|
||||
.tree-item { display: grid; grid-template-columns: 20px 10px 1fr; align-items: center; gap: 8px; min-height: 40px; border-radius: 14px; border: 1px solid rgba(154,186,198,.22); background: linear-gradient(180deg, rgba(44,58,68,.58), rgba(37,49,58,.54)); cursor: pointer; }
|
||||
.tree-item:hover { border-color: rgba(83,214,206,.32); background: linear-gradient(180deg, rgba(51,67,78,.66), rgba(43,57,67,.62)); }
|
||||
.tree-item.is-active { border-color: rgba(132,188,255,.3); background: linear-gradient(180deg, rgba(112,146,198,.34), rgba(95,128,180,.28)); }
|
||||
.tree-caret { border: 0; background: transparent; color: rgba(203,230,236,.88); cursor: pointer; font-size: 14px; }
|
||||
.tree-caret.is-leaf { cursor: default; }
|
||||
.tree-bullet { width: 10px; height: 10px; border-radius: 50%; background: rgba(83,214,206,.85); }
|
||||
.tree-text { font-size: 13px; font-weight: 800; color: rgba(222,238,244,.9); }
|
||||
|
||||
.bottompanel { position: absolute; left: 370px; right: 16px; bottom: 100px; height: 390px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(420px 180px at 14% 0%, rgba(83,214,206,.2), transparent 66%) padding-box, radial-gradient(520px 220px at 82% 0%, rgba(40,156,228,.14), transparent 70%) padding-box, linear-gradient(180deg, rgba(18,33,40,.94), rgba(14,24,31,.9)) padding-box; box-shadow: 0 24px 70px rgba(0,0,0,.36), 0 0 0 1px rgba(83,214,206,.14) inset; overflow: hidden; z-index: 26; }
|
||||
.bottompanel-header { padding: 12px 52px 12px 12px; border-bottom: 1px solid rgba(83,214,206,.28); background: radial-gradient(380px 120px at 18% 0%, rgba(83,214,206,.28), transparent 70%), linear-gradient(180deg, rgba(25,137,124,.86), rgba(16,66,82,.64)); }
|
||||
.bottompanel-toggle { position: absolute; right: 12px; top: 12px; }
|
||||
.tabs { display: flex; gap: 10px; }
|
||||
.tab { border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.08); color: rgba(214,235,241,.9); border-radius: 999px; padding: 8px 16px; cursor: pointer; font-weight: 900; font-size: 12px; }
|
||||
.tab.is-on { border-color: rgba(83,214,206,.32); background: rgba(83,214,206,.24); }
|
||||
.bottompanel.is-collapsed { height: 70px; }
|
||||
.bottompanel-body { padding: 0 12px 12px; overflow: auto; height: calc(100% - 66px); }
|
||||
|
||||
.table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; }
|
||||
.table th, .table td { text-align: left; padding: 12px 14px; border-bottom: 1px solid rgba(178,206,216,.16); }
|
||||
.table th { color: rgba(219,240,246,.86); font-weight: 900; background: linear-gradient(90deg, rgba(12,38,52,.92), rgba(16,47,61,.86)); position: sticky; top: 0; z-index: 2; }
|
||||
.table td { color: rgba(222,238,244,.86); background: linear-gradient(90deg, rgba(37,51,61,.42), rgba(31,45,54,.36)); }
|
||||
.table tbody tr:hover td { background: linear-gradient(90deg, rgba(69,102,130,.36), rgba(56,86,113,.32)); }
|
||||
|
||||
.modal-mask { position: absolute; inset: 0; z-index: 90; display: grid; place-items: center; background: rgba(13,36,74,.08); backdrop-filter: blur(3px); }
|
||||
.period-modal { width: min(520px, calc(100% - 32px)); border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(260px 160px at 18% 0%, rgba(83,214,206,.14), transparent 62%) padding-box, linear-gradient(180deg, rgba(18,26,31,.94), rgba(14,20,25,.86)) padding-box; box-shadow: 0 30px 80px rgba(0,0,0,.3); padding: 14px; }
|
||||
.period-modal-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.period-modal-title { font-weight: 900; color: rgba(255,255,255,.9); font-size: 14px; }
|
||||
.period-list { margin-top: 12px; border-top: 1px solid rgba(83,214,206,.2); padding-top: 12px; display: grid; gap: 8px; }
|
||||
.period-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 12px; }
|
||||
|
||||
@media (max-width: 1300px) {
|
||||
.module-topbar { left: 50%; max-width: calc(100% - 32px); transform: translateX(-50%); }
|
||||
.sidepanel { width: 280px; }
|
||||
.bottompanel { left: 330px; }
|
||||
}
|
||||
</style>
|
||||
269
src/pages/progress/index.vue
Normal file
269
src/pages/progress/index.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="progress-page">
|
||||
<div class="canvas">
|
||||
<header class="topbar"><div class="topbar-center"><div class="model-title">XXX特大桥主体模型.rvt</div></div></header>
|
||||
|
||||
<section class="model-stage">
|
||||
<ModelPlaceholder />
|
||||
</section>
|
||||
|
||||
<section class="module-topbar">
|
||||
<div class="module-topbar-panel">
|
||||
<div class="field field-time">
|
||||
<select class="select" v-model="timeStatMode"><option value="cutoff">日期截止统计</option></select>
|
||||
<input class="date-input" type="date" v-model="cutoffDateDraft" />
|
||||
<button class="btn btn-sm btn-accent" type="button" @click="applyCutoffDate">查询</button>
|
||||
<div class="kpi kpi-progress">整体完成百分比:<span class="kpi-number">{{ formatPercentValue(overallPercent, 2) }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="field field-percent">
|
||||
<span class="field-label">进度百分比</span>
|
||||
<label class="chip chip-status"><input type="checkbox" :checked="percentFilters.all" @change="togglePercentFilter('all', $event.target.checked)" /> 全部</label>
|
||||
<label class="chip chip-status chip-gray"><input type="checkbox" :checked="percentFilters.p0" @change="togglePercentFilter('p0', $event.target.checked)" /> 0%</label>
|
||||
<label class="chip chip-status chip-red"><input type="checkbox" :checked="percentFilters.p0_50" @change="togglePercentFilter('p0_50', $event.target.checked)" /> 0%-50%</label>
|
||||
<label class="chip chip-status chip-blue"><input type="checkbox" :checked="percentFilters.p50_100" @change="togglePercentFilter('p50_100', $event.target.checked)" /> 50%-100%</label>
|
||||
<label class="chip chip-status chip-green"><input type="checkbox" :checked="percentFilters.p100" @change="togglePercentFilter('p100', $event.target.checked)" /> 100%</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="sidepanel" :class="{ 'is-collapsed': sideCollapsed }">
|
||||
<header class="sidepanel-header">
|
||||
<div class="sidepanel-title">工程部位</div>
|
||||
<button class="iconbtn" type="button" @click="sideCollapsed = !sideCollapsed">{{ sideCollapsed ? "▸" : "▾" }}</button>
|
||||
</header>
|
||||
<div class="sidepanel-body" v-show="!sideCollapsed">
|
||||
<div class="tree">
|
||||
<div class="tree-item" :class="{ 'is-active': row.leaf && row.id === selectedStructureId }" :style="{ paddingLeft: `${10 + row.level * 14}px` }" v-for="row in visibleTreeRows" :key="row.id" @click="onTreeRowClick(row)">
|
||||
<button class="tree-caret" type="button" :class="{ 'is-leaf': row.leaf }" :disabled="row.leaf" @click.stop="toggleTreeExpand(row)">{{ row.leaf ? "•" : row.open ? "▾" : "▸" }}</button>
|
||||
<span class="tree-bullet"></span>
|
||||
<span class="tree-text">{{ row.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="bottompanel" :class="{ 'is-collapsed': bottomCollapsed }">
|
||||
<header class="bottompanel-header"><div class="tabs"><button class="tab is-on" type="button">项目日进度明细</button></div></header>
|
||||
<button class="iconbtn bottompanel-toggle" type="button" @click="bottomCollapsed = !bottomCollapsed">{{ bottomCollapsed ? "▴" : "▾" }}</button>
|
||||
<div class="bottompanel-body" v-show="!bottomCollapsed">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>序号</th><th>全名称</th><th>清单编号</th><th>清单名称</th><th>单位</th><th>合同数量</th><th>合同金额</th><th>填报日期</th><th>完成数量</th><th>完成金额</th><th>累计完成数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in progressRows" :key="r.no">
|
||||
<td>{{ r.no }}</td><td>{{ r.fullName }}</td><td>{{ r.code }}</td><td>{{ r.name }}</td><td>{{ r.unit }}</td><td>{{ formatNumber(r.contractQty,2) }}</td><td>{{ formatMoney(r.contractAmt) }}</td><td>{{ r.reportDate }}</td><td>{{ formatNumber(r.doneQty,2) }}</td><td>{{ formatMoney(r.doneAmt) }}</td><td>{{ formatNumber(r.cumDoneQty,2) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
|
||||
|
||||
const structures = [
|
||||
{ id: "S-001", name: "主桥-0#墩" }, { id: "S-002", name: "主桥-1#墩" }, { id: "S-003", name: "主桥-2#墩" },
|
||||
{ id: "S-004", name: "引桥-桩基" }, { id: "S-005", name: "引桥-承台" }, { id: "S-006", name: "引桥-盖梁" },
|
||||
{ id: "S-007", name: "路基-填筑" }, { id: "S-008", name: "路面-基层" }, { id: "S-009", name: "路面-面层" },
|
||||
];
|
||||
|
||||
const elementTree = [
|
||||
{ id: "E-G-bridge", name: "桥梁工程", children: [
|
||||
{ id: "E-G-main-bridge", name: "主桥", children: ["S-001", "S-002", "S-003"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
|
||||
{ id: "E-G-approach", name: "引桥", children: ["S-004", "S-005", "S-006"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
|
||||
] },
|
||||
{ id: "E-G-road", name: "道路工程", children: ["S-007", "S-008", "S-009"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
|
||||
];
|
||||
|
||||
const sideCollapsed = ref(false);
|
||||
const bottomCollapsed = ref(false);
|
||||
const selectedStructureId = ref("S-001");
|
||||
const expanded = ref(new Set(["E-G-bridge", "E-G-main-bridge", "E-G-approach", "E-G-road"]));
|
||||
|
||||
const timeStatMode = ref("cutoff");
|
||||
const baselineDate = ref(todayISODate());
|
||||
const baselineOverallPercent = ref(61.54);
|
||||
const cutoffDate = ref(todayISODate());
|
||||
const cutoffDateDraft = ref(cutoffDate.value);
|
||||
|
||||
const percentFilters = reactive({ all: true, p0: false, p0_50: false, p50_100: false, p100: false });
|
||||
|
||||
const visibleTreeRows = computed(() => {
|
||||
const rows = [];
|
||||
const walk = (node, level) => {
|
||||
const leaf = !node.children || node.children.length === 0;
|
||||
const open = expanded.value.has(node.id);
|
||||
rows.push({ id: node.id, name: node.name, level, leaf, open });
|
||||
if (!leaf && open) node.children.forEach((c) => walk(c, level + 1));
|
||||
};
|
||||
elementTree.forEach((n) => walk(n, 0));
|
||||
return rows;
|
||||
});
|
||||
|
||||
const selectedStructureName = computed(() => structures.find((s) => s.id === selectedStructureId.value)?.name || "");
|
||||
|
||||
const overallPercent = computed(() => {
|
||||
const deltaDays = daysBetweenISO(baselineDate.value, cutoffDate.value);
|
||||
return clamp(baselineOverallPercent.value + deltaDays * 0.06, 0, 100);
|
||||
});
|
||||
|
||||
const progressRows = computed(() => {
|
||||
const reportDate = cutoffDate.value;
|
||||
return Array.from({ length: 16 }, (_, i) => {
|
||||
const contractQty = 30 + i * 2.2;
|
||||
const unitPrice = 860 + i * 30;
|
||||
const contractAmt = contractQty * unitPrice;
|
||||
const doneQty = contractQty * (0.03 + (i % 6) * 0.06);
|
||||
const doneAmt = doneQty * unitPrice;
|
||||
const cumDoneQty = contractQty * (0.18 + (i % 5) * 0.14);
|
||||
return {
|
||||
no: i + 1,
|
||||
fullName: selectedStructureName.value || structures[i % structures.length].name,
|
||||
code: `BOQ-${String(5001 + i)}`,
|
||||
name: ["混凝土浇筑", "钢筋制安", "模板工程", "土方开挖", "路基填筑", "防水层"][i % 6],
|
||||
unit: ["m³", "t", "㎡", "m³", "m³", "㎡"][i % 6],
|
||||
contractQty,
|
||||
contractAmt,
|
||||
reportDate,
|
||||
doneQty,
|
||||
doneAmt,
|
||||
cumDoneQty,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
function todayISODate() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function daysBetweenISO(a, b) {
|
||||
const da = new Date(`${a}T00:00:00`);
|
||||
const db = new Date(`${b}T00:00:00`);
|
||||
return Math.round((db.getTime() - da.getTime()) / 86400000);
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function formatPercentValue(value, digits = 2) {
|
||||
return `${Number(value).toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
function formatMoney(value) {
|
||||
if (value == null || Number.isNaN(value)) return "--";
|
||||
const abs = Math.abs(value);
|
||||
const sign = value < 0 ? "-" : "";
|
||||
if (abs >= 1e8) return `${sign}${(abs / 1e8).toFixed(2)}亿`;
|
||||
if (abs >= 1e4) return `${sign}${(abs / 1e4).toFixed(2)}万`;
|
||||
return `${sign}${abs.toFixed(2)}元`;
|
||||
}
|
||||
|
||||
function formatNumber(value, digits = 0) {
|
||||
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function applyCutoffDate() {
|
||||
cutoffDate.value = cutoffDateDraft.value || cutoffDate.value || todayISODate();
|
||||
}
|
||||
|
||||
function applyAllExclusive(key, checked) {
|
||||
if (key === "all") {
|
||||
percentFilters.all = checked;
|
||||
if (checked) {
|
||||
percentFilters.p0 = false;
|
||||
percentFilters.p0_50 = false;
|
||||
percentFilters.p50_100 = false;
|
||||
percentFilters.p100 = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
percentFilters[key] = checked;
|
||||
if (checked) percentFilters.all = false;
|
||||
if (!percentFilters.p0 && !percentFilters.p0_50 && !percentFilters.p50_100 && !percentFilters.p100) percentFilters.all = true;
|
||||
}
|
||||
|
||||
function togglePercentFilter(key, checked) { applyAllExclusive(key, checked); }
|
||||
|
||||
function toggleTreeExpand(row) { if (!row.leaf) (expanded.value.has(row.id) ? expanded.value.delete(row.id) : expanded.value.add(row.id)); }
|
||||
function onTreeRowClick(row) { if (row.leaf) selectedStructureId.value = row.id; else toggleTreeExpand(row); }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.progress-page { min-height: 100vh; font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif; }
|
||||
.canvas { position: relative; min-height: 100vh; overflow: hidden; border-radius: 24px; border: 1px solid rgba(98,191,206,.34); background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%); box-shadow: inset 0 0 0 8px rgba(214,230,241,.55); }
|
||||
.topbar { position: absolute; inset: 0 0 auto; height: 120px; z-index: 30; }
|
||||
.topbar-center { position: absolute; left: 50%; transform: translateX(-50%); top: 16px; }
|
||||
.model-title { font-size: 20px; letter-spacing: 1px; font-weight: 700; color: rgba(255,128,52,.95); text-shadow: 0 2px 10px rgba(0,0,0,.25); }
|
||||
.model-stage { position: absolute; inset: 0; }
|
||||
|
||||
.module-topbar { position: absolute; left: 50%; top: 20px; transform: translateX(-50%); width: min(800px, calc(100% - 180px)); z-index: 99; }
|
||||
.module-topbar-panel { border-radius: 18px; border: 1px solid rgba(83,214,206,.26); background: radial-gradient(420px 120px at 18% 0%, rgba(83,214,206,.22), transparent 70%), linear-gradient(180deg, rgba(18,32,40,.94), rgba(15,25,32,.88)); box-shadow: 0 20px 56px rgba(0,0,0,.28), 0 0 0 1px rgba(83,214,206,.12) inset; padding: 10px 14px; display: grid; gap: 10px; }
|
||||
.field { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.field-time { flex-wrap: nowrap; }
|
||||
.field-percent { flex-wrap: nowrap; }
|
||||
.select, .date-input { height: 34px; border-radius: 10px; border: 1px solid rgba(255,255,255,.14); background: rgba(0,0,0,.18); color: rgba(255,255,255,.9); padding: 0 10px; font-size: 13px; font-weight: 700; }
|
||||
.date-input { width: 150px; }
|
||||
.btn { appearance: none; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.08); color: rgba(255,255,255,.88); border-radius: 12px; padding: 8px 10px; cursor: pointer; }
|
||||
.btn-sm { padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 800; }
|
||||
.btn-accent { border-color: rgba(83,214,206,.22); background: rgba(43,191,178,.22); }
|
||||
|
||||
.kpi { margin-left: auto; font-size: 13px; font-weight: 800; color: rgba(236,248,251,.86); white-space: nowrap; }
|
||||
.kpi-number { font-weight: 900; color: rgba(83,214,206,.95); margin-left: 4px; }
|
||||
|
||||
.field-label { font-weight: 900; color: rgba(244,252,255,.92); font-size: 14px; white-space: nowrap; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 999px; border: 1px solid rgba(255,255,255,.12); background: rgba(0,0,0,.2); color: rgba(255,255,255,.88); font-weight: 800; font-size: 13px; white-space: nowrap; }
|
||||
.chip input { width: 13px; height: 13px; margin: 0; accent-color: rgba(64,224,208,.92); }
|
||||
.chip-status::before { content: ""; width: 10px; height: 10px; border-radius: 3px; border: 1px solid rgba(255,255,255,.2); background: transparent; }
|
||||
.chip-status.chip-gray::before { border-color: rgba(110,125,150,.45); background: rgba(110,125,150,.24); }
|
||||
.chip-status.chip-red::before { border-color: rgba(217,54,62,.4); background: rgba(217,54,62,.22); }
|
||||
.chip-status.chip-blue::before { border-color: rgba(21,108,255,.4); background: rgba(21,108,255,.22); }
|
||||
.chip-status.chip-green::before { border-color: rgba(47,143,47,.4); background: rgba(47,143,47,.22); }
|
||||
|
||||
.sidepanel { position: absolute; left: 16px; top: 175px; width: 320px; bottom: 100px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(260px 140px at 8% 0%, rgba(63,203,191,.16), transparent 62%) padding-box, linear-gradient(180deg, rgba(20,31,37,.92), rgba(15,23,29,.88)) padding-box; box-shadow: 0 22px 60px rgba(0,0,0,.34), 0 0 0 1px rgba(83,214,206,.12) inset; overflow: hidden; z-index: 25; }
|
||||
.sidepanel.is-collapsed { width: 64px; }
|
||||
.sidepanel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 10px 10px 12px; border-bottom: 1px solid rgba(83,214,206,.16); background: radial-gradient(360px 100px at 10% 0%, rgba(83,214,206,.24), transparent 68%), linear-gradient(180deg, rgba(28,134,122,.84), rgba(15,60,74,.62)); }
|
||||
.sidepanel-title { font-size: 14px; font-weight: 900; color: rgba(207,247,242,.96); }
|
||||
.iconbtn { width: 30px; height: 30px; border-radius: 10px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.42); color: rgba(11,27,58,.86); font-weight: 900; cursor: pointer; }
|
||||
.sidepanel-body { height: calc(100% - 52px); overflow: auto; padding: 8px; }
|
||||
|
||||
.tree { display: grid; gap: 8px; }
|
||||
.tree-item { display: grid; grid-template-columns: 20px 10px 1fr; align-items: center; gap: 8px; min-height: 40px; border-radius: 14px; border: 1px solid rgba(154,186,198,.22); background: linear-gradient(180deg, rgba(44,58,68,.58), rgba(37,49,58,.54)); cursor: pointer; }
|
||||
.tree-item:hover { border-color: rgba(83,214,206,.32); background: linear-gradient(180deg, rgba(51,67,78,.66), rgba(43,57,67,.62)); }
|
||||
.tree-item.is-active { border-color: rgba(132,188,255,.3); background: linear-gradient(180deg, rgba(112,146,198,.34), rgba(95,128,180,.28)); }
|
||||
.tree-caret { border: 0; background: transparent; color: rgba(203,230,236,.88); cursor: pointer; font-size: 14px; }
|
||||
.tree-caret.is-leaf { cursor: default; }
|
||||
.tree-bullet { width: 10px; height: 10px; border-radius: 50%; background: rgba(83,214,206,.85); }
|
||||
.tree-text { font-size: 13px; font-weight: 800; color: rgba(222,238,244,.9); }
|
||||
|
||||
.bottompanel { position: absolute; left: 370px; right: 16px; bottom: 100px; height: 390px; border-radius: 18px; border: 1px solid rgba(83,214,206,.24); background: radial-gradient(420px 180px at 14% 0%, rgba(83,214,206,.2), transparent 66%) padding-box, radial-gradient(520px 220px at 82% 0%, rgba(40,156,228,.14), transparent 70%) padding-box, linear-gradient(180deg, rgba(18,33,40,.94), rgba(14,24,31,.9)) padding-box; box-shadow: 0 24px 70px rgba(0,0,0,.36), 0 0 0 1px rgba(83,214,206,.14) inset; overflow: hidden; z-index: 26; }
|
||||
.bottompanel-header { padding: 12px 52px 12px 12px; border-bottom: 1px solid rgba(83,214,206,.28); background: radial-gradient(380px 120px at 18% 0%, rgba(83,214,206,.28), transparent 70%), linear-gradient(180deg, rgba(25,137,124,.86), rgba(16,66,82,.64)); }
|
||||
.bottompanel-toggle { position: absolute; right: 12px; top: 12px; }
|
||||
.tabs { display: flex; gap: 10px; }
|
||||
.tab { border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.08); color: rgba(214,235,241,.9); border-radius: 999px; padding: 8px 16px; cursor: pointer; font-weight: 900; font-size: 12px; }
|
||||
.tab.is-on { border-color: rgba(83,214,206,.32); background: rgba(83,214,206,.24); }
|
||||
.bottompanel.is-collapsed { height: 70px; }
|
||||
.bottompanel-body { padding: 0 12px 12px; overflow: auto; height: calc(100% - 66px); }
|
||||
|
||||
.table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; }
|
||||
.table th, .table td { text-align: left; padding: 12px 14px; border-bottom: 1px solid rgba(178,206,216,.16); }
|
||||
.table th { color: rgba(219,240,246,.86); font-weight: 900; background: linear-gradient(90deg, rgba(12,38,52,.92), rgba(16,47,61,.86)); position: sticky; top: 0; z-index: 2; }
|
||||
.table td { color: rgba(222,238,244,.86); background: linear-gradient(90deg, rgba(37,51,61,.42), rgba(31,45,54,.36)); }
|
||||
.table tbody tr:hover td { background: linear-gradient(90deg, rgba(69,102,130,.36), rgba(56,86,113,.32)); }
|
||||
|
||||
@media (max-width: 1300px) {
|
||||
.module-topbar { width: calc(100% - 32px); }
|
||||
.field-time, .field-percent { flex-wrap: wrap; }
|
||||
.kpi { margin-left: 0; }
|
||||
.sidepanel { width: 280px; }
|
||||
.bottompanel { left: 330px; }
|
||||
}
|
||||
</style>
|
||||
250
src/pages/project/index.vue
Normal file
250
src/pages/project/index.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="project-page">
|
||||
<PageCanvas>
|
||||
<SidePanel v-model:collapsed="sideCollapsed" title="项目|左侧区">
|
||||
<div class="tree">
|
||||
<div
|
||||
class="tree-item"
|
||||
:class="{ 'is-active': row.leaf && row.id === selectedStructureId }"
|
||||
:style="{ paddingLeft: `${10 + row.level * 14}px` }"
|
||||
v-for="row in visibleTreeRows"
|
||||
:key="row.id"
|
||||
@click="onTreeRowClick(row)"
|
||||
>
|
||||
<button
|
||||
class="tree-caret"
|
||||
type="button"
|
||||
:class="{ 'is-leaf': row.leaf }"
|
||||
:disabled="row.leaf"
|
||||
@click.stop="toggleTreeExpand(row)"
|
||||
>
|
||||
{{ row.leaf ? "•" : row.open ? "▾" : "▸" }}
|
||||
</button>
|
||||
<span class="tree-bullet"></span>
|
||||
<span class="tree-text">{{ row.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidePanel>
|
||||
|
||||
<BottomPanel v-model:collapsed="bottomCollapsed">
|
||||
<template #header>
|
||||
<div class="tabs">
|
||||
<button class="tab" :class="{ 'is-on': activeTab === 'boq_decompose' }" type="button" @click="activeTab = 'boq_decompose'">分解清单</button>
|
||||
<button class="tab" :class="{ 'is-on': activeTab === 'materials' }" type="button" @click="activeTab = 'materials'">项目主材</button>
|
||||
<button class="tab" type="button" disabled>模型汇总(未选择)</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<table class="table" v-if="activeTab === 'boq_decompose'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>清单编号</th>
|
||||
<th>清单名称</th>
|
||||
<th>单位</th>
|
||||
<th>分解数量</th>
|
||||
<th>变更后数量</th>
|
||||
<th>计量数量</th>
|
||||
<th>计量完成比例</th>
|
||||
<th>已完成数量</th>
|
||||
<th>完成比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in decomposeRows" :key="r.code">
|
||||
<td>{{ r.code }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td>{{ r.unit }}</td>
|
||||
<td>{{ formatNumber(r.decomposeQty, 2) }}</td>
|
||||
<td>{{ formatNumber(r.changedQty, 2) }}</td>
|
||||
<td>{{ formatNumber(r.measuredQty, 2) }}</td>
|
||||
<td>{{ formatPercent(r.measureRate) }}</td>
|
||||
<td>{{ formatNumber(r.doneQty, 2) }}</td>
|
||||
<td>{{ formatPercent(r.doneRate) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="table" v-else>
|
||||
<thead>
|
||||
<tr><th>材料编码</th><th>名称</th><th>单位</th><th>数量</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in materialRows" :key="r.code">
|
||||
<td>{{ r.code }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td>{{ r.unit }}</td>
|
||||
<td>{{ r.qty }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</BottomPanel>
|
||||
</PageCanvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import PageCanvas from "../../components/layout/PageCanvas.vue";
|
||||
import SidePanel from "../../components/layout/SidePanel.vue";
|
||||
import BottomPanel from "../../components/layout/BottomPanel.vue";
|
||||
import { structures, elementTree } from "../../constants/structures.js";
|
||||
import { projects, getDecomposeRows, getMaterialRows } from "../../constants/mock.js";
|
||||
import { formatNumber, formatPercent } from "../../utils/format.js";
|
||||
|
||||
const currentProjectId = ref("qz-a2");
|
||||
const selectedStructureId = ref("S-001");
|
||||
const sideCollapsed = ref(false);
|
||||
const bottomCollapsed = ref(false);
|
||||
const activeTab = ref("boq_decompose");
|
||||
const expanded = ref(new Set(["E-G-bridge", "E-G-main-bridge", "E-G-approach", "E-G-road"]));
|
||||
|
||||
const currentProject = computed(() => projects.find((p) => p.id === currentProjectId.value) || projects[0]);
|
||||
|
||||
const structureIndex = computed(() => Math.max(0, structures.findIndex((s) => s.id === selectedStructureId.value)));
|
||||
|
||||
const visibleTreeRows = computed(() => {
|
||||
const rows = [];
|
||||
const walk = (node, level) => {
|
||||
const leaf = !node.children || node.children.length === 0;
|
||||
const open = expanded.value.has(node.id);
|
||||
rows.push({ id: node.id, name: node.name, level, leaf, open });
|
||||
if (!leaf && open) node.children.forEach((c) => walk(c, level + 1));
|
||||
};
|
||||
elementTree.forEach((n) => walk(n, 0));
|
||||
return rows;
|
||||
});
|
||||
|
||||
const decomposeRows = computed(() => getDecomposeRows(structureIndex.value, currentProject.value.scale));
|
||||
const materialRows = computed(() => getMaterialRows(structureIndex.value, currentProject.value.scale));
|
||||
|
||||
function toggleTreeExpand(row) {
|
||||
if (row.leaf) return;
|
||||
if (expanded.value.has(row.id)) expanded.value.delete(row.id);
|
||||
else expanded.value.add(row.id);
|
||||
}
|
||||
|
||||
function onTreeRowClick(row) {
|
||||
if (row.leaf) {
|
||||
selectedStructureId.value = row.id;
|
||||
return;
|
||||
}
|
||||
toggleTreeExpand(row);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-page {
|
||||
min-height: 98vh;
|
||||
font-family: var(--bim-font-cn);
|
||||
}
|
||||
|
||||
.tree {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 10px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(154, 186, 198, 0.22);
|
||||
background: linear-gradient(180deg, rgba(44, 58, 68, 0.58), rgba(37, 49, 58, 0.54));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tree-item:hover {
|
||||
border-color: rgba(83, 214, 206, 0.32);
|
||||
background: linear-gradient(180deg, rgba(51, 67, 78, 0.66), rgba(43, 57, 67, 0.62));
|
||||
}
|
||||
|
||||
.tree-item.is-active {
|
||||
border-color: rgba(132, 188, 255, 0.3);
|
||||
background: linear-gradient(180deg, rgba(112, 146, 198, 0.34), rgba(95, 128, 180, 0.28));
|
||||
}
|
||||
|
||||
.tree-caret {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(203, 230, 236, 0.88);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tree-caret.is-leaf {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tree-bullet {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: rgba(83, 214, 206, 0.85);
|
||||
}
|
||||
|
||||
.tree-text {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: rgba(222, 238, 244, 0.9);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(214, 235, 241, 0.9);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 900;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab.is-on {
|
||||
border-color: rgba(83, 214, 206, 0.32);
|
||||
background: rgba(83, 214, 206, 0.24);
|
||||
}
|
||||
|
||||
.tab:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(178, 206, 216, 0.16);
|
||||
}
|
||||
|
||||
.table th {
|
||||
color: rgba(219, 240, 246, 0.86);
|
||||
font-weight: 900;
|
||||
background: linear-gradient(90deg, rgba(12, 38, 52, 0.92), rgba(16, 47, 61, 0.86));
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.table td {
|
||||
color: rgba(222, 238, 244, 0.86);
|
||||
background: linear-gradient(90deg, rgba(37, 51, 61, 0.42), rgba(31, 45, 54, 0.36));
|
||||
}
|
||||
|
||||
.table tbody tr:hover td {
|
||||
background: linear-gradient(90deg, rgba(69, 102, 130, 0.36), rgba(56, 86, 113, 0.32));
|
||||
}
|
||||
</style>
|
||||
651
src/pages/subcontract/index.vue
Normal file
651
src/pages/subcontract/index.vue
Normal file
@@ -0,0 +1,651 @@
|
||||
<template>
|
||||
<div class="subcontract-page">
|
||||
<div class="canvas">
|
||||
<header class="topbar">
|
||||
<div class="topbar-center">
|
||||
<div class="model-title">XXX特大桥主体模型.rvt</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="model-stage">
|
||||
<ModelPlaceholder />
|
||||
</section>
|
||||
|
||||
<aside class="sidepanel" :class="{ 'is-collapsed': sideCollapsed }">
|
||||
<header class="sidepanel-header">
|
||||
<div class="sidepanel-title">分包|左侧区</div>
|
||||
<button class="iconbtn" type="button" @click="sideCollapsed = !sideCollapsed">{{ sideCollapsed ? "▸" : "▾" }}</button>
|
||||
</header>
|
||||
<div class="sidepanel-body" v-show="!sideCollapsed">
|
||||
<section class="placeholder">
|
||||
<div class="placeholder-top">
|
||||
<div class="placeholder-title">合同分包构件树</div>
|
||||
<button class="btn btn-sm" type="button" @click="openContractModal">选择合同</button>
|
||||
</div>
|
||||
<div class="placeholder-sub">当前合同:<b>{{ selectedContractId }}</b></div>
|
||||
</section>
|
||||
|
||||
<div class="tree">
|
||||
<div class="tree-item" :class="{ 'is-active': row.leaf && row.id === selectedStructureId }" :style="{ paddingLeft: `${10 + row.level * 14}px` }" v-for="row in visibleTreeRows" :key="row.id" @click="onTreeRowClick(row)">
|
||||
<button class="tree-caret" type="button" :class="{ 'is-leaf': row.leaf }" :disabled="row.leaf" @click.stop="toggleTreeExpand(row)">{{ row.leaf ? "•" : row.open ? "▾" : "▸" }}</button>
|
||||
<span class="tree-bullet"></span>
|
||||
<span class="tree-text">{{ row.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="bottompanel" :class="{ 'is-collapsed': bottomCollapsed }">
|
||||
<header class="bottompanel-header">
|
||||
<div class="tabs">
|
||||
<button class="tab" :class="{ 'is-on': activeTab === 'sub_boq' }" type="button" @click="activeTab = 'sub_boq'">分包清单</button>
|
||||
<button class="tab" :class="{ 'is-on': activeTab === 'sub_measure' }" type="button" @click="activeTab = 'sub_measure'">分包计量</button>
|
||||
<button class="tab" :class="{ 'is-on': activeTab === 'boq' }" type="button" @click="activeTab = 'boq'">分解清单</button>
|
||||
<button class="tab" :class="{ 'is-on': activeTab === 'materials' }" type="button" @click="activeTab = 'materials'">项目主材</button>
|
||||
</div>
|
||||
</header>
|
||||
<button class="iconbtn bottompanel-toggle" type="button" @click="bottomCollapsed = !bottomCollapsed">{{ bottomCollapsed ? "▴" : "▾" }}</button>
|
||||
|
||||
<div class="bottompanel-body" v-show="!bottomCollapsed">
|
||||
<table class="table" v-if="activeTab === 'sub_boq'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>清单编号</th>
|
||||
<th>清单名称</th>
|
||||
<th>单位</th>
|
||||
<th>分包内容</th>
|
||||
<th>分包单位</th>
|
||||
<th>含税单价</th>
|
||||
<th>合同数量</th>
|
||||
<th>合同金额</th>
|
||||
<th>累计产值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in subcontractRows" :key="r.code">
|
||||
<td>{{ r.code }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td>{{ r.unit }}</td>
|
||||
<td>{{ r.content }}</td>
|
||||
<td>{{ r.vendor }}</td>
|
||||
<td>{{ formatYuan(r.unitPrice) }}</td>
|
||||
<td>{{ formatNumber(r.qty, 2) }}</td>
|
||||
<td>{{ formatMoney(r.amount) }}</td>
|
||||
<td>{{ formatMoney(r.cumValue) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="placeholder" v-else>
|
||||
<div class="placeholder-sub">当前合同:{{ selectedContractId }};选中结构:{{ selectedStructureName || "未选择" }}(占位)。</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="modal-mask" v-show="contractModalOpen" @click.self="closeContractModal">
|
||||
<div class="contract-modal">
|
||||
<div class="contract-modal-head">
|
||||
<div class="contract-modal-title">协作合同选择</div>
|
||||
<button class="iconbtn" type="button" @click="closeContractModal">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="contract-filters">
|
||||
<input class="input" placeholder="合同编号" v-model="contractFilterNo" />
|
||||
<input class="input" placeholder="队伍名称" v-model="contractFilterTeam" />
|
||||
<input class="input" placeholder="签订日期(YYYY-MM-DD)" v-model="contractFilterDate" />
|
||||
</div>
|
||||
|
||||
<div class="contract-list">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>合同编号</th>
|
||||
<th>合同名称</th>
|
||||
<th>协作队伍名称</th>
|
||||
<th>合同额</th>
|
||||
<th>合同签订日期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in filteredContracts" :key="c.id" :class="{ 'is-active': c.id === contractDraftId }" @click="contractDraftId = c.id">
|
||||
<td>{{ c.no }}</td>
|
||||
<td>{{ c.name }}</td>
|
||||
<td>{{ c.team }}</td>
|
||||
<td>{{ formatMoney(c.amount) }}</td>
|
||||
<td>{{ c.date }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="contract-actions">
|
||||
<button class="btn btn-ghost" type="button" @click="closeContractModal">取消</button>
|
||||
<button class="btn" type="button" @click="confirmContract">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
|
||||
|
||||
const structures = [
|
||||
{ id: "S-001", name: "主桥-0#墩" },
|
||||
{ id: "S-002", name: "主桥-1#墩" },
|
||||
{ id: "S-003", name: "主桥-2#墩" },
|
||||
{ id: "S-004", name: "引桥-桩基" },
|
||||
{ id: "S-005", name: "引桥-承台" },
|
||||
{ id: "S-006", name: "引桥-盖梁" },
|
||||
{ id: "S-007", name: "路基-填筑" },
|
||||
{ id: "S-008", name: "路面-基层" },
|
||||
{ id: "S-009", name: "路面-面层" },
|
||||
];
|
||||
|
||||
const contracts = [
|
||||
{ id: "sc-001", no: "SC-2025-001", name: "桥梁下部结构劳务分包", team: "一分包队伍", amount: 8600000, date: "2025-03-12" },
|
||||
{ id: "sc-002", no: "SC-2025-002", name: "引桥上部结构劳务分包", team: "二分包队伍", amount: 6250000, date: "2025-04-08" },
|
||||
{ id: "sc-003", no: "SC-2025-003", name: "路基路面专业分包", team: "三分包队伍", amount: 9800000, date: "2025-05-20" },
|
||||
];
|
||||
|
||||
const elementTree = [
|
||||
{
|
||||
id: "E-G-bridge",
|
||||
name: "桥梁工程",
|
||||
children: [
|
||||
{ id: "E-G-main-bridge", name: "主桥", children: ["S-001", "S-002", "S-003"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
|
||||
{ id: "E-G-approach", name: "引桥", children: ["S-004", "S-005", "S-006"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
|
||||
],
|
||||
},
|
||||
{ id: "E-G-road", name: "道路工程", children: ["S-007", "S-008", "S-009"].map((id) => ({ id, name: structures.find((s) => s.id === id)?.name || id })) },
|
||||
];
|
||||
|
||||
const selectedContractId = ref("sc-001");
|
||||
const selectedStructureId = ref("S-001");
|
||||
const sideCollapsed = ref(false);
|
||||
const bottomCollapsed = ref(false);
|
||||
const activeTab = ref("sub_boq");
|
||||
const expanded = ref(new Set(["E-G-bridge", "E-G-main-bridge", "E-G-approach", "E-G-road"]));
|
||||
|
||||
const contractModalOpen = ref(false);
|
||||
const contractDraftId = ref(selectedContractId.value);
|
||||
const contractFilterNo = ref("");
|
||||
const contractFilterTeam = ref("");
|
||||
const contractFilterDate = ref("");
|
||||
|
||||
const selectedStructureName = computed(() => structures.find((s) => s.id === selectedStructureId.value)?.name || "");
|
||||
const selectedVendor = computed(() =>
|
||||
selectedContractId.value === "sc-001" ? "一分包队伍" : selectedContractId.value === "sc-002" ? "二分包队伍" : "三分包队伍"
|
||||
);
|
||||
|
||||
const visibleTreeRows = computed(() => {
|
||||
const rows = [];
|
||||
const walk = (node, level) => {
|
||||
const leaf = !node.children || node.children.length === 0;
|
||||
const open = expanded.value.has(node.id);
|
||||
rows.push({ id: node.id, name: node.name, level, leaf, open });
|
||||
if (!leaf && open) node.children.forEach((c) => walk(c, level + 1));
|
||||
};
|
||||
elementTree.forEach((n) => walk(n, 0));
|
||||
return rows;
|
||||
});
|
||||
|
||||
const filteredContracts = computed(() => {
|
||||
const qNo = contractFilterNo.value.trim();
|
||||
const qTeam = contractFilterTeam.value.trim();
|
||||
const qDate = contractFilterDate.value.trim();
|
||||
return contracts
|
||||
.filter((c) => (qNo ? c.no.includes(qNo) : true))
|
||||
.filter((c) => (qTeam ? c.team.includes(qTeam) : true))
|
||||
.filter((c) => (qDate ? c.date.includes(qDate) : true));
|
||||
});
|
||||
|
||||
const subcontractRows = computed(() => {
|
||||
const structure = selectedStructureName.value;
|
||||
return Array.from({ length: 14 }, (_, i) => {
|
||||
const qty = 20 + i * 3;
|
||||
const unitPrice = 680 + i * 35;
|
||||
const amount = qty * unitPrice;
|
||||
const cumValue = amount * (0.2 + (i % 5) * 0.12);
|
||||
return {
|
||||
code: `SUB-${String(2001 + i)}`,
|
||||
name: `${["钢筋制安", "混凝土浇筑", "模板工程", "支架搭设", "土方外运", "路基填筑"][i % 6]}${structure ? `(${structure})` : ""}`,
|
||||
unit: ["t", "m³", "㎡", "m³", "m³", "m³"][i % 6],
|
||||
content: ["劳务分包", "专业分包", "机械配合", "材料辅材"][i % 4],
|
||||
vendor: selectedVendor.value,
|
||||
unitPrice,
|
||||
qty,
|
||||
amount,
|
||||
cumValue,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
function formatMoney(value) {
|
||||
if (value == null || Number.isNaN(value)) return "--";
|
||||
const abs = Math.abs(value);
|
||||
const sign = value < 0 ? "-" : "";
|
||||
if (abs >= 1e8) return `${sign}${(abs / 1e8).toFixed(2)}亿`;
|
||||
if (abs >= 1e4) return `${sign}${(abs / 1e4).toFixed(2)}万`;
|
||||
return `${sign}${abs.toFixed(2)}元`;
|
||||
}
|
||||
|
||||
function formatYuan(value) {
|
||||
if (value == null || Number.isNaN(value)) return "--";
|
||||
return `${Number(value).toFixed(2)}元`;
|
||||
}
|
||||
|
||||
function formatNumber(value, digits = 0) {
|
||||
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function toggleTreeExpand(row) {
|
||||
if (row.leaf) return;
|
||||
if (expanded.value.has(row.id)) expanded.value.delete(row.id);
|
||||
else expanded.value.add(row.id);
|
||||
}
|
||||
|
||||
function onTreeRowClick(row) {
|
||||
if (row.leaf) {
|
||||
selectedStructureId.value = row.id;
|
||||
return;
|
||||
}
|
||||
toggleTreeExpand(row);
|
||||
}
|
||||
|
||||
function closeContractModal() {
|
||||
contractModalOpen.value = false;
|
||||
}
|
||||
|
||||
function openContractModal() {
|
||||
contractDraftId.value = selectedContractId.value;
|
||||
contractModalOpen.value = true;
|
||||
}
|
||||
|
||||
function confirmContract() {
|
||||
selectedContractId.value = contractDraftId.value;
|
||||
contractModalOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subcontract-page {
|
||||
min-height: 100vh;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(98, 191, 206, 0.34);
|
||||
background: radial-gradient(circle at 50% 30%, #bcc9d6 0%, #b2c3d2 60%, #a8b8c9 100%);
|
||||
box-shadow: inset 0 0 0 8px rgba(214, 230, 241, 0.55);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: 120px;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.topbar-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 16px;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.model-title {
|
||||
font-size: 20px;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 128, 52, 0.95);
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.model-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.sidepanel {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 175px;
|
||||
width: 320px;
|
||||
bottom: 100px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(83, 214, 206, 0.24);
|
||||
background:
|
||||
radial-gradient(260px 140px at 8% 0%, rgba(63, 203, 191, 0.16), transparent 62%) padding-box,
|
||||
linear-gradient(180deg, rgba(20, 31, 37, 0.92), rgba(15, 23, 29, 0.88)) padding-box;
|
||||
box-shadow:
|
||||
0 22px 60px rgba(0, 0, 0, 0.34),
|
||||
0 0 0 1px rgba(83, 214, 206, 0.12) inset;
|
||||
overflow: hidden;
|
||||
z-index: 25;
|
||||
}
|
||||
|
||||
.sidepanel.is-collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.sidepanel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 10px 10px 12px;
|
||||
border-bottom: 1px solid rgba(83, 214, 206, 0.16);
|
||||
background:
|
||||
radial-gradient(360px 100px at 10% 0%, rgba(83, 214, 206, 0.24), transparent 68%),
|
||||
linear-gradient(180deg, rgba(28, 134, 122, 0.84), rgba(15, 60, 74, 0.62));
|
||||
}
|
||||
|
||||
.sidepanel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
color: rgba(207, 247, 242, 0.96);
|
||||
}
|
||||
|
||||
.iconbtn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
color: rgba(11, 27, 58, 0.86);
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidepanel-body {
|
||||
height: calc(100% - 52px);
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.14);
|
||||
border-radius: 14px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.placeholder-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.placeholder-title {
|
||||
color: rgba(222, 238, 244, 0.9);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.placeholder-sub {
|
||||
margin-top: 8px;
|
||||
color: rgba(222, 238, 244, 0.74);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.tree {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 10px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(154, 186, 198, 0.22);
|
||||
background: linear-gradient(180deg, rgba(44, 58, 68, 0.58), rgba(37, 49, 58, 0.54));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tree-item:hover {
|
||||
border-color: rgba(83, 214, 206, 0.32);
|
||||
background: linear-gradient(180deg, rgba(51, 67, 78, 0.66), rgba(43, 57, 67, 0.62));
|
||||
}
|
||||
|
||||
.tree-item.is-active {
|
||||
border-color: rgba(132, 188, 255, 0.3);
|
||||
background: linear-gradient(180deg, rgba(112, 146, 198, 0.34), rgba(95, 128, 180, 0.28));
|
||||
}
|
||||
|
||||
.tree-caret {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(203, 230, 236, 0.88);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tree-caret.is-leaf {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tree-bullet {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: rgba(83, 214, 206, 0.85);
|
||||
}
|
||||
|
||||
.tree-text {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: rgba(222, 238, 244, 0.9);
|
||||
}
|
||||
|
||||
.bottompanel {
|
||||
position: absolute;
|
||||
left: 370px;
|
||||
right: 16px;
|
||||
bottom: 100px;
|
||||
height: 390px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(83, 214, 206, 0.24);
|
||||
background:
|
||||
radial-gradient(420px 180px at 14% 0%, rgba(83, 214, 206, 0.2), transparent 66%) padding-box,
|
||||
radial-gradient(520px 220px at 82% 0%, rgba(40, 156, 228, 0.14), transparent 70%) padding-box,
|
||||
linear-gradient(180deg, rgba(18, 33, 40, 0.94), rgba(14, 24, 31, 0.9)) padding-box;
|
||||
box-shadow:
|
||||
0 24px 70px rgba(0, 0, 0, 0.36),
|
||||
0 0 0 1px rgba(83, 214, 206, 0.14) inset;
|
||||
overflow: hidden;
|
||||
z-index: 26;
|
||||
}
|
||||
|
||||
.bottompanel-header {
|
||||
padding: 12px 52px 12px 12px;
|
||||
border-bottom: 1px solid rgba(83, 214, 206, 0.28);
|
||||
background:
|
||||
radial-gradient(380px 120px at 18% 0%, rgba(83, 214, 206, 0.28), transparent 70%),
|
||||
linear-gradient(180deg, rgba(25, 137, 124, 0.86), rgba(16, 66, 82, 0.64));
|
||||
}
|
||||
|
||||
.bottompanel-toggle {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(214, 235, 241, 0.9);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 900;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab.is-on {
|
||||
border-color: rgba(83, 214, 206, 0.32);
|
||||
background: rgba(83, 214, 206, 0.24);
|
||||
}
|
||||
|
||||
.bottompanel.is-collapsed {
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.bottompanel-body {
|
||||
padding: 0 12px 12px;
|
||||
overflow: auto;
|
||||
height: calc(100% - 66px);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(178, 206, 216, 0.16);
|
||||
}
|
||||
|
||||
.table th {
|
||||
color: rgba(219, 240, 246, 0.86);
|
||||
font-weight: 900;
|
||||
background: linear-gradient(90deg, rgba(12, 38, 52, 0.92), rgba(16, 47, 61, 0.86));
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.table td {
|
||||
color: rgba(222, 238, 244, 0.86);
|
||||
background: linear-gradient(90deg, rgba(37, 51, 61, 0.42), rgba(31, 45, 54, 0.36));
|
||||
}
|
||||
|
||||
.table tbody tr:hover td {
|
||||
background: linear-gradient(90deg, rgba(69, 102, 130, 0.36), rgba(56, 86, 113, 0.32));
|
||||
}
|
||||
|
||||
.table tbody tr.is-active td {
|
||||
background: linear-gradient(90deg, rgba(60, 110, 132, 0.46), rgba(54, 98, 120, 0.42));
|
||||
}
|
||||
|
||||
.modal-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(13, 36, 74, 0.08);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.contract-modal {
|
||||
width: min(640px, calc(100% - 32px));
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(83, 214, 206, 0.24);
|
||||
background:
|
||||
radial-gradient(260px 160px at 18% 0%, rgba(83, 214, 206, 0.14), transparent 62%) padding-box,
|
||||
linear-gradient(180deg, rgba(18, 26, 31, 0.94), rgba(14, 20, 25, 0.86)) padding-box;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.3);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.contract-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.contract-modal-title {
|
||||
font-weight: 900;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.contract-filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.contract-filters .input {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.contract-list {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid rgba(83, 214, 206, 0.2);
|
||||
padding-top: 12px;
|
||||
max-height: 340px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.contract-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1300px) {
|
||||
.sidepanel {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.bottompanel {
|
||||
left: 330px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
34
src/router/index.js
Normal file
34
src/router/index.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
import HomePage from "../pages/home/index.vue";
|
||||
import ProjectPage from "../pages/project/index.vue";
|
||||
import SubcontractPage from "../pages/subcontract/index.vue";
|
||||
import MeasurementPage from "../pages/measurement/index.vue";
|
||||
import PlanPage from "../pages/plan/index.vue";
|
||||
import ProgressPage from "../pages/progress/index.vue";
|
||||
import ChangePage from "../pages/change/index.vue";
|
||||
import MaterialPage from "../pages/material/index.vue";
|
||||
import InspectionPage from "../pages/inspection/index.vue";
|
||||
import DebugPage from "../pages/debug/index.vue";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", redirect: "/home" },
|
||||
{ path: "/home", component: HomePage },
|
||||
{ path: "/project", component: ProjectPage },
|
||||
{ path: "/subcontract", component: SubcontractPage },
|
||||
{ path: "/measurement", component: MeasurementPage },
|
||||
{ path: "/plan", component: PlanPage },
|
||||
{ path: "/progress", component: ProgressPage },
|
||||
{ path: "/change", component: ChangePage },
|
||||
{ path: "/material", component: MaterialPage },
|
||||
{ path: "/inspection", component: InspectionPage },
|
||||
{ path: "/debug", component: DebugPage },
|
||||
{ path: "/:pathMatch(.*)*", redirect: "/home" },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
||||
15
src/stores/app.js
Normal file
15
src/stores/app.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useAppStore = defineStore("app", {
|
||||
state: () => ({
|
||||
devSidebarCollapsed: false,
|
||||
}),
|
||||
actions: {
|
||||
toggleDevSidebar() {
|
||||
this.devSidebarCollapsed = !this.devSidebarCollapsed;
|
||||
},
|
||||
setDevSidebarCollapsed(val) {
|
||||
this.devSidebarCollapsed = !!val;
|
||||
},
|
||||
},
|
||||
});
|
||||
3
src/stores/index.js
Normal file
3
src/stores/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
export const pinia = createPinia();
|
||||
28
src/utils/format.js
Normal file
28
src/utils/format.js
Normal file
@@ -0,0 +1,28 @@
|
||||
export function formatMoney(value) {
|
||||
if (value == null || Number.isNaN(value)) return "--";
|
||||
const abs = Math.abs(value);
|
||||
const sign = value < 0 ? "-" : "";
|
||||
if (abs >= 1e8) return `${sign}${(abs / 1e8).toFixed(2)}亿`;
|
||||
if (abs >= 1e4) return `${sign}${(abs / 1e4).toFixed(2)}万`;
|
||||
return `${sign}${abs.toFixed(2)}元`;
|
||||
}
|
||||
|
||||
export function formatYuan(value) {
|
||||
if (value == null || Number.isNaN(value)) return "--";
|
||||
return `${Number(value).toFixed(2)}元`;
|
||||
}
|
||||
|
||||
export function formatNumber(value, digits = 0) {
|
||||
return Number(value).toLocaleString("zh-CN", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatPercent(value) {
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function clamp(n, min, max) {
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
Reference in New Issue
Block a user