进度管理页面的百分比展示

This commit is contained in:
cjh
2026-06-24 15:42:32 +08:00
parent 965bb309db
commit 29a44642f9
4 changed files with 289 additions and 50 deletions

View File

@@ -12,17 +12,17 @@
<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>
<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"><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>
<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>
@@ -205,7 +205,7 @@
</template>
<script setup>
import {computed, nextTick, onMounted, reactive, ref} from "vue";
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";
@@ -222,6 +222,7 @@ 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);
@@ -321,10 +322,12 @@ 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",
@@ -364,8 +367,7 @@ const selectedStructureNode = computed(() => {
});
const overallPercent = computed(() => {
if (!selectedStructureNode.value) return 0;
return 0;
return calculateOverallProgressPercent(overallProgressItems.value);
});
const progressRows = computed(() => {
@@ -553,13 +555,29 @@ function normalizeIsLeaf(node) {
}
onMounted(async() => {
await modelCenterStore.loadModels().catch((error) => console.error("加载模型列表失败:", error));
await loadWbsTree();
window.addEventListener("click", closeContextMenu);
const result = await progressApi.calculateProgress(selectedPeriod.value);
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);
}
@@ -591,6 +609,43 @@ 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);
@@ -605,13 +660,45 @@ function formatNumber(value, digits = 0) {
}
function extractProgressItems(response) {
if (Array.isArray(response)) return response;
if (Array.isArray(response?.current?.data)) return response.current.data;
if (Array.isArray(response?.data?.data)) return response.data.data;
if (Array.isArray(response?.data)) return response.data;
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;
@@ -631,16 +718,17 @@ function isProgressMatched(ratio, key) {
function buildModelCodeData(list) {
const groupedByUrl = new Map();
for (const item of list) {
const rawCodeData = String(item?.codeData || "").trim();
if (!rawCodeData) continue;
const params = getParams(rawCodeData);
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);
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,
@@ -648,6 +736,89 @@ function buildModelCodeData(list) {
}));
}
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 = [];
@@ -657,9 +828,11 @@ async function refreshFilteredProgressCodeData() {
// }
try {
// const response = await progressApi.getProgress(taskId.value);
const response = await progressApi.getProgress(selectedPeriod.value);
console.log(77777,response)
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;
@@ -673,10 +846,11 @@ async function refreshFilteredProgressCodeData() {
const groupedParams = [];
const allMatchedCodeData = [];
activeKeys.forEach((key) => {
const matched = items.filter((item) => isProgressMatched(normalizeProgressRatio(item.progressData), key));
const matched = items.filter((item) => isProgressMatched(resolveProgressRatio(item), key));
if (!matched.length) return;
allMatchedCodeData.push(...matched.map((item) => item.codeData).filter(Boolean));
const modelCodeData = buildModelCodeData(matched);
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,
@@ -700,10 +874,31 @@ async function refreshFilteredProgressCodeData() {
}
}
function applyCutoffDate() {
async function applyCutoffDate() {
if (queryLoading.value) return;
queryLoading.value = true;
cutoffDate.value = cutoffDateDraft.value || cutoffDate.value || todayISODate();
const result = progressApi.calculateProgress(selectedPeriod.value);
console.log(2121,result)
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) {
@@ -756,6 +951,10 @@ async function applyAllExclusive(key, checked) {
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)) {
@@ -809,9 +1008,21 @@ async function onTreeRowClick(row) {
//获取"{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 = {};
str.replace(/{([\s\S]+)}/, '$1').split(', ').forEach(i => {
let [k, v] = i.split('=');
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;
@@ -1197,6 +1408,7 @@ async function loadProgressData(positionId,time) {
.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); }
@@ -1204,7 +1416,9 @@ async function loadProgressData(positionId,time) {
.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 { 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); }