Jelajahi Sumber

3d:新增登录鉴权与数据接口权限控制(内存token、401拦截、登录页/退出);测试/生产模式配置(APP_MODE/run_prod.sh);移除未用的 /api/state 持久化

18922397810 2 minggu lalu
induk
melakukan
a6a7b96b80

+ 50 - 16
3d/README.md

@@ -1,31 +1,34 @@
 # 压缩机 3D 部件查看与配色
 
 Python(FastAPI)服务端 + Ant Design Vue(Vue3 + Vite + TypeScript)客户端。
-加载 `models/*.glb`,按部件单独选中与着色,配色状态持久化到 `data/color_state.json`。
+加载 `models/*.glb`,按部件单独选中与着色;故障列表、曲线、场景位置、故障↔部件对照等。
 
 ## 目录结构
 
 ```
 3d/
 ├─ backend/            # FastAPI 服务端(扁平结构,无 app/ 层)
-│  ├─ main.py          # 应用入口:/api、/models、前端托管与 SPA 回退
-│  ├─ config.py        # 路径配置
-│  ├─ routers/         # files.py(模型)、state.py(配色状态)
-│  ├─ services/        # model_service、state_service
+│  ├─ 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
 │  ├─ run.py           # 启动脚本
+│  ├─ run.sh           # 启动脚本(自动选当前 conda/venv 解释器)
+│  ├─ run_prod.sh      # 生产启动(APP_MODE=prod)
 │  └─ requirements.txt
 ├─ frontend/           # Ant Design Vue 客户端
 │  └─ src/
-│     ├─ api/          # 接口封装(axios)
-│     ├─ stores/       # pinia:model / parts / viewer
+│     ├─ api/          # 接口封装(axios + token)
+│     ├─ stores/       # pinia:model / parts / viewer / alarms / scenes / charts / faultParts
 │     ├─ three/        # 纯 three.js 逻辑(Viewer.ts 等)
 │     ├─ composables/  # useThreeViewer(Vue 与 three 的桥接)
-│     ├─ components/   # TopBar / ViewerCanvas / DataPanel / 卡片 / 查询弹窗
+│     ├─ components/   # TopBar / ViewerCanvas / 登录页 / 各卡片 / 弹窗
 │     ├─ types/        # 类型定义
 │     └─ styles/
 ├─ tools/              # CAD/GLB 处理脚本(split_glb / remove_nodes / scale_part)
 ├─ models/             # *.glb 模型
-├─ data/               # color_state.json
+├─ data/               # scene_positions.csv、fault_part_map.csv
 └─ docs/               # 建模与GLB转换指南.md
 ```
 
