Files
admin/src/pages/progress/index.vue

2012 lines
71 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="progress-page">
<div class="canvas">
<header class="topbar"><div class="topbar-center"><div class="model-title">{{ modelCenterStore.activeModelName }}</div></div></header>
<section class="model-stage">
<ModelPlaceholder ref="modelRef" @componentBindEncoding="onComponentBindEncoding" @codeBindComponent="codeBindComponent" />
</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" :disabled="queryLoading" @click="applyCutoffDate">{{ queryLoading ? "查询中..." : "查询" }}</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" :class="{ 'is-on': percentFilters.all }"><input type="checkbox" :checked="percentFilters.all" @change="togglePercentFilter('all', $event.target.checked)" /> 全部</label>
<label class="chip chip-status chip-gray" :class="{ 'is-on': percentFilters.p0 }"><input type="checkbox" :checked="percentFilters.p0" @change="togglePercentFilter('p0', $event.target.checked)" /> 0%</label>
<label class="chip chip-status chip-red" :class="{ 'is-on': percentFilters.p0_50 }"><input type="checkbox" :checked="percentFilters.p0_50" @change="togglePercentFilter('p0_50', $event.target.checked)" /> 0%-50%</label>
<label class="chip chip-status chip-blue" :class="{ 'is-on': percentFilters.p50_100 }"><input type="checkbox" :checked="percentFilters.p50_100" @change="togglePercentFilter('p50_100', $event.target.checked)" /> 50%-100%</label>
<label class="chip chip-status chip-green" :class="{ 'is-on': percentFilters.p100 }"><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)" @contextmenu.prevent="onTreeRightClick($event, 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 v-if="showContextMenu" class="context-menu" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }" @click.stop>
<div class="context-menu-summary">已绑定 {{ contextBoundCount }}</div>
<button class="context-menu-action" type="button" @click="beginBindingTask('bind')">绑定构件</button>
<button class="context-menu-action" type="button" @click="beginBindingTask('unbind')">解绑构件</button>
</div>
<div v-if="showAddModal" class="add-modal" :style="modalPosition" @pointerdown.stop="onModalPointerDown">
<header class="add-modal-header">
<div class="add-modal-title">绑定编码</div>
<button class="add-modal-close" type="button" @click="showAddModalClose"></button>
</header>
<div class="add-modal-body">
<div class="add-modal-split">
<div class="add-modal-left">
<div class="add-modal-section-title">部位列表</div>
<div class="add-modal-list">
<div class="add-modal-tag" v-for="(id, index) in partList" :key="index">
<span>{{ getStructureName(id.id) }}</span>
<button class="add-modal-tag-delete" type="button" @click="partList.splice(index, 1)"></button>
</div>
</div>
</div>
<div class="add-modal-right">
<div class="add-modal-section-title">构件列表</div>
<div class="add-modal-list">
<div class="add-modal-tag" v-for="(item, index) in componentList" :key="index">
<!-- <input class="add-form-input" v-model="item.code" placeholder="请输入构件编码" />-->
<span>{{ item.code }}</span>
<button class="add-modal-tag-delete" type="button" @click="componentList.splice(index, 1)"></button>
</div>
</div>
</div>
</div>
<section class="construct-tree-panel">
<header class="construct-tree-head">
<div>
<div class="construct-tree-title">构件树</div>
<div class="construct-tree-sub">从模型构件树中选择需要绑定的构件</div>
</div>
<button class="construct-tree-refresh" type="button" @click="refreshConstructTreeData">刷新</button>
</header>
<div class="construct-tree-search">
<input v-model.trim="constructTreeQuery" type="search" placeholder="搜索构件" autocomplete="off" />
<span>已选 {{ componentList.length }} </span>
</div>
<div class="construct-tree-list">
<button
v-for="row in visibleConstructTreeRows"
:key="row.key"
class="construct-tree-row"
:class="{ 'is-group': !row.leaf, 'is-selected': row.selected }"
type="button"
:style="{ paddingLeft: `${12 + row.level * 14}px` }"
@click="onConstructTreeRowClick(row)"
>
<span class="construct-tree-caret" @click.stop="toggleConstructTreeExpand(row)">{{ row.leaf ? (row.selected ? '' : '') : row.open ? '' : '' }}</span>
<span class="construct-tree-name">{{ row.name }}</span>
<span v-if="row.count" class="construct-tree-count">{{ row.count }}</span>
</button>
<div v-if="!visibleConstructTreeRows.length" class="construct-tree-empty">暂无构件树数据请等待模型加载完成后刷新</div>
</div>
</section>
<div class="add-modal-footer">
<button class="add-modal-btn add-modal-btn-secondary" type="button" @click="showAddModalHandle">取消</button>
<button class="add-modal-btn add-modal-btn-primary" type="button" @click="onSaveComponent">保存</button>
</div>
</div>
</div>
<div v-if="showViewModal" class="add-modal" :style="viewModalPosition" @pointerdown.stop="onViewModalPointerDown">
<header class="add-modal-header">
<div class="add-modal-title">查看构件</div>
<button class="add-modal-close" type="button" @click="showViewModal = false"></button>
</header>
<div class="add-modal-body">
<div class="add-modal-split">
<div class="add-modal-left">
<div class="add-modal-section-title">部位列表</div>
<div class="add-modal-list">
<div class="add-modal-tag" v-for="(id, index) in viewPartList" :key="index">
<span>{{ getStructureName(id?.id || id) }}</span>
</div>
</div>
</div>
<div class="add-modal-right">
<div class="add-modal-section-title">构件列表</div>
<div class="add-modal-list">
<div class="add-modal-tag" v-for="(item, index) in viewComponentList" :key="index">
<span>{{ getViewComponentCode(item) }}</span>
<button class="add-modal-tag-delete" type="button" @click="viewComponentList.splice(index, 1)"></button>
</div>
</div>
</div>
</div>
<div class="add-modal-footer">
<button class="add-modal-btn add-modal-btn-secondary" type="button" @click="showViewModal = false">取消</button>
<button class="add-modal-btn add-modal-btn-primary" type="button" @click="onDeleteComponent">保存</button>
</div>
</div>
</div>
</div>
</aside>
<section v-if="bindingTask.active" class="binding-task-page">
<aside class="binding-task-card">
<div class="binding-task-label">{{ bindingTask.mode === 'unbind' ? '解绑构件' : '绑定构件' }}</div>
<div class="binding-task-title">{{ getStructureName(bindingTask.part?.id) }}</div>
<div class="binding-task-count">已选择 {{ componentList.length }} 个模型构件</div>
<div class="binding-task-divider"></div>
<button class="binding-task-tree-toggle" type="button">构件树 <span></span></button>
<div class="binding-task-quick-head">
<span>快速选择模型构件</span>
<span>已选 {{ componentList.length }} </span>
</div>
<input v-model.trim="constructTreeQuery" class="binding-task-search" type="search" placeholder="搜索构件" autocomplete="off" />
<div class="binding-task-tree-list">
<button
v-for="row in visibleConstructTreeRows"
:key="row.key"
class="binding-task-tree-row"
:class="{ 'is-group': !row.leaf, 'is-selected': row.selected }"
type="button"
:style="{ paddingLeft: `${12 + row.level * 14}px` }"
@click="onConstructTreeRowClick(row)"
>
<span class="binding-task-check" @click.stop="toggleConstructTreeExpand(row)">{{ row.leaf ? (row.selected ? '' : '') : row.open ? '' : '' }}</span>
<span>{{ row.name }}</span>
</button>
<div v-if="!visibleConstructTreeRows.length" class="binding-task-empty">暂无构件树数据请等待模型加载完成后刷新</div>
</div>
</aside>
<div v-if="bindingTask.mode === 'unbind' && boundComponentList.length" class="binding-task-tip">已高亮当前工程部位已绑定构件可直接取消选择后确定解绑</div>
<div class="binding-task-actions-float">
<button class="binding-task-cancel" type="button" @click="cancelBindingTask">取消</button>
<button class="binding-task-confirm" type="button" @click="confirmBindingTask">{{ bindingTask.mode === 'unbind' ? '确定解绑' : '确定绑定' }}</button>
</div>
</section>
<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>-->
<th>序号</th><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.workCode }}</td><td>{{ r.name }}</td><td>{{ r.workUnit }}</td><td>{{ formatNumber(r.meteringNum,2) }}</td><td>{{ formatMoney(r.meteringAmt) }}</td><td>{{ formatMoney(r.meteringNotaxAmt) }}</td><td>{{ r.totalNum }}</td><td>{{formatMoney(r.totalAmt) }}</td><td>{{ formatMoney(r.totalNotaxAmt) }}</td><td>{{ proportion(r.meteringAmt,r.totalAmt) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
<div v-if="toastText" class="toast" :class="'toast-' + toastType">{{ toastText }}</div>
</template>
<script setup>
import {computed, nextTick, onMounted, reactive, ref, watch} from "vue";
import { useRouter } from "vue-router";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
import {progressApi} from "../../service/api/progress.js";
import { useModelCenterStore } from "../../stores/modelCenter.js";
const structures = ref([]);
const router = useRouter();
const modelCenterStore = useModelCenterStore();
const modelRef = ref(null);
const elementTree = ref([]);
const progressData = ref([]);
const sideCollapsed = ref(false);
const bottomCollapsed = ref(false);
const selectedStructureId = ref("");
const expanded = ref(new Set());
const loading = ref(false);
const queryLoading = ref(false);
const showContextMenu = ref(false);
const contextMenuX = ref(0);
const contextMenuY = ref(0);
const contextMenuRow = ref(null);
const contextBoundCount = ref(0);
const showAddModal = ref(false);
const componentList = ref([]);
const partList = ref([]);
const boundComponentList = ref([]);
const constructTreeData = ref([]);
const constructTreeQuery = ref("");
const constructTreeExpanded = ref(new Set());
const bindingTask = reactive({ active: false, mode: "bind", part: null });
const showViewModal = ref(false);
const viewPartList = ref([]);
const viewComponentList = ref([]);
const toastText = ref("");
const toastType = ref("success");
const normalLight = ref([]);
const taskId = ref("");
const modalPosition = ref({ left: "50%", top: "50%", transform: "translate(-50%, -50%)" });
let modalDragging = false;
let modalStartX = 0, modalStartY = 0;
let modalStartLeft = 0, modalStartTop = 0;
function getActiveBackendModelId() {
const id = String(modelCenterStore.activeId || "").trim();
return /^\d+$/.test(id) ? id : undefined;
}
function onModalPointerDown(e) {
if (e.target.closest(".add-modal-close, button, input, .construct-tree-panel, .add-modal-list") || e.button !== 0) return;
modalDragging = true;
modalStartX = e.clientX;
modalStartY = e.clientY;
const pos = modalPosition.value;
if (pos.transform) {
modalStartLeft = 50;
modalStartTop = 50;
}
window.addEventListener("pointermove", onModalPointerMove);
window.addEventListener("pointerup", onModalPointerUp);
}
function onModalPointerMove(e) {
if (!modalDragging) return;
const dx = e.clientX - modalStartX;
const dy = e.clientY - modalStartY;
modalPosition.value = {
left: `calc(50% + ${dx}px)`,
top: `calc(50% + ${dy}px)`,
transform: "translate(-50%, -50%)"
};
}
function onModalPointerUp() {
modalDragging = false;
window.removeEventListener("pointermove", onModalPointerMove);
window.removeEventListener("pointerup", onModalPointerUp);
}
const viewModalPosition = ref({ left: "50%", top: "50%", transform: "translate(-50%, -50%)" });
let viewModalDragging = false;
let viewModalStartX = 0, viewModalStartY = 0;
function onViewModalPointerDown(e) {
if (e.target.closest(".add-modal-close") || e.button !== 0) return;
viewModalDragging = true;
viewModalStartX = e.clientX;
viewModalStartY = e.clientY;
window.addEventListener("pointermove", onViewModalPointerMove);
window.addEventListener("pointerup", onViewModalPointerUp);
}
function onViewModalPointerMove(e) {
if (!viewModalDragging) return;
const dx = e.clientX - viewModalStartX;
const dy = e.clientY - viewModalStartY;
viewModalPosition.value = {
left: `calc(50% + ${dx}px)`,
top: `calc(50% + ${dy}px)`,
transform: "translate(-50%, -50%)"
};
}
function onViewModalPointerUp() {
viewModalDragging = false;
window.removeEventListener("pointermove", onViewModalPointerMove);
window.removeEventListener("pointerup", onViewModalPointerUp);
}
const timeStatMode = ref("cutoff");
const baselineDate = ref(todayISODate());
const baselineOverallPercent = ref(0);
const cutoffDate = ref(todayISODate());
const cutoffDateDraft = ref(cutoffDate.value);
const selectedPeriod = computed(() => cutoffDateDraft.value || cutoffDate.value || todayISODate());
const overallProgressItems = ref([]);
const percentFilters = reactive({ all: false, p0: false, p0_50: false, p50_100: false, p100: false });
const filteredProgressCodeData = ref([]);
const filteredProgressColorParams = ref([]);
const codeWbsMappings = ref([]);
const percentColorMap = {
p0: "#B0B8C4",
p0_50: "#D9363E",
p50_100: "#156CFF",
p100: "#2F8F2F",
};
const visibleTreeRows = computed(() => {
const rows = [];
const walk = (node, level) => {
const leaf = normalizeIsLeaf(node);
const open = expanded.value.has(node.id);
rows.push({ id: node.id, name: node.name, level, leaf, isLeaf: leaf, is_leaf: leaf, open, createDate: selectedPeriod.value, projectId: node.projectId });
if (!leaf && open && node.children) {
node.children.forEach((c) => walk(c, level + 1));
}
};
elementTree.value.forEach((n) => walk(n, 0));
return rows;
});
const selectedStructureName = computed(() => {
const findById = (nodes) => {
for (const n of nodes) {
if (n.id === selectedStructureId.value) return n.name;
if (n.children) {
const found = findById(n.children);
if (found) return found;
}
}
};
return findById(elementTree.value) || "";
});
const selectedStructureNode = computed(() => {
return findNode(elementTree.value, selectedStructureId.value);
});
const overallPercent = computed(() => {
return calculateOverallProgressPercent(overallProgressItems.value);
});
const progressRows = computed(() => {
if (!selectedStructureId.value && progressData.value.length === 0) return [];
return progressData.value.map((item, index) => ({
no: index + 1,
fullName: item.fullName || "",
workCode: item.workCode || "",
name: item.name || "",
workUnit: item.workUnit || "-",
meteringAmt: item.meteringAmt || 0,
meteringNotaxAmt: item.meteringNotaxAmt || 0,
meteringNum: item.meteringNum || 0,
totalNum: item.totalNum || '',
totalAmt: item.totalAmt ?? item.actAmt ?? 0,
totalNotaxAmt: item.totalNotaxAmt ?? item.actNotaxAmt ?? 0,
thisNum: item.thisNum || 0,
thisAmt: item.thisAmt || 0,
}));
});
const visibleConstructTreeRows = computed(() => {
const rows = [];
const query = constructTreeQuery.value.trim().toLowerCase();
const selectedCodes = new Set(componentList.value.map((item) => String(item?.code || "")));
const matches = (node) => {
if (!query) return true;
if (String(node?.name || "").toLowerCase().includes(query)) return true;
return getConstructChildren(node).some((child) => matches(child));
};
const walk = (node, level, parentKey, inheritedUrl) => {
if (!matches(node)) return;
const key = `${parentKey}/${String(node?.name || "node")}-${String(node?.id || node?.url || rows.length)}`;
const children = getConstructChildren(node);
const ids = getConstructNodeIdsDeep(node);
const leaf = !children.length;
const url = String(node?.url || inheritedUrl || "").trim();
const open = query ? true : constructTreeExpanded.value.has(key);
const selected = ids.length > 0 && ids.every((id) => selectedCodes.has(String(id)));
rows.push({ key, name: String(node?.name || node?.id || "未命名构件"), level, leaf, open, selected, ids, url, count: leaf ? ids.length : children.length });
if (!leaf && open) children.forEach((child) => walk(child, level + 1, key, url));
};
constructTreeData.value.forEach((node, index) => walk(node, 0, `root-${index}`, node?.url));
return rows;
});
async function loadWbsTree(parentId) {
loading.value = true;
try {
const data = await progressApi.getTree(parentId);
if (!parentId) {
elementTree.value = data.map((item) => ({
...item,
children: normalizeIsLeaf(item) ? [] : [],
isLeaf: normalizeIsLeaf(item),
is_leaf: normalizeIsLeaf(item),
}));
if (data.length > 0) {
const firstLeaf = findFirstLeaf(elementTree.value);
if (firstLeaf) selectedStructureId.value = firstLeaf.id;
}
} else {
updateChildren(elementTree.value, parentId, data);
}
} catch (e) {
console.error("加载WBS树失败:", e);
} finally {
loading.value = false;
}
}
function findFirstLeaf(nodes) {
for (const n of nodes) {
if (n.isLeaf) return n;
if (n.children && n.children.length > 0) {
const found = findFirstLeaf(n.children);
if (found) return found;
}
}
}
function updateChildren(nodes, parentId, children) {
for (const n of nodes) {
if (n.id === parentId) {
n.children = children.map((item) => ({
...item,
children: normalizeIsLeaf(item) ? [] : [],
isLeaf: normalizeIsLeaf(item),
is_leaf: normalizeIsLeaf(item),
}));
return true;
}
if (n.children && updateChildren(n.children, parentId, children)) return true;
}
return false;
}
function findNode(nodes, id) {
for (const n of nodes) {
if (n.id === id) return n;
if (n.children) {
const found = findNode(n.children, id);
if (found) return found;
}
}
return null;
}
function getConstructChildren(node) {
return Array.isArray(node?.children) ? node.children : [];
}
function getConstructNodeIds(node) {
const ids = [];
if (Array.isArray(node?.ids)) ids.push(...node.ids);
if (node?.id != null) ids.push(node.id);
return Array.from(new Set(ids.map((id) => String(id)).filter(Boolean)));
}
function getConstructNodeIdsDeep(node) {
const ids = new Set(getConstructNodeIds(node));
getConstructChildren(node).forEach((child) => getConstructNodeIdsDeep(child).forEach((id) => ids.add(id)));
return Array.from(ids);
}
function normalizeConstructTreeData(raw) {
return Array.isArray(raw?.level) ? raw.level : [];
}
function refreshConstructTreeData() {
const raw = modelRef.value?.getConstructTreeData?.();
constructTreeData.value = normalizeConstructTreeData(raw);
if (constructTreeData.value.length > 0 && constructTreeExpanded.value.size === 0) {
constructTreeData.value.forEach((node, index) => expandConstructTreeFirstLevel(node, `root-${index}`, index));
}
}
function expandConstructTreeFirstLevel(node, parentKey, index) {
const key = `${parentKey}/${String(node?.name || "node")}-${String(node?.id || node?.url || index)}`;
constructTreeExpanded.value.add(key);
}
function removeComponentFromBindingList(code) {
const target = String(code || "");
componentList.value = componentList.value.filter((item) => String(item?.code || "") !== target);
}
function onConstructTreeRowClick(row) {
if (!row.ids.length) {
toggleConstructTreeExpand(row);
return;
}
const selectedCodes = new Set(componentList.value.map((item) => String(item?.code || "")));
const allSelected = row.ids.length > 0 && row.ids.every((id) => selectedCodes.has(String(id)));
if (allSelected) {
row.ids.forEach((id) => removeComponentFromBindingList(id));
} else {
row.ids.forEach((id) => addComponentToBindingList({ id, url: row.url, name: row.name }));
highlightConstructTreeRow(row);
}
}
function toggleConstructTreeExpand(row) {
if (!row || row.leaf) return;
const next = new Set(constructTreeExpanded.value);
if (next.has(row.key)) next.delete(row.key);
else next.add(row.key);
constructTreeExpanded.value = next;
}
function highlightConstructTreeRow(row) {
if (!row?.url || !row?.ids?.length) return;
const ids = row.ids.map((id) => Number(id)).filter((id) => Number.isFinite(id));
if (!ids.length) return;
modelRef.value?.highlightModels?.([{ url: row.url, ids }]);
}
function normalizeIsLeaf(node) {
const isTruthyLeaf = (value) => value === true || value === 1 || value === "1" || value === "true";
return isTruthyLeaf(node?.isLeaf) || isTruthyLeaf(node?.is_leaf);
}
onMounted(async() => {
await modelCenterStore.loadModels().catch((error) => console.error("加载模型列表失败:", error));
await loadWbsTree();
window.addEventListener("click", closeContextMenu);
const result = await progressApi.calculateProgress(selectedPeriod.value, getActiveBackendModelId());
console.log(888888,result)
await fetchCompletedProgressResult().catch((error) => console.error("获取整体进度失败:", error));
// taskId.value = result.taskId
});
watch(
() => modelCenterStore.activeId,
async () => {
modelRef.value?.cancelLightModels?.();
filteredProgressCodeData.value = [];
filteredProgressColorParams.value = [];
await progressApi.calculateProgress(selectedPeriod.value, getActiveBackendModelId());
await fetchCompletedProgressResult().catch((error) => console.error("刷新整体进度失败:", error));
if (hasActivePercentFilter()) {
await refreshFilteredProgressCodeData();
}
}
);
function todayISODate() {
return new Date().toISOString().slice(0, 10);
}
function getStructureName(id) {
const findName = (nodes) => {
for (const n of nodes) {
if (n.id === id) return n.name;
if (n.children) {
const found = findName(n.children);
if (found) return found;
}
}
};
return findName(elementTree.value) || id;
}
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 calculateOverallProgressPercent(items) {
if (!Array.isArray(items) || items.length === 0) return 0;
let numeratorSum = 0;
let denominatorSum = 0;
items.forEach((item) => {
numeratorSum += Number(item?.progressNumerator || 0);
denominatorSum += Number(item?.progressDenominator || 0);
});
if (denominatorSum <= 0) return 0;
return (numeratorSum / denominatorSum) * 100;
}
function updateOverallProgressItems(response) {
overallProgressItems.value = extractProgressItems(response);
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchCompletedProgressResult({ maxAttempts = 1, interval = 800 } = {}) {
let response = null;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
response = await progressApi.getProgress(selectedPeriod.value, getActiveBackendModelId());
if (response?.status === "COMPLETED") {
updateOverallProgressItems(response);
return response;
}
if (response?.status === "FAILED") {
throw new Error(response?.error || "进度统计失败");
}
if (attempt < maxAttempts - 1) await wait(interval);
}
updateOverallProgressItems([]);
return response;
}
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 extractProgressItems(response) {
const candidates = [
response,
response?.data,
response?.current,
response?.result,
response?.data?.data,
response?.data?.current,
response?.data?.result,
response?.current?.data,
response?.result?.data,
response?.data?.current?.data,
response?.data?.result?.data,
];
for (const candidate of candidates) {
if (Array.isArray(candidate)) return candidate;
}
for (const candidate of candidates) {
if (!candidate || typeof candidate !== "object") continue;
const nested = Object.values(candidate).find((value) => Array.isArray(value));
if (nested) return nested;
}
return [];
}
function resolveProgressRatio(item) {
return normalizeProgressRatio(
item?.progressData ??
item?.progress ??
item?.percent ??
item?.percentage ??
item?.completionRate ??
item?.completePercent ??
item?.rate
);
}
function normalizeProgressRatio(value) {
const n = Number(value);
if (!Number.isFinite(n)) return 0;
if (n > 1) return clamp(n / 100, 0, 1);
return clamp(n, 0, 1);
}
function isProgressMatched(ratio, key) {
const EPS = 1e-8;
if (key === "p0") return Math.abs(ratio) <= EPS;
if (key === "p0_50") return ratio > EPS && ratio <= 0.5 + EPS;
if (key === "p50_100") return ratio > 0.5 + EPS && ratio < 1 - EPS;
if (key === "p100") return Math.abs(ratio - 1) <= EPS;
return false;
}
function buildModelCodeData(list) {
const groupedByUrl = new Map();
for (const item of list) {
const codeItems = Array.isArray(item?.codeList) && item.codeList.length > 0 ? item.codeList : [item];
for (const codeItem of codeItems) {
const params = getParams(codeItem?.codeData ?? codeItem?.data ?? codeItem?.code ?? item?.codeData ?? item?.data ?? item?.code);
const url = String(params.url || "").trim();
const rawId = String(params.id || "").trim();
if (!url || !rawId) continue;
const id = Number(rawId);
const idValue = Number.isFinite(id) ? id : rawId;
if (!groupedByUrl.has(url)) groupedByUrl.set(url, new Set());
groupedByUrl.get(url).add(idValue);
}
}
return Array.from(groupedByUrl.entries()).map(([url, idSet]) => ({
url,
ids: Array.from(idSet),
}));
}
async function loadCodeWbsMappings() {
if (codeWbsMappings.value.length > 0) return codeWbsMappings.value;
try {
const data = await progressApi.getCodeWbsMappings();
codeWbsMappings.value = Array.isArray(data) ? data : [];
} catch (error) {
console.error("获取构件WBS映射失败:", error);
codeWbsMappings.value = [];
}
return codeWbsMappings.value;
}
function collectIdsFromValue(value) {
if (value == null || value === "") return [];
if (Array.isArray(value)) return value.flatMap((item) => collectIdsFromValue(item));
return String(value).split(/[\s,;]+/).map((item) => item.trim()).filter(Boolean);
}
function collectWbsIds(item) {
return new Set([
...collectIdsFromValue(item?.positionId),
...collectIdsFromValue(item?.positionIds),
...collectIdsFromValue(item?.partId),
...collectIdsFromValue(item?.partIds),
...collectIdsFromValue(item?.wbsId),
...collectIdsFromValue(item?.wbsIds),
...collectIdsFromValue(item?.wbsCode),
...collectIdsFromValue(item?.wbsCodes),
...collectIdsFromValue(item?.projectWbsId),
...collectIdsFromValue(item?.projectWbsIds),
]);
}
function collectCodeIds(item) {
const params = getParams(item?.codeData ?? item?.data ?? item?.code);
return new Set([
...collectIdsFromValue(params?.id),
...collectIdsFromValue(item?.codeId),
...collectIdsFromValue(item?.codeIds),
...collectIdsFromValue(item?.code),
...collectIdsFromValue(item?.id),
]);
}
function isSameId(a, b) {
return String(a) === String(b);
}
function expandMatchedProgressItems(matchedItems, mappings) {
if (!Array.isArray(mappings) || mappings.length === 0) return matchedItems;
const expanded = [...matchedItems];
const seen = new Set(expanded.map((item) => String(item?.codeData ?? item?.data ?? item?.code ?? "")).filter(Boolean));
matchedItems.forEach((item) => {
const wbsIds = collectWbsIds(item);
const codeIds = collectCodeIds(item);
if (wbsIds.size === 0 && codeIds.size > 0) {
mappings.forEach((mapping) => {
const mappingCodeIds = collectCodeIds(mapping);
if ([...codeIds].some((id) => [...mappingCodeIds].some((mappingId) => isSameId(id, mappingId)))) {
collectWbsIds(mapping).forEach((id) => wbsIds.add(id));
}
});
}
if (wbsIds.size === 0) return;
mappings.forEach((mapping) => {
const mappingWbsIds = collectWbsIds(mapping);
const isSameWbs = [...wbsIds].some((id) => [...mappingWbsIds].some((mappingId) => isSameId(id, mappingId)));
if (!isSameWbs) return;
const key = String(mapping?.codeData ?? mapping?.data ?? mapping?.code ?? mapping?.codeId ?? "");
if (key && seen.has(key)) return;
if (key) seen.add(key);
expanded.push(mapping);
});
});
return expanded;
}
async function refreshFilteredProgressCodeData() {
// if (!taskId.value) {
// filteredProgressCodeData.value = [];
// filteredProgressColorParams.value = [];
// modelRef.value?.cancelLightModels();
// return;
// }
try {
// const response = await progressApi.getProgress(taskId.value);
const response = await progressApi.getProgress(selectedPeriod.value, getActiveBackendModelId());
const items = extractProgressItems(response);
updateOverallProgressItems(response);
const needsMappingFallback = items.some((item) => !Array.isArray(item?.codeList) || item.codeList.length === 0);
const mappings = needsMappingFallback ? await loadCodeWbsMappings() : [];
const enabledKeys = ["p0", "p0_50", "p50_100", "p100"].filter((k) => percentFilters[k]);
const activeKeys = percentFilters.all ? ["p0", "p0_50", "p50_100", "p100"] : enabledKeys;
if (activeKeys.length === 0) {
filteredProgressCodeData.value = [];
filteredProgressColorParams.value = [];
modelRef.value?.cancelLightModels();
return;
}
const groupedParams = [];
const allMatchedCodeData = [];
activeKeys.forEach((key) => {
const matched = items.filter((item) => isProgressMatched(resolveProgressRatio(item), key));
if (!matched.length) return;
const expandedMatched = needsMappingFallback ? expandMatchedProgressItems(matched, mappings) : matched;
allMatchedCodeData.push(...expandedMatched.map((item) => item.codeData).filter(Boolean));
const modelCodeData = buildModelCodeData(expandedMatched);
if (!modelCodeData.length) return;
groupedParams.push({
range: key,
codeData: modelCodeData,
color: percentColorMap[key],
});
});
filteredProgressCodeData.value = Array.from(new Set(allMatchedCodeData));
filteredProgressColorParams.value = groupedParams;
if (groupedParams.length > 0) {
modelRef.value?.otherLightModels({ codeData: groupedParams, color: "" });
} else {
modelRef.value?.cancelLightModels();
}
} catch (e) {
console.error("按百分比筛选构件失败:", e);
filteredProgressCodeData.value = [];
filteredProgressColorParams.value = [];
modelRef.value?.cancelLightModels();
}
}
async function applyCutoffDate() {
if (queryLoading.value) return;
queryLoading.value = true;
cutoffDate.value = cutoffDateDraft.value || cutoffDate.value || todayISODate();
overallProgressItems.value = [];
try {
const startResult = await progressApi.calculateProgress(selectedPeriod.value, getActiveBackendModelId());
const result = await fetchCompletedProgressResult({ maxAttempts: startResult?.status === "COMPLETED" ? 1 : 10, interval: 800 });
if (result?.status === "COMPLETED") {
if (hasActivePercentFilter()) {
await refreshFilteredProgressCodeData();
}
showToast(`查询成功,已更新 ${selectedPeriod.value} 的进度统计`, "success");
} else {
modelRef.value?.cancelLightModels?.();
showToast("进度正在统计中,请稍后再次查询", "success");
}
} catch (error) {
console.error("查询进度统计失败:", error);
overallProgressItems.value = [];
modelRef.value?.cancelLightModels?.();
showToast(error?.message || "查询失败,请稍后重试", "error");
} finally {
queryLoading.value = false;
}
}
// 计算比例count / allCount并显示百分比
function proportion(allCount, count) {
if (count==="" || allCount==="")
return ""
// 防止除以 0 报错
if (!allCount || allCount === 0) return `0%`
// 计算百分比保留2位小数
let percent = ((count / allCount) * 100).toFixed(2)
// 返回格式5 / 10 (50.00%)
// return `${count} / ${allCount} (${percent}%)`
return `${percent}%`
}
async function applyAllExclusive(key, checked) {
const isUncheckAction = checked === false;
if (key === "all") {
percentFilters.all = checked;
if (checked) {
percentFilters.p0 = true;
percentFilters.p0_50 = true;
percentFilters.p50_100 = true;
percentFilters.p100 = true;
} else {
percentFilters.p0 = false;
percentFilters.p0_50 = false;
percentFilters.p50_100 = false;
percentFilters.p100 = false;
}
if (isUncheckAction) {
modelRef.value?.cancelLightModels();
}
await refreshFilteredProgressCodeData();
return;
}
percentFilters[key] = checked;
if (checked) {
percentFilters.all = false;
} else {
// 取消任一区间时,"全部"必须退出选中态
percentFilters.all = false;
modelRef.value?.cancelLightModels();
}
if (!percentFilters.p0 && !percentFilters.p0_50 && !percentFilters.p50_100 && !percentFilters.p100) percentFilters.all = false;
await refreshFilteredProgressCodeData();
}
function togglePercentFilter(key, checked) { applyAllExclusive(key, checked).catch((e) => console.error("切换百分比筛选失败:", e)); }
function hasActivePercentFilter() {
return percentFilters.all || percentFilters.p0 || percentFilters.p0_50 || percentFilters.p50_100 || percentFilters.p100;
}
function toggleTreeExpand(row) {
if (row.leaf) return;
if (expanded.value.has(row.id)) {
expanded.value.delete(row.id);
} else {
expanded.value.add(row.id);
const node = findNode(elementTree.value, row.id);
if (node && (!node.children || node.children.length === 0)) {
loadWbsTree(row.id);
}
}
}
async function onTreeRowClick(row) {
if (showAddModal.value) {
addPartToBindingList(row);
}
modelRef.value?.cancelLightModels();
normalLight.value = [];
const data = await progressApi.getByPartId(row.id, getActiveBackendModelId());
if (showViewModal.value) {
applyViewModalData(row, data);
}
if (data.length > 0) {
for (const item of data) {
const codeData = getParams(item.codeData);
const url = codeData.url;
const id = Number(codeData.id);
// 1. 去找数组里有没有相同的 url
let existItem = normalLight.value.find((i) => i.url === url);
if (existItem) {
// 2. 找到了 → 把id加进去
existItem.ids.push(id);
} else {
// 3. 没找到 → 新增
normalLight.value.push({ url, ids: [id] });
}
}
modelRef.value?.highlightModels(normalLight.value);
}
if (row.leaf) {
selectedStructureId.value = row.id;
await loadProgressData(row.id, selectedPeriod.value);
} else {
toggleTreeExpand(row);
progressData.value = []
progressData.value = await progressApi.findActAmtByPositionId(row.projectId, row.id, selectedPeriod.value);
}
}
//获取"{url=https://lyz-1259524260.cos.ap-guangzhou.myqcloud.com/iflow/models/417664a3-76c8-4d94-9344-1337246a5d4e, id=353503}" return obj =>{url,id}
function getParams(str) {
if (str && typeof str === "object") return str;
let obj = {};
const text = String(str || "").trim();
if (!text) return obj;
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === "object") return parsed;
} catch (e) {
// codeData may be stored as "{url=..., id=...}" instead of JSON.
}
text.replace(/{([\s\S]+)}/, '$1').split(/\s*,\s*/).forEach(i => {
const index = i.indexOf('=');
if (index < 0) return;
let k = i.slice(0, index);
let v = i.slice(index + 1);
obj[k] = v;
});
return obj;
}
async function onTreeRightClick(e, row) {
// if (!row.leaf) return;
contextMenuX.value = e.clientX;
contextMenuY.value = e.clientY;
contextMenuRow.value = row;
contextBoundCount.value = 0;
showContextMenu.value = true;
try {
const data = await progressApi.getByPartId(row.id, getActiveBackendModelId());
contextBoundCount.value = Array.isArray(data) ? data.length : 0;
} catch (error) {
console.error("获取绑定数量失败:", error);
}
}
function addPartToBindingList(row) {
if (!row?.id) return;
const exists = partList.value.some((item) => item.id === row.id);
if (exists) return;
const isLeaf = normalizeIsLeaf(row) || row.leaf === true;
partList.value.push({ id: row.id, createDate: selectedPeriod.value, is_leaf: isLeaf, isLeaf });
}
async function onAddComponent() {
// Start a new binding session from current right-click row.
partList.value = [];
addPartToBindingList(contextMenuRow.value);
componentList.value = [];
constructTreeQuery.value = "";
showAddModal.value = true;
showContextMenu.value = false;
await nextTick();
refreshConstructTreeData();
}
async function beginBindingTask(mode) {
const row = contextMenuRow.value;
if (!row?.id) return;
showContextMenu.value = false;
router.push({
path: "/progress/binding",
query: {
mode: mode === "unbind" ? "unbind" : "bind",
partId: row.id,
partName: row.name,
isLeaf: String(row.leaf === true || normalizeIsLeaf(row)),
createDate: selectedPeriod.value,
modelId: getActiveBackendModelId(),
},
});
return;
bindingTask.active = true;
bindingTask.mode = mode === "unbind" ? "unbind" : "bind";
bindingTask.part = row;
showContextMenu.value = false;
showAddModal.value = false;
showViewModal.value = false;
partList.value = [];
componentList.value = [];
boundComponentList.value = [];
constructTreeQuery.value = "";
addPartToBindingList(row);
await nextTick();
refreshConstructTreeData();
try {
const data = await progressApi.getByPartId(row.id, getActiveBackendModelId());
boundComponentList.value = (data || []).map((item) => toBindingCodeItem(item)).filter(Boolean);
componentList.value = boundComponentList.value.map((item) => ({ ...item }));
if (boundComponentList.value.length) {
highlightBoundComponents();
}
} catch (error) {
console.error("获取已绑定构件失败:", error);
showToast("获取已绑定构件失败", "error");
}
}
function cancelBindingTask() {
bindingTask.active = false;
bindingTask.part = null;
componentList.value = [];
boundComponentList.value = [];
partList.value = [];
constructTreeQuery.value = "";
modelRef.value?.cancelLightModels?.();
}
async function confirmBindingTask() {
if (!bindingTask.active || !bindingTask.part?.id) return;
if (!componentList.value.length && bindingTask.mode === "bind") {
showToast("请先选择需要绑定的模型构件", "error");
return;
}
try {
const selectedCodes = new Set(componentList.value.map((item) => String(item?.code || "")));
const codeIds = bindingTask.mode === "unbind"
? boundComponentList.value.filter((item) => !selectedCodes.has(String(item?.code || "")))
: componentList.value;
if (bindingTask.mode === "unbind" && !codeIds.length) {
showToast("请取消选择需要解绑的构件", "error");
return;
}
const payload = { partIds: partList.value, codeIds, modelId: getActiveBackendModelId() };
const res = bindingTask.mode === "unbind"
? await progressApi.deleteBatch(payload)
: await progressApi.saveBatch(payload);
if (res?.code === 200) {
showToast(bindingTask.mode === "unbind" ? "解绑成功!" : "绑定成功!", "success");
cancelBindingTask();
} else {
showToast(res?.message || "操作失败!", "error");
}
} catch (error) {
console.error("绑定任务保存失败:", error);
showToast("操作失败!", "error");
}
}
function highlightBoundComponents() {
const grouped = new Map();
boundComponentList.value.forEach((item) => {
const url = String(item?.data?.url || "").trim();
const id = Number(item?.code);
if (!url || !Number.isFinite(id)) return;
if (!grouped.has(url)) grouped.set(url, []);
grouped.get(url).push(id);
});
const refs = Array.from(grouped.entries()).map(([url, ids]) => ({ url, ids }));
if (refs.length) modelRef.value?.highlightModels?.(refs);
}
async function onViewComponent() {
const partId = contextMenuRow.value?.id;
if (!partId) return;
showContextMenu.value = false;
try {
const data = await progressApi.getByPartId(partId, getActiveBackendModelId());
applyViewModalData(contextMenuRow.value, data);
} catch (e) {
console.error("获取构件列表失败:", e);
showToast("获取失败", "error");
}
}
function applyViewModalData(row, data) {
const partId = row?.id;
if (!partId) return;
viewPartList.value = [{ id: partId, createDate: selectedPeriod.value }];
const mapped = (data || [])
.map((item) => toBindingCodeItem(item))
.filter(Boolean);
const seen = new Set();
viewComponentList.value = mapped.filter((item) => {
if (seen.has(item.code)) return false;
seen.add(item.code);
return true;
});
showViewModal.value = true;
}
function resolveComponentCode(item) {
const directId = item?.codeId ?? item?.code ?? item?.id;
if (directId != null && String(directId).trim()) return String(directId).trim();
const rawCodeData = String(item?.codeData || "").trim();
if (rawCodeData) {
try {
const parsed = getParams(rawCodeData);
const parsedId = String(parsed?.id || "").trim();
if (parsedId) return parsedId;
} catch (e) {
// ignore malformed codeData records
}
}
return "";
}
function resolveComponentCodeDataPayload(item, code) {
const rawCodeData = String(item?.codeData || "").trim();
if (rawCodeData) {
try {
return getParams(rawCodeData);
} catch (e) {
// fallback below
}
}
const rawData = item?.data;
if (typeof rawData === "string" && rawData.trim()) {
try {
return getParams(rawData.trim());
} catch (e) {
return { value: rawData.trim(), id: code };
}
}
if (rawData && typeof rawData === "object") {
const nestedCodeData = String(rawData?.codeData || "").trim();
if (nestedCodeData) {
try {
return getParams(nestedCodeData);
} catch (e) {
// fallback below
}
}
const nestedUrl = String(rawData?.url || "").trim();
const nestedId = String(rawData?.id || code || "").trim();
if (nestedUrl && nestedId) return { url: nestedUrl, id: nestedId };
}
const url = String(item?.url || "").trim();
if (url && code) return { url, id: code };
return rawData && typeof rawData === "object" ? rawData : { id: code };
}
function toBindingCodeItem(item) {
const code = resolveComponentCode(item);
if (!code) return null;
const data = resolveComponentCodeDataPayload(item, code);
if (data && typeof data === "object" && !Array.isArray(data) && !data.id) {
data.id = code;
}
return { code, data };
}
function getViewComponentCode(item) {
const code = resolveComponentCode(item);
if (code) return code;
return "--";
}
async function onDeleteComponent() {
const partIds = viewPartList.value;
const codeIds = viewComponentList.value;
if (!partIds.length || !codeIds.length) return;
try {
const res = await progressApi.deleteBatch({ partIds, codeIds, modelId: getActiveBackendModelId() });
if (res?.code === 200) {
showToast("保存成功!", "success");
showViewModal.value = false;
} else {
showToast(res?.message || "保存失败!", "error");
}
} catch (e) {
console.error("保存失败:", e);
showToast("保存失败!", "error");
}
}
function closeContextMenu() {
showContextMenu.value = false;
}
function addComponentToBindingList(component) {
const code = String(component?.id || "").trim();
if (!code) return;
const exists = componentList.value.some((item) => String(item?.code || "") === code);
if (exists) return;
componentList.value.push({ code, data: component });
}
function addComponentToViewList(component) {
const mapped = toBindingCodeItem(component);
if (!mapped) return;
const exists = viewComponentList.value.some((item) => String(item?.code || "") === mapped.code);
if (exists) return;
viewComponentList.value.push(mapped);
}
function onComponentBindEncoding(data) {
if (showAddModal.value) {
showAddModal.value = false;
return;
}
componentList.value = [];
partList.value = [];
constructTreeQuery.value = "";
showAddModal.value = true;
for (let component of data.components) {
addComponentToBindingList(component);
}
nextTick().then(() => refreshConstructTreeData());
}
async function codeBindComponent(data) {
const inBindMode = showAddModal.value;
const inViewMode = showViewModal.value;
if (!data?.components?.length) return;
progressData.value = []
const newData = [];
for (const comp of data.components) {
const res = await progressApi.getByCodeId(comp.id);
for (const item of res) {
if (item.pjDayData) {
if (Array.isArray(item.pjDayData)) {
newData.push(...item.pjDayData);
} else {
newData.push(item.pjDayData);
}
}
}
if (inBindMode) {
addComponentToBindingList(comp);
}
if (inViewMode) {
addComponentToViewList(comp);
}
}
progressData.value = [...progressData.value, ...newData];
}
async function onSaveComponent() {
const partIds = partList.value;
const codeIds = componentList.value;
// const codeIds = componentList.value.filter((c) => c.code).map((c) => c.code);
if (!partIds.length || !codeIds.length) return;
try {
const res = await progressApi.saveBatch({ partIds, codeIds, modelId: getActiveBackendModelId() });
if (res.code === 200) {
showToast("保存成功!", "success");
showAddModal.value = false;
} else {
showToast(res.message || "保存失败!", "error");
}
} catch (e) {
console.error("保存失败:", e);
showToast("保存失败!", "error");
}
}
function showToast(text, type = "success") {
toastText.value = text;
toastType.value = type;
setTimeout(() => { toastText.value = ""; }, 2000);
}
async function showAddModalHandle() {
partList.value = [];
componentList.value = [];
constructTreeQuery.value = "";
showAddModal.value = false;
}
async function showAddModalClose() {
partList.value = [];
componentList.value = [];
constructTreeQuery.value = "";
showAddModal.value = false;
}
async function loadProgressData(positionId,time) {
loading.value = true;
try {
const data = await progressApi.getPjProgress({
projectId: "5e4bde33ec084f1a8673eb59b190dce7",
positionIds: positionId,
period: time,
});
progressData.value = data;
} catch (e) {
console.error("加载进度明细失败:", e);
progressData.value = [];
} finally {
loading.value = false;
}
}
</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: 514px; top: 36px; width: min(800px, calc(100% - 554px)); 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:disabled { cursor: not-allowed; opacity: .62; }
.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; cursor: pointer; user-select: none; transition: border-color .18s ease, background .18s ease, box-shadow .18s ease; }
.chip:hover { border-color: rgba(83,214,206,.34); background: rgba(83,214,206,.12); }
.chip.is-on { border-color: rgba(83,214,206,.52); background: rgba(83,214,206,.20); box-shadow: 0 0 0 1px rgba(83,214,206,.16) inset; }
.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);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
display: inline-block;
}
.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 { left: 24px; right: 24px; top: 108px; width: auto; }
.field-time, .field-percent { flex-wrap: wrap; }
.kpi { margin-left: 0; }
.sidepanel { width: 280px; }
.bottompanel { left: 330px; }
}
.context-menu {
position: fixed;
z-index: 9999;
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 10px;
border: 1px solid rgba(190, 215, 255, 0.76);
background: rgba(244, 249, 255, 0.96);
box-shadow: 0 18px 42px rgba(17, 34, 64, 0.22);
}
.context-menu-summary {
padding: 7px 8px;
border-right: 1px solid rgba(60, 92, 140, 0.16);
color: rgba(42, 64, 98, 0.82);
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.context-menu-action {
height: 30px;
border-radius: 7px;
border: 1px solid rgba(65, 116, 220, 0.28);
background: rgba(255, 255, 255, 0.92);
color: rgba(28, 55, 112, 0.92);
font-size: 12px;
font-weight: 900;
cursor: pointer;
}
.context-menu-action:hover {
background: rgba(226, 238, 255, 0.96);
}
.binding-task-page {
position: absolute;
inset: 0;
z-index: 120;
background: rgba(132, 143, 154, 0.88);
}
.binding-task-card {
position: absolute;
left: 22px;
top: 22px;
width: 300px;
max-height: calc(100% - 120px);
overflow: hidden;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(25, 37, 45, 0.96);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.28);
padding: 14px;
color: rgba(240, 247, 250, 0.96);
}
.binding-task-label { font-size: 13px; font-weight: 900; }
.binding-task-title { margin-top: 8px; font-size: 17px; font-weight: 900; }
.binding-task-count { margin-top: 8px; font-size: 12px; font-weight: 800; color: rgba(218, 231, 237, 0.76); }
.binding-task-divider { height: 1px; margin: 14px 0 10px; background: rgba(255,255,255,.12); }
.binding-task-tree-toggle {
width: 100%;
height: 38px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.10);
color: rgba(240,247,250,.95);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
font-size: 13px;
font-weight: 900;
}
.binding-task-quick-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
color: rgba(234, 244, 248, 0.86);
font-size: 12px;
font-weight: 900;
}
.binding-task-search {
width: 100%;
height: 34px;
margin-top: 12px;
border-radius: 7px;
border: 1px solid rgba(255,255,255,.14);
background: rgba(255,255,255,.08);
color: rgba(245,250,252,.94);
padding: 0 10px;
outline: none;
}
.binding-task-tree-list {
margin-top: 14px;
max-height: min(430px, calc(100vh - 360px));
overflow: auto;
border-radius: 10px;
border: 1px solid rgba(255,255,255,.08);
padding: 8px;
}
.binding-task-tree-row {
width: 100%;
min-height: 34px;
border: 0;
border-radius: 7px;
background: transparent;
color: rgba(231, 241, 246, 0.86);
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
align-items: center;
gap: 6px;
text-align: left;
cursor: pointer;
}
.binding-task-tree-row:hover { background: rgba(255,255,255,.07); }
.binding-task-tree-row.is-group { color: rgba(210, 226, 233, 0.78); font-weight: 900; }
.binding-task-tree-row.is-selected { color: rgba(245, 255, 253, 0.98); }
.binding-task-check { width: 16px; height: 16px; border-radius: 4px; border: 1px solid rgba(203, 226, 236, 0.36); display: grid; place-items: center; color: rgba(83,214,206,.95); font-weight: 900; }
.binding-task-tree-row.is-group .binding-task-check { border: 0; }
.binding-task-empty { padding: 22px 8px; text-align: center; color: rgba(221,235,241,.58); font-size: 12px; }
.binding-task-tip {
position: absolute;
left: 50%;
top: 120px;
transform: translateX(-50%);
border-radius: 10px;
background: rgba(245, 250, 255, 0.96);
color: rgba(48, 65, 96, 0.9);
padding: 10px 16px;
font-size: 14px;
font-weight: 800;
box-shadow: 0 18px 44px rgba(30, 42, 60, 0.18);
}
.binding-task-actions-float {
position: absolute;
right: 18px;
bottom: 18px;
display: flex;
gap: 10px;
padding: 10px;
border-radius: 10px;
background: rgba(18, 31, 39, 0.95);
box-shadow: 0 16px 40px rgba(0,0,0,.28);
}
.binding-task-cancel,
.binding-task-confirm {
min-width: 128px;
height: 46px;
border-radius: 8px;
font-size: 14px;
font-weight: 900;
cursor: pointer;
}
.binding-task-cancel { border: 0; background: rgba(255,255,255,.94); color: rgba(46,58,74,.82); }
.binding-task-confirm { border: 1px solid rgba(83,214,206,.34); background: linear-gradient(180deg, rgba(38,190,178,.95), rgba(21,149,142,.95)); color: white; }
.add-modal {
position: fixed;
width: min(780px, 90vw);
border-radius: 16px;
border: 1px solid rgba(83, 214, 206, 0.25);
background: linear-gradient(180deg, rgba(20, 31, 37, 0.95), rgba(15, 23, 29, 0.92));
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.45);
z-index: 100;
}
.add-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid rgba(83, 214, 206, 0.16);
background: radial-gradient(360px 100px at 10% 0%, rgba(83, 214, 206, 0.18), transparent 68%),
linear-gradient(180deg, rgba(28, 134, 122, 0.45), rgba(15, 60, 74, 0.32));
}
.add-modal-title {
font-size: 15px;
font-weight: 800;
color: rgba(207, 247, 242, 0.96);
}
.add-modal-close {
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(203, 230, 236, 0.75);
font-size: 14px;
cursor: pointer;
display: grid;
place-items: center;
}
.add-modal-close:hover {
background: rgba(255, 255, 255, 0.14);
color: rgba(236, 246, 250, 0.95);
}
.add-modal-body {
padding: 18px;
min-height: 520px;
max-height: min(720px, 82vh);
overflow: auto;
}
.add-modal-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
height: 100%;
}
.add-modal-left,
.add-modal-right {
display: flex;
flex-direction: column;
gap: 10px;
}
.add-modal-section-title {
font-size: 13px;
font-weight: 800;
color: rgba(207, 247, 242, 0.9);
}
.add-modal-list {
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
flex: 1;
}
.construct-tree-panel {
margin-top: 16px;
border-radius: 14px;
border: 1px solid rgba(83, 214, 206, 0.18);
background: rgba(0, 0, 0, 0.16);
overflow: hidden;
}
.construct-tree-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid rgba(83, 214, 206, 0.14);
background: rgba(83, 214, 206, 0.08);
}
.construct-tree-title {
font-size: 13px;
font-weight: 900;
color: rgba(207, 247, 242, 0.95);
}
.construct-tree-sub {
margin-top: 3px;
font-size: 11px;
color: rgba(203, 230, 236, 0.58);
}
.construct-tree-refresh {
flex: none;
height: 30px;
border-radius: 8px;
border: 1px solid rgba(83, 214, 206, 0.28);
background: rgba(83, 214, 206, 0.12);
color: rgba(207, 247, 242, 0.9);
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.construct-tree-search {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid rgba(83, 214, 206, 0.12);
}
.construct-tree-search input {
flex: 1;
height: 32px;
min-width: 0;
border-radius: 8px;
border: 1px solid rgba(83, 214, 206, 0.22);
background: rgba(0, 0, 0, 0.2);
color: rgba(236, 246, 250, 0.92);
padding: 0 10px;
outline: none;
}
.construct-tree-search span {
flex: none;
color: rgba(203, 230, 236, 0.62);
font-size: 12px;
font-weight: 800;
}
.construct-tree-list {
display: grid;
align-content: start;
gap: 4px;
max-height: 250px;
overflow: auto;
padding: 8px;
}
.construct-tree-row {
width: 100%;
min-height: 32px;
border: 0;
border-radius: 8px;
background: transparent;
color: rgba(222, 238, 244, 0.86);
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
text-align: left;
cursor: pointer;
}
.construct-tree-row:hover {
background: rgba(83, 214, 206, 0.10);
}
.construct-tree-row.is-group {
color: rgba(255, 220, 164, 0.9);
font-weight: 900;
}
.construct-tree-row.is-selected {
background: rgba(83, 214, 206, 0.18);
color: rgba(230, 255, 252, 0.98);
}
.construct-tree-caret {
color: rgba(83, 214, 206, 0.92);
font-weight: 900;
}
.construct-tree-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 800;
}
.construct-tree-count {
min-width: 22px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: rgba(203, 230, 236, 0.68);
font-size: 11px;
font-weight: 900;
text-align: center;
padding: 2px 6px;
}
.construct-tree-empty {
padding: 22px 12px;
color: rgba(203, 230, 236, 0.58);
font-size: 12px;
text-align: center;
}
.add-modal-tag {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(83, 214, 206, 0.25);
background: rgba(0, 0, 0, 0.2);
color: rgba(207, 247, 242, 0.9);
font-size: 13px;
}
.add-modal-tag-delete {
width: 22px;
height: 22px;
border-radius: 6px;
border: 1px solid rgba(217, 54, 62, 0.4);
background: rgba(217, 54, 62, 0.1);
color: rgba(217, 54, 62, 0.8);
font-size: 12px;
cursor: pointer;
}
.add-form-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.add-form-item {
display: flex;
align-items: center;
gap: 10px;
}
.add-form-input {
flex: 1;
height: 36px;
border-radius: 8px;
border: 1px solid rgba(83, 214, 206, 0.25);
background: rgba(0, 0, 0, 0.2);
color: rgba(255, 255, 255, 0.9);
padding: 0 12px;
font-size: 13px;
}
.add-form-input:focus {
outline: none;
border-color: rgba(83, 214, 206, 0.5);
}
.add-form-delete {
width: 32px;
height: 36px;
border-radius: 8px;
border: 1px solid rgba(217, 54, 62, 0.4);
background: rgba(217, 54, 62, 0.15);
color: rgba(217, 54, 62, 0.9);
font-size: 14px;
cursor: pointer;
}
.add-form-delete:hover {
background: rgba(217, 54, 62, 0.25);
}
.add-form-add-btn {
margin-top: 12px;
height: 36px;
border-radius: 8px;
border: 1px solid rgba(83, 214, 206, 0.3);
background: rgba(83, 214, 206, 0.15);
color: rgba(83, 214, 206, 0.95);
font-size: 13px;
font-weight: 700;
padding: 0 16px;
cursor: pointer;
}
.add-form-add-btn:hover {
background: rgba(83, 214, 206, 0.25);
}
.add-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 16px;
border-top: 1px solid rgba(83, 214, 206, 0.16);
margin-top: 16px;
}
.add-modal-btn {
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
border: 1px solid transparent;
}
.add-modal-btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.85);
border-color: rgba(255, 255, 255, 0.15);
}
.add-modal-btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
}
.add-modal-btn-primary {
background: rgba(83, 214, 206, 0.25);
color: rgba(83, 214, 206, 0.95);
border-color: rgba(83, 214, 206, 0.3);
}
.add-modal-btn-primary:hover {
background: rgba(83, 214, 206, 0.35);
}
.toast {
position: fixed;
left: 50%;
top: 16px;
transform: translateX(-50%);
z-index: 9999;
padding: 10px 20px;
border-radius: 10px;
font-size: 14px;
font-weight: 700;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.toast-success {
background: rgba(83, 214, 206, 0.25);
border: 1px solid rgba(83, 214, 206, 0.4);
color: rgba(83, 214, 206, 0.95);
}
.toast-error {
background: rgba(217, 54, 62, 0.25);
border: 1px solid rgba(217, 54, 62, 0.4);
color: rgba(255, 255, 255, 0.95);
}
</style>