浏览代码

viewer:部件状态控制支持重命名并写回 .glb,自动同步故障映射表

- 后端 model_edit_service:按查看器标签定位节点、校验全局唯一、原子写回 GLB,生成位移映射并调用 fault_map_service.remap_parts 同步 CSV;新增 POST /api/models/rename-part(需登录)
- 前端 PartStatusCard 名称内联编辑;store reloadToken/pendingSelectLabel 重载并按新名重选
- 模型更新:压缩机2326-3000-one.glb 节点 区域 -> 地板
18922397810 2 周之前
父节点
当前提交
d1df6c2e96

+ 3 - 2
viewer/README.md

@@ -11,8 +11,8 @@ viewer/
 │  ├─ main.py          # 应用入口:鉴权、/api、/models、前端托管与 SPA 回退
 │  ├─ config.py        # 路径、运行模式与账号配置
 │  ├─ auth.py          # 登录鉴权(内存 token、require_auth 依赖)
-│  ├─ routers/         # files / alarms / series / scenes / fault_map
-│  ├─ services/        # model / alarm / series / scene / fault_map
+│  ├─ routers/         # files / alarms / series / scenes / fault_map / model_edit
+│  ├─ services/        # model / alarm / series / scene / fault_map / model_edit
 │  ├─ run.py           # 启动脚本
 │  ├─ run.sh           # 启动脚本(自动选当前 conda/venv 解释器)
 │  ├─ run_prod.sh      # 生产启动(APP_MODE=prod)
@@ -98,6 +98,7 @@ APP_MODE=prod AUTH_USER=admin AUTH_PASSWORD='***' bash viewer/backend/run_prod.s
 | GET | `/api/auth-config` | 否 | 运行模式(测试模式含默认账号密码) |
 | GET | `/api/models` | 是 | 可用模型名列表 |
 | GET | `/models/<file>` | 否 | GLB / glTF 模型文件 |
+| POST | `/api/models/rename-part` | 是 | 重命名 GLB 部件标签(写回文件并同步故障↔部件映射) |
 | GET | `/api/alarms` | 是 | 故障列表(筛选/分页,倒序) |
 | GET | `/api/alarm-filters` | 是 | 故障列表筛选项 |
 | GET | `/api/pks-series` | 是 | 故障曲线原始序列(YSJ_5/10/11) |

+ 4 - 2
viewer/backend/main.py

@@ -9,7 +9,8 @@
 
 需登录接口(Bearer token):
     /api/models、/api/alarms、/api/alarm-filters、/api/pks-series、