@@ -58,19 +61,50 @@ Vite 已把 `/api`、`/models` 代理到 `http://127.0.0.1:5182`(见 `vite.con
 
 ```bash
 cd 3d/frontend && npm run build            # 产物:3d/frontend/dist
-python3 3d/backend/run.py --host 0.0.0.0 --port 5182
+bash 3d/backend/run_prod.sh --host 0.0.0.0 --port 5182   # 以 APP_MODE=prod 启动
 ```
 
 FastAPI 托管 `frontend/dist`,非 `/api`、`/models` 的路径回退 `index.html`。
 
-## 接口
+## 登录与运行模式
+
+- **默认 `APP_MODE=test`(测试模式)**:登录页会**自动填入**账号密码(来自 `/api/auth-config`),仍需点“登录”。
+- **生产**:用 `run_prod.sh`(等价于设置 `APP_MODE=prod`),`/api/auth-config` 不再返回账号密码。
+- 数据接口需登录(`Authorization: Bearer <token>`);`/models/*` 模型文件为静态资源,**不鉴权**。
+- token 存于进程内存,服务重启后失效,需重新登录。
+
+环境变量:
 
-| 方法 | 路径 | 说明 |
+| 变量 | 默认值 | 说明 |
 | --- | --- | --- |
-| GET | `/api/models` | 可用模型名列表 |
-| GET | `/api/state?model=x` | 读取某模型的配色状态 |
-| POST | `/api/state?model=x` | 保存配色状态(body 为 `{部件id: {color, status}}`) |
-| GET | `/models/<file>` | GLB / glTF 模型文件 |
+| `APP_MODE` | `test` | `test` 测试模式;`prod` 生产模式 |
+| `AUTH_USER` | `aaabbb` | 登录账号 |
+| `AUTH_PASSWORD` | `Aa*147258&cd` | 登录密码 |
+| `AUTH_TOKEN_TTL_SECONDS` | `43200` | token 有效期(秒) |
+| `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME` | 读根目录 `数据库.md` | 数据库连接 |
+
+示例:
+
+```bash
+APP_MODE=prod AUTH_USER=admin AUTH_PASSWORD='***' bash 3d/backend/run_prod.sh --port 5182
+```
+
+## 接口
+
+| 方法 | 路径 | 鉴权 | 说明 |
+| --- | --- | --- | --- |
+| POST | `/api/login` | 否 | 登录,返回 token |
+| POST | `/api/logout` | 否 | 退出 |
+| GET | `/api/auth-config` | 否 | 运行模式(测试模式含默认账号密码) |
+| GET | `/api/models` | 是 | 可用模型名列表 |
+| GET | `/models/<file>` | 否 | GLB / glTF 模型文件 |
+| GET | `/api/alarms` | 是 | 故障列表(筛选/分页,倒序) |
+| GET | `/api/alarm-filters` | 是 | 故障列表筛选项 |
+| GET | `/api/pks-series` | 是 | 故障曲线原始序列(YSJ_5/10/11) |
+| GET | `/api/scene-positions` | 是 | 场景位置列表 |
+| POST/PUT/DELETE | `/api/scene-positions[/id]` | 是 | 场景位置增/改/删 |
+| GET | `/api/fault-part-map` | 是 | 故障↔3D 部件对照表 |
+| POST | `/api/fault-part-map/scene` | 是 | 记录故障场景位置(贴合故障) |
 
 ## CAD 工具
 

+ 48 - 0
3d/backend/auth.py

@@ -0,0 +1,48 @@
+"""登录鉴权:进程内存 token(与根目录 backend/app/auth.py 同款)。
+
+服务重启后 token 失效,需重新登录。单账号,无数据级权限。
+"""
+
+import secrets
+import time
+from typing import Optional
+
+from fastapi import Header, HTTPException
+
+from config import AUTH_PASSWORD, AUTH_TOKEN_TTL_SECONDS, AUTH_USER
+
+_tokens: dict[str, float] = {}
+
+
+def create_token() -> str:
+    token = secrets.token_hex(32)
+    _tokens[token] = time.monotonic() + AUTH_TOKEN_TTL_SECONDS
+    return token
+
+
+def validate_token(token: Optional[str]) -> bool:
+    if not token:
+        return False
+    expires = _tokens.get(token)
+    if expires is None:
+        return False
+    if time.monotonic() > expires:
+        _tokens.pop(token, None)
+        return False
+    return True
+
+
+def revoke_token(token: Optional[str]) -> None:
+    if token:
+        _tokens.pop(token, None)
+
+
+def verify_credentials(username: str, password: str) -> bool:
+    return username == AUTH_USER and password == AUTH_PASSWORD
+
+
+def require_auth(authorization: str | None = Header(default=None)) -> str:
+    token = (authorization or "").removeprefix("Bearer ").strip()
+    if not validate_token(token):
+        raise HTTPException(status_code=401, detail="未登录或登录已过期")
+    return token

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

@@ -7,11 +7,18 @@ BASE_DIR = Path(__file__).resolve().parent          # 3d/backend
 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"
 FAULT_MAP_CSV = DATA_DIR / "fault_part_map.csv"
 FRONTEND_DIST = MODULE_DIR / "frontend" / "dist"
 
+# 运行模式与登录鉴权。默认 test(测试模式,登录页自动填入账号密码);
+# 生产用 APP_MODE=prod 启动(见 run_prod.sh / README)。
+APP_MODE = os.getenv("APP_MODE", "test").strip().lower()
+IS_TEST = APP_MODE != "prod"
+AUTH_USER = os.getenv("AUTH_USER", "aaabbb")
+AUTH_PASSWORD = os.getenv("AUTH_PASSWORD", "Aa*147258&cd")
+AUTH_TOKEN_TTL_SECONDS = int(os.getenv("AUTH_TOKEN_TTL_SECONDS", "43200"))
+
 # 数据库凭据沿用项目根目录的 数据库.md(与 backend/app/config.py 同源,不复制到代码里)。
 _DB_NOTE = BASE_DIR.parents[1] / "数据库.md"
 

+ 50 - 13
3d/backend/main.py

@@ -1,19 +1,33 @@
 """压缩机 3D 部件查看服务(FastAPI)。
 
-接口:
-    GET  /api/models          可用模型列表
-    GET  /api/state?model=x   读取配色状态
-    POST /api/state?model=x   保存配色状态
-    GET  /models/<file>       GLB / glTF 模型文件
+公共接口(无需登录):
+    POST /api/login           登录
+    POST /api/logout          退出
+    GET  /api/auth-config     运行模式与(测试模式下的)默认账号密码
+    GET  /models/<file>       GLB / glTF 模型文件(静态资源,不鉴权)
     GET  /*                   前端构建产物(frontend/dist),SPA 回退 index.html
+
+需登录接口(Bearer token):
+    /api/models、/api/alarms、/api/alarm-filters、/api/pks-series、
+    /api/scene-positions、/api/fault-part-map
 """
 
-from fastapi import FastAPI, HTTPException
+from typing import Any
+
+from fastapi import Depends, FastAPI, Header, HTTPException
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import FileResponse
+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, scenes, series
+
+
+class LoginInput(BaseModel):
+    username: str = Field(min_length=1)
+    password: str = Field(min_length=1)
 
-from config import FRONTEND_DIST
-from routers import alarms, fault_map, files, scenes, series, state
 
 app = FastAPI(title="压缩机 3D 部件查看服务", version="0.1.0")
 app.add_middleware(
@@ -23,12 +37,35 @@ app.add_middleware(
     allow_headers=["*"],
 )
 
-app.include_router(alarms.router)
-app.include_router(fault_map.router)
+
+@app.post("/api/login")
+def login(payload: LoginInput) -> dict[str, Any]:
+    if not verify_credentials(payload.username, payload.password):
+        raise HTTPException(status_code=401, detail="用户名或密码错误")
+    return {"token": create_token(), "username": payload.username}
+
+
+@app.post("/api/logout")
+def logout(authorization: str | None = Header(default=None)) -> dict[str, Any]:
+    token = (authorization or "").removeprefix("Bearer ").strip()
+    revoke_token(token)
+    return {"ok": True}
+
+
+@app.get("/api/auth-config")
+def auth_config() -> dict[str, Any]:
+    # 测试模式把默认账号密码给到前端登录页自动填入;生产不返回。
+    if IS_TEST:
+        return {"mode": APP_MODE, "test": True, "username": AUTH_USER, "password": AUTH_PASSWORD}
+    return {"mode": APP_MODE, "test": False}
+
+
+# 数据接口需登录;files 里只有 /api/models 需登录,模型文件保持公开。
+app.include_router(alarms.router, dependencies=[Depends(require_auth)])
+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(files.router)
-app.include_router(scenes.router)
-app.include_router(series.router)
-app.include_router(state.router)
 
 
 @app.get("/{full_path:path}")

+ 3 - 2
3d/backend/routers/files.py

@@ -1,15 +1,16 @@
 """模型列表与模型文件下载。"""
 
-from fastapi import APIRouter, HTTPException
+from fastapi import APIRouter, Depends, HTTPException
 from fastapi.responses import FileResponse
 
+from auth import require_auth
 from services import model_service
 
 router = APIRouter()
 
 
 @router.get("/api/models")
-def list_models() -> list[str]:
+def list_models(_auth: str = Depends(require_auth)) -> list[str]:
     return model_service.list_models()
 
 

+ 0 - 22
3d/backend/routers/state.py

@@ -1,22 +0,0 @@
-"""每个模型的配色状态读写。"""
-
-from typing import Any
-
-from fastapi import APIRouter, HTTPException, Query
-
-from services import state_service
-
-router = APIRouter()
-
-
-@router.get("/api/state")
-def get_state(model: str = Query(...)) -> dict[str, Any]:
-    return state_service.load_model_state(model)
-
-
-@router.post("/api/state")
-def save_state(payload: dict[str, Any], model: str = Query(...)) -> dict[str, Any]:
-    if not model:
-        raise HTTPException(status_code=400, detail="缺少 model 参数")
-    state_service.save_model_state(model, payload)
-    return {"ok": True, "model": model, "count": len(payload)}

+ 7 - 0
3d/backend/run_prod.sh

@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+# 生产启动:关闭测试模式(APP_MODE=prod),登录页不再自动填入账号密码。
+# 账号密码等可用环境变量覆盖,例如:
+#   AUTH_USER=admin AUTH_PASSWORD='***' bash 3d/backend/run_prod.sh --host 0.0.0.0 --port 5182
+set -e
+export APP_MODE="${APP_MODE:-prod}"
+exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/run.sh" "$@"

+ 0 - 27
3d/backend/services/state_service.py

@@ -1,27 +0,0 @@
-"""配色状态持久化:data/color_state.json(原子写)。"""
-
-import json
-import os
-from typing import Any
-
-from config import DATA_DIR, DATA_FILE
-
-
-def load_all() -> dict[str, Any]:
-    try:
-        return json.loads(DATA_FILE.read_text(encoding="utf-8"))
-    except (OSError, json.JSONDecodeError):
-        return {}
-
-
-def load_model_state(model: str) -> dict[str, Any]:
-    return load_all().get(model, {})
-
-
-def save_model_state(model: str, state: dict[str, Any]) -> None:
-    DATA_DIR.mkdir(parents=True, exist_ok=True)
-    all_state = load_all()
-    all_state[model] = state
-    tmp = DATA_FILE.with_suffix(DATA_FILE.suffix + ".tmp")
-    tmp.write_text(json.dumps(all_state, ensure_ascii=False, indent=2), encoding="utf-8")
-    os.replace(tmp, DATA_FILE)

+ 0 - 7
3d/data/color_state.json

@@ -1,7 +0,0 @@
-{
-  "2CylinderEngine.glb": {},
-  "压缩机2326-3000.glb": {},
-  "压缩机2326-3000_模块2.glb": {},
-  "压缩机2326-3000_删墙.glb": {},
-  "压缩机2326-3000-one.glb": {}
-}

+ 52 - 3
3d/frontend/src/App.vue

@@ -1,15 +1,19 @@
 <script setup lang="ts">
 import { theme } from 'ant-design-vue'
 import zhCN from 'ant-design-vue/es/locale/zh_CN'
-import { onMounted } from 'vue'
+import { onBeforeUnmount, onMounted, ref } from 'vue'
 
+import { fetchAuthConfig } from './api/auth'
+import { getToken, setToken } from './api/token'
 import AlarmDetailCard from './components/AlarmDetailCard.vue'
 import DataPanel from './components/DataPanel.vue'
+import LoginView from './components/LoginView.vue'
 import PartQueryModal from './components/PartQueryModal.vue'
 import PartStatusCard from './components/PartStatusCard.vue'
 import ScenePositionCard from './components/ScenePositionCard.vue'
 import TopBar from './components/TopBar.vue'
 import ViewerCanvas from './components/ViewerCanvas.vue'
+import { useAlarmsStore } from './stores/alarms'
 import { useModelStore } from './stores/model'
 import { useScenesStore } from './stores/scenes'
 import { useViewerStore } from './stores/viewer'
@@ -17,10 +21,49 @@ import { useViewerStore } from './stores/viewer'
 const modelStore = useModelStore()
 const viewerStore = useViewerStore()
 const scenesStore = useScenesStore()
+const alarmsStore = useAlarmsStore()
 
-onMounted(() => {
+const authed = ref(false)
+const prefill = ref<{ username?: string; password?: string }>({})
+
+function boot(): void {
   modelStore.loadModelList()
   scenesStore.load()
+}
+
+function onLoginSuccess(): void {
+  authed.value = true
+  boot()
+}
+
+function onAuthExpired(): void {
+  authed.value = false
+}
+
+function onLogout(): void {
+  setToken(null)
+  authed.value = false
+  modelStore.reset()
+  scenesStore.reset()
+  alarmsStore.reset()
+}
+
+onMounted(async () => {
+  try {
+    const config = await fetchAuthConfig()
+    if (config.test) prefill.value = { username: config.username, password: config.password }
+  } catch {
+    // 忽略:未取到配置时仍可手动登录
+  }
+  authed.value = getToken() != null
+  if (authed.value) boot()
+  window.addEventListener('auth-expired', onAuthExpired)
+  window.addEventListener('auth-logout', onLogout)
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('auth-expired', onAuthExpired)
+  window.removeEventListener('auth-logout', onLogout)
 })
 </script>
 
@@ -29,7 +72,13 @@ onMounted(() => {
     :locale="zhCN"
     :theme="{ algorithm: theme.darkAlgorithm, token: { colorPrimary: '#4da3ff' } }"
   >
-    <div class="app">
+    <LoginView
+      v-if="!authed"
+      :prefill-username="prefill.username"
+      :prefill-password="prefill.password"
+      @success="onLoginSuccess"
+    />
+    <div v-else class="app">
       <TopBar />
       <ViewerCanvas>
         <DataPanel />

+ 27 - 0
3d/frontend/src/api/auth.ts

@@ -0,0 +1,27 @@
+import { http } from './http'
+import { setToken } from './token'
+
+export interface AuthConfig {
+  mode: string
+  test: boolean
+  username?: string
+  password?: string
+}
+
+export async function fetchAuthConfig(): Promise<AuthConfig> {
+  const res = await http.get<AuthConfig>('/api/auth-config')
+  return res.data
+}
+
+export async function login(username: string, password: string): Promise<void> {
+  const res = await http.post<{ token: string }>('/api/login', { username, password })
+  setToken(res.data.token)
+}
+
+export async function logout(): Promise<void> {
+  try {
+    await http.post('/api/logout')
+  } finally {
+    setToken(null)
+  }
+}

+ 23 - 0
3d/frontend/src/api/http.ts

@@ -1,4 +1,27 @@
 import axios from 'axios'
 
+import { getToken, setToken } from './token'
+
 /** 同源请求:开发由 Vite 代理到后端,生产由 FastAPI 统一托管。 */
 export const http = axios.create({ baseURL: '', timeout: 60000 })
+
+http.interceptors.request.use((config) => {
+  const token = getToken()
+  if (token) {
+    config.headers = config.headers ?? {}
+    config.headers.Authorization = `Bearer ${token}`
+  }
+  return config
+})
+
+http.interceptors.response.use(
+  (response) => response,
+  (error) => {
+    const url = String(error?.config?.url ?? '')
+    if (error?.response?.status === 401 && !url.includes('/api/login')) {
+      setToken(null)
+      window.dispatchEvent(new CustomEvent('auth-expired'))
+    }
+    return Promise.reject(error)
+  },
+)

+ 0 - 11
3d/frontend/src/api/state.ts

@@ -1,11 +0,0 @@
-import { http } from './http'
-import type { SavedState } from '../types'
-
-export async function fetchState(model: string): Promise<SavedState> {
-  const res = await http.get<SavedState>('/api/state', { params: { model } })
-  return res.data
-}
-
-export async function saveState(model: string, state: SavedState): Promise<void> {
-  await http.post('/api/state', state, { params: { model } })
-}

+ 10 - 0
3d/frontend/src/api/token.ts

@@ -0,0 +1,10 @@
+const TOKEN_KEY = 'auth_token'
+
+export function getToken(): string | null {
+  return localStorage.getItem(TOKEN_KEY)
+}
+
+export function setToken(token: string | null): void {
+  if (token) localStorage.setItem(TOKEN_KEY, token)
+  else localStorage.removeItem(TOKEN_KEY)
+}

+ 92 - 0
3d/frontend/src/components/LoginView.vue

@@ -0,0 +1,92 @@
+<script setup lang="ts">
+import { reactive, ref, watch } from 'vue'
+
+import { login } from '../api/auth'
+
+const props = defineProps<{ prefillUsername?: string; prefillPassword?: string }>()
+const emit = defineEmits<{ (event: 'success'): void }>()
+
+const form = reactive({
+  username: props.prefillUsername ?? '',
+  password: props.prefillPassword ?? '',
+})
+const loading = ref(false)
+const error = ref('')
+
+watch(() => props.prefillUsername, (value) => { if (value) form.username = value })
+watch(() => props.prefillPassword, (value) => { if (value) form.password = value })
+
+async function submit(): Promise<void> {
+  if (!form.username || !form.password) {
+    error.value = '请输入账号和密码'
+    return
+  }
+  loading.value = true
+  error.value = ''
+  try {
+    await login(form.username, form.password)
+    emit('success')
+  } catch (err) {
+    error.value = (err as Error).message || '登录失败'
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<template>
+  <div class="login-shell">
+    <div class="login-card">
+      <h1 class="login-title">压缩机故障预测模型</h1>
+      <p class="login-hint">请登录后继续使用</p>
+      <a-input
+        v-model:value="form.username"
+        size="large"
+        placeholder="账号"
+        allow-clear
+        @input="error = ''"
+      />
+      <a-input-password
+        v-model:value="form.password"
+        size="large"
+        placeholder="密码"
+        @press-enter="submit"
+        @input="error = ''"
+      />
+      <a-alert v-if="error" type="error" :message="error" show-icon :closable="false" />
+      <a-button type="primary" size="large" block :loading="loading" @click="submit">登 录</a-button>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.login-shell {
+  height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #14161a;
+}
+.login-card {
+  width: 360px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  padding: 28px;
+  background: #1d2128;
+  border: 1px solid #333a45;
+  border-radius: 12px;
+}
+.login-title {
+  font-size: 20px;
+  color: #fff;
+  margin: 0;
+  text-align: center;
+}
+.login-hint {
+  color: #8b93a1;
+  font-size: 12px;
+  margin: 0 0 4px;
+  text-align: center;
+}
+</style>

+ 7 - 0
3d/frontend/src/components/TopBar.vue

@@ -3,6 +3,7 @@ import { BorderOutlined, CaretRightOutlined } from '@ant-design/icons-vue'
 import { Modal, message } from 'ant-design-vue'
 import { ref } from 'vue'
 
+import { logout } from '../api/auth'
 import { useAlarmsStore } from '../stores/alarms'
 import { useModelStore } from '../stores/model'
 import { useScenesStore } from '../stores/scenes'
@@ -19,6 +20,11 @@ function onPlayToggle(): void {
   else alarmsStore.startPlayback()
 }
 
+async function onLogoutClick(): Promise<void> {
+  await logout()
+  window.dispatchEvent(new Event('auth-logout'))
+}
+
 const renameOpen = ref(false)
 const renameId = ref<number | null>(null)
 const renameName = ref('')
@@ -105,6 +111,7 @@ function removeFromRename(): void {
       <BorderOutlined v-if="alarmsStore.playing" />
       <CaretRightOutlined v-else />
     </a-button>
+    <a-button class="rot-btn" size="small" @click="onLogoutClick">退出登录</a-button>
 
     <a-modal v-model:open="renameOpen" title="场景位置" :footer="null">
       <a-input v-model:value="renameName" placeholder="请输入场景名称" @press-enter="confirmRename" />

+ 12 - 1
3d/frontend/src/stores/alarms.ts

@@ -161,10 +161,21 @@ export const useAlarmsStore = defineStore('alarms', () => {
     else schedule()
   }
 
+  function reset(): void {
+    clearTimer()
+    items.value = []
+    total.value = 0
+    page.value = 1
+    selectedId.value = null
+    playing.value = false
+    paused.value = false
+    displayDate.value = today()
+  }
+
   return {
     devices, alarmTypes, deviceCode, alarmType, page, pageSize, total, items,
     loading, selectedId, selected, playing, paused, displayDate,
     loadFilters, load, setDevice, setType, setPage, select, clearSelected,
-    autoSelectFirst, startPlayback, stopPlayback, togglePause,
+    autoSelectFirst, startPlayback, stopPlayback, togglePause, reset,
   }
 })

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

@@ -14,5 +14,12 @@ export const useModelStore = defineStore('model', () => {
     if (!current.value && models.value.length) current.value = models.value[0]
   }
 
-  return { models, current, loading, loadingText, loadModelList }
+  function reset(): void {
+    models.value = []
+    current.value = ''
+    loading.value = false
+    loadingText.value = ''
+  }
+
+  return { models, current, loading, loadingText, loadModelList, reset }
 })

+ 7 - 1
3d/frontend/src/stores/scenes.ts

@@ -46,5 +46,11 @@ export const useScenesStore = defineStore('scenes', () => {
     items.value = items.value.filter((item) => item.id !== id)
   }
 
-  return { items, loaded, loading, load, apply, add, rename, remove }
+  function reset(): void {
+    items.value = []
+    loaded.value = false
+    loading.value = false
+  }
+
+  return { items, loaded, loading, load, apply, add, rename, remove, reset }
 })