Explorar el Código

3d:数据区域接入 compressor_alarm 列表(筛选/分页/选中+故障详情卡片);场景位置改为 CSV 存储与顶部圆角卡片(应用/改名/删除、添加位置);一律大模型模式与相关 UI 调整

18922397810 hace 2 semanas
padre
commit
962a5e3796

+ 31 - 1
3d/backend/config.py

@@ -1,5 +1,6 @@
-"""3D 模块服务端配置:路径与端口。"""
+"""3D 模块服务端配置:路径、端口与数据库。"""
 
+import os
 from pathlib import Path
 
 BASE_DIR = Path(__file__).resolve().parent          # 3d/backend
@@ -7,4 +8,33 @@ MODULE_DIR = BASE_DIR.parent                        # 3d
 MODELS_DIR = MODULE_DIR / "models"
 DATA_DIR = MODULE_DIR / "data"
 DATA_FILE = DATA_DIR / "color_state.json"
+SCENE_CSV = DATA_DIR / "scene_positions.csv"
 FRONTEND_DIST = MODULE_DIR / "frontend" / "dist"
+
+# 数据库凭据沿用项目根目录的 数据库.md(与 backend/app/config.py 同源,不复制到代码里)。
+_DB_NOTE = BASE_DIR.parents[1] / "数据库.md"
+
+
+def _db_values() -> dict[str, str]:
+    values: dict[str, str] = {}
+    if not _DB_NOTE.exists():
+        return values
+    for line in _DB_NOTE.read_text(encoding="utf-8").splitlines():
+        if ":" in line:
+            key, value = line.split(":", 1)
+        elif "=" in line:
+            key, value = line.split("=", 1)
+        else:
+            continue
+        values[key.strip().lower()] = value.strip()
+    return values
+
+
+_db = _db_values()
+
+DB_HOST = os.getenv("DB_HOST", _db.get("host", "127.0.0.1"))
+DB_PORT = int(os.getenv("DB_PORT", _db.get("port", "3306")))
+DB_USER = os.getenv("DB_USER", _db.get("user", "root"))
+DB_PASSWORD = os.getenv("DB_PASSWORD", _db.get("password", ""))
+DB_NAME = os.getenv("DB_NAME", _db.get("db_name", "compressorsensor"))
+DB_CONNECT_TIMEOUT = int(os.getenv("DB_CONNECT_TIMEOUT", "5"))

+ 27 - 0
3d/backend/db.py

@@ -0,0 +1,27 @@
+"""数据库连接(只读告警数据)。"""
+
+import pymysql
+
+from config import (
+    DB_CONNECT_TIMEOUT,
+    DB_HOST,
+    DB_NAME,
+    DB_PASSWORD,
+    DB_PORT,
+    DB_USER,
+)
+
+
+def connect():
+    return pymysql.connect(
+        host=DB_HOST,
+        port=DB_PORT,
+        user=DB_USER,
+        password=DB_PASSWORD,
+        database=DB_NAME,
+        charset="utf8mb4",
+        cursorclass=pymysql.cursors.DictCursor,
+        connect_timeout=DB_CONNECT_TIMEOUT,
+        read_timeout=600,
+        autocommit=True,
+    )

+ 3 - 1
3d/backend/main.py

@@ -13,7 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import FileResponse
 
 from config import FRONTEND_DIST