-    /api/recent-series、/api/indicator、/api/scene-positions、/api/fault-part-map
+    /api/recent-series、/api/indicator、/api/scene-positions、/api/fault-part-map、
+    /api/models/rename-part
 """
 
 from typing import Any
@@ -21,7 +22,7 @@ from pydantic import BaseModel, Field
 
 from auth import create_token, require_auth, revoke_token, verify_credentials
 from config import APP_MODE, AUTH_PASSWORD, AUTH_USER, FRONTEND_DIST, IS_TEST
-from routers import alarms, fault_map, files, indicator, scenes, series
+from routers import alarms, fault_map, files, indicator, model_edit, scenes, series
 
 
 class LoginInput(BaseModel):
@@ -66,6 +67,7 @@ app.include_router(fault_map.router, dependencies=[Depends(require_auth)])
 app.include_router(scenes.router, dependencies=[Depends(require_auth)])
 app.include_router(series.router, dependencies=[Depends(require_auth)])
 app.include_router(indicator.router, dependencies=[Depends(require_auth)])
+app.include_router(model_edit.router, dependencies=[Depends(require_auth)])
 app.include_router(files.router)
 
 

+ 28 - 0
viewer/backend/routers/model_edit.py

@@ -0,0 +1,28 @@
+"""模型部件重命名:按查看器标签定位 GLB 节点并写回文件。"""
+
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel, Field
+
+from services import model_edit_service
+
+router = APIRouter()
+
+
+class RenamePartInput(BaseModel):
+    model: str = Field(min_length=1)
+    label: str = Field(min_length=1)
+    new_name: str = Field(min_length=1, max_length=100)
+
+
+@router.post("/api/models/rename-part")
+def rename_part(payload: RenamePartInput) -> dict[str, Any]:
+    try:
+        return model_edit_service.rename_part(payload.model, payload.label, payload.new_name)
+    except FileNotFoundError as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except LookupError as exc:
+        raise HTTPException(status_code=409, detail=str(exc)) from exc
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc

+ 18 - 0
viewer/backend/services/fault_map_service.py

@@ -91,6 +91,24 @@ def list_rows() -> list[dict[str, Any]]:
     return [_to_api(row) for row in _read() if row.get("part")]
 
 
+def remap_parts(model: str, mapping: dict[str, str]) -> int:
+    """按 旧label→新label 映射更新指定模型的 part 列,返回改动行数。"""
+    if not mapping:
+        return 0
+    rows = _read()
+    changed = 0
+    for row in rows:
+        if row["model"] != model:
+            continue
+        part = row.get("part", "")
+        if part in mapping:
+            row["part"] = mapping[part]
+            changed += 1
+    if changed:
+        _write(rows)
+    return changed
+
+
 def set_scene_position(
     model: str,
     alarm_type: str,

+ 72 - 0
viewer/backend/services/model_edit_service.py

@@ -0,0 +1,72 @@
+"""GLB 部件重命名:按查看器标签定位节点,写回 .glb,并同步故障↔部件映射表。
+
+- 标签规则与前端一致(three.js createUniqueName:同名按出现次序 ``_1``、``_2``…),
+  直接复用 ``tools/remove_nodes.viewer_labels``。
+- 名称强制全局唯一(不得与其它任何节点的原始名重复),避免产生后缀歧义。
+- 改名会导致其后同名部件的标签整体前移,故同时生成 旧label→新label 映射,
+  并调用 ``fault_map_service.remap_parts`` 同步 ``data/fault_part_map.csv``。
+"""
+
+import os
+import sys
+import threading
+
+from config import MODULE_DIR
+from services import fault_map_service, model_service
+
+_TOOLS_DIR = MODULE_DIR / "tools"
+if str(_TOOLS_DIR) not in sys.path:
+    sys.path.insert(0, str(_TOOLS_DIR))
+
+from remove_nodes import viewer_labels  # noqa: E402
+from split_glb import read_glb, write_glb  # noqa: E402
+
+_lock = threading.Lock()
+
+
+def rename_part(model: str, label: str, new_name: str) -> dict:
+    new_name = (new_name or "").strip()
+    if not new_name:
+        raise ValueError("名称不能为空")
+    if "#" in new_name:
+        raise ValueError("名称不能包含 #")
+    path = model_service.resolve_model(model)
+    if path is None:
+        raise FileNotFoundError("模型不存在")
+    if path.suffix.lower() != ".glb":
+        raise ValueError("仅支持 .glb 模型")
+
+    with _lock:
+        gltf, bin_data = read_glb(str(path))
+        old_labels = viewer_labels(gltf)
+        targets = [i for i, value in old_labels.items() if value == label]
+        if len(targets) != 1:
+            raise LookupError(f"部件标签未唯一匹配:{label}")
+        index = targets[0]
+
+        for j, node in enumerate(gltf["nodes"]):
+            if j == index:
+                continue
+            if (node.get("name") or "") == new_name:
+                raise ValueError(f"名称已存在:{new_name}")
+
+        gltf["nodes"][index]["name"] = new_name
+        new_labels = viewer_labels(gltf)
+        mapping = {
+            old_labels[i]: new_labels[i]
+            for i in old_labels
+            if old_labels[i] != new_labels[i]
+        }
+
+        tmp = str(path) + ".tmp"
+        write_glb(tmp, gltf, bin_data)
+        os.replace(tmp, path)
+
+    remapped = fault_map_service.remap_parts(model, mapping)
+    return {
+        "node_index": index,
+        "old_label": label,
+        "new_label": new_labels[index],
+        "mapping": mapping,
+        "remapped_rows": remapped,
+    }

+ 21 - 0
viewer/frontend/src/api/models.ts

@@ -8,3 +8,24 @@ export async function fetchModels(): Promise<string[]> {
 export function modelUrl(name: string): string {
   return `/models/${encodeURIComponent(name)}`
 }
+
+export interface RenamePartResult {
+  node_index: number
+  old_label: string
+  new_label: string
+  mapping: Record<string, string>
+  remapped_rows: number
+}
+
+export async function renamePart(
+  model: string,
+  label: string,
+  newName: string,
+): Promise<RenamePartResult> {
+  const res = await http.post<RenamePartResult>('/api/models/rename-part', {
+    model,
+    label,
+    new_name: newName,
+  })
+  return res.data
+}

+ 79 - 2
viewer/frontend/src/components/PartStatusCard.vue

@@ -1,5 +1,7 @@
 <script setup lang="ts">
-import { computed } from 'vue'
+import { EditOutlined } from '@ant-design/icons-vue'
+import { message } from 'ant-design-vue'
+import { computed, ref } from 'vue'
 
 import { usePartsStore } from '../stores/parts'
 import { PRESETS, type PartMeta } from '../types'
@@ -15,6 +17,41 @@ const statusLabel = computed(() => {
   if (!p) return ''
   return PRESETS.find((s) => s.key === p.status)?.label ?? '未设置'
 })
+
+const editing = ref(false)
+const draft = ref('')
+const saving = ref(false)
+
+function startEdit(): void {
+  if (!single.value) return
+  draft.value = single.value.label
+  editing.value = true
+}
+
+function cancelEdit(): void {
+  editing.value = false
+}
+
+async function saveEdit(): Promise<void> {
+  const part = single.value
+  if (!part) return
+  const name = draft.value.trim()
+  if (!name || name === part.label) {
+    editing.value = false
+    return
+  }
+  saving.value = true
+  try {
+    await partsStore.renamePart(part.label, name)
+    message.success(`已重命名为「${name}」`)
+    editing.value = false
+  } catch (error) {
+    const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail
+    message.error(detail || '重命名失败')
+  } finally {
+    saving.value = false
+  }
+}
 </script>
 
 <template>
@@ -49,7 +86,25 @@ const statusLabel = computed(() => {
         </div>
       </template>
       <template v-else-if="single">
-        <div class="line">名称:{{ single.label }}</div>
+        <div class="line name-line">
+          <span>名称:</span>
+          <template v-if="editing">
+            <a-input
+              v-model:value="draft"
+              size="small"
+              class="name-input"
+              :disabled="saving"
+              @press-enter="saveEdit"
+              @keyup.esc="cancelEdit"
+            />
+            <a-button type="link" size="small" :loading="saving" @click="saveEdit">保存</a-button>
+            <a-button type="link" size="small" :disabled="saving" @click="cancelEdit">取消</a-button>
+          </template>
+          <template v-else>
+            <span class="name-text">{{ single.label }}</span>
+            <EditOutlined class="edit-icon" title="重命名" @click="startEdit" />
+          </template>
+        </div>
         <div class="line">顶点数:{{ single.vtx.toLocaleString() }} 三角面:{{ single.tri.toLocaleString() }}</div>
         <div class="line">尺寸:{{ single.size }} 颜色:{{ single.colorHex || single.defaults[0] }} 状态:{{ statusLabel }}</div>
       </template>
@@ -112,6 +167,28 @@ const statusLabel = computed(() => {
   overflow: hidden;
   text-overflow: ellipsis;
 }
+.name-line {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+.name-text {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.name-input {
+  flex: 1;
+}
+.edit-icon {
+  flex: 0 0 auto;
+  color: #8b93a1;
+  cursor: pointer;
+  font-size: 13px;
+}
+.edit-icon:hover {
+  color: #4da3ff;
+}
 .dim {
   color: #fff;
 }

+ 12 - 1
viewer/frontend/src/composables/useThreeViewer.ts

@@ -25,13 +25,19 @@ export function useThreeViewer(container: Ref<HTMLElement | null>): void {
     modelStore.loadingText = '模型加载中…'
     partsStore.clearSelection()
     try {
-      await viewer.loadModel(name, modelUrl(name))
+      await viewer.loadModel(name, `${modelUrl(name)}?v=${modelStore.reloadToken}`)
       viewer.prepareViews()
       viewer.fitAll(1.2)
       await scenesStore.load()
       if (scenesStore.items[0]) scenesStore.apply(scenesStore.items[0], false)
       await faultPartsStore.ensureLoaded()
       faultPartsStore.apply()
+      const pendingLabel = partsStore.pendingSelectLabel
+      if (pendingLabel) {
+        const target = partsStore.parts.find((part) => part.label === pendingLabel)
+        if (target) partsStore.selectOnly(target.id)
+        partsStore.pendingSelectLabel = null
+      }
       modelStore.loading = false
       modelStore.loadingText = ''
     } catch (error) {
@@ -57,6 +63,11 @@ export function useThreeViewer(container: Ref<HTMLElement | null>): void {
   })
 
   watch(() => modelStore.current, (name) => { load(name) })
+  watch(() => modelStore.reloadToken, () => {
+    if (!modelStore.current) return
+    faultPartsStore.loaded = false
+    load(modelStore.current)
+  })
   watch(() => alarmsStore.selected?.id, () => { faultPartsStore.apply() })
 
   onBeforeUnmount(() => {

+ 8 - 1
viewer/frontend/src/stores/model.ts

@@ -8,18 +8,25 @@ export const useModelStore = defineStore('model', () => {
   const current = ref('')
   const loading = ref(false)
   const loadingText = ref('')
+  const reloadToken = ref(0)
 
   async function loadModelList(): Promise<void> {
     models.value = await fetchModels()
     if (!current.value && models.value.length) current.value = models.value[0]
   }
 
+  /** 请求重新加载当前模型(用于 .glb 被外部改写后刷新)。 */
+  function requestReload(): void {
+    reloadToken.value += 1
+  }
+
   function reset(): void {
     models.value = []
     current.value = ''
     loading.value = false
     loadingText.value = ''
+    reloadToken.value = 0
   }
 
-  return { models, current, loading, loadingText, loadModelList, reset }
+  return { models, current, loading, loadingText, reloadToken, loadModelList, requestReload, reset }
 })

+ 13 - 2
viewer/frontend/src/stores/parts.ts

@@ -1,14 +1,17 @@
 import { defineStore } from 'pinia'
 import { computed, ref } from 'vue'
 
+import { renamePart as apiRenamePart } from '../api/models'
 import { getViewer } from '../three/instance'
 import type { PartMeta, PartStatus } from '../types'
+import { useModelStore } from './model'
 
 export const usePartsStore = defineStore('parts', () => {
   const parts = ref<PartMeta[]>([])
   const selected = ref<Set<string>>(new Set())
   const queryOpen = ref(false)
   const search = ref('')
+  const pendingSelectLabel = ref<string | null>(null)
 
   const selectedParts = computed(() => parts.value.filter((p) => selected.value.has(p.id)))
   const selectedCount = computed(() => selected.value.size)
@@ -75,10 +78,18 @@ export const usePartsStore = defineStore('parts', () => {
     parts.value.forEach((p) => resetColor(p.id))
   }
 
+  /** 重命名部件(写回 .glb 并同步映射表),随后重载模型并按新名重新选中。 */
+  async function renamePart(label: string, newName: string): Promise<void> {
+    const modelStore = useModelStore()
+    const result = await apiRenamePart(modelStore.current, label, newName)
+    pendingSelectLabel.value = result.new_label
+    modelStore.requestReload()
+  }
+
   return {
-    parts, selected, queryOpen, search,
+    parts, selected, queryOpen, search, pendingSelectLabel,
     selectedParts, selectedCount,
     byId, setParts, selectOnly, toggle, clearSelection,
-    setColor, resetColor, applyPreset, resetSelected, resetAll,
+    setColor, resetColor, applyPreset, resetSelected, resetAll, renamePart,
   }
 })

二进制
viewer/models/压缩机2326-3000-one.glb