Добавить Vue 3 web-dashboard с расширенным API и Docker-сборкой.

Полноценный браузерный UI (Pinia, Three.js) с паритетом Qt: редактор цепочки, wizard, пресеты, метрики и 3D viewer; API расширен для catalog/validate/demo/user-presets; CLI отдаёт step metrics в JSON.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-18 14:53:53 +03:00
co-authored by Cursor
parent 45b2ed6e22
commit 18a58f2e85
33 changed files with 4304 additions and 56 deletions
@@ -0,0 +1,97 @@
<script setup>
import { ref } from "vue";
import { usePipelineStore } from "@/stores/pipeline";
import PipelineCanvas from "@/components/pipeline/PipelineCanvas.vue";
import StageSettingsDialog from "@/components/pipeline/StageSettingsDialog.vue";
const store = usePipelineStore();
const selectedPreset = ref("");
const snapshotName = ref("");
const presetTitle = ref("");
const settings = ref(null);
async function onApplyPreset() {
if (!selectedPreset.value) return;
await store.applyPreset(selectedPreset.value);
}
async function onRun() {
const useBinary = (store.currentFile?.size || 0) > 8 * 1024 * 1024;
await store.runPipeline({ geometryFormat: useBinary ? "binary" : "json" });
if (store.geometryUrl) {
const workId = store.geometryUrl.split("/").pop();
await store.loadGeometryFromUrl(workId);
}
}
async function onGenerateDemo() {
await store.runPipeline({ useDemo: true, geometryFormat: "json" });
}
</script>
<template>
<section class="panel controls">
<div class="toolbar">
<select v-model="store.demoSurfaceType">
<option v-for="type in store.demoSurfaceTypes" :key="type" :value="type">{{ type }}</option>
</select>
<button type="button" @click="onGenerateDemo" :disabled="store.busy">Сгенерировать</button>
<label class="file-label">
Загрузить
<input type="file" accept=".ply,.txt,.csv,.xyz,.bin" @change="store.setCurrentFile($event.target.files?.[0] || null)" />
</label>
</div>
<PipelineCanvas @open-settings="settings?.open($event)" />
<div class="toolbar">
<label><input type="checkbox" v-model="store.surfaceVisible" /> Показать mesh</label>
<button type="button" class="primary" :disabled="store.busy" @click="onRun">Применить</button>
</div>
<div class="toolbar presets">
<select v-model="selectedPreset">
<option value="">Пресет...</option>
<option v-for="preset in store.presetItems" :key="preset.idValue || preset.id" :value="preset.idValue || preset.id">
{{ preset.title }}
</option>
</select>
<button type="button" @click="onApplyPreset">Применить пресет</button>
</div>
<div class="toolbar presets">
<input v-model="presetTitle" placeholder="Имя пресета" />
<button type="button" @click="store.saveCurrentPreset(presetTitle)" :disabled="!presetTitle">Сохранить пресет</button>
</div>
<div class="toolbar presets">
<input v-model="snapshotName" placeholder="Snapshot name" />
<button type="button" @click="store.saveSnapshot(snapshotName)">Save snapshot</button>
<button
v-for="snap in store.snapshots"
:key="snap.name"
type="button"
@click="store.loadSnapshot(snap.name)"
>{{ snap.name }}</button>
</div>
<p class="status">{{ store.statusText }}</p>
<StageSettingsDialog ref="settings" />
</section>
</template>
<style scoped>
.controls {
display: grid;
gap: 10px;
align-content: start;
min-width: 0;
max-height: calc(100vh - 120px);
overflow-y: auto;
overflow-x: hidden;
}
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.file-label { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; }
.status { color: var(--muted-text); font-size: 13px; white-space: pre-wrap; margin: 0; }
.presets input { flex: 1; min-width: 120px; }
</style>
+32
View File
@@ -0,0 +1,32 @@
<script setup>
import { usePipelineStore } from "@/stores/pipeline";
const store = usePipelineStore();
</script>
<template>
<div class="wizard panel">
<h3>Quick Wizard</h3>
<label>Data profile</label>
<select v-model="store.wizardProfile">
<option value="general">general</option>
<option value="urban_scan">urban_scan</option>
<option value="indoor_object">indoor_object</option>
</select>
<label>Optimization goal</label>
<select v-model="store.wizardGoal">
<option value="speed">speed</option>
<option value="balanced">balanced</option>
<option value="quality">quality</option>
</select>
<p class="hint">Wizard builds a start chain and explains trade-offs.</p>
<button type="button" @click="store.applyWizard()">Generate suggested chain</button>
</div>
</template>
<style scoped>
.wizard { display: grid; gap: 8px; }
.wizard h3 { margin: 0; font-size: 14px; }
.hint { color: var(--hint-text); font-size: 12px; margin: 0; }
label { font-size: 12px; color: var(--label-text); }
</style>
@@ -0,0 +1,63 @@
<script setup>
import { usePipelineStore } from "@/stores/pipeline";
const store = usePipelineStore();
function localizedHealth(value) {
if (value === "OK") return "ОК";
if (value === "Warning") return "Предупреждение";
if (value === "Risk") return "Риск";
return value || "-";
}
</script>
<template>
<section class="panel metrics-strip">
<h3>Сводка</h3>
<div class="row">
<strong>Состояние: {{ localizedHealth(store.chainHealth) }}</strong>
<span>Треугольники: {{ store.metrics.triangles }}</span>
<span>Реконструкция, мс: {{ store.metrics.reconstructMs }}</span>
</div>
<p class="recommendation">{{ store.recommendation }}</p>
<ul v-if="store.warningsList.length" class="warnings">
<li v-for="(warning, index) in store.warningsList" :key="index">{{ warning }}</li>
</ul>
<table v-if="store.stageMetrics.length" class="metrics-table">
<thead>
<tr>
<th>Этап</th>
<th>Вход</th>
<th>Выход</th>
<th>Удалено</th>
<th>мс</th>
</tr>
</thead>
<tbody>
<tr v-for="row in store.stageMetrics" :key="row.stageId">
<td>{{ row.stageId }}</td>
<td>{{ row.inputPoints }}</td>
<td>{{ row.outputPoints }}</td>
<td>{{ row.removedPoints }}</td>
<td>{{ row.elapsedMs }}</td>
</tr>
</tbody>
</table>
<div class="summary-grid">
<div>Вход: {{ store.metrics.inputPoints }}</div>
<div>После preprocess: {{ store.metrics.afterPreprocess }}</div>
<div>Кластеры: {{ store.metrics.clusters }}</div>
<div>Удалено: {{ store.metrics.removedPoints }}</div>
</div>
</section>
</template>
<style scoped>
.metrics-strip h3 { margin: 0 0 8px; }
.row { display: flex; gap: 12px; flex-wrap: wrap; font-size: 13px; }
.recommendation { color: var(--summary-recommendation); font-size: 13px; }
.warnings { margin: 8px 0; padding-left: 18px; color: var(--warning-text); font-size: 13px; }
.metrics-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; }
.metrics-table th, .metrics-table td { border-bottom: 1px solid var(--card-border); padding: 4px; text-align: left; }
.summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; font-size: 13px; color: var(--summary-secondary); }
</style>
@@ -0,0 +1,41 @@
<script setup>
import StageChainView from "./StageChainView.vue";
import StagePalette from "./StagePalette.vue";
const emit = defineEmits(["openSettings"]);
</script>
<template>
<div class="canvas panel">
<div class="canvas-grid">
<StageChainView @open-settings="emit('openSettings', $event)" />
<StagePalette />
</div>
</div>
</template>
<style scoped>
.canvas {
min-width: 0;
overflow: hidden;
}
.canvas-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 260px);
gap: 12px;
align-items: start;
max-height: min(420px, 48vh);
}
.canvas-grid > * {
min-width: 0;
}
@media (max-width: 900px) {
.canvas-grid {
grid-template-columns: 1fr;
max-height: none;
}
}
</style>
@@ -0,0 +1,95 @@
<script setup>
import { ref, onMounted } from "vue";
import Sortable from "sortablejs";
import { usePipelineStore } from "@/stores/pipeline";
const store = usePipelineStore();
const listRef = ref(null);
const emit = defineEmits(["openSettings"]);
function categoryColor(category) {
const map = {
"Обрезка": "#88d1ff",
"NaN (предочистка)": "#8fe8ff",
"Условия/Индексы": "#9fd7ff",
"Шум": "#a4f4b9",
"Морфология": "#9cf5d1",
"Прореживание": "#ffcb8a",
"Сглаживание": "#ffd892",
"Нормали": "#d2b7ff",
"Реконструкция": "#ff9dc4",
};
return map[category] || "#9fb4da";
}
onMounted(() => {
if (!listRef.value) return;
Sortable.create(listRef.value, {
animation: 150,
handle: ".drag-handle",
onEnd(evt) {
if (evt.oldIndex == null || evt.newIndex == null) return;
store.moveStage(evt.oldIndex, evt.newIndex);
},
});
});
</script>
<template>
<div ref="listRef" class="chain-list">
<div
v-for="(card, index) in store.stageCards"
:key="`${card.id}-${index}`"
class="chain-row"
:class="{ selected: store.selectedStageIndex === index, disabled: !card.enabled }"
@click="store.setSelectedStageIndex(index)"
@dblclick="emit('openSettings', card)"
>
<span class="accent" :style="{ background: categoryColor(card.category) }" />
<span class="drag-handle" :class="{ hidden: card.family === 'reconstruction' }"></span>
<div class="meta">
<strong>{{ card.title }}</strong>
<small>{{ card.category }} | {{ card.id }}</small>
</div>
<button
v-if="card.family !== 'reconstruction'"
type="button"
class="mini"
@click.stop="store.removeStage(index)"
>×</button>
</div>
</div>
</template>
<style scoped>
.chain-list {
display: grid;
gap: 6px;
max-height: min(420px, 48vh);
overflow: auto;
}
.chain-row {
display: grid;
grid-template-columns: 4px 20px 1fr auto;
gap: 8px;
align-items: center;
padding: 8px;
border-radius: var(--radius-md);
border: 1px solid var(--chain-idle-border);
background: var(--chain-enabled-bg);
cursor: pointer;
}
.chain-row.selected { border-color: var(--chain-selected-border); }
.chain-row.disabled { background: var(--chain-disabled-bg); opacity: 0.7; }
.accent { height: 100%; border-radius: 2px; }
.drag-handle { text-align: center; color: var(--chain-handle-text); cursor: grab; }
.drag-handle.hidden { visibility: hidden; }
.meta { display: grid; gap: 2px; min-width: 0; }
.meta strong, .meta small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta small { color: var(--chain-handle-text); font-size: 11px; }
.mini { width: 28px; height: 28px; padding: 0; }
</style>
@@ -0,0 +1,81 @@
<script setup>
import { usePipelineStore } from "@/stores/pipeline";
import { useTheme } from "@/composables/useTheme";
const store = usePipelineStore();
const { theme } = useTheme();
function addFilter(item) {
store.addStage(item.idValue);
}
function phaseHeaderStyle(group) {
if (theme.value === "light") {
return {
color: "#1e293b",
borderColor: group.phaseFilterBorder,
background: `color-mix(in srgb, ${group.phaseAccent} 16%, #ffffff)`,
};
}
return {
color: group.phaseAccent,
borderColor: group.phaseFilterBorder,
background: group.phaseFilterBg,
};
}
</script>
<template>
<div class="palette">
<section v-for="group in store.paletteGroups" :key="group.phaseId" class="phase">
<header :style="phaseHeaderStyle(group)">
{{ group.phaseTitle }}
</header>
<button
v-for="item in group.items"
:key="item.idValue"
type="button"
class="palette-item"
@click="addFilter(item)"
>
<span>{{ item.title }}</span>
<small>{{ item.hint }}</small>
</button>
</section>
</div>
</template>
<style scoped>
.palette {
display: grid;
gap: 8px;
max-height: min(420px, 48vh);
overflow: auto;
}
.phase header {
font-size: 12px;
font-weight: 700;
padding: 6px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--palette-filter-border);
margin-bottom: 4px;
}
.palette-item {
width: 100%;
text-align: left;
display: grid;
gap: 2px;
margin-bottom: 4px;
background: var(--palette-filter-bg);
border-color: var(--palette-filter-border);
color: var(--control-text);
min-width: 0;
}
.palette-item span,
.palette-item small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.palette-item small { color: var(--palette-hint-text); font-size: 10px; }
</style>
@@ -0,0 +1,68 @@
<script setup>
import { ref } from "vue";
import { usePipelineStore } from "@/stores/pipeline";
import { parseDefaults, serializeParams, paramDescription } from "@/catalog/pipelineUiCatalog";
const store = usePipelineStore();
const visible = ref(false);
const paramItems = ref([]);
const editingCard = ref(null);
function open(card) {
editingCard.value = card;
paramItems.value = parseDefaults(card.defaults || "");
visible.value = true;
}
function close() {
visible.value = false;
}
function save() {
const index = store.stageCards.findIndex((c) => c.id === editingCard.value?.id);
if (index >= 0) {
store.setStageDefaults(index, serializeParams(paramItems.value));
}
close();
}
async function resetDefaults() {
if (!editingCard.value) return;
const defaults = await store.defaultsForStage(editingCard.value.id);
paramItems.value = parseDefaults(defaults);
}
defineExpose({ open });
</script>
<template>
<div v-if="visible" class="dialog-backdrop" @click.self="close">
<div class="dialog panel">
<h3>{{ editingCard?.title }}</h3>
<p class="hint">{{ editingCard?.hint }}</p>
<div v-for="(param, index) in paramItems" :key="param.key" class="param-row">
<label>{{ param.key }}</label>
<select v-if="param.kind === 'bool'" v-model="param.value">
<option value="false">false</option>
<option value="true">true</option>
</select>
<input v-else v-model="param.value" />
<small>{{ paramDescription(param.key) }}</small>
</div>
<div class="actions">
<button type="button" @click="resetDefaults">Установить по умолчанию</button>
<button type="button" class="primary" @click="save">OK</button>
</div>
</div>
</div>
</template>
<style scoped>
.dialog-backdrop { position: fixed; inset: 0; background: var(--dialog-backdrop); display: grid; place-items: center; z-index: 20; }
.dialog { width: min(420px, 92vw); max-height: 80vh; overflow: auto; }
.hint { color: var(--dialog-hint-text); font-size: 13px; }
.param-row { display: grid; gap: 4px; margin-bottom: 10px; }
.param-row label { font-size: 12px; color: var(--dialog-label-text); }
.param-row small { color: var(--dialog-hint-text); font-size: 11px; }
.actions { display: flex; justify-content: space-between; gap: 8px; margin-top: 12px; }
</style>
@@ -0,0 +1,70 @@
<script setup>
const emit = defineEmits(["download", "resetCamera"]);
</script>
<template>
<div class="modebar" role="toolbar" aria-label="Управление 3D-просмотром">
<button
type="button"
class="modebar-btn"
title="Сохранить снимок"
aria-label="Сохранить снимок"
@click="emit('download')"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h4l2-3h4l2 3h4v12H4V7z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" />
<circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="1.6" />
</svg>
</button>
<button
type="button"
class="modebar-btn"
title="Сбросить камеру"
aria-label="Сбросить камеру"
@click="emit('resetCamera')"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 10.5 10.5 4 17 10.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path d="M10.5 4v16" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
</svg>
</button>
</div>
</template>
<style scoped>
.modebar {
position: absolute;
top: 8px;
left: 8px;
z-index: 3;
display: flex;
gap: 2px;
padding: 2px 4px;
border-radius: 4px;
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
border: 1px solid var(--card-border);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
.modebar-btn {
width: 28px;
height: 28px;
padding: 0;
display: grid;
place-items: center;
border: none;
border-radius: 3px;
background: transparent;
color: var(--modebar-icon, #636363);
cursor: pointer;
}
.modebar-btn:hover {
background: color-mix(in srgb, var(--control-text) 8%, transparent);
}
.modebar-btn svg {
width: 18px;
height: 18px;
}
</style>
@@ -0,0 +1,230 @@
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { usePipelineStore } from "@/stores/pipeline";
import { useTheme } from "@/composables/useTheme";
import { usePointCloudViewer } from "@/composables/usePointCloudViewer";
import ViewerModebar from "./ViewerModebar.vue";
const VIEWPORT_HEIGHT_KEY = "dotstosirface-viewport-height";
const MIN_VIEWPORT_HEIGHT = 240;
const MAX_VIEWPORT_HEIGHT = Math.min(window.innerHeight - 80, 1200);
const store = usePipelineStore();
const { theme } = useTheme();
const viewportRef = ref(null);
const isFullscreen = ref(false);
const viewportHeight = ref(loadViewportHeight());
const {
renderGeometry,
resize,
setBackground,
resetCamera,
downloadSnapshot,
} = usePointCloudViewer(viewportRef);
function loadViewportHeight() {
const saved = Number(localStorage.getItem(VIEWPORT_HEIGHT_KEY));
if (Number.isFinite(saved) && saved >= MIN_VIEWPORT_HEIGHT) {
return Math.min(saved, MAX_VIEWPORT_HEIGHT);
}
return 520;
}
function saveViewportHeight() {
localStorage.setItem(VIEWPORT_HEIGHT_KEY, String(viewportHeight.value));
}
function viewerBackground() {
return theme.value === "light" ? 0xe2e8f0 : 0x0b1118;
}
function onFullscreenChange() {
isFullscreen.value = document.fullscreenElement === viewportRef.value;
resize();
}
async function toggleFullscreen() {
if (!viewportRef.value) return;
if (document.fullscreenElement === viewportRef.value) {
await document.exitFullscreen();
} else {
await viewportRef.value.requestFullscreen();
}
}
function startResize(event) {
if (isFullscreen.value) return;
event.preventDefault();
const startY = event.clientY;
const startHeight = viewportHeight.value;
function onMove(moveEvent) {
const nextHeight = startHeight + (moveEvent.clientY - startY);
viewportHeight.value = Math.min(
Math.max(nextHeight, MIN_VIEWPORT_HEIGHT),
Math.min(window.innerHeight - 80, 1200),
);
resize();
}
function onUp() {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
saveViewportHeight();
}
document.body.style.cursor = "ns-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
watch(
() => [store.viewerPoints, store.viewerTriangles, store.surfaceVisible],
() => {
renderGeometry(store.viewerPoints, store.viewerTriangles, store.surfaceVisible);
resize();
},
{ deep: true },
);
watch(theme, () => {
setBackground(viewerBackground());
}, { immediate: true });
watch(viewportHeight, () => {
resize();
});
onMounted(() => {
resize();
document.addEventListener("fullscreenchange", onFullscreenChange);
});
onBeforeUnmount(() => {
document.removeEventListener("fullscreenchange", onFullscreenChange);
});
</script>
<template>
<section class="panel viewer">
<h2>3D Viewer</h2>
<div
class="viewport-shell"
:class="{ fullscreen: isFullscreen }"
:style="isFullscreen ? undefined : { height: `${viewportHeight}px` }"
>
<div ref="viewportRef" class="viewport">
<ViewerModebar
@download="downloadSnapshot"
@reset-camera="resetCamera"
/>
<button
type="button"
class="fullscreen-btn"
:title="isFullscreen ? 'Свернуть' : 'Развернуть на весь экран'"
@click="toggleFullscreen"
>
<svg v-if="!isFullscreen" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 3H3v5M16 3h5v5M16 21h5v-5M8 21H3v-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 9H3V3M15 9h6V3M15 15h6v6M9 15H3v6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
<div
v-if="!isFullscreen"
class="resize-handle"
title="Потяните, чтобы изменить высоту"
@mousedown="startResize"
/>
</div>
<p class="hint">Drag to rotate, wheel to zoom.</p>
</section>
</template>
<style scoped>
.viewer { display: flex; flex-direction: column; min-width: 0; }
.viewer h2 { margin: 0 0 8px; font-size: 18px; }
.viewport-shell {
position: relative;
display: flex;
flex-direction: column;
min-height: 240px;
}
.viewport {
position: relative;
flex: 1;
min-height: 0;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--card-border);
background: var(--viewer-bg);
}
.viewport:fullscreen {
border-radius: 0;
border: none;
width: 100vw;
height: 100vh;
}
.fullscreen-btn {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
width: 28px;
height: 28px;
padding: 0;
display: grid;
place-items: center;
border-radius: 3px;
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
border: 1px solid var(--card-border);
color: var(--modebar-icon, #636363);
opacity: 0.95;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
.fullscreen-btn:hover {
opacity: 1;
background: var(--card-bg);
}
.fullscreen-btn svg {
width: 18px;
height: 18px;
}
.resize-handle {
flex: 0 0 10px;
margin-top: 4px;
cursor: ns-resize;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
}
.resize-handle::after {
content: "";
width: 56px;
height: 4px;
border-radius: 999px;
background: var(--card-border);
transition: background 0.15s ease;
}
.resize-handle:hover::after {
background: var(--chain-selected-border);
}
.hint { color: var(--hint-text); font-size: 13px; margin-top: 8px; }
</style>