-from routers import files, state
+from routers import alarms, files, scenes, state
 
 app = FastAPI(title="压缩机 3D 部件查看服务", version="0.1.0")
 app.add_middleware(
@@ -23,7 +23,9 @@ app.add_middleware(
     allow_headers=["*"],
 )
 
+app.include_router(alarms.router)
 app.include_router(files.router)
+app.include_router(scenes.router)
 app.include_router(state.router)
 
 

+ 1 - 0
3d/backend/requirements.txt

@@ -1,3 +1,4 @@
 fastapi>=0.115,<1
 uvicorn[standard]>=0.30,<1
 pydantic>=2.0,<3
+PyMySQL>=1.1,<2

+ 24 - 0
3d/backend/routers/alarms.py

@@ -0,0 +1,24 @@
+"""压缩机告警列表与筛选选项。"""
+
+from typing import Any
+
+from fastapi import APIRouter, Query
+
+from services import alarm_service
+
+router = APIRouter()
+
+
+@router.get("/api/alarms")
+def list_alarms(
+    device_code: str | None = None,
+    alarm_type: str | None = None,
+    page: int = Query(1, ge=1),
+    page_size: int = Query(10, ge=1, le=100),
+) -> dict[str, Any]:
+    return alarm_service.list_alarms(device_code, alarm_type, page, page_size)
+
+
+@router.get("/api/alarm-filters")
+def alarm_filters() -> dict[str, Any]:
+    return alarm_service.alarm_filters()

+ 45 - 0
3d/backend/routers/scenes.py

@@ -0,0 +1,45 @@
+"""场景位置的增删改查。"""
+
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel, Field
+
+from services import scene_service
+
+router = APIRouter()
+
+
+class SceneCreate(BaseModel):
+    name: str = Field(min_length=1, max_length=50)
+    camera: list[float] = Field(min_length=3, max_length=3)
+    target: list[float] = Field(min_length=3, max_length=3)
+
+
+class SceneRename(BaseModel):
+    name: str = Field(min_length=1, max_length=50)
+
+
+@router.get("/api/scene-positions")
+def list_scenes() -> list[dict[str, Any]]:
+    return scene_service.list_scenes()
+
+
+@router.post("/api/scene-positions")
+def add_scene(payload: SceneCreate) -> dict[str, Any]:
+    return scene_service.add_scene(payload.name.strip(), payload.camera, payload.target)
+
+
+@router.put("/api/scene-positions/{scene_id}")
+def rename_scene(scene_id: int, payload: SceneRename) -> dict[str, Any]:
+    scene = scene_service.rename_scene(scene_id, payload.name.strip())
+    if scene is None:
+        raise HTTPException(status_code=404, detail="场景位置不存在")
+    return scene
+
+
+@router.delete("/api/scene-positions/{scene_id}")
+def delete_scene(scene_id: int) -> dict[str, Any]:
+    if not scene_service.delete_scene(scene_id):
+        raise HTTPException(status_code=404, detail="场景位置不存在")
+    return {"ok": True}

+ 91 - 0
3d/backend/services/alarm_service.py

@@ -0,0 +1,91 @@
+"""压缩机告警(compressor_alarm)查询,仅限压力/振动两类。"""
+
+import json
+from typing import Any
+
+from db import connect
+
+ALARM_TYPES = ("振动故障", "润滑油压力故障")
+
+
+def _parse_info(raw: Any) -> dict[str, Any]:
+    if not raw:
+        return {}
+    try:
+        value = json.loads(raw)
+    except (TypeError, ValueError):
+        return {}
+    return value if isinstance(value, dict) else {}
+
+
+def list_alarms(
+    device_code: str | None = None,
+    alarm_type: str | None = None,
+    page: int = 1,
+    page_size: int = 10,
+) -> dict[str, Any]:
+    where = ["alarm_type IN (%s, %s)"]
+    params: list[Any] = list(ALARM_TYPES)
+    if device_code:
+        where.append("device_code = %s")
+        params.append(device_code)
+    if alarm_type:
+        where.append("alarm_type = %s")
+        params.append(alarm_type)
+    clause = " AND ".join(where)
+
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(f"SELECT COUNT(*) AS c FROM compressor_alarm WHERE {clause}", params)
+            total = int(cursor.fetchone()["c"])
+            offset = (page - 1) * page_size
+            cursor.execute(
+                f"""
+                SELECT id, device_code, device_part, device_point, alarm_type, alarm_level,
+                       alarm_time_start, alarm_time_end, alarm_des, alarm_info_json
+                FROM compressor_alarm
+                WHERE {clause}
+                ORDER BY alarm_time_start DESC, id DESC
+                LIMIT %s OFFSET %s
+                """,
+                params + [page_size, offset],
+            )
+            rows = cursor.fetchall()
+    finally:
+        connection.close()
+
+    items = [
+        {
+            "id": int(row["id"]),
+            "device_code": row["device_code"],
+            "device_part": row["device_part"],
+            "device_point": row["device_point"],
+            "alarm_type": row["alarm_type"],
+            "alarm_level": row["alarm_level"],
+            "alarm_time_start": row["alarm_time_start"],
+            "alarm_time_end": row["alarm_time_end"],
+            "alarm_des": row["alarm_des"],
+            "alarm_info": _parse_info(row["alarm_info_json"]),
+        }
+        for row in rows
+    ]
+    return {"total": total, "page": page, "page_size": page_size, "items": items}
+
+
+def alarm_filters() -> dict[str, Any]:
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                SELECT DISTINCT device_code FROM compressor_alarm
+                WHERE alarm_type IN (%s, %s) AND device_code IS NOT NULL
+                ORDER BY device_code
+                """,
+                ALARM_TYPES,
+            )
+            devices = [row["device_code"] for row in cursor.fetchall()]
+    finally:
+        connection.close()
+    return {"devices": devices, "alarm_types": list(ALARM_TYPES)}

+ 95 - 0
3d/backend/services/scene_service.py

@@ -0,0 +1,95 @@
+"""场景位置:data/scene_positions.csv 的读写(含初始种子)。
+
+坐标存“参考系”(Z 不含随模型变化的 dz),前端应用时再叠加 dz。
+"""
+
+import csv
+import os
+from typing import Any
+
+from config import DATA_DIR, SCENE_CSV
+
+FIELDS = [
+    "id", "name",
+    "camera_x", "camera_y", "camera_z",
+    "target_x", "target_y", "target_z",
+]
+
+# 初始种子:俯视 / 角度A(参考系坐标,与前端 VIEW_PRESETS 一致)。
+SEED: list[tuple[int, str, list[float], list[float]]] = [
+    (1, "俯视", [2.0, 22.0, -3.2], [2.0, 0.0, -22.0]),
+    (2, "角度A", [5.4, 16.7, -6.3], [9.4, 0.0, -22.5]),
+]
+
+
+def _ensure_file() -> None:
+    if SCENE_CSV.exists():
+        return
+    DATA_DIR.mkdir(parents=True, exist_ok=True)
+    with SCENE_CSV.open("w", encoding="utf-8", newline="") as handle:
+        writer = csv.writer(handle)
+        writer.writerow(FIELDS)
+        for scene_id, name, camera, target in SEED:
+            writer.writerow([scene_id, name, *camera, *target])
+
+
+def _to_scene(row: dict[str, str]) -> dict[str, Any]:
+    return {
+        "id": int(row["id"]),
+        "name": row["name"],
+        "camera": [float(row["camera_x"]), float(row["camera_y"]), float(row["camera_z"])],
+        "target": [float(row["target_x"]), float(row["target_y"]), float(row["target_z"])],
+    }
+
+
+def _read() -> list[dict[str, Any]]:
+    _ensure_file()
+    with SCENE_CSV.open("r", encoding="utf-8", newline="") as handle:
+        return [_to_scene(row) for row in csv.DictReader(handle)]
+
+
+def _write(scenes: list[dict[str, Any]]) -> None:
+    DATA_DIR.mkdir(parents=True, exist_ok=True)
+    tmp = SCENE_CSV.parent / (SCENE_CSV.name + ".tmp")
+    with tmp.open("w", encoding="utf-8", newline="") as handle:
+        writer = csv.writer(handle)
+        writer.writerow(FIELDS)
+        for scene in scenes:
+            writer.writerow([scene["id"], scene["name"], *scene["camera"], *scene["target"]])
+    os.replace(tmp, SCENE_CSV)
+
+
+def list_scenes() -> list[dict[str, Any]]:
+    return _read()
+
+
+def add_scene(name: str, camera: list[float], target: list[float]) -> dict[str, Any]:
+    scenes = _read()
+    scene = {
+        "id": max((s["id"] for s in scenes), default=0) + 1,
+        "name": name,
+        "camera": [float(v) for v in camera],
+        "target": [float(v) for v in target],
+    }
+    scenes.append(scene)
+    _write(scenes)
+    return scene
+
+
+def rename_scene(scene_id: int, name: str) -> dict[str, Any] | None:
+    scenes = _read()
+    for scene in scenes:
+        if scene["id"] == scene_id:
+            scene["name"] = name
+            _write(scenes)
+            return scene
+    return None
+
+
+def delete_scene(scene_id: int) -> bool:
+    scenes = _read()
+    remaining = [scene for scene in scenes if scene["id"] != scene_id]
+    if len(remaining) == len(scenes):
+        return False
+    _write(remaining)
+    return True

+ 4 - 0
3d/data/scene_positions.csv

@@ -0,0 +1,4 @@
+id,name,camera_x,camera_y,camera_z,target_x,target_y,target_z
+1,俯视,2.0,22.0,-3.2,2.0,0.0,-22.0
+2,压缩机,5.4,16.7,-6.3,9.4,0.0,-22.5
+3,主体视角,17.974953109809466,8.170543787595482,-29.243920776201154,5.340199340854499,-4.950376594417323,-19.851266531021484

+ 8 - 1
3d/frontend/src/App.vue

@@ -3,6 +3,7 @@ import { theme } from 'ant-design-vue'
 import zhCN from 'ant-design-vue/es/locale/zh_CN'
 import { onMounted } from 'vue'
 
+import AlarmDetailCard from './components/AlarmDetailCard.vue'
 import DataPanel from './components/DataPanel.vue'
 import PartQueryModal from './components/PartQueryModal.vue'
 import PartStatusCard from './components/PartStatusCard.vue'
@@ -10,11 +11,16 @@ import ScenePositionCard from './components/ScenePositionCard.vue'
 import TopBar from './components/TopBar.vue'
 import ViewerCanvas from './components/ViewerCanvas.vue'
 import { useModelStore } from './stores/model'
+import { useScenesStore } from './stores/scenes'
+import { useViewerStore } from './stores/viewer'
 
 const modelStore = useModelStore()
+const viewerStore = useViewerStore()
+const scenesStore = useScenesStore()
 
 onMounted(() => {
   modelStore.loadModelList()
+  scenesStore.load()
 })
 </script>
 
@@ -29,8 +35,9 @@ onMounted(() => {
         <DataPanel />
         <div class="ext-panel">
           <PartStatusCard />
-          <ScenePositionCard />
+          <AlarmDetailCard />
         </div>
+        <ScenePositionCard v-if="viewerStore.sceneVisible" class="scene-overlay" />
       </ViewerCanvas>
       <PartQueryModal />
     </div>

+ 19 - 0
3d/frontend/src/api/alarms.ts

@@ -0,0 +1,19 @@
+import { http } from './http'
+import type { AlarmFilters, AlarmPage } from '../types'
+
+export interface AlarmQuery {
+  device_code?: string
+  alarm_type?: string
+  page?: number
+  page_size?: number
+}
+
+export async function fetchAlarms(params: AlarmQuery): Promise<AlarmPage> {
+  const res = await http.get<AlarmPage>('/api/alarms', { params })
+  return res.data
+}
+
+export async function fetchAlarmFilters(): Promise<AlarmFilters> {
+  const res = await http.get<AlarmFilters>('/api/alarm-filters')
+  return res.data
+}

+ 25 - 0
3d/frontend/src/api/scenes.ts

@@ -0,0 +1,25 @@
+import { http } from './http'
+import type { ScenePosition } from '../types'
+
+export async function fetchScenes(): Promise<ScenePosition[]> {
+  const res = await http.get<ScenePosition[]>('/api/scene-positions')
+  return res.data
+}
+
+export async function createScene(
+  name: string,
+  camera: [number, number, number],
+  target: [number, number, number],
+): Promise<ScenePosition> {
+  const res = await http.post<ScenePosition>('/api/scene-positions', { name, camera, target })
+  return res.data
+}
+
+export async function renameScene(id: number, name: string): Promise<ScenePosition> {
+  const res = await http.put<ScenePosition>(`/api/scene-positions/${id}`, { name })
+  return res.data
+}
+
+export async function deleteScene(id: number): Promise<void> {
+  await http.delete(`/api/scene-positions/${id}`)
+}

+ 128 - 0
3d/frontend/src/components/AlarmDetailCard.vue

@@ -0,0 +1,128 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import { useAlarmsStore } from '../stores/alarms'
+import { deviceLabel } from '../utils/device'
+import { levelColor } from '../utils/level'
+
+const store = useAlarmsStore()
+const info = computed<Record<string, unknown>>(() => store.selected?.alarm_info ?? {})
+const isVibration = computed(() => store.selected?.alarm_type === '振动故障')
+
+function pick(...keys: string[]): string {
+  for (const key of keys) {
+    const value = info.value[key]
+    if (value !== undefined && value !== null && value !== '') return String(value)
+  }
+  return '—'
+}
+
+function fmt(dt: string): string {
+  return dt ? dt.replace('T', ' ').slice(0, 16) : ''
+}
+
+const stage = computed(() => (isVibration.value ? pick('振动阶段') : pick('压力阶段')))
+const stageColor = computed(() => levelColor(store.selected?.alarm_level ?? null))
+const machine = computed(
+  () => String(info.value['机组'] ?? deviceLabel(store.selected?.device_code ?? '')),
+)
+const hours = computed(() => pick('有效运行小时数'))
+const timeRange = computed(() => {
+  const start = pick('开始小时')
+  const end = pick('结束小时')
+  if (start !== '—' || end !== '—') {
+    return `${start === '—' ? start : fmt(start)}~${end === '—' ? end : fmt(end)}`
+  }
+  const row = store.selected
+  return row ? `${fmt(row.alarm_time_start)}~${fmt(row.alarm_time_end)}` : '—'
+})
+const baselineLabel = computed(() => (isVibration.value ? '近期基线' : '周期基线'))
+const baseline = computed(() => (isVibration.value ? pick('近期基线振动') : pick('周期基线油压')))
+const consecutive = computed(() =>
+  isVibration.value ? pick('阶段最大连续异常有效小时数') : pick('阶段最大连续下降有效小时数'),
+)
+const basis = computed(() => pick('阶段触发依据'))
+</script>
+
+<template>
+  <section v-if="store.selected" class="glass card detail-card">
+    <div class="detail-head">
+      <h3 class="card-title">故障详情</h3>
+      <span class="stage" :style="{ background: stageColor }">{{ stage }}</span>
+    </div>
+    <div class="detail">
+      <div class="pair">
+        <div class="cell"><span class="label">机组:</span><span class="value">{{ machine }}</span></div>
+        <div class="cell"><span class="label">有效运行小时数:</span><span class="value">{{ hours }}</span></div>
+      </div>
+      <div class="dline"><span class="label">时间:</span><span class="value wrap">{{ timeRange }}</span></div>
+      <div class="pair">
+        <div class="cell"><span class="label">{{ baselineLabel }}:</span><span class="value">{{ baseline }}</span></div>
+        <div class="cell"><span class="label">最大连续:</span><span class="value">{{ consecutive }}</span></div>
+      </div>
+      <div class="dline"><span class="label">触发依据:</span><span class="value wrap">{{ basis }}</span></div>
+      <div class="dline"><span class="label">模型预测:</span><span class="value">—</span></div>
+    </div>
+  </section>
+</template>
+
+<style scoped>
+.detail-card {
+  pointer-events: auto;
+  padding: 8px 10px;
+}
+.detail-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 4px;
+}
+.card-title {
+  font-size: 14px;
+  color: #fff;
+  font-weight: 600;
+  letter-spacing: 0.5px;
+  margin: 0;
+}
+.stage {
+  display: inline-flex;
+  align-items: center;
+  height: 18px;
+  line-height: 18px;
+  font-size: 12px;
+  color: #fff;
+  border-radius: 3px;
+  padding: 0 8px;
+  white-space: nowrap;
+}
+.detail {
+  font-size: 12px;
+}
+.pair {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 8px;
+}
+.cell,
+.dline {
+  display: flex;
+  min-width: 0;
+  line-height: 20px;
+}
+.label {
+  color: #aaaaaa;
+  flex: 0 0 auto;
+}
+.value {
+  color: #fff;
+  min-width: 0;
+}
+.cell .value {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.dline .value.wrap {
+  word-break: break-all;
+}
+</style>

+ 172 - 4
3d/frontend/src/components/DataPanel.vue

@@ -1,7 +1,91 @@
+<script setup lang="ts">
+import { onMounted } from 'vue'
+
+import { useAlarmsStore } from '../stores/alarms'
+import type { AlarmRow } from '../types'
+import { deviceLabel } from '../utils/device'
+import { levelColor, levelLabel } from '../utils/level'
+
+const store = useAlarmsStore()
+
+function fmt(dt: string): string {
+  return dt ? dt.replace('T', ' ').slice(0, 16) : ''
+}
+
+function onDeviceChange(value: unknown): void {
+  store.setDevice(value === undefined || value === null ? '' : String(value))
+}
+function onTypeChange(value: unknown): void {
+  store.setType(value === undefined || value === null ? '' : String(value))
+}
+function onRowClick(row: AlarmRow): void {
+  store.select(row)
+}
+
+onMounted(async () => {
+  await store.loadFilters()
+  await store.load()
+})
+</script>
+
 <template>
   <section class="glass card data-panel">
     <h3 class="card-title">数据区域</h3>
-    <div class="placeholder">内容待定…</div>
+
+    <div class="filters">
+      <a-select
+        :value="store.deviceCode"
+        size="small"
+        style="width: 110px"
+        :options="[{ value: '', label: '全部机组' }, ...store.devices.map((d) => ({ value: d, label: deviceLabel(d) }))]"
+        @change="onDeviceChange"
+      />
+      <a-select
+        :value="store.alarmType"
+        size="small"
+        style="flex: 1"
+        :options="[
+          { value: '', label: '全部类型' },
+          ...store.alarmTypes.map((t) => ({ value: t, label: t })),
+        ]"
+        @change="onTypeChange"
+      />
+    </div>
+
+    <div class="list">
+      <div
+        v-for="row in store.items"
+        :key="row.id"
+        class="row"
+        :class="{ selected: store.selectedId === row.id }"
+        @click="onRowClick(row)"
+      >
+        <div class="line1">
+          <span
+            v-if="row.alarm_level"
+            class="level"
+            :style="{ background: levelColor(row.alarm_level) }"
+          >{{ levelLabel(row.alarm_level) }}</span>
+          <span class="type">{{ row.alarm_type }}</span>
+          <span class="flex-spacer" />
+          <span class="device">{{ deviceLabel(row.device_code) }}</span>
+        </div>
+        <div class="line2">{{ fmt(row.alarm_time_start) }} ~ {{ fmt(row.alarm_time_end) }}</div>
+        <div class="line3">{{ row.alarm_des }}</div>
+      </div>
+      <div v-if="!store.loading && !store.items.length" class="empty">暂无数据</div>
+    </div>
+
+    <div class="pager">
+      <a-pagination
+        size="small"
+        :current="store.page"
+        :page-size="store.pageSize"
+        :total="store.total"
+        :show-size-changer="false"
+        @change="store.setPage"
+      />
+    </div>
   </section>
 </template>
 
@@ -21,11 +105,95 @@
   color: #fff;
   font-weight: 600;
   letter-spacing: 0.5px;
-  margin: 0;
+  margin: 0 0 8px;
+}
+.data-panel :deep(.ant-select) {
+  font-size: 12px;
+}
+.filters {
+  display: flex;
+  gap: 6px;
+  margin-bottom: 8px;
+}
+.list {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+}
+.list::-webkit-scrollbar {
+  width: 6px;
+}
+.list::-webkit-scrollbar-thumb {
+  background: #3a424f;
+  border-radius: 3px;
+}
+.row {
+  padding: 3px 4px;
+  border-bottom: 1px solid #999999;
+  cursor: pointer;
 }
-.placeholder {
-  margin-top: 10px;
+.row:hover {
+  background: rgba(255, 255, 255, 0.05);
+}
+.row.selected {
+  background: rgba(77, 163, 255, 0.22);
+}
+.line1 {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  line-height: 32px;
+}
+.line1 .type {
+  font-size: 14px;
   color: #fff;
+}
+.line1 .level {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 20px;
+  line-height: 20px;
+  min-width: 38px;
+  padding: 0 6px;
+  font-size: 12px;
+  color: #fff;
+  border-radius: 3px;
+  white-space: nowrap;
+}
+.line1 .flex-spacer {
+  flex: 1;
+}
+.line1 .device {
+  font-size: 12px;
+  color: #aaaaaa;
+}
+.line2 {
+  font-size: 12px;
+  line-height: 18px;
+  color: #aaaaaa;
+}
+.line3 {
+  font-size: 12px;
+  line-height: 18px;
+  color: #aaaaaa;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+.empty {
+  color: #aaaaaa;
+  font-size: 12px;
+  text-align: center;
+  padding: 20px 0;
+}
+.pager {
+  margin-top: 8px;
+  display: flex;
+  justify-content: center;
+}
+.data-panel :deep(.ant-pagination) {
   font-size: 12px;
 }
 </style>

+ 8 - 0
3d/frontend/src/components/PartStatusCard.vue

@@ -57,6 +57,7 @@ const statusLabel = computed(() => {
 
     <div class="card-foot">
       <a-button type="link" size="small" @click="partsStore.queryOpen = true">打开查询部件…</a-button>
+      <span class="part-count">共 {{ partsStore.parts.length }} 个部件</span>
     </div>
   </section>
 </template>
@@ -118,5 +119,12 @@ const statusLabel = computed(() => {
   margin-top: 8px;
   padding-top: 6px;
   border-top: 1px solid #333a45;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.part-count {
+  color: #aaaaaa;
+  font-size: 12px;
 }
 </style>

+ 40 - 3
3d/frontend/src/components/ScenePositionCard.vue

@@ -1,9 +1,15 @@
 <script setup lang="ts">
-import { computed } from 'vue'
+import { message } from 'ant-design-vue'
+import { computed, ref } from 'vue'
 
+import { useScenesStore } from '../stores/scenes'
 import { useViewerStore } from '../stores/viewer'
 
 const viewerStore = useViewerStore()
+const scenesStore = useScenesStore()
+
+const addOpen = ref(false)
+const addName = ref('')
 
 const lines = computed(() => {
   const info = viewerStore.camInfo
@@ -16,15 +22,37 @@ const lines = computed(() => {
     `目标 (${tx.toFixed(1)}, ${ty.toFixed(1)}, ${tz.toFixed(1)})`,
   ]
 })
+
+function openAdd(): void {
+  addName.value = ''
+  addOpen.value = true
+}
+
+async function confirmAdd(): Promise<void> {
+  const name = addName.value.trim()
+  if (!name) {
+    message.error('请输入场景名称')
+    return
+  }
+  await scenesStore.add(name)
+  addOpen.value = false
+}
 </script>
 
 <template>
   <section class="glass card scene-card">
-    <h3 class="card-title">场景位置</h3>
+    <div class="card-head">
+      <h3 class="card-title">场景位置</h3>
+      <a-button type="link" size="small" class="add-btn" @click="openAdd">添加位置</a-button>
+    </div>
     <div v-if="lines.length" class="cam-info">
       <div v-for="(line, i) in lines" :key="i">{{ line }}</div>
     </div>
     <div v-else class="cam-info dim">—</div>
+
+    <a-modal v-model:open="addOpen" title="添加场景位置" @ok="confirmAdd">
+      <a-input v-model:value="addName" placeholder="请输入场景名称" @press-enter="confirmAdd" />
+    </a-modal>
   </section>
 </template>
 
@@ -33,12 +61,21 @@ const lines = computed(() => {
   pointer-events: auto;
   padding: 10px;
 }
+.card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8px;
+}
 .card-title {
   font-size: 14px;
   color: #fff;
   font-weight: 600;
   letter-spacing: 0.5px;
-  margin: 0 0 8px;
+  margin: 0;
+}
+.scene-card :deep(.ant-btn) {
+  font-size: 12px;
 }
 .cam-info {
   color: #fff;

+ 125 - 24
3d/frontend/src/components/TopBar.vue

@@ -1,16 +1,62 @@
 <script setup lang="ts">
-import { VIEW_PRESETS } from '../three/Viewer'
+import { Modal, message } from 'ant-design-vue'
+import { ref } from 'vue'
+
 import { useModelStore } from '../stores/model'
 import { usePartsStore } from '../stores/parts'
+import { useScenesStore } from '../stores/scenes'
 import { useViewerStore } from '../stores/viewer'
+import type { ScenePosition } from '../types'
 
 const modelStore = useModelStore()
 const partsStore = usePartsStore()
 const viewerStore = useViewerStore()
+const scenesStore = useScenesStore()
+
+const renameOpen = ref(false)
+const renameId = ref<number | null>(null)
+const renameName = ref('')
 
 function onModelChange(value: unknown): void {
   modelStore.current = String(value)
 }
+
+function applyScene(scene: ScenePosition): void {
+  scenesStore.apply(scene)
+}
+
+function openRename(scene: ScenePosition): void {
+  renameId.value = scene.id
+  renameName.value = scene.name
+  renameOpen.value = true
+}
+
+async function confirmRename(): Promise<void> {
+  const name = renameName.value.trim()
+  if (!name || renameId.value === null) {
+    message.error('请输入场景名称')
+    return
+  }
+  await scenesStore.rename(renameId.value, name)
+  renameOpen.value = false
+}
+
+function confirmRemove(scene: ScenePosition): void {
+  Modal.confirm({
+    title: '删除场景位置',
+    content: `确定删除「${scene.name}」吗?`,
+    okText: '删除',
+    okType: 'danger',
+    cancelText: '取消',
+    onOk: () => scenesStore.remove(scene.id),
+  })
+}
+
+function removeFromRename(): void {
+  const scene = scenesStore.items.find((item) => item.id === renameId.value)
+  renameOpen.value = false
+  if (scene) confirmRemove(scene)
+}
 </script>
 
 <template>
@@ -26,24 +72,35 @@ function onModelChange(value: unknown): void {
         @change="onModelChange"
       />
     </div>
-    <span class="count">共 {{ partsStore.parts.length }} 个部件{{ modelStore.largeMode ? ' · 大模型模式' : '' }}</span>
     <div class="spacer" />
     <a-tag v-if="partsStore.cycleText" color="blue">{{ partsStore.cycleText }}</a-tag>
-    <a-button-group>
-      <a-button
-        v-for="(v, i) in VIEW_PRESETS"
-        :key="v.label"
-        size="small"
-        :type="viewerStore.activeView === i ? 'primary' : 'default'"
-        @click="viewerStore.activateView(i)"
+    <div class="scene-chips">
+      <div
+        v-for="scene in scenesStore.items"
+        :key="scene.id"
+        class="scene-chip"
+        title="单击应用 · 双击编辑"
+        @click="applyScene(scene)"
+        @dblclick="openRename(scene)"
       >
-        {{ v.label }}
-      </a-button>
-    </a-button-group>
-    <a-button size="small" @click="viewerStore.toggleFreeMode()">
+        <span class="chip-name">{{ scene.name }}</span>
+      </div>
+    </div>
+    <a-button size="small" class="rot-btn" @click="viewerStore.toggleFreeMode()">
       {{ viewerStore.freeMode ? '轨道旋转' : '自由旋转' }}
     </a-button>
-    <span class="save-status">{{ partsStore.saveStatus }}</span>
+    <span v-if="partsStore.saveStatus" class="save-status">{{ partsStore.saveStatus }}</span>
+    <a-checkbox v-model:checked="viewerStore.sceneVisible">场景位置</a-checkbox>
+
+    <a-modal v-model:open="renameOpen" title="场景位置" :footer="null">
+      <a-input v-model:value="renameName" placeholder="请输入场景名称" @press-enter="confirmRename" />
+      <div class="rename-actions">
+        <a-button danger @click="removeFromRename">删除</a-button>
+        <span class="rename-spacer" />
+        <a-button @click="renameOpen = false">取消</a-button>
+        <a-button type="primary" @click="confirmRename">保存</a-button>
+      </div>
+    </a-modal>
   </header>
 </template>
 
@@ -53,7 +110,7 @@ function onModelChange(value: unknown): void {
   align-items: center;
   gap: 14px;
   height: 48px;
-  padding: 0 16px;
+  padding: 0 20px;
   background: #1d2128;
   border-bottom: 1px solid #333a45;
   color: #d6dbe2;
@@ -65,16 +122,15 @@ function onModelChange(value: unknown): void {
   font-size: 23px;
   font-weight: 600;
   line-height: 1;
-  /* 与左侧「数据区域」卡片同宽同起点后水平居中,使两者中心对齐
-     header 左内边距 16 + 4 = 20 = 数据区域卡片 left */
   width: 300px;
-  margin: 0 0 0 4px;
+  margin: 0;
   text-align: center;
 }
 /* 顶部除标题外统一 12px(含 antd 控件) */
 .topbar :deep(.ant-select),
 .topbar :deep(.ant-btn),
-.topbar :deep(.ant-tag) {
+.topbar :deep(.ant-tag),
+.topbar :deep(.ant-checkbox-wrapper) {
   font-size: 12px;
 }
 .field {
@@ -85,17 +141,62 @@ function onModelChange(value: unknown): void {
 .field .label {
   color: #8b93a1;
 }
-.count {
-  color: #8b93a1;
+.spacer {
+  flex: 1;
+}
+.scene-chips {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  max-width: 46vw;
+  overflow-x: auto;
+}
+.scene-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  flex: 0 0 auto;
+  height: 22px;
+  background: #242a33;
+  border: 1px solid #aaaaaa;
+  border-radius: 5px;
+  padding: 0 12px;
+  line-height: 1;
   font-size: 12px;
+  cursor: pointer;
+  user-select: none;
 }
-.spacer {
+.scene-chip:hover {
+  border-color: #4da3ff;
+}
+.rename-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-top: 16px;
+}
+.rename-spacer {
   flex: 1;
 }
 .save-status {
   color: #8b93a1;
   font-size: 12px;
-  min-width: 96px;
-  text-align: right;
+}
+/* 与场景位置圆角卡片统一样式 */
+.rot-btn {
+  display: inline-flex !important;
+  align-items: center !important;
+  justify-content: center !important;
+  background: #242a33 !important;
+  border: 1px solid #aaaaaa !important;
+  border-radius: 5px !important;
+  color: #d6dbe2 !important;
+  padding: 0 12px !important;
+  height: 22px !important;
+  line-height: 1 !important;
+}
+.rot-btn:hover {
+  border-color: #4da3ff !important;
+  color: #fff !important;
 }
 </style>

+ 0 - 14
3d/frontend/src/components/ViewerCanvas.vue

@@ -29,7 +29,6 @@ function onMove(e: MouseEvent): void {
     <div v-if="viewerStore.tooltip.visible" class="tooltip" :style="tooltipStyle">
       {{ viewerStore.hoveredLabel }}
     </div>
-    <div class="hint">左键拖动=旋转 · 滚轮=缩放 · 右键拖动=平移 · 点击部件=选中</div>
     <slot />
   </div>
 </template>
@@ -68,17 +67,4 @@ function onMove(e: MouseEvent): void {
   white-space: nowrap;
   z-index: 20;
 }
-.hint {
-  position: absolute;
-  left: 50%;
-  transform: translateX(-50%);
-  bottom: 12px;
-  color: #8b93a1;
-  font-size: 12px;
-  background: rgba(0, 0, 0, 0.45);
-  padding: 5px 10px;
-  border-radius: 6px;
-  pointer-events: none;
-  z-index: 4;
-}
 </style>

+ 5 - 7
3d/frontend/src/composables/useThreeViewer.ts

@@ -5,12 +5,14 @@ import { setViewer } from '../three/instance'
 import { Viewer } from '../three/Viewer'
 import { useModelStore } from '../stores/model'
 import { usePartsStore } from '../stores/parts'
+import { useScenesStore } from '../stores/scenes'
 import { useViewerStore } from '../stores/viewer'
 
 export function useThreeViewer(container: Ref<HTMLElement | null>): void {
   const modelStore = useModelStore()
   const partsStore = usePartsStore()
   const viewerStore = useViewerStore()
+  const scenesStore = useScenesStore()
   let viewer: Viewer | null = null
 
   async function load(name: string): Promise<void> {
@@ -23,8 +25,8 @@ export function useThreeViewer(container: Ref<HTMLElement | null>): void {
       await viewer.loadModel(name, modelUrl(name))
       viewer.prepareViews()
       viewer.fitAll(1.2)
-      viewer.activateView(0)
-      viewerStore.activeView = 0
+      await scenesStore.load()
+      if (scenesStore.items[0]) scenesStore.apply(scenesStore.items[0])
       await partsStore.applySavedState(name)
       if (/^压缩机2326/.test(name) && /one/.test(name)) {
         const target = partsStore.parts.find(
@@ -47,11 +49,7 @@ export function useThreeViewer(container: Ref<HTMLElement | null>): void {
     if (!container.value) return
     viewer = new Viewer(container.value, {
       onProgress: (text) => { modelStore.loadingText = text },
-      onParts: (list, info) => {
-        partsStore.setParts(list)
-        modelStore.meshCount = info.meshCount
-        modelStore.largeMode = info.largeMode
-      },
+      onParts: (list) => { partsStore.setParts(list) },
       onHover: (id, label) => { viewerStore.setHover(id, label) },
       onPick: (id, additive) => { if (id) partsStore.toggle(id, additive) },
       onCamInfo: (info) => { viewerStore.setCamInfo(info) },

+ 74 - 0
3d/frontend/src/stores/alarms.ts

@@ -0,0 +1,74 @@
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+
+import { fetchAlarmFilters, fetchAlarms } from '../api/alarms'
+import type { AlarmRow } from '../types'
+
+export const useAlarmsStore = defineStore('alarms', () => {
+  const devices = ref<string[]>([])
+  const alarmTypes = ref<string[]>([])
+  const deviceCode = ref('')   // 空 = 全部
+  const alarmType = ref('')    // 空 = 全部
+  const page = ref(1)
+  const pageSize = ref(10)
+  const total = ref(0)
+  const items = ref<AlarmRow[]>([])
+  const loading = ref(false)
+  const selectedId = ref<number | null>(null)
+
+  const selected = computed<AlarmRow | null>(
+    () => items.value.find((row) => row.id === selectedId.value) ?? null,
+  )
+
+  async function loadFilters(): Promise<void> {
+    const filters = await fetchAlarmFilters()
+    devices.value = filters.devices
+    alarmTypes.value = filters.alarm_types
+  }
+
+  async function load(): Promise<void> {
+    loading.value = true
+    try {
+      const res = await fetchAlarms({
+        device_code: deviceCode.value || undefined,
+        alarm_type: alarmType.value || undefined,
+        page: page.value,
+        page_size: pageSize.value,
+      })
+      items.value = res.items
+      total.value = res.total
+      if (selectedId.value && !items.value.some((row) => row.id === selectedId.value)) {
+        selectedId.value = null
+      }
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function setDevice(value: string): void {
+    deviceCode.value = value || ''
+    page.value = 1
+    load()
+  }
+
+  function setType(value: string): void {
+    alarmType.value = value || ''
+    page.value = 1
+    load()
+  }
+
+  function setPage(value: number): void {
+    page.value = value
+    load()
+  }
+
+  function select(row: AlarmRow): void {
+    selectedId.value = selectedId.value === row.id ? null : row.id
+  }
+
+  return {
+    devices, alarmTypes, deviceCode, alarmType, page, pageSize, total, items,
+    loading, selectedId, selected,
+    loadFilters, load, setDevice, setType, setPage, select,
+  }
+})

+ 1 - 3
3d/frontend/src/stores/model.ts

@@ -8,13 +8,11 @@ export const useModelStore = defineStore('model', () => {
   const current = ref('')
   const loading = ref(false)
   const loadingText = ref('')
-  const meshCount = ref(0)
-  const largeMode = ref(false)
 
   async function loadModelList(): Promise<void> {
     models.value = await fetchModels()
     if (!current.value && models.value.length) current.value = models.value[0]
   }
 
-  return { models, current, loading, loadingText, meshCount, largeMode, loadModelList }
+  return { models, current, loading, loadingText, loadModelList }
 })

+ 47 - 0
3d/frontend/src/stores/scenes.ts

@@ -0,0 +1,47 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+import { createScene, deleteScene, fetchScenes, renameScene } from '../api/scenes'
+import { getViewer } from '../three/instance'
+import type { ScenePosition } from '../types'
+
+export const useScenesStore = defineStore('scenes', () => {
+  const items = ref<ScenePosition[]>([])
+  const loaded = ref(false)
+  const loading = ref(false)
+
+  async function load(force = false): Promise<void> {
+    if (loaded.value && !force) return
+    loading.value = true
+    try {
+      items.value = await fetchScenes()
+      loaded.value = true
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function apply(scene: ScenePosition): void {
+    getViewer()?.applyCamera(scene.camera, scene.target)
+  }
+
+  async function add(name: string): Promise<void> {
+    const state = getViewer()?.getCameraState()
+    if (!state) return
+    const scene = await createScene(name, state.camera, state.target)
+    items.value = [...items.value, scene]
+  }
+
+  async function rename(id: number, name: string): Promise<void> {
+    const scene = await renameScene(id, name)
+    const target = items.value.find((item) => item.id === id)
+    if (target) target.name = scene.name
+  }
+
+  async function remove(id: number): Promise<void> {
+    await deleteScene(id)
+    items.value = items.value.filter((item) => item.id !== id)
+  }
+
+  return { items, loaded, loading, load, apply, add, rename, remove }
+})

+ 3 - 7
3d/frontend/src/stores/viewer.ts

@@ -6,11 +6,11 @@ import type { CameraInfo } from '../types'
 
 export const useViewerStore = defineStore('viewer', () => {
   const camInfo = ref<CameraInfo | null>(null)
-  const activeView = ref(0)
   const freeMode = ref(false)
   const hoveredId = ref<string | null>(null)
   const hoveredLabel = ref<string | null>(null)
   const tooltip = ref({ x: 0, y: 0, visible: false })
+  const sceneVisible = ref(false)
 
   function setCamInfo(info: CameraInfo): void {
     camInfo.value = info
@@ -24,17 +24,13 @@ export const useViewerStore = defineStore('viewer', () => {
     tooltip.value.x = x
     tooltip.value.y = y
   }
-  function activateView(index: number): void {
-    activeView.value = index
-    getViewer()?.activateView(index)
-  }
   function toggleFreeMode(): void {
     freeMode.value = !freeMode.value
     getViewer()?.setFreeMode(freeMode.value)
   }
 
   return {
-    camInfo, activeView, freeMode, hoveredId, hoveredLabel, tooltip,
-    setCamInfo, setHover, moveTooltip, activateView, toggleFreeMode,
+    camInfo, freeMode, hoveredId, hoveredLabel, tooltip, sceneVisible,
+    setCamInfo, setHover, moveTooltip, toggleFreeMode,
   }
 })

+ 16 - 1
3d/frontend/src/styles/index.css

@@ -48,6 +48,17 @@ body {
   flex: 0 0 auto;
 }
 
+/* 底部居中:场景位置卡片(由顶部勾选控制显示) */
+.scene-overlay {
+  position: absolute;
+  left: 50%;
+  transform: translateX(-50%);
+  bottom: 20px;
+  width: 300px;
+  z-index: 9;
+  pointer-events: auto;
+}
+
 /* 让 antd 组件使用与页面一致的字体(含中文 PingFang SC),避免中文回退字形偏大 */
 .ant-select,
 .ant-select *,
@@ -71,6 +82,10 @@ body {
 .ant-select-dropdown {
   font-size: 12px;
 }
+.data-panel .ant-select,
+.data-panel .ant-select * {
+  font-size: 12px !important;
+}
 
 /* 文本型(链接)按钮:亮蓝色,暗底上更清晰 */
 .ant-btn-link {
@@ -87,7 +102,7 @@ body {
 }
 .part-query-modal .ant-modal-header {
   background: #fff;
-  border-bottom: 1px solid #e7e9ec;
+  border-bottom: none;
   margin-bottom: 12px;
 }
 .part-query-modal .ant-modal-title {

+ 32 - 126
3d/frontend/src/three/Viewer.ts

@@ -5,28 +5,13 @@ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
 import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'
 import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
 import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
-import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js'
 import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'
 
 import type { CameraInfo, PartMeta, PartStatus } from '../types'
 
-const LARGE_THRESHOLD = 1500
 const HIGHLIGHT = new THREE.Color(0x4da3ff)
 const REF_MEDIAN_Z = -22.46
 
-export interface ViewPreset {
-  label: string
-  camera?: [number, number, number]
-  target?: [number, number, number]
-  axis?: [number, number, number]
-}
-
-export const VIEW_PRESETS: ViewPreset[] = [
-  { label: '俯视', camera: [2.0, 22.0, -3.2], target: [2.0, 0.0, -22.0] },
-  { label: '角度A', camera: [5.4, 16.7, -6.3], target: [9.4, 0.0, -22.5] },
-  { label: '短边', axis: [1, 0, 0] },
-]
-
 interface PartHandle {
   id: string
   label: string
@@ -41,7 +26,7 @@ interface PartHandle {
 }
 
 interface ViewerCallbacks {
-  onParts?: (parts: PartMeta[], info: { meshCount: number; largeMode: boolean; medianZ: number }) => void
+  onParts?: (parts: PartMeta[]) => void
   onProgress?: (text: string) => void
   onHover?: (id: string | null, label: string | null) => void
   onPick?: (id: string | null, additive: boolean) => void
@@ -69,7 +54,6 @@ export class Viewer {
   private controls!: OrbitControls | TrackballControls
   private freeMode = false
   private composer!: EffectComposer
-  private outline!: OutlinePass
   private grid: THREE.GridHelper | null = null
   private loader = new GLTFLoader()
   private raycaster = new THREE.Raycaster()
@@ -84,7 +68,6 @@ export class Viewer {
 
   private parts: PartHandle[] = []
   private byId = new Map<string, PartHandle>()
-  private largeMode = false
   private instancedMeshes: THREE.InstancedMesh[] = []
   private instMaterial = new THREE.MeshStandardMaterial({
     color: 0xffffff, metalness: 0.2, roughness: 0.7, side: THREE.DoubleSide,
@@ -137,18 +120,8 @@ export class Viewer {
 
     const composer = new EffectComposer(renderer)
     composer.addPass(new RenderPass(scene, camera))
-    const outline = new OutlinePass(
-      new THREE.Vector2(this.container.clientWidth, this.container.clientHeight), scene, camera,
-    )
-    outline.edgeStrength = 4
-    outline.edgeGlow = 0.2
-    outline.edgeThickness = 1.2
-    outline.visibleEdgeColor = new THREE.Color(0x4da3ff)
-    outline.hiddenEdgeColor = new THREE.Color(0x000000)
-    composer.addPass(outline)
     composer.addPass(new OutputPass())
     this.composer = composer
-    this.outline = outline
 
     this.bindEvents()
     this.updateAspect()
@@ -184,14 +157,9 @@ export class Viewer {
     this.camera.updateProjectionMatrix()
     this.renderer.setSize(w, h)
     this.composer.setSize(w, h)
-    this.outline.setSize(w, h)
     this.trackball?.handleResize()
   }
 
-  private get modelMedianZValue(): number {
-    return this.modelMedianZ
-  }
-
   private emitCamInfo = (): void => {
     const off = this.camera.position.clone().sub(this.controls.target)
     if (off.lengthSq() === 0) return
@@ -220,18 +188,12 @@ export class Viewer {
     this.modelGroup = group
     this.scene.add(group)
 
+    // 一律使用实例化(大模型)模式加载
     const meshCount = this.countMeshNodes(group)
-    this.largeMode = meshCount > LARGE_THRESHOLD
-    if (this.largeMode) {
-      this.cb.onProgress?.(`大模型模式:正在合并 ${meshCount} 个部件…`)
-      group.visible = false
-      this.collectPartsInstanced(group)
-    } else {
-      this.collectParts(group)
-    }
-    this.cb.onParts?.(this.parts.map((p) => p.meta), {
-      meshCount, largeMode: this.largeMode, medianZ: this.modelMedianZValue,
-    })
+    this.cb.onProgress?.(`正在合并 ${meshCount} 个部件…`)
+    group.visible = false
+    this.collectPartsInstanced(group)
+    this.cb.onParts?.(this.parts.map((p) => p.meta))
     this.cb.onProgress?.('')
   }
 
@@ -253,8 +215,6 @@ export class Viewer {
     this.byId.clear()
     this.selectedIds.clear()
     this.prevSelected = new Set()
-    this.largeMode = false
-    this.outline.selectedObjects = []
     this.hoveredId = null
   }
 
@@ -264,41 +224,6 @@ export class Viewer {
     return n
   }
 
-  private ensureOwnMaterial(obj: THREE.Mesh): void {
-    if (Array.isArray(obj.material)) obj.material = obj.material.map((m) => m.clone())
-    else if (obj.material) obj.material = obj.material.clone()
-    eachMaterial(obj, (m) => { m.needsUpdate = true })
-  }
-
-  private collectParts(group: THREE.Group): void {
-    group.updateMatrixWorld(true)
-    const seen = new Map<string, number>()
-    group.traverse((obj) => {
-      const mesh = obj as THREE.Mesh
-      if (!mesh.isMesh || !mesh.geometry) return
-      this.ensureOwnMaterial(mesh)
-      const base = mesh.name && mesh.name.trim() ? mesh.name.trim() : '部件'
-      const n = (seen.get(base) || 0) + 1
-      seen.set(base, n)
-      const defaults: string[] = []
-      eachMaterial(mesh, (m) => {
-        const anyM = m as THREE.MeshStandardMaterial
-        const hex = toHex(anyM.color || 0xcccccc)
-        defaults.push(hex)
-        anyM.color.set(hex)
-      })
-      const id = `${base}#${n}`
-      const handle: PartHandle = {
-        id, label: n > 1 ? `${base} [${n}]` : base, name: base, defaults,
-        obj: mesh, status: null, colorHex: null, meta: null as unknown as PartMeta,
-      }
-      handle.meta = this.buildMeta(handle)
-      this.parts.push(handle)
-      this.byId.set(id, handle)
-    })
-    this.sortParts()
-  }
-
   private collectPartsInstanced(group: THREE.Group): void {
     group.updateMatrixWorld(true)
     const byGeom = new Map<string, { geometry: THREE.BufferGeometry; entries: { matrix: THREE.Matrix4; handle: PartHandle }[] }>()
@@ -436,33 +361,29 @@ export class Viewer {
     return vals[Math.floor(vals.length / 2)]
   }
 
-  activateView(index: number): void {
-    const view = VIEW_PRESETS[index]
-    if (!view || !this.modelDist) return
-    let target: THREE.Vector3
-    let cam: THREE.Vector3
-    if (view.axis) {
-      const dir = new THREE.Vector3(...view.axis).normalize()
-      const ai = view.axis.findIndex((x) => x !== 0)
-      const cross = Math.max(...[0, 1, 2].filter((i) => i !== ai).map((i) => this.modelSize.getComponent(i)))
-      const fovRad = THREE.MathUtils.degToRad(this.camera.fov)
-      const crossFit = (cross / (2 * Math.tan(fovRad / 2))) * 1.2
-      const halfAxis = this.modelSize.getComponent(ai) / 2
-      const dist = halfAxis + crossFit
-      target = this.modelCenter.clone()
-      cam = this.modelCenter.clone().addScaledVector(dir, dist)
-    } else {
-      const dz = this.modelMedianZ - REF_MEDIAN_Z
-      target = new THREE.Vector3(view.target![0], view.target![1], view.target![2] + dz)
-      cam = new THREE.Vector3(view.camera![0], view.camera![1], view.camera![2] + dz)
-    }
+  /** 应用“参考系”相机/目标(Z 叠加随模型变化的 dz)。 */
+  applyCamera(camera: [number, number, number], target: [number, number, number]): void {
+    const dz = this.modelMedianZ - REF_MEDIAN_Z
+    const cam = new THREE.Vector3(camera[0], camera[1], camera[2] + dz)
+    const tgt = new THREE.Vector3(target[0], target[1], target[2] + dz)
     this.camera.position.copy(cam)
-    this.camera.lookAt(target)
-    this.controls.target.copy(target)
+    this.camera.lookAt(tgt)
+    this.controls.target.copy(tgt)
     this.controls.update()
     this.emitCamInfo()
   }
 
+  /** 返回“参考系”下的当前相机/目标(Z 去掉 dz)。 */
+  getCameraState(): { camera: [number, number, number]; target: [number, number, number] } {
+    const dz = this.modelMedianZ - REF_MEDIAN_Z
+    const p = this.camera.position
+    const t = this.controls.target
+    return {
+      camera: [p.x, p.y, p.z - dz],
+      target: [t.x, t.y, t.z - dz],
+    }
+  }
+
   moveForward(sign: number): void {
     const dir = new THREE.Vector3().subVectors(this.controls.target, this.camera.position).normalize()
     const step = (this.modelDist || 10) * 0.08
@@ -535,18 +456,10 @@ export class Viewer {
     this.pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1
     this.pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1
     this.raycaster.setFromCamera(this.pointer, this.camera)
-    if (this.largeMode) {
-      if (!this.instancedMeshes.length) return null
-      const hits = this.raycaster.intersectObjects(this.instancedMeshes, false)
-      if (!hits.length) return null
-      return this.idFromInstance(hits[0].object as THREE.InstancedMesh, hits[0].instanceId ?? 0)
-    }
-    const meshes = this.parts.filter((p) => p.obj).map((p) => p.obj!)
-    const hits = this.raycaster.intersectObjects(meshes, false)
+    if (!this.instancedMeshes.length) return null
+    const hits = this.raycaster.intersectObjects(this.instancedMeshes, false)
     if (!hits.length) return null
-    const obj = hits[0].object as THREE.Mesh
-    const found = this.parts.find((p) => p.obj === obj)
-    return found ? found.id : null
+    return this.idFromInstance(hits[0].object as THREE.InstancedMesh, hits[0].instanceId ?? 0)
   }
 
   private idFromInstance(inst: THREE.InstancedMesh, instanceId: number): string | null {
@@ -557,18 +470,11 @@ export class Viewer {
   /* ---------------- 选择 / 上色 ---------------- */
   setSelectedIds(ids: string[]): void {
     this.selectedIds = new Set(ids)
-    if (this.largeMode) {
-      this.outline.selectedObjects = []
-      const changed = new Set<string>()
-      this.selectedIds.forEach((id) => { if (!this.prevSelected.has(id)) changed.add(id) })
-      this.prevSelected.forEach((id) => { if (!this.selectedIds.has(id)) changed.add(id) })
-      changed.forEach((id) => this.applyDisplay(id))
-      this.prevSelected = new Set(this.selectedIds)
-    } else {
-      this.outline.selectedObjects = ids
-        .map((id) => this.byId.get(id)?.obj)
-        .filter((o): o is THREE.Mesh => Boolean(o))
-    }
+    const changed = new Set<string>()
+    this.selectedIds.forEach((id) => { if (!this.prevSelected.has(id)) changed.add(id) })
+    this.prevSelected.forEach((id) => { if (!this.selectedIds.has(id)) changed.add(id) })
+    changed.forEach((id) => this.applyDisplay(id))
+    this.prevSelected = new Set(this.selectedIds)
   }
 
   setPartColor(id: string, hex: string, status: PartStatus | null): void {

+ 32 - 0
3d/frontend/src/types/index.ts

@@ -27,6 +27,38 @@ export interface SavedPartState {
 
 export type SavedState = Record<string, SavedPartState>
 
+export interface ScenePosition {
+  id: number
+  name: string
+  camera: [number, number, number]
+  target: [number, number, number]
+}
+
+export interface AlarmRow {
+  id: number
+  device_code: string
+  device_part: string
+  device_point: string
+  alarm_type: string
+  alarm_level: string | null
+  alarm_time_start: string
+  alarm_time_end: string
+  alarm_des: string
+  alarm_info: Record<string, unknown>
+}
+
+export interface AlarmFilters {
+  devices: string[]
+  alarm_types: string[]
+}
+
+export interface AlarmPage {
+  total: number
+  page: number
+  page_size: number
+  items: AlarmRow[]
+}
+
 export interface PresetDef {
   key: PartStatus
   label: string

+ 9 - 0
3d/frontend/src/utils/device.ts

@@ -0,0 +1,9 @@
+export const DEVICE_TO_UNIT: Record<string, string> = {
+  '7#': '7号机',
+  '8#': '8号机',
+  '9#': '9号机',
+}
+
+export function deviceLabel(code: string): string {
+  return DEVICE_TO_UNIT[code] ?? code
+}

+ 18 - 0
3d/frontend/src/utils/level.ts

@@ -0,0 +1,18 @@
+export const LEVEL_COLORS: Record<string, string> = {
+  轻微: '#F9A825',
+  异常: '#FB8C00',
+  严重异常: '#E53935',
+}
+
+export function levelColor(level?: string | null): string {
+  return level ? LEVEL_COLORS[level] ?? '#8b93a1' : '#8b93a1'
+}
+
+const LEVEL_LABELS: Record<string, string> = {
+  严重异常: '严重',
+}
+
+export function levelLabel(level?: string | null): string {
+  if (!level) return ''
+  return LEVEL_LABELS[level] ?? level
+}