Pārlūkot izejas kodu

Initial Windows monitor implementation

codex 1 mēnesi atpakaļ
revīzija
5fda4fc81b

+ 35 - 0
.gitignore

@@ -0,0 +1,35 @@
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.Python
+.venv/
+venv/
+env/
+.env
+.env.*
+!.env.example
+
+backend/data/
+
+frontend/node_modules/
+frontend/dist/
+
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+.pytest_cache/
+.ruff_cache/
+.mypy_cache/
+.coverage
+htmlcov/
+
+.DS_Store
+Thumbs.db
+Desktop.ini
+
+.vscode/
+.idea/

+ 388 - 0
api-docs.md

@@ -0,0 +1,388 @@
+# Windows 监控管理系统接口说明
+
+## 基础信息
+
+- 后端默认地址:`http://127.0.0.1:8000`
+- 返回格式:JSON
+- 确认状态枚举:`PENDING`、`TRUSTED`、`SUSPICIOUS`、`IGNORED`、`NEED_MORE_INFO`
+- AI 风险等级枚举:`LOW`、`MEDIUM`、`HIGH`
+
+## 仪表盘
+
+### 获取仪表盘概览
+
+`GET /api/dashboard`
+
+返回最近扫描记录、服务总数、进程总数、待确认数量、本次未出现数量。
+
+## 标签
+
+系统内置两个默认标签:
+
+| 标签 | 默认是否可以被控制 | 说明 |
+| --- | --- | --- |
+| windows系统 | 否 | 用于标记 Windows 系统原生服务或进程 |
+| 本系统相关 | 否 | 用于标记本项目自身相关服务或进程 |
+
+如果服务或进程存在任一 `is_controllable = false` 的标签,前端不会显示启动、停止、重启等控制操作,后端控制接口也会拒绝执行。
+
+### 查询标签
+
+`GET /api/tags`
+
+### 新增标签
+
+`POST /api/tags`
+
+```json
+{
+  "name": "业务软件",
+  "description": "业务系统相关组件",
+  "is_controllable": true
+}
+```
+
+### 更新标签
+
+`PATCH /api/tags/{tag_id}`
+
+```json
+{
+  "name": "windows系统",
+  "description": "Windows 系统原生服务或进程",
+  "is_controllable": false
+}
+```
+
+### 删除标签
+
+`DELETE /api/tags/{tag_id}`
+
+内置标签不可删除。
+
+## 扫描
+
+### 执行完整扫描
+
+`POST /api/scans/run`
+
+执行 Windows 服务和进程采集,写入扫描记录,并按业务规则更新 `is_present_now`、`last_seen_at`、运行状态等字段。
+
+### 查询扫描历史
+
+`GET /api/scans?page=1&page_size=20`
+
+### 查询扫描详情
+
+`GET /api/scans/{scan_id}`
+
+## 传感器
+
+### 获取实时传感器信息
+
+`GET /api/sensors`
+
+说明:
+
+- 数据不写入数据库。
+- CPU、内存负载来自 `psutil`。
+- 显卡优先使用 `nvidia-smi`;如果不可用,尝试 Windows GPU 性能计数器。
+- 温度优先读取 LibreHardwareMonitor/OpenHardwareMonitor WMI,其次尝试 `psutil` 和 ACPI 热区。
+- Windows 原生接口不一定能提供 CPU/GPU 真实温度,实际可见字段取决于硬件、驱动和监控组件。
+
+返回主要字段:
+
+| 字段 | 说明 |
+| --- | --- |
+| collected_at | 采集时间 |
+| cpu | CPU 总负载、每核心负载、核心数、频率 |
+| memory | 物理内存和交换区使用情况 |
+| gpu | 显卡负载、温度、显存、功耗等 |
+| temperatures | psutil/ACPI 温度 |
+| hardware_sensors | LibreHardwareMonitor/OpenHardwareMonitor 传感器 |
+
+## 硬盘 SMART
+
+### 扫描 smartctl 设备列表
+
+`GET /api/smart/scan`
+
+等价于执行:
+
+```powershell
+smartctl --scan
+```
+
+### 获取全部硬盘 SMART 信息
+
+`GET /api/smart/devices?include_jmb39x=true&jmb39x_slots=8`
+
+查询参数:
+
+| 参数 | 类型 | 说明 |
+| --- | --- | --- |
+| include_jmb39x | boolean | 是否对 SAT/ATA 类设备额外探测 JMB39x 阵列槽位 |
+| jmb39x_slots | integer | 探测槽位数量,范围 0-16,默认 8 |
+
+普通设备会按 `smartctl --scan` 返回的设备和类型执行:
+
+```powershell
+smartctl -a -d sat /dev/sdb
+```
+
+JMB39x 阵列会按槽位执行:
+
+```powershell
+smartctl -a -d jmb39x,0 /dev/sdb
+smartctl -a -d jmb39x,1 /dev/sdb
+```
+
+返回每块盘的解析摘要、ATA SMART 属性表、警告信息和原始 `smartctl` 输出。
+
+### 获取单个设备 SMART 信息
+
+`GET /api/smart/device?device=/dev/sda&device_type=nvme`
+
+查询参数:
+
+| 参数 | 类型 | 说明 |
+| --- | --- | --- |
+| device | string | 设备路径,例如 `/dev/sda` |
+| device_type | string | 可选,smartctl 的 `-d` 类型,例如 `nvme`、`sat`、`jmb39x,0` |
+
+## Windows 服务
+
+### 查询服务列表
+
+`GET /api/services`
+
+查询参数:
+
+| 参数 | 类型 | 说明 |
+| --- | --- | --- |
+| keyword | string | 按名称、显示名称、路径、描述搜索 |
+| confirm_status | string | 确认状态 |
+| present | boolean | `true` 本次出现,`false` 本次未出现 |
+| page | integer | 页码,默认 1 |
+| page_size | integer | 每页数量,默认 20,最大 200 |
+
+### 获取服务详情
+
+`GET /api/services/{service_id}`
+
+### 更新服务说明和状态
+
+`PATCH /api/services/{service_id}`
+
+请求体:
+
+```json
+{
+  "confirm_status": "TRUSTED",
+  "user_note": "人工确认说明"
+}
+```
+
+### 批量更新服务状态
+
+`PATCH /api/services/batch`
+
+请求体:
+
+```json
+{
+  "ids": [1, 2, 3],
+  "confirm_status": "SUSPICIOUS",
+  "user_note": "批量标记说明,可为空"
+}
+```
+
+### 根据 AI JSON 批量更新服务
+
+`POST /api/services/import-ai`
+
+请求体:
+
+```json
+{
+  "items": [
+    {
+      "type": "service",
+      "name": "WinDefend",
+      "description": "Microsoft Defender 防病毒核心服务。",
+      "judgement": "TRUSTED",
+      "risk_level": "LOW",
+      "reason": "Microsoft 官方安全组件。",
+      "suggestion": "保持运行。"
+    }
+  ]
+}
+```
+
+### 生成服务 AI 分析提示词
+
+`POST /api/services/ai-prompt`
+
+请求体:
+
+```json
+{
+  "scope": "selected",
+  "ids": [1, 2]
+}
+```
+
+说明:
+
+- `scope = selected` 时使用 `ids` 指定的服务。
+- `scope = pending` 时忽略 `ids`,生成全部待确认服务的提示词。
+- 返回 `prompt_text`、`markdown_table` 和 `items`。
+
+### 更新服务标签
+
+`PUT /api/services/{service_id}/tags`
+
+```json
+{
+  "tag_ids": [1, 2]
+}
+```
+
+### 启动服务
+
+`POST /api/services/{service_id}/start`
+
+### 停止服务
+
+`POST /api/services/{service_id}/stop`
+
+### 重新启动服务
+
+`POST /api/services/{service_id}/restart`
+
+控制规则:
+
+- `confirm_status` 必须是 `TRUSTED`、`SUSPICIOUS` 或 `IGNORED`。
+- `PENDING` 和 `NEED_MORE_INFO` 不允许控制。
+- 任一关联标签 `is_controllable = false` 时不允许控制。
+- 服务必须在最近一次扫描中仍然存在。
+
+## Windows 进程
+
+### 查询进程列表
+
+`GET /api/processes`
+
+查询参数:
+
+| 参数 | 类型 | 说明 |
+| --- | --- | --- |
+| keyword | string | 按名称、路径、命令行、用户搜索 |
+| confirm_status | string | 确认状态 |
+| present | boolean | `true` 本次出现,`false` 本次未出现 |
+| page | integer | 页码,默认 1 |
+| page_size | integer | 每页数量,默认 20,最大 200 |
+
+### 获取进程详情
+
+`GET /api/processes/{process_id}`
+
+### 更新进程说明和状态
+
+`PATCH /api/processes/{process_id}`
+
+请求体:
+
+```json
+{
+  "confirm_status": "SUSPICIOUS",
+  "user_note": "人工确认说明"
+}
+```
+
+### 批量更新进程状态
+
+`PATCH /api/processes/batch`
+
+请求体:
+
+```json
+{
+  "ids": [10, 11],
+  "confirm_status": "IGNORED",
+  "user_note": "批量忽略说明,可为空"
+}
+```
+
+### 根据 AI JSON 批量更新进程
+
+`POST /api/processes/import-ai`
+
+请求体:
+
+```json
+{
+  "items": [
+    {
+      "type": "process",
+      "name": "unknown.exe",
+      "description": "未知用途执行文件。",
+      "judgement": "SUSPICIOUS",
+      "risk_level": "HIGH",
+      "reason": "路径和命令行异常。",
+      "suggestion": "建议隔离并检查哈希和网络连接。"
+    }
+  ]
+}
+```
+
+### 生成进程 AI 分析提示词
+
+`POST /api/processes/ai-prompt`
+
+请求体:
+
+```json
+{
+  "scope": "pending"
+}
+```
+
+返回 `prompt_text`、`markdown_table` 和 `items`。
+
+### 更新进程标签
+
+`PUT /api/processes/{process_id}/tags`
+
+```json
+{
+  "tag_ids": [2]
+}
+```
+
+### 启动进程
+
+`POST /api/processes/{process_id}/start`
+
+使用数据库中记录的 `cmdline` 或 `exe_path` 重新启动进程。
+
+### 停止进程
+
+`POST /api/processes/{process_id}/stop`
+
+使用数据库中记录的 `last_pid` 停止进程,并校验当前 PID 的进程名与记录一致,避免误杀 PID 复用后的其他进程。
+
+控制规则:
+
+- `confirm_status` 必须是 `TRUSTED`、`SUSPICIOUS` 或 `IGNORED`。
+- `PENDING` 和 `NEED_MORE_INFO` 不允许控制。
+- 任一关联标签 `is_controllable = false` 时不允许控制。
+- 停止进程要求该进程在最近一次扫描中仍然存在。
+
+## AI 提示词中的标签信息
+
+服务和进程的 AI 提示词会包含:
+
+- 每个待分析对象的 `tags` 字段。
+- 系统中已有标签列表,包括 `name`、`description`、`is_controllable`。
+- 标签判断要求:AI 需要结合已有标签进行分析,如果适合已有标签,可以在 `suggestion` 中建议使用对应标签名称,但不能创造不存在的新标签。

+ 1 - 0
backend/app/__init__.py

@@ -0,0 +1 @@
+

+ 129 - 0
backend/app/control.py

@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+import locale
+import os
+import subprocess
+import time
+from typing import Any
+
+import psutil
+from fastapi import HTTPException
+
+
+CONFIRMED_CONTROL_STATUSES = {"TRUSTED", "SUSPICIOUS", "IGNORED"}
+
+
+def hidden_creationflags() -> int:
+    if os.name != "nt":
+        return 0
+    return subprocess.CREATE_NO_WINDOW
+
+
+def command_encoding() -> str:
+    return locale.getpreferredencoding(False) or "utf-8"
+
+
+def run_sc(args: list[str], timeout: int = 30) -> dict[str, Any]:
+    if os.name != "nt":
+        raise HTTPException(status_code=400, detail="Windows service control is only available on Windows")
+    result = subprocess.run(
+        ["sc.exe", *args],
+        capture_output=True,
+        text=True,
+        encoding=command_encoding(),
+        errors="replace",
+        timeout=timeout,
+        creationflags=hidden_creationflags(),
+        check=False,
+    )
+    output = "\n".join(part for part in [result.stdout.strip(), result.stderr.strip()] if part)
+    if result.returncode != 0:
+        raise HTTPException(status_code=500, detail=output or f"sc.exe exited with {result.returncode}")
+    return {"returncode": result.returncode, "output": output}
+
+
+def service_status(name: str) -> str | None:
+    if not hasattr(psutil, "win_service_get"):
+        return None
+    try:
+        return psutil.win_service_get(name).status()
+    except (psutil.Error, OSError):
+        return None
+
+
+def wait_service_status(name: str, expected: str, timeout: int = 20) -> str | None:
+    deadline = time.time() + timeout
+    current = service_status(name)
+    while time.time() < deadline:
+        current = service_status(name)
+        if current == expected:
+            return current
+        time.sleep(0.5)
+    return current
+
+
+def start_service(name: str) -> dict[str, Any]:
+    result = run_sc(["start", name])
+    return {"action": "start", "status": wait_service_status(name, "running", 20), **result}
+
+
+def stop_service(name: str) -> dict[str, Any]:
+    result = run_sc(["stop", name])
+    return {"action": "stop", "status": wait_service_status(name, "stopped", 20), **result}
+
+
+def restart_service(name: str) -> dict[str, Any]:
+    current = service_status(name)
+    stop_result = None
+    if current and current != "stopped":
+        stop_result = stop_service(name)
+    start_result = start_service(name)
+    return {"action": "restart", "stop": stop_result, "start": start_result, "status": start_result.get("status")}
+
+
+def stop_process(row: dict[str, Any]) -> dict[str, Any]:
+    pid = row.get("last_pid")
+    if pid is None:
+        raise HTTPException(status_code=400, detail="No PID is recorded for this process")
+    try:
+        proc = psutil.Process(int(pid))
+        recorded_name = (row.get("name") or "").lower()
+        current_name = (proc.name() or "").lower()
+        if recorded_name and current_name and recorded_name != current_name:
+            raise HTTPException(status_code=409, detail="Recorded PID now belongs to a different process")
+        proc.terminate()
+        try:
+            proc.wait(timeout=8)
+            stopped_by = "terminate"
+        except psutil.TimeoutExpired:
+            proc.kill()
+            proc.wait(timeout=5)
+            stopped_by = "kill"
+        return {"action": "stop", "pid": pid, "stopped_by": stopped_by}
+    except HTTPException:
+        raise
+    except psutil.NoSuchProcess:
+        return {"action": "stop", "pid": pid, "already_stopped": True}
+    except psutil.AccessDenied as exc:
+        raise HTTPException(status_code=403, detail=f"Access denied: {exc}") from exc
+    except psutil.Error as exc:
+        raise HTTPException(status_code=500, detail=str(exc)) from exc
+
+
+def start_process(row: dict[str, Any]) -> dict[str, Any]:
+    command = row.get("cmdline") or row.get("exe_path")
+    if not command:
+        raise HTTPException(status_code=400, detail="No command line or executable path is recorded for this process")
+    cwd = row.get("cwd")
+    if cwd and not os.path.isdir(cwd):
+        cwd = None
+    try:
+        proc = subprocess.Popen(
+            command,
+            cwd=cwd,
+            shell=True,
+            creationflags=hidden_creationflags(),
+        )
+        return {"action": "start", "pid": proc.pid, "command": command}
+    except OSError as exc:
+        raise HTTPException(status_code=500, detail=str(exc)) from exc

+ 158 - 0
backend/app/database.py

@@ -0,0 +1,158 @@
+from __future__ import annotations
+
+import sqlite3
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Iterator
+
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+DATA_DIR = BASE_DIR / "data"
+DB_PATH = DATA_DIR / "win_monitor.db"
+
+
+def dict_factory(cursor: sqlite3.Cursor, row: sqlite3.Row) -> dict:
+    fields = [column[0] for column in cursor.description]
+    return {key: row[index] for index, key in enumerate(fields)}
+
+
+@contextmanager
+def get_db() -> Iterator[sqlite3.Connection]:
+    DATA_DIR.mkdir(parents=True, exist_ok=True)
+    conn = sqlite3.connect(DB_PATH)
+    conn.row_factory = dict_factory
+    conn.execute("PRAGMA foreign_keys = ON")
+    try:
+        yield conn
+        conn.commit()
+    except Exception:
+        conn.rollback()
+        raise
+    finally:
+        conn.close()
+
+
+def init_db() -> None:
+    with get_db() as conn:
+        conn.executescript(
+            """
+            CREATE TABLE IF NOT EXISTS scan_records (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                started_at TEXT NOT NULL,
+                finished_at TEXT,
+                status TEXT NOT NULL,
+                services_found INTEGER NOT NULL DEFAULT 0,
+                processes_found INTEGER NOT NULL DEFAULT 0,
+                new_services INTEGER NOT NULL DEFAULT 0,
+                new_processes INTEGER NOT NULL DEFAULT 0,
+                error_message TEXT
+            );
+
+            CREATE TABLE IF NOT EXISTS windows_services (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                identity_key TEXT NOT NULL UNIQUE,
+                name TEXT NOT NULL,
+                display_name TEXT,
+                status TEXT,
+                start_type TEXT,
+                username TEXT,
+                binary_path TEXT,
+                description TEXT,
+                is_present_now INTEGER NOT NULL DEFAULT 1,
+                first_seen_at TEXT NOT NULL,
+                last_seen_at TEXT NOT NULL,
+                confirm_status TEXT NOT NULL DEFAULT 'PENDING',
+                user_note TEXT,
+                ai_description TEXT,
+                ai_reason TEXT,
+                ai_suggestion TEXT,
+                risk_level TEXT,
+                updated_at TEXT NOT NULL
+            );
+
+            CREATE TABLE IF NOT EXISTS windows_processes (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                identity_key TEXT NOT NULL UNIQUE,
+                name TEXT NOT NULL,
+                exe_path TEXT,
+                cmdline TEXT,
+                username TEXT,
+                status TEXT,
+                cwd TEXT,
+                last_pid INTEGER,
+                parent_pid INTEGER,
+                create_time TEXT,
+                is_present_now INTEGER NOT NULL DEFAULT 1,
+                first_seen_at TEXT NOT NULL,
+                last_seen_at TEXT NOT NULL,
+                confirm_status TEXT NOT NULL DEFAULT 'PENDING',
+                user_note TEXT,
+                ai_description TEXT,
+                ai_reason TEXT,
+                ai_suggestion TEXT,
+                risk_level TEXT,
+                updated_at TEXT NOT NULL
+            );
+
+            CREATE INDEX IF NOT EXISTS idx_services_confirm_status
+                ON windows_services(confirm_status);
+            CREATE INDEX IF NOT EXISTS idx_processes_confirm_status
+                ON windows_processes(confirm_status);
+            CREATE INDEX IF NOT EXISTS idx_scan_records_started_at
+                ON scan_records(started_at DESC);
+
+            CREATE TABLE IF NOT EXISTS tags (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                name TEXT NOT NULL UNIQUE,
+                description TEXT,
+                is_controllable INTEGER NOT NULL DEFAULT 1,
+                is_builtin INTEGER NOT NULL DEFAULT 0,
+                created_at TEXT NOT NULL,
+                updated_at TEXT NOT NULL
+            );
+
+            CREATE TABLE IF NOT EXISTS item_tags (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                item_type TEXT NOT NULL CHECK(item_type IN ('service', 'process')),
+                item_id INTEGER NOT NULL,
+                tag_id INTEGER NOT NULL,
+                created_at TEXT NOT NULL,
+                UNIQUE(item_type, item_id, tag_id),
+                FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
+            );
+
+            CREATE INDEX IF NOT EXISTS idx_item_tags_item
+                ON item_tags(item_type, item_id);
+            """
+        )
+        seed_default_tags(conn)
+
+
+def seed_default_tags(conn: sqlite3.Connection) -> None:
+    rows = [
+        (
+            "windows系统",
+            "Windows 系统原生服务或进程。默认不可控制,避免误停止系统关键组件。",
+            0,
+            1,
+        ),
+        (
+            "本系统相关",
+            "本项目程序自身相关服务或进程。默认不可控制,避免通过监控系统停止自身。",
+            0,
+            1,
+        ),
+    ]
+    now = __import__("datetime").datetime.now().astimezone().isoformat(timespec="seconds")
+    for name, description, is_controllable, is_builtin in rows:
+        conn.execute(
+            """
+            INSERT INTO tags (name, description, is_controllable, is_builtin, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?, ?)
+            ON CONFLICT(name) DO UPDATE SET
+                description = COALESCE(tags.description, excluded.description),
+                is_builtin = 1,
+                updated_at = excluded.updated_at
+            """,
+            (name, description, is_controllable, is_builtin, now, now),
+        )

+ 664 - 0
backend/app/main.py

@@ -0,0 +1,664 @@
+from __future__ import annotations
+
+import json
+import sqlite3
+from typing import Any
+
+from fastapi import FastAPI, HTTPException, Query
+from fastapi.middleware.cors import CORSMiddleware
+
+from .control import (
+    CONFIRMED_CONTROL_STATUSES,
+    restart_service,
+    start_process,
+    start_service,
+    stop_process,
+    stop_service,
+)
+from .database import get_db, init_db
+from .scanner import now_iso, run_full_scan
+from .sensors import collect_sensors
+from .schemas import AiImportRequest, BatchStatusUpdate, PromptRequest, StatusUpdate, TagAssignRequest, TagCreate, TagUpdate
+from .smart import collect_all_smart, get_device_smart, scan_devices
+
+
+AI_PROMPT_TEMPLATE = """请作为资深的 Windows 系统安全专家,帮我分析下面这些 Windows 服务和进程是否可信,并严格按照 JSON 数组格式输出结果。
+
+输出要求:
+1. 必须且只能输出纯 JSON 数组,不要输出任何额外的解释、问候语,也不要使用 Markdown 代码块(如 ```json)包裹。
+2. 每个对象必须包含以下 7 个字段:type、name、description、judgement、risk_level、reason、suggestion。
+3. type 只能是 "service" 或 "process"。
+4. description 请简要说明该服务或进程的官方用途或常规功能(如果是未知/恶意程序,请描述其伪装意图或表现)。
+5. judgement 只能是 "TRUSTED"、"SUSPICIOUS"、"NEED_MORE_INFO"。
+6. risk_level 只能是 "LOW"、"MEDIUM"、"HIGH"。
+7. 如果提供的信息不足以做出判断,请将 judgement 设为 "NEED_MORE_INFO"。
+8. 请结合每个对象的 tags 字段进行判断。已有标签是人工上下文,不代表最终结论,但如果标签显示为“windows系统”或“本系统相关”,请在 reason 或 suggestion 中体现这一点。
+9. 如果你认为某个对象适合系统已有标签,可以在 suggestion 中建议使用对应标签名称;不要创造不存在的新标签。
+
+JSON 格式示例:
+
+[
+  {
+    "type": "service",
+    "name": "WinDefend",
+    "description": "Microsoft Defender 防病毒核心服务,负责保护系统免受恶意软件和间谍软件的威胁。",
+    "judgement": "TRUSTED",
+    "risk_level": "LOW",
+    "reason": "这是 Microsoft 官方的安全组件,路径和名称符合系统原生服务的标准特征。",
+    "suggestion": "可标记为可信,建议保持运行。"
+  },
+  {
+    "type": "process",
+    "name": "unknown.exe",
+    "description": "未知用途的执行文件,无明确的官方功能说明。",
+    "judgement": "SUSPICIOUS",
+    "risk_level": "HIGH",
+    "reason": "进程位于用户 AppData 临时目录,启动命令行异常,且缺少有效的官方数字签名。",
+    "suggestion": "建议立即隔离,检查文件的 SHA256 散列值及外部网络连接记录,不要直接运行或信任。"
+  }
+]
+
+下面是待分析数据:
+
+{pending_items_json}
+
+系统中已有标签信息:
+
+{tags_json}
+"""
+
+
+app = FastAPI(title="Windows Monitor API", version="1.0.0")
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+
+@app.on_event("startup")
+def startup() -> None:
+    init_db()
+
+
+def build_where(
+    keyword: str | None,
+    confirm_status: str | None,
+    present: bool | None,
+    fields: list[str],
+) -> tuple[str, list[Any]]:
+    clauses: list[str] = []
+    params: list[Any] = []
+    if keyword:
+        like = f"%{keyword}%"
+        clauses.append("(" + " OR ".join(f"{field} LIKE ?" for field in fields) + ")")
+        params.extend([like] * len(fields))
+    if confirm_status:
+        clauses.append("confirm_status = ?")
+        params.append(confirm_status)
+    if present is not None:
+        clauses.append("is_present_now = ?")
+        params.append(1 if present else 0)
+    return ("WHERE " + " AND ".join(clauses)) if clauses else "", params
+
+
+def list_items(
+    table: str,
+    keyword: str | None,
+    confirm_status: str | None,
+    present: bool | None,
+    page: int,
+    page_size: int,
+    fields: list[str],
+) -> dict[str, Any]:
+    where_sql, params = build_where(keyword, confirm_status, present, fields)
+    offset = (page - 1) * page_size
+    with get_db() as conn:
+        total = conn.execute(f"SELECT COUNT(*) AS total FROM {table} {where_sql}", params).fetchone()["total"]
+        rows = conn.execute(
+            f"SELECT * FROM {table} {where_sql} ORDER BY is_present_now DESC, last_seen_at DESC LIMIT ? OFFSET ?",
+            [*params, page_size, offset],
+        ).fetchall()
+        rows = attach_item_metadata(conn, table_to_item_type(table), rows)
+    return {"items": rows, "total": total, "page": page, "page_size": page_size}
+
+
+def get_item(table: str, item_id: int) -> dict[str, Any]:
+    with get_db() as conn:
+        item = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (item_id,)).fetchone()
+        if item:
+            item = attach_item_metadata(conn, table_to_item_type(table), [item])[0]
+    if not item:
+        raise HTTPException(status_code=404, detail="Item not found")
+    return item
+
+
+def table_to_item_type(table: str) -> str:
+    if table == "windows_services":
+        return "service"
+    if table == "windows_processes":
+        return "process"
+    raise ValueError(f"Unsupported table: {table}")
+
+
+def bool_tag(row: dict[str, Any]) -> dict[str, Any]:
+    item = dict(row)
+    item["is_controllable"] = bool(item["is_controllable"])
+    item["is_builtin"] = bool(item["is_builtin"])
+    return item
+
+
+def all_tags(conn) -> list[dict[str, Any]]:
+    return [
+        bool_tag(row)
+        for row in conn.execute("SELECT * FROM tags ORDER BY is_builtin DESC, name ASC").fetchall()
+    ]
+
+
+def tags_for_items(conn, item_type: str, item_ids: list[int]) -> dict[int, list[dict[str, Any]]]:
+    if not item_ids:
+        return {}
+    placeholders = ",".join("?" for _ in item_ids)
+    rows = conn.execute(
+        f"""
+        SELECT it.item_id, t.*
+        FROM item_tags it
+        JOIN tags t ON t.id = it.tag_id
+        WHERE it.item_type = ? AND it.item_id IN ({placeholders})
+        ORDER BY t.name ASC
+        """,
+        [item_type, *item_ids],
+    ).fetchall()
+    result = {item_id: [] for item_id in item_ids}
+    for row in rows:
+        item_id = row["item_id"]
+        tag = {key: value for key, value in row.items() if key != "item_id"}
+        result.setdefault(item_id, []).append(bool_tag(tag))
+    return result
+
+
+def can_control_item(item_type: str, row: dict[str, Any], tags: list[dict[str, Any]]) -> bool:
+    if row.get("confirm_status") not in CONFIRMED_CONTROL_STATUSES:
+        return False
+    if item_type == "process":
+        protected_names = {"system idle process", "system", "registry"}
+        if row.get("last_pid") in (0, 4) or (row.get("name") or "").lower() in protected_names:
+            return False
+    return all(tag.get("is_controllable", True) for tag in tags)
+
+
+def attach_item_metadata(conn, item_type: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    tag_map = tags_for_items(conn, item_type, [row["id"] for row in rows])
+    enriched = []
+    for row in rows:
+        item = dict(row)
+        item["is_present_now"] = bool(item.get("is_present_now"))
+        item["tags"] = tag_map.get(row["id"], [])
+        item["can_control"] = can_control_item(item_type, item, item["tags"])
+        enriched.append(item)
+    return enriched
+
+
+def update_one(table: str, item_id: int, payload: StatusUpdate) -> dict[str, Any]:
+    get_item(table, item_id)
+    with get_db() as conn:
+        conn.execute(
+            f"UPDATE {table} SET confirm_status = ?, user_note = ?, updated_at = ? WHERE id = ?",
+            (payload.confirm_status, payload.user_note, now_iso(), item_id),
+        )
+    return get_item(table, item_id)
+
+
+def update_batch(table: str, payload: BatchStatusUpdate) -> dict[str, Any]:
+    if not payload.ids:
+        return {"updated": 0}
+    placeholders = ",".join("?" for _ in payload.ids)
+    with get_db() as conn:
+        cursor = conn.execute(
+            f"""
+            UPDATE {table}
+            SET confirm_status = ?, user_note = COALESCE(?, user_note), updated_at = ?
+            WHERE id IN ({placeholders})
+            """,
+            [payload.confirm_status, payload.user_note, now_iso(), *payload.ids],
+        )
+    return {"updated": cursor.rowcount}
+
+
+def rows_for_prompt(table: str, item_type: str, payload: PromptRequest) -> list[dict[str, Any]]:
+    with get_db() as conn:
+        if payload.scope == "selected" and payload.ids:
+            placeholders = ",".join("?" for _ in payload.ids)
+            rows = conn.execute(f"SELECT * FROM {table} WHERE id IN ({placeholders})", payload.ids).fetchall()
+        else:
+            rows = conn.execute(f"SELECT * FROM {table} WHERE confirm_status = 'PENDING'").fetchall()
+        rows = attach_item_metadata(conn, item_type, rows)
+    return [normalize_prompt_row(item_type, row) for row in rows]
+
+
+def normalize_prompt_row(item_type: str, row: dict[str, Any]) -> dict[str, Any]:
+    if item_type == "service":
+        return {
+            "type": "service",
+            "id": row["id"],
+            "name": row["name"],
+            "display_name": row["display_name"],
+            "status": row["status"],
+            "start_type": row["start_type"],
+            "username": row["username"],
+            "binary_path": row["binary_path"],
+            "description": row["description"],
+            "is_present_now": bool(row["is_present_now"]),
+            "tags": [
+                {
+                    "name": tag["name"],
+                    "description": tag["description"],
+                    "is_controllable": tag["is_controllable"],
+                }
+                for tag in row.get("tags", [])
+            ],
+        }
+    return {
+        "type": "process",
+        "id": row["id"],
+        "name": row["name"],
+        "exe_path": row["exe_path"],
+        "cmdline": row["cmdline"],
+        "username": row["username"],
+        "status": row["status"],
+        "last_pid": row["last_pid"],
+        "parent_pid": row["parent_pid"],
+        "is_present_now": bool(row["is_present_now"]),
+        "tags": [
+            {
+                "name": tag["name"],
+                "description": tag["description"],
+                "is_controllable": tag["is_controllable"],
+            }
+            for tag in row.get("tags", [])
+        ],
+    }
+
+
+def markdown_table(rows: list[dict[str, Any]]) -> str:
+    headers = ["type", "id", "name", "status", "tags", "path_or_command", "user", "present"]
+    lines = ["| " + " | ".join(headers) + " |", "| " + " | ".join(["---"] * len(headers)) + " |"]
+    for row in rows:
+        path_or_command = row.get("binary_path") or row.get("exe_path") or row.get("cmdline") or ""
+        values = [
+            str(row.get("type", "")),
+            str(row.get("id", "")),
+            str(row.get("name", "")),
+            str(row.get("status", "")),
+            ", ".join(tag.get("name", "") for tag in row.get("tags", [])).replace("|", "\\|"),
+            str(path_or_command).replace("|", "\\|"),
+            str(row.get("username", "")).replace("|", "\\|"),
+            "yes" if row.get("is_present_now") else "no",
+        ]
+        lines.append("| " + " | ".join(values) + " |")
+    return "\n".join(lines)
+
+
+def prompt_response(rows: list[dict[str, Any]]) -> dict[str, Any]:
+    pending_json = json.dumps(rows, ensure_ascii=False, indent=2)
+    table = markdown_table(rows)
+    with get_db() as conn:
+        tags_json = json.dumps(all_tags(conn), ensure_ascii=False, indent=2)
+    prompt_text = AI_PROMPT_TEMPLATE.replace("{pending_items_json}", pending_json).replace("{tags_json}", tags_json)
+    return {"prompt_text": prompt_text, "markdown_table": table, "items": rows}
+
+
+def set_item_tags(item_type: str, table: str, item_id: int, payload: TagAssignRequest) -> dict[str, Any]:
+    get_item(table, item_id)
+    unique_ids = sorted(set(payload.tag_ids))
+    now = now_iso()
+    with get_db() as conn:
+        if unique_ids:
+            placeholders = ",".join("?" for _ in unique_ids)
+            found = conn.execute(f"SELECT id FROM tags WHERE id IN ({placeholders})", unique_ids).fetchall()
+            if len(found) != len(unique_ids):
+                raise HTTPException(status_code=400, detail="One or more tag ids do not exist")
+        conn.execute("DELETE FROM item_tags WHERE item_type = ? AND item_id = ?", (item_type, item_id))
+        for tag_id in unique_ids:
+            conn.execute(
+                "INSERT INTO item_tags (item_type, item_id, tag_id, created_at) VALUES (?, ?, ?, ?)",
+                (item_type, item_id, tag_id, now),
+            )
+    return get_item(table, item_id)
+
+
+def ensure_control_allowed(table: str, item_id: int) -> dict[str, Any]:
+    item = get_item(table, item_id)
+    if not item.get("can_control"):
+        raise HTTPException(status_code=403, detail="This item is not controllable because it is unconfirmed or has a non-controllable tag")
+    return item
+
+
+def import_ai_results(table: str, item_type: str, payload: AiImportRequest) -> dict[str, Any]:
+    updated = 0
+    with get_db() as conn:
+        for item in payload.items:
+            if item.type != item_type:
+                continue
+            cursor = conn.execute(
+                f"""
+                UPDATE {table}
+                SET confirm_status = ?, ai_description = ?, ai_reason = ?,
+                    ai_suggestion = ?, risk_level = ?, updated_at = ?
+                WHERE name = ?
+                """,
+                (
+                    item.judgement,
+                    item.description,
+                    item.reason,
+                    item.suggestion,
+                    item.risk_level,
+                    now_iso(),
+                    item.name,
+                ),
+            )
+            updated += cursor.rowcount
+    return {"updated": updated}
+
+
+@app.get("/api/dashboard")
+def dashboard() -> dict[str, Any]:
+    with get_db() as conn:
+        latest_scan = conn.execute("SELECT * FROM scan_records ORDER BY started_at DESC LIMIT 1").fetchone()
+        service_total = conn.execute("SELECT COUNT(*) AS total FROM windows_services").fetchone()["total"]
+        process_total = conn.execute("SELECT COUNT(*) AS total FROM windows_processes").fetchone()["total"]
+        pending_services = conn.execute(
+            "SELECT COUNT(*) AS total FROM windows_services WHERE confirm_status = 'PENDING'"
+        ).fetchone()["total"]
+        pending_processes = conn.execute(
+            "SELECT COUNT(*) AS total FROM windows_processes WHERE confirm_status = 'PENDING'"
+        ).fetchone()["total"]
+        missing_services = conn.execute(
+            "SELECT COUNT(*) AS total FROM windows_services WHERE is_present_now = 0"
+        ).fetchone()["total"]
+        missing_processes = conn.execute(
+            "SELECT COUNT(*) AS total FROM windows_processes WHERE is_present_now = 0"
+        ).fetchone()["total"]
+    return {
+        "latest_scan": latest_scan,
+        "service_total": service_total,
+        "process_total": process_total,
+        "pending_services": pending_services,
+        "pending_processes": pending_processes,
+        "missing_services": missing_services,
+        "missing_processes": missing_processes,
+    }
+
+
+@app.get("/api/tags")
+def tags() -> dict[str, Any]:
+    with get_db() as conn:
+        rows = all_tags(conn)
+    return {"items": rows}
+
+
+@app.post("/api/tags")
+def tag_create(payload: TagCreate) -> dict[str, Any]:
+    now = now_iso()
+    try:
+        with get_db() as conn:
+            cursor = conn.execute(
+                """
+                INSERT INTO tags (name, description, is_controllable, is_builtin, created_at, updated_at)
+                VALUES (?, ?, ?, 0, ?, ?)
+                """,
+                (payload.name.strip(), payload.description, 1 if payload.is_controllable else 0, now, now),
+            )
+            tag_id = cursor.lastrowid
+            row = conn.execute("SELECT * FROM tags WHERE id = ?", (tag_id,)).fetchone()
+    except sqlite3.IntegrityError as exc:
+        raise HTTPException(status_code=409, detail="Tag name already exists") from exc
+    return bool_tag(row)
+
+
+@app.patch("/api/tags/{tag_id}")
+def tag_update(tag_id: int, payload: TagUpdate) -> dict[str, Any]:
+    now = now_iso()
+    try:
+        with get_db() as conn:
+            existing = conn.execute("SELECT * FROM tags WHERE id = ?", (tag_id,)).fetchone()
+            if not existing:
+                raise HTTPException(status_code=404, detail="Tag not found")
+            conn.execute(
+                """
+                UPDATE tags
+                SET name = ?, description = ?, is_controllable = ?, updated_at = ?
+                WHERE id = ?
+                """,
+                (payload.name.strip(), payload.description, 1 if payload.is_controllable else 0, now, tag_id),
+            )
+            row = conn.execute("SELECT * FROM tags WHERE id = ?", (tag_id,)).fetchone()
+    except sqlite3.IntegrityError as exc:
+        raise HTTPException(status_code=409, detail="Tag name already exists") from exc
+    return bool_tag(row)
+
+
+@app.delete("/api/tags/{tag_id}")
+def tag_delete(tag_id: int) -> dict[str, Any]:
+    with get_db() as conn:
+        row = conn.execute("SELECT * FROM tags WHERE id = ?", (tag_id,)).fetchone()
+        if not row:
+            raise HTTPException(status_code=404, detail="Tag not found")
+        if row["is_builtin"]:
+            raise HTTPException(status_code=400, detail="Built-in tags cannot be deleted")
+        cursor = conn.execute("DELETE FROM tags WHERE id = ?", (tag_id,))
+    return {"deleted": cursor.rowcount}
+
+
+@app.get("/api/sensors")
+def sensors() -> dict[str, Any]:
+    return collect_sensors()
+
+
+@app.get("/api/smart/scan")
+def smart_scan() -> dict[str, Any]:
+    return scan_devices()
+
+
+@app.get("/api/smart/devices")
+def smart_devices(include_jmb39x: bool = True, jmb39x_slots: int = Query(default=8, ge=0, le=16)) -> dict[str, Any]:
+    return collect_all_smart(include_jmb39x=include_jmb39x, jmb39x_slots=jmb39x_slots)
+
+
+@app.get("/api/smart/device")
+def smart_device(device: str, device_type: str | None = None) -> dict[str, Any]:
+    return get_device_smart(device, device_type)
+
+
+@app.post("/api/scans/run")
+def run_scan() -> dict[str, Any]:
+    return run_full_scan()
+
+
+@app.get("/api/scans")
+def scan_history(page: int = 1, page_size: int = 20) -> dict[str, Any]:
+    offset = (page - 1) * page_size
+    with get_db() as conn:
+        total = conn.execute("SELECT COUNT(*) AS total FROM scan_records").fetchone()["total"]
+        rows = conn.execute(
+            "SELECT * FROM scan_records ORDER BY started_at DESC LIMIT ? OFFSET ?",
+            (page_size, offset),
+        ).fetchall()
+    return {"items": rows, "total": total, "page": page, "page_size": page_size}
+
+
+@app.get("/api/scans/{scan_id}")
+def scan_detail(scan_id: int) -> dict[str, Any]:
+    with get_db() as conn:
+        scan = conn.execute("SELECT * FROM scan_records WHERE id = ?", (scan_id,)).fetchone()
+    if not scan:
+        raise HTTPException(status_code=404, detail="Scan not found")
+    return scan
+
+
+@app.get("/api/services")
+def services(
+    keyword: str | None = None,
+    confirm_status: str | None = None,
+    present: bool | None = None,
+    page: int = Query(default=1, ge=1),
+    page_size: int = Query(default=20, ge=1, le=200),
+) -> dict[str, Any]:
+    return list_items(
+        "windows_services",
+        keyword,
+        confirm_status,
+        present,
+        page,
+        page_size,
+        ["name", "display_name", "binary_path", "description"],
+    )
+
+
+@app.patch("/api/services/batch")
+def service_batch_update(payload: BatchStatusUpdate) -> dict[str, Any]:
+    return update_batch("windows_services", payload)
+
+
+@app.post("/api/services/import-ai")
+def service_import_ai(payload: AiImportRequest) -> dict[str, Any]:
+    return import_ai_results("windows_services", "service", payload)
+
+
+@app.post("/api/services/ai-prompt")
+def service_ai_prompt(payload: PromptRequest) -> dict[str, Any]:
+    return prompt_response(rows_for_prompt("windows_services", "service", payload))
+
+
+@app.put("/api/services/{service_id}/tags")
+def service_tags_update(service_id: int, payload: TagAssignRequest) -> dict[str, Any]:
+    return set_item_tags("service", "windows_services", service_id, payload)
+
+
+@app.post("/api/services/{service_id}/start")
+def service_start(service_id: int) -> dict[str, Any]:
+    item = ensure_control_allowed("windows_services", service_id)
+    if not item.get("is_present_now"):
+        raise HTTPException(status_code=400, detail="This service was not present in the latest scan")
+    result = start_service(item["name"])
+    with get_db() as conn:
+        conn.execute(
+            "UPDATE windows_services SET status = ?, updated_at = ? WHERE id = ?",
+            (result.get("status") or "running", now_iso(), service_id),
+        )
+    return result
+
+
+@app.post("/api/services/{service_id}/stop")
+def service_stop(service_id: int) -> dict[str, Any]:
+    item = ensure_control_allowed("windows_services", service_id)
+    if not item.get("is_present_now"):
+        raise HTTPException(status_code=400, detail="This service was not present in the latest scan")
+    result = stop_service(item["name"])
+    with get_db() as conn:
+        conn.execute(
+            "UPDATE windows_services SET status = ?, updated_at = ? WHERE id = ?",
+            (result.get("status") or "stopped", now_iso(), service_id),
+        )
+    return result
+
+
+@app.post("/api/services/{service_id}/restart")
+def service_restart(service_id: int) -> dict[str, Any]:
+    item = ensure_control_allowed("windows_services", service_id)
+    if not item.get("is_present_now"):
+        raise HTTPException(status_code=400, detail="This service was not present in the latest scan")
+    result = restart_service(item["name"])
+    with get_db() as conn:
+        conn.execute(
+            "UPDATE windows_services SET status = ?, updated_at = ? WHERE id = ?",
+            (result.get("status") or "running", now_iso(), service_id),
+        )
+    return result
+
+
+@app.get("/api/services/{service_id}")
+def service_detail(service_id: int) -> dict[str, Any]:
+    return get_item("windows_services", service_id)
+
+
+@app.patch("/api/services/{service_id}")
+def service_update(service_id: int, payload: StatusUpdate) -> dict[str, Any]:
+    return update_one("windows_services", service_id, payload)
+
+
+@app.get("/api/processes")
+def processes(
+    keyword: str | None = None,
+    confirm_status: str | None = None,
+    present: bool | None = None,
+    page: int = Query(default=1, ge=1),
+    page_size: int = Query(default=20, ge=1, le=200),
+) -> dict[str, Any]:
+    return list_items(
+        "windows_processes",
+        keyword,
+        confirm_status,
+        present,
+        page,
+        page_size,
+        ["name", "exe_path", "cmdline", "username"],
+    )
+
+
+@app.patch("/api/processes/batch")
+def process_batch_update(payload: BatchStatusUpdate) -> dict[str, Any]:
+    return update_batch("windows_processes", payload)
+
+
+@app.post("/api/processes/import-ai")
+def process_import_ai(payload: AiImportRequest) -> dict[str, Any]:
+    return import_ai_results("windows_processes", "process", payload)
+
+
+@app.post("/api/processes/ai-prompt")
+def process_ai_prompt(payload: PromptRequest) -> dict[str, Any]:
+    return prompt_response(rows_for_prompt("windows_processes", "process", payload))
+
+
+@app.put("/api/processes/{process_id}/tags")
+def process_tags_update(process_id: int, payload: TagAssignRequest) -> dict[str, Any]:
+    return set_item_tags("process", "windows_processes", process_id, payload)
+
+
+@app.post("/api/processes/{process_id}/start")
+def process_start(process_id: int) -> dict[str, Any]:
+    item = ensure_control_allowed("windows_processes", process_id)
+    result = start_process(item)
+    with get_db() as conn:
+        conn.execute(
+            "UPDATE windows_processes SET last_pid = ?, is_present_now = 1, status = ?, updated_at = ? WHERE id = ?",
+            (result.get("pid"), "running", now_iso(), process_id),
+        )
+    return result
+
+
+@app.post("/api/processes/{process_id}/stop")
+def process_stop(process_id: int) -> dict[str, Any]:
+    item = ensure_control_allowed("windows_processes", process_id)
+    if not item.get("is_present_now"):
+        raise HTTPException(status_code=400, detail="This process was not present in the latest scan")
+    result = stop_process(item)
+    with get_db() as conn:
+        conn.execute(
+            "UPDATE windows_processes SET is_present_now = 0, status = ?, updated_at = ? WHERE id = ?",
+            ("stopped", now_iso(), process_id),
+        )
+    return result
+
+
+@app.get("/api/processes/{process_id}")
+def process_detail(process_id: int) -> dict[str, Any]:
+    return get_item("windows_processes", process_id)
+
+
+@app.patch("/api/processes/{process_id}")
+def process_update(process_id: int, payload: StatusUpdate) -> dict[str, Any]:
+    return update_one("windows_processes", process_id, payload)

+ 227 - 0
backend/app/scanner.py

@@ -0,0 +1,227 @@
+from __future__ import annotations
+
+import hashlib
+from datetime import datetime
+from typing import Any
+
+import psutil
+
+from .database import get_db
+
+
+def now_iso() -> str:
+    return datetime.now().astimezone().isoformat(timespec="seconds")
+
+
+def process_identity(name: str, exe_path: str | None, cmdline: str | None) -> str:
+    raw = f"{name.lower()}|{(exe_path or cmdline or '').lower()}"
+    return hashlib.sha256(raw.encode("utf-8", errors="ignore")).hexdigest()
+
+
+def collect_services() -> list[dict[str, Any]]:
+    if not hasattr(psutil, "win_service_iter"):
+        return []
+
+    services: list[dict[str, Any]] = []
+    for service in psutil.win_service_iter():
+        try:
+            info = service.as_dict()
+        except (psutil.Error, OSError):
+            continue
+        services.append(
+            {
+                "identity_key": info.get("name") or service.name(),
+                "name": info.get("name") or service.name(),
+                "display_name": info.get("display_name"),
+                "status": info.get("status"),
+                "start_type": info.get("start_type"),
+                "username": info.get("username"),
+                "binary_path": info.get("binpath"),
+                "description": info.get("description"),
+            }
+        )
+    return services
+
+
+def collect_processes() -> list[dict[str, Any]]:
+    processes: list[dict[str, Any]] = []
+    attrs = ["pid", "name", "exe", "cmdline", "username", "status", "cwd", "ppid", "create_time"]
+    for proc in psutil.process_iter(attrs=attrs):
+        try:
+            info = proc.info
+            name = info.get("name") or f"pid-{info.get('pid')}"
+            cmdline_list = info.get("cmdline") or []
+            cmdline = " ".join(str(part) for part in cmdline_list)
+            create_time = info.get("create_time")
+            processes.append(
+                {
+                    "identity_key": process_identity(name, info.get("exe"), cmdline),
+                    "name": name,
+                    "exe_path": info.get("exe"),
+                    "cmdline": cmdline,
+                    "username": info.get("username"),
+                    "status": info.get("status"),
+                    "cwd": info.get("cwd"),
+                    "last_pid": info.get("pid"),
+                    "parent_pid": info.get("ppid"),
+                    "create_time": datetime.fromtimestamp(create_time).astimezone().isoformat(timespec="seconds")
+                    if create_time
+                    else None,
+                }
+            )
+        except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, OSError):
+            continue
+    return processes
+
+
+def run_full_scan() -> dict[str, Any]:
+    started_at = now_iso()
+    with get_db() as conn:
+        cursor = conn.execute(
+            "INSERT INTO scan_records (started_at, status) VALUES (?, 'RUNNING')",
+            (started_at,),
+        )
+        scan_id = cursor.lastrowid
+
+    try:
+        services = collect_services()
+        processes = collect_processes()
+        scanned_at = now_iso()
+        new_services = 0
+        new_processes = 0
+
+        with get_db() as conn:
+            conn.execute("UPDATE windows_services SET is_present_now = 0")
+            conn.execute("UPDATE windows_processes SET is_present_now = 0")
+
+            for item in services:
+                existing = conn.execute(
+                    "SELECT id FROM windows_services WHERE identity_key = ?",
+                    (item["identity_key"],),
+                ).fetchone()
+                if existing:
+                    conn.execute(
+                        """
+                        UPDATE windows_services
+                        SET display_name = ?, status = ?, start_type = ?, username = ?,
+                            binary_path = ?, description = ?, is_present_now = 1,
+                            last_seen_at = ?, updated_at = ?
+                        WHERE identity_key = ?
+                        """,
+                        (
+                            item["display_name"],
+                            item["status"],
+                            item["start_type"],
+                            item["username"],
+                            item["binary_path"],
+                            item["description"],
+                            scanned_at,
+                            scanned_at,
+                            item["identity_key"],
+                        ),
+                    )
+                else:
+                    new_services += 1
+                    conn.execute(
+                        """
+                        INSERT INTO windows_services (
+                            identity_key, name, display_name, status, start_type, username,
+                            binary_path, description, is_present_now, first_seen_at,
+                            last_seen_at, confirm_status, updated_at
+                        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, 'PENDING', ?)
+                        """,
+                        (
+                            item["identity_key"],
+                            item["name"],
+                            item["display_name"],
+                            item["status"],
+                            item["start_type"],
+                            item["username"],
+                            item["binary_path"],
+                            item["description"],
+                            scanned_at,
+                            scanned_at,
+                            scanned_at,
+                        ),
+                    )
+
+            for item in processes:
+                existing = conn.execute(
+                    "SELECT id FROM windows_processes WHERE identity_key = ?",
+                    (item["identity_key"],),
+                ).fetchone()
+                if existing:
+                    conn.execute(
+                        """
+                        UPDATE windows_processes
+                        SET exe_path = ?, cmdline = ?, username = ?, status = ?, cwd = ?,
+                            last_pid = ?, parent_pid = ?, create_time = ?,
+                            is_present_now = 1, last_seen_at = ?, updated_at = ?
+                        WHERE identity_key = ?
+                        """,
+                        (
+                            item["exe_path"],
+                            item["cmdline"],
+                            item["username"],
+                            item["status"],
+                            item["cwd"],
+                            item["last_pid"],
+                            item["parent_pid"],
+                            item["create_time"],
+                            scanned_at,
+                            scanned_at,
+                            item["identity_key"],
+                        ),
+                    )
+                else:
+                    new_processes += 1
+                    conn.execute(
+                        """
+                        INSERT INTO windows_processes (
+                            identity_key, name, exe_path, cmdline, username, status, cwd,
+                            last_pid, parent_pid, create_time, is_present_now,
+                            first_seen_at, last_seen_at, confirm_status, updated_at
+                        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, 'PENDING', ?)
+                        """,
+                        (
+                            item["identity_key"],
+                            item["name"],
+                            item["exe_path"],
+                            item["cmdline"],
+                            item["username"],
+                            item["status"],
+                            item["cwd"],
+                            item["last_pid"],
+                            item["parent_pid"],
+                            item["create_time"],
+                            scanned_at,
+                            scanned_at,
+                            scanned_at,
+                        ),
+                    )
+
+            conn.execute(
+                """
+                UPDATE scan_records
+                SET finished_at = ?, status = 'SUCCESS', services_found = ?,
+                    processes_found = ?, new_services = ?, new_processes = ?
+                WHERE id = ?
+                """,
+                (now_iso(), len(services), len(processes), new_services, new_processes, scan_id),
+            )
+
+        return {
+            "scan_id": scan_id,
+            "status": "SUCCESS",
+            "services_found": len(services),
+            "processes_found": len(processes),
+            "new_services": new_services,
+            "new_processes": new_processes,
+        }
+    except Exception as exc:
+        with get_db() as conn:
+            conn.execute(
+                "UPDATE scan_records SET finished_at = ?, status = 'FAILED', error_message = ? WHERE id = ?",
+                (now_iso(), str(exc), scan_id),
+            )
+        raise

+ 60 - 0
backend/app/schemas.py

@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+
+ConfirmStatus = Literal["PENDING", "TRUSTED", "SUSPICIOUS", "IGNORED", "NEED_MORE_INFO"]
+ItemType = Literal["service", "process"]
+
+
+class StatusUpdate(BaseModel):
+    confirm_status: ConfirmStatus
+    user_note: str | None = None
+
+
+class BatchStatusUpdate(BaseModel):
+    ids: list[int] = Field(default_factory=list)
+    confirm_status: ConfirmStatus
+    user_note: str | None = None
+
+
+class AiImportItem(BaseModel):
+    type: ItemType
+    name: str
+    description: str | None = None
+    judgement: Literal["TRUSTED", "SUSPICIOUS", "NEED_MORE_INFO"]
+    risk_level: Literal["LOW", "MEDIUM", "HIGH"]
+    reason: str | None = None
+    suggestion: str | None = None
+
+
+class AiImportRequest(BaseModel):
+    items: list[AiImportItem]
+
+
+class PromptRequest(BaseModel):
+    ids: list[int] | None = None
+    scope: Literal["selected", "pending"] = "pending"
+
+
+class TagCreate(BaseModel):
+    name: str = Field(min_length=1, max_length=80)
+    description: str | None = None
+    is_controllable: bool = True
+
+
+class TagUpdate(BaseModel):
+    name: str = Field(min_length=1, max_length=80)
+    description: str | None = None
+    is_controllable: bool = True
+
+
+class TagAssignRequest(BaseModel):
+    tag_ids: list[int] = Field(default_factory=list)
+
+
+class ProcessStartRequest(BaseModel):
+    command: str = Field(min_length=1)
+    cwd: str | None = None

+ 298 - 0
backend/app/sensors.py

@@ -0,0 +1,298 @@
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import subprocess
+from datetime import datetime
+from typing import Any
+
+import psutil
+
+
+def now_iso() -> str:
+    return datetime.now().astimezone().isoformat(timespec="seconds")
+
+
+def run_command(args: list[str], timeout: int = 8) -> tuple[int, str, str]:
+    creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
+    try:
+        process = subprocess.Popen(
+            args,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            text=True,
+            encoding="utf-8",
+            errors="replace",
+            creationflags=creationflags,
+        )
+        stdout, stderr = process.communicate(timeout=timeout)
+        return process.returncode or 0, stdout.strip(), stderr.strip()
+    except subprocess.TimeoutExpired:
+        terminate_process_tree(process.pid)
+        stdout, stderr = process.communicate()
+        message = f"command timed out after {timeout} seconds and process tree was terminated"
+        return 1, (stdout or "").strip(), ((stderr or "").strip() + "\n" + message).strip()
+    except OSError as exc:
+        return 1, "", str(exc)
+
+
+def terminate_process_tree(pid: int) -> None:
+    if os.name == "nt":
+        subprocess.run(
+            ["taskkill", "/PID", str(pid), "/T", "/F"],
+            stdout=subprocess.DEVNULL,
+            stderr=subprocess.DEVNULL,
+            creationflags=subprocess.CREATE_NO_WINDOW,
+            check=False,
+        )
+        return
+    try:
+        psutil.Process(pid).kill()
+    except psutil.Error:
+        pass
+
+
+def powershell_path() -> str | None:
+    candidates = [
+        r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
+        shutil.which("powershell"),
+        shutil.which("pwsh"),
+    ]
+    for candidate in candidates:
+        if candidate and os.path.exists(candidate):
+            return candidate
+    return None
+
+
+def run_powershell(script: str, timeout: int = 8) -> Any:
+    ps = powershell_path()
+    if not ps:
+        return None
+    code, stdout, _ = run_command(
+        [
+            ps,
+            "-NoProfile",
+            "-NonInteractive",
+            "-ExecutionPolicy",
+            "Bypass",
+            "-Command",
+            script,
+        ],
+        timeout=timeout,
+    )
+    if code != 0 or not stdout:
+        return None
+    try:
+        return json.loads(stdout)
+    except json.JSONDecodeError:
+        return None
+
+
+def collect_cpu() -> dict[str, Any]:
+    freq = psutil.cpu_freq()
+    return {
+        "load_percent": psutil.cpu_percent(interval=0.2),
+        "per_core_percent": psutil.cpu_percent(interval=None, percpu=True),
+        "physical_cores": psutil.cpu_count(logical=False),
+        "logical_cores": psutil.cpu_count(logical=True),
+        "frequency_mhz": round(freq.current, 2) if freq else None,
+    }
+
+
+def collect_memory() -> dict[str, Any]:
+    vm = psutil.virtual_memory()
+    swap = psutil.swap_memory()
+    return {
+        "total": vm.total,
+        "available": vm.available,
+        "used": vm.used,
+        "percent": vm.percent,
+        "swap_total": swap.total,
+        "swap_used": swap.used,
+        "swap_percent": swap.percent,
+    }
+
+
+def collect_psutil_temperatures() -> list[dict[str, Any]]:
+    sensors: list[dict[str, Any]] = []
+    if not hasattr(psutil, "sensors_temperatures"):
+        return sensors
+    try:
+        groups = psutil.sensors_temperatures(fahrenheit=False) or {}
+    except (AttributeError, OSError):
+        return sensors
+    for group, entries in groups.items():
+        for entry in entries:
+            sensors.append(
+                {
+                    "source": "psutil",
+                    "hardware_type": "temperature",
+                    "name": entry.label or group,
+                    "value": entry.current,
+                    "unit": "C",
+                    "high": entry.high,
+                    "critical": entry.critical,
+                }
+            )
+    return sensors
+
+
+def collect_hardware_monitor_sensors() -> list[dict[str, Any]]:
+    script = r"""
+$namespaces = @('root\LibreHardwareMonitor', 'root\OpenHardwareMonitor')
+$items = @()
+foreach ($ns in $namespaces) {
+  try {
+    $items += Get-CimInstance -Namespace $ns -ClassName Sensor -ErrorAction Stop |
+      Where-Object { $_.SensorType -in @('Temperature','Load','Fan','Voltage','Power','Clock') } |
+      Select-Object @{Name='source';Expression={$ns}}, Identifier, Name, SensorType, Value, Min, Max
+  } catch {}
+}
+$items | ConvertTo-Json -Depth 4
+"""
+    raw = run_powershell(script)
+    if not raw:
+        return []
+    rows = raw if isinstance(raw, list) else [raw]
+    sensors: list[dict[str, Any]] = []
+    unit_map = {
+        "Temperature": "C",
+        "Load": "%",
+        "Fan": "RPM",
+        "Voltage": "V",
+        "Power": "W",
+        "Clock": "MHz",
+    }
+    for row in rows:
+        sensor_type = row.get("SensorType")
+        sensors.append(
+            {
+                "source": row.get("source"),
+                "hardware_type": sensor_type,
+                "name": row.get("Name"),
+                "identifier": row.get("Identifier"),
+                "value": row.get("Value"),
+                "unit": unit_map.get(sensor_type),
+                "min": row.get("Min"),
+                "max": row.get("Max"),
+            }
+        )
+    return sensors
+
+
+def collect_acpi_temperatures() -> list[dict[str, Any]]:
+    script = r"""
+Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue |
+  Select-Object InstanceName, CurrentTemperature |
+  ConvertTo-Json -Depth 3
+"""
+    raw = run_powershell(script)
+    if not raw:
+        return []
+    rows = raw if isinstance(raw, list) else [raw]
+    sensors: list[dict[str, Any]] = []
+    for row in rows:
+        current = row.get("CurrentTemperature")
+        celsius = round((current / 10) - 273.15, 1) if isinstance(current, (int, float)) else None
+        sensors.append(
+            {
+                "source": "MSAcpi_ThermalZoneTemperature",
+                "hardware_type": "Temperature",
+                "name": row.get("InstanceName"),
+                "value": celsius,
+                "unit": "C",
+            }
+        )
+    return sensors
+
+
+def collect_nvidia_gpus() -> list[dict[str, Any]]:
+    nvidia_smi = shutil.which("nvidia-smi")
+    if not nvidia_smi:
+        return []
+    query = "name,utilization.gpu,utilization.memory,temperature.gpu,memory.total,memory.used,power.draw"
+    code, stdout, stderr = run_command(
+        [
+            nvidia_smi,
+            f"--query-gpu={query}",
+            "--format=csv,noheader,nounits",
+        ],
+        timeout=8,
+    )
+    if code != 0:
+        return [{"source": "nvidia-smi", "error": stderr or stdout}]
+    gpus: list[dict[str, Any]] = []
+    for index, line in enumerate(stdout.splitlines()):
+        parts = [part.strip() for part in line.split(",")]
+        if len(parts) < 7:
+            continue
+        gpus.append(
+            {
+                "source": "nvidia-smi",
+                "index": index,
+                "name": parts[0],
+                "load_percent": to_float(parts[1]),
+                "memory_load_percent": to_float(parts[2]),
+                "temperature_c": to_float(parts[3]),
+                "memory_total_mb": to_float(parts[4]),
+                "memory_used_mb": to_float(parts[5]),
+                "power_w": to_float(parts[6]),
+            }
+        )
+    return gpus
+
+
+def collect_windows_gpu_counters() -> list[dict[str, Any]]:
+    script = r"""
+$counters = Get-Counter '\GPU Engine(*)\Utilization Percentage' -ErrorAction SilentlyContinue
+$rows = @()
+if ($counters) {
+  $rows = $counters.CounterSamples |
+    Where-Object { $_.CookedValue -gt 0 } |
+    Select-Object InstanceName, CookedValue
+}
+$rows | ConvertTo-Json -Depth 3
+"""
+    raw = run_powershell(script, timeout=10)
+    if not raw:
+        return []
+    rows = raw if isinstance(raw, list) else [raw]
+    total = sum(float(row.get("CookedValue") or 0) for row in rows)
+    return [
+        {
+            "source": "Windows Performance Counter",
+            "name": "GPU Engine Utilization",
+            "load_percent": round(total, 2),
+            "engines": rows[:20],
+        }
+    ]
+
+
+def to_float(value: Any) -> float | None:
+    try:
+        return float(str(value).replace("W", "").strip())
+    except (TypeError, ValueError):
+        return None
+
+
+def collect_sensors() -> dict[str, Any]:
+    hardware_sensors = collect_hardware_monitor_sensors()
+    temperatures = collect_psutil_temperatures() + collect_acpi_temperatures()
+    gpu = collect_nvidia_gpus()
+    if not gpu:
+        gpu = collect_windows_gpu_counters()
+
+    return {
+        "collected_at": now_iso(),
+        "cpu": collect_cpu(),
+        "memory": collect_memory(),
+        "gpu": gpu,
+        "temperatures": temperatures,
+        "hardware_sensors": hardware_sensors,
+        "notes": [
+            "CPU 和内存负载来自 psutil。",
+            "显卡优先使用 nvidia-smi;否则尝试 Windows GPU 性能计数器。",
+            "温度优先读取 LibreHardwareMonitor/OpenHardwareMonitor WMI,其次 psutil 和 ACPI 热区;Windows 原生接口可能无法提供 CPU/GPU 真实温度。",
+        ],
+    }

+ 231 - 0
backend/app/smart.py

@@ -0,0 +1,231 @@
+from __future__ import annotations
+
+import re
+import os
+import shutil
+import subprocess
+from datetime import datetime
+from typing import Any
+
+
+def now_iso() -> str:
+    return datetime.now().astimezone().isoformat(timespec="seconds")
+
+
+def smartctl_path() -> str | None:
+    configured = os.environ.get("SMARTCTL_PATH")
+    candidates = [
+        configured,
+        shutil.which("smartctl"),
+        r"C:\Program Files\smartmontools\bin\smartctl.exe",
+        r"C:\Program Files (x86)\smartmontools\bin\smartctl.exe",
+    ]
+    for candidate in candidates:
+        if candidate and os.path.exists(candidate):
+            return candidate
+    return None
+
+
+def run_smartctl(args: list[str], timeout: int = 30) -> dict[str, Any]:
+    exe = smartctl_path()
+    if not exe:
+        return {
+            "ok": False,
+            "returncode": None,
+            "stdout": "",
+            "stderr": "smartctl not found. Please install smartmontools and add it to PATH.",
+        }
+    try:
+        result = subprocess.run(
+            [exe, *args],
+            capture_output=True,
+            text=True,
+            encoding="utf-8",
+            errors="replace",
+            timeout=timeout,
+            check=False,
+        )
+        return {
+            "ok": result.returncode in (0, 2, 4, 64),
+            "returncode": result.returncode,
+            "stdout": result.stdout,
+            "stderr": result.stderr,
+        }
+    except (OSError, subprocess.TimeoutExpired) as exc:
+        return {"ok": False, "returncode": None, "stdout": "", "stderr": str(exc)}
+
+
+def scan_devices() -> dict[str, Any]:
+    result = run_smartctl(["--scan"], timeout=15)
+    devices = []
+    for line in result["stdout"].splitlines():
+        parsed = parse_scan_line(line)
+        if parsed:
+            devices.append(parsed)
+    return {
+        "smartctl_available": smartctl_path() is not None,
+        "collected_at": now_iso(),
+        "devices": devices,
+        "raw_output": result["stdout"],
+        "error": result["stderr"] if not result["ok"] else None,
+    }
+
+
+def parse_scan_line(line: str) -> dict[str, Any] | None:
+    stripped = line.strip()
+    if not stripped or stripped.startswith("#"):
+        return None
+    comment = ""
+    command_part = stripped
+    if "#" in stripped:
+        command_part, comment = stripped.split("#", 1)
+    parts = command_part.split()
+    if not parts:
+        return None
+    name = parts[0]
+    device_type = None
+    if "-d" in parts:
+        index = parts.index("-d")
+        if index + 1 < len(parts):
+            device_type = parts[index + 1]
+    return {
+        "name": name,
+        "device_type": device_type,
+        "comment": comment.strip(),
+        "scan_line": stripped,
+    }
+
+
+def get_device_smart(device: str, device_type: str | None = None, timeout: int = 45) -> dict[str, Any]:
+    args = ["-a"]
+    if device_type:
+        args.extend(["-d", device_type])
+    args.append(device)
+    result = run_smartctl(args, timeout=timeout)
+    parsed = parse_smart_output(result["stdout"])
+    return {
+        "device": device,
+        "device_type": device_type,
+        "collected_at": now_iso(),
+        "ok": result["ok"],
+        "returncode": result["returncode"],
+        "summary": parsed,
+        "raw_output": result["stdout"],
+        "error": result["stderr"] if not result["ok"] else None,
+    }
+
+
+def collect_all_smart(include_jmb39x: bool = True, jmb39x_slots: int = 8) -> dict[str, Any]:
+    scan = scan_devices()
+    devices: list[dict[str, Any]] = []
+    for item in scan["devices"]:
+        devices.append(get_device_smart(item["name"], item.get("device_type")))
+        if include_jmb39x and should_probe_jmb39x(item):
+            for slot in range(max(0, min(jmb39x_slots, 16))):
+                detail = get_device_smart(item["name"], f"jmb39x,{slot}", timeout=30)
+                if is_jmb39x_present(detail):
+                    detail["jmb39x_slot"] = slot
+                    devices.append(detail)
+    return {
+        "smartctl_available": scan["smartctl_available"],
+        "collected_at": now_iso(),
+        "scan": scan,
+        "devices": devices,
+        "jmb39x_probe_enabled": include_jmb39x,
+        "jmb39x_slots": jmb39x_slots,
+    }
+
+
+def should_probe_jmb39x(item: dict[str, Any]) -> bool:
+    device_type = (item.get("device_type") or "").lower()
+    comment = (item.get("comment") or "").lower()
+    return device_type in {"sat", "scsi", "ata"} or "ata device" in comment or "sat" in comment
+
+
+def is_jmb39x_present(detail: dict[str, Any]) -> bool:
+    text = detail.get("raw_output") or ""
+    error = detail.get("error") or ""
+    if not detail.get("ok") and not text:
+        return False
+    missing_patterns = [
+        "No such device",
+        "Unable to detect device type",
+        "Please specify device type",
+        "Read Device Identity failed",
+        "Unknown USB bridge",
+    ]
+    return not any(pattern.lower() in (text + error).lower() for pattern in missing_patterns)
+
+
+def parse_smart_output(output: str) -> dict[str, Any]:
+    summary: dict[str, Any] = {
+        "model": first_value(output, ["Model Number", "Device Model", "Product"]),
+        "serial_number": first_value(output, ["Serial Number"]),
+        "firmware": first_value(output, ["Firmware Version", "Revision"]),
+        "capacity": first_value(output, ["Total NVM Capacity", "User Capacity"]),
+        "health": first_value(output, ["SMART overall-health self-assessment test result", "SMART Health Status"]),
+        "temperature_c": parse_temperature(output),
+        "power_on_hours": first_value(output, ["Power On Hours"]),
+        "power_cycles": first_value(output, ["Power Cycles"]),
+        "percentage_used": first_value(output, ["Percentage Used"]),
+        "data_units_read": first_value(output, ["Data Units Read"]),
+        "data_units_written": first_value(output, ["Data Units Written"]),
+        "attributes": parse_ata_attributes(output),
+        "warnings": parse_warnings(output),
+    }
+    return summary
+
+
+def first_value(output: str, keys: list[str]) -> str | None:
+    for key in keys:
+        pattern = re.compile(rf"^{re.escape(key)}\s*:\s*(.+)$", re.MULTILINE)
+        match = pattern.search(output)
+        if match:
+            return match.group(1).strip()
+    return None
+
+
+def parse_temperature(output: str) -> int | None:
+    keys = ["Temperature", "Temperature_Celsius", "Airflow_Temperature_Cel"]
+    for key in keys:
+        pattern = re.compile(rf"^{re.escape(key)}\s*:\s*([0-9]+)\s*Celsius", re.MULTILINE)
+        match = pattern.search(output)
+        if match:
+            return int(match.group(1))
+    attr_pattern = re.compile(r"^\s*(?:190|194)\s+\S+.*?\s+([0-9]+)(?:\s|\()", re.MULTILINE)
+    match = attr_pattern.search(output)
+    if match:
+        return int(match.group(1))
+    return None
+
+
+def parse_ata_attributes(output: str) -> list[dict[str, Any]]:
+    attrs: list[dict[str, Any]] = []
+    pattern = re.compile(
+        r"^\s*(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$",
+        re.MULTILINE,
+    )
+    for match in pattern.finditer(output):
+        attrs.append(
+            {
+                "id": int(match.group(1)),
+                "name": match.group(2),
+                "flag": match.group(3),
+                "value": int(match.group(4)),
+                "worst": int(match.group(5)),
+                "threshold": int(match.group(6)),
+                "type": match.group(7),
+                "updated": match.group(8),
+                "when_failed": match.group(9),
+                "raw": match.group(10).strip(),
+            }
+        )
+    return attrs
+
+
+def parse_warnings(output: str) -> list[str]:
+    warnings = []
+    for line in output.splitlines():
+        if line.startswith("Warning:") or "failed" in line.lower() or "not supported" in line.lower():
+            warnings.append(line.strip())
+    return warnings

+ 4 - 0
backend/requirements.txt

@@ -0,0 +1,4 @@
+fastapi>=0.115.0
+uvicorn[standard]>=0.30.0
+psutil>=6.0.0
+pydantic>=2.8.0

+ 418 - 0
deployment.md

@@ -0,0 +1,418 @@
+# Windows 设备部署文档
+
+## 1. 环境要求
+
+- Windows 10/11 或 Windows Server
+- Python 3.11 及以上
+- Node.js 20 及以上
+- smartmontools,且 `smartctl.exe` 已加入 `PATH`
+- 可选:LibreHardwareMonitor 或 OpenHardwareMonitor,用于提供更完整的 CPU/GPU/主板温度传感器
+- 可选:NVIDIA 驱动自带 `nvidia-smi`,用于 NVIDIA 显卡负载、温度、显存和功耗
+
+## 2. 获取项目
+
+将项目目录复制到目标设备,例如:
+
+```powershell
+C:\Apps\win_monitor
+```
+
+以下命令默认在项目根目录执行:
+
+```powershell
+cd C:\Apps\win_monitor
+```
+
+## 3. 安装 Python 依赖
+
+建议使用虚拟环境:
+
+```powershell
+python -m venv .venv
+.\.venv\Scripts\Activate.ps1
+python -m pip install --upgrade pip
+python -m pip install -r backend\requirements.txt
+```
+
+如果 PowerShell 禁止执行激活脚本,可先执行:
+
+```powershell
+Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
+```
+
+## 4. 安装前端依赖并构建
+
+```powershell
+cd frontend
+npm install
+npm run build
+cd ..
+```
+
+开发调试时可运行:
+
+```powershell
+cd frontend
+npm run dev -- --port 5173
+```
+
+生产部署建议使用前端构建产物 `frontend\dist`,可由 Nginx、IIS 或其他静态文件服务承载。
+
+## 5. 安装 smartmontools
+
+下载安装 smartmontools for Windows,并确认命令可用:
+
+```powershell
+smartctl --version
+smartctl --scan
+```
+
+如果提示找不到命令,请将 smartmontools 安装目录加入系统 `PATH`,常见路径类似:
+
+```text
+C:\Program Files\smartmontools\bin
+```
+
+项目也会自动尝试以下常见路径:
+
+```text
+C:\Program Files\smartmontools\bin\smartctl.exe
+C:\Program Files (x86)\smartmontools\bin\smartctl.exe
+```
+
+如果安装在其他目录,可设置环境变量:
+
+```powershell
+$env:SMARTCTL_PATH="D:\Tools\smartmontools\bin\smartctl.exe"
+```
+
+JMB39x USB 阵列硬盘柜中的硬盘可使用类似命令验证:
+
+```powershell
+smartctl -a -d jmb39x,0 /dev/sdb
+smartctl -a -d jmb39x,1 /dev/sdb
+```
+
+本项目的 SMART 页面会在启用“探测 JMB39x 阵列槽位”后自动按槽位读取。
+
+## 6. 可选:启用更完整的温度传感器
+
+Windows 原生接口经常无法直接提供 CPU、GPU 和主板真实温度。建议在目标设备上运行 LibreHardwareMonitor 或 OpenHardwareMonitor,并开启 WMI 暴露能力。
+
+后端会尝试读取以下 WMI 命名空间:
+
+```text
+root\LibreHardwareMonitor
+root\OpenHardwareMonitor
+```
+
+如果没有这些组件,页面仍会展示 CPU/内存负载、NVIDIA 显卡信息、Windows GPU 性能计数器和可用的 ACPI 温度。
+
+## 7. 启动后端
+
+开发或本机使用:
+
+```powershell
+cd C:\Apps\win_monitor\backend
+..\.venv\Scripts\python.exe -m uvicorn app.main:app --host 127.0.0.1 --port 8000
+```
+
+如果上一条命令因路径复制产生问题,请使用绝对路径:
+
+```powershell
+C:\Apps\win_monitor\.venv\Scripts\python.exe -m uvicorn app.main:app --host 127.0.0.1 --port 8000
+```
+
+局域网访问:
+
+```powershell
+C:\Apps\win_monitor\.venv\Scripts\python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8000
+```
+
+如需局域网访问,请在 Windows 防火墙中放行 TCP 8000 端口,并按实际安全要求限制访问来源。
+
+## 8. 启动前端
+
+开发模式:
+
+```powershell
+cd C:\Apps\win_monitor\frontend
+npm run dev -- --host 0.0.0.0 --port 5173
+```
+
+访问:
+
+```text
+http://目标设备IP:5173
+```
+
+如果前端和后端不在同一台机器,或后端不是 `127.0.0.1:8000`,请在启动或构建前设置:
+
+```powershell
+$env:VITE_API_BASE="http://目标设备IP:8000"
+npm run build
+```
+
+## 9. 作为 Windows 服务运行后端
+
+可使用 NSSM:
+
+```powershell
+nssm install WinMonitorApi
+```
+
+推荐配置:
+
+- Application path:`C:\Apps\win_monitor\.venv\Scripts\python.exe`
+- Startup directory:`C:\Apps\win_monitor\backend`
+- Arguments:`-m uvicorn app.main:app --host 127.0.0.1 --port 8000`
+
+保存后启动:
+
+```powershell
+nssm start WinMonitorApi
+```
+
+## 10. 数据和日志
+
+- SQLite 数据库位置:`backend\data\win_monitor.db`
+- 服务、进程和扫描记录会写入数据库。
+- 传感器信息和 SMART 信息是实时读取,不写入数据库。
+- 如果需要备份,只需停止后端后复制 `backend\data\win_monitor.db`。
+
+## 11. 项目自身会增加的进程
+
+项目启动后,进程列表中会出现一些属于本项目自身或由本项目临时调用的进程。做服务/进程扫描结果确认时,可以将这些进程按实际部署方式标记为可信。
+
+### 后端常驻进程
+
+使用以下命令启动后端时:
+
+```powershell
+C:\Apps\win_monitor\.venv\Scripts\python.exe -m uvicorn app.main:app --host 127.0.0.1 --port 8000
+```
+
+通常会看到:
+
+| 进程名 | 说明 |
+| --- | --- |
+| `python.exe` | FastAPI 后端主进程,运行 `uvicorn app.main:app`,负责 API、扫描、传感器和 SMART 查询 |
+
+如果使用 NSSM 注册为 Windows 服务,还会看到:
+
+| 进程名 | 说明 |
+| --- | --- |
+| `nssm.exe` | NSSM 服务包装器,用于托管后端 Python 进程 |
+| `python.exe` | 被 NSSM 启动的 FastAPI 后端主进程 |
+
+### 前端开发模式进程
+
+如果使用开发模式启动前端:
+
+```powershell
+npm run dev -- --host 0.0.0.0 --port 5173
+```
+
+通常会看到:
+
+| 进程名 | 说明 |
+| --- | --- |
+| `node.exe` | Vite 前端开发服务器 |
+| `npm.cmd` / `cmd.exe` | 启动脚本外壳,某些启动方式下会短暂或持续存在 |
+
+实际部署时,如果前端使用 `frontend\dist` 静态文件,并由 IIS、Nginx 或其他 Web 服务承载,则不会再有 Vite 的 `node.exe` 进程。此时前端相关进程取决于你选择的静态文件服务,例如 `nginx.exe` 或 IIS 的 `w3wp.exe`。
+
+### 实时查询产生的临时进程
+
+访问传感器或 SMART 页面时,后端可能临时启动以下进程,执行完会退出:
+
+| 进程名 | 触发场景 | 说明 |
+| --- | --- | --- |
+| `smartctl.exe` | 打开或刷新硬盘 SMART 页面 | 读取硬盘 SMART 信息,包括 JMB39x 阵列槽位探测 |
+| `powershell.exe` / `pwsh.exe` | 打开或刷新传感器页面 | 读取 WMI 传感器、ACPI 温度或 Windows GPU 性能计数器 |
+| `nvidia-smi.exe` | 存在 NVIDIA 显卡并刷新传感器页面 | 读取 NVIDIA 显卡负载、温度、显存和功耗 |
+
+这些临时进程一般只在接口请求期间出现,不属于常驻服务。如果扫描进程列表时刚好捕获到它们,可以结合命令行和父进程判断是否由本项目后端触发。
+
+### 最小常驻进程总结
+
+推荐生产部署方式下,项目自身最少只需要:
+
+| 进程名 | 是否常驻 | 用途 |
+| --- | --- | --- |
+| `python.exe` | 是 | 后端 API 服务 |
+| `nssm.exe` | 是,可选 | 仅当使用 NSSM 托管后端服务时出现 |
+| `nginx.exe` / `w3wp.exe` / 其他静态服务进程 | 是,可选 | 仅当前端由独立静态 Web 服务承载时出现 |
+
+不建议在生产环境长期使用 `npm run dev`,因为它会额外保留 `node.exe` 开发服务器进程,适合调试,不适合作为正式前端服务。
+
+## 12. 结束程序
+
+结束程序时,先确认你当前采用的是哪种启动方式。推荐优先使用启动方式对应的正常停止命令,避免直接结束无关的 `python.exe`、`node.exe` 或 Web 服务进程。
+
+### 命令行窗口中启动的后端
+
+如果后端是在 PowerShell 或 CMD 窗口中用以下方式启动的:
+
+```powershell
+python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
+```
+
+在该窗口按:
+
+```text
+Ctrl + C
+```
+
+即可停止后端。
+
+如果窗口已经关闭但进程仍在,可按端口查找并停止:
+
+```powershell
+$pid = (Get-NetTCPConnection -LocalPort 8000 -State Listen).OwningProcess
+Stop-Process -Id $pid
+```
+
+### NSSM 服务方式启动的后端
+
+如果后端注册成了 NSSM 服务,例如服务名是 `WinMonitorApi`:
+
+```powershell
+nssm stop WinMonitorApi
+```
+
+或使用 Windows 服务命令:
+
+```powershell
+Stop-Service WinMonitorApi
+```
+
+如需禁止开机自启:
+
+```powershell
+Set-Service WinMonitorApi -StartupType Manual
+```
+
+如需彻底删除该服务:
+
+```powershell
+nssm remove WinMonitorApi confirm
+```
+
+### 前端开发服务器
+
+如果前端是开发模式启动的:
+
+```powershell
+npm run dev -- --host 0.0.0.0 --port 5173
+```
+
+在该窗口按:
+
+```text
+Ctrl + C
+```
+
+即可停止前端开发服务器。
+
+如果窗口已经关闭但 `node.exe` 仍在监听 5173 端口,可执行:
+
+```powershell
+$pid = (Get-NetTCPConnection -LocalPort 5173 -State Listen).OwningProcess
+Stop-Process -Id $pid
+```
+
+### 静态 Web 服务方式部署的前端
+
+如果前端由 Nginx 承载:
+
+```powershell
+nginx -s stop
+```
+
+如果前端由 IIS 承载,可停止对应站点,或停止 IIS:
+
+```powershell
+Stop-Website -Name "WinMonitor"
+```
+
+如果需要停止整个 IIS 服务:
+
+```powershell
+iisreset /stop
+```
+
+注意:`iisreset /stop` 会影响该设备上的所有 IIS 站点,使用前请确认没有其他业务依赖 IIS。
+
+### 临时查询进程
+
+`smartctl.exe`、`powershell.exe` / `pwsh.exe`、`nvidia-smi.exe` 是由后端在刷新传感器或 SMART 页面时临时启动的查询进程,正常情况下会自动退出,不需要手动停止。
+
+传感器接口已对外部命令设置超时,并会在超时后清理对应进程树。也就是说,由本项目后端启动的 PowerShell 查询进程不应该长期残留。
+
+如果看到长期存在的 `powershell.exe` 或 `pwsh.exe`,请先查看它的父进程和命令行,确认是否属于本项目:
+
+```powershell
+Get-CimInstance Win32_Process -Filter "name = 'powershell.exe' or name = 'pwsh.exe'" |
+  Select-Object ProcessId, ParentProcessId, CreationDate, CommandLine |
+  Format-List
+```
+
+再查看后端进程 PID:
+
+```powershell
+Get-CimInstance Win32_Process -Filter "name = 'python.exe'" |
+  Where-Object { $_.CommandLine -like '*uvicorn app.main:app*' } |
+  Select-Object ProcessId, ParentProcessId, CommandLine |
+  Format-List
+```
+
+如果 PowerShell 的 `ParentProcessId` 等于后端 `python.exe` 的 `ProcessId`,才说明它是本项目后端临时启动的查询进程。
+
+如果某次硬盘或 WMI 查询卡住,可先停止后端;后端停止后仍残留且确认属于本项目的临时查询进程,再按需结束:
+
+```powershell
+Get-Process smartctl,powershell,pwsh,nvidia-smi -ErrorAction SilentlyContinue
+```
+
+确认是本项目触发且不再需要后,可结束指定 PID:
+
+```powershell
+Stop-Process -Id 进程ID
+```
+
+### 一次性检查项目是否仍在运行
+
+检查后端端口:
+
+```powershell
+Get-NetTCPConnection -LocalPort 8000 -State Listen -ErrorAction SilentlyContinue
+```
+
+检查前端开发端口:
+
+```powershell
+Get-NetTCPConnection -LocalPort 5173 -State Listen -ErrorAction SilentlyContinue
+```
+
+如果没有输出,表示对应端口没有进程监听。
+
+## 13. 常见问题
+
+### 页面没有温度
+
+优先确认 LibreHardwareMonitor/OpenHardwareMonitor 是否运行并开启 WMI。部分硬件或驱动不向 Windows 暴露温度,这是正常限制。
+
+### SMART 页面提示 smartctl 不存在
+
+确认 smartmontools 已安装,并且 `smartctl.exe` 所在目录已加入系统 `PATH`。重新打开 PowerShell 后执行 `smartctl --scan` 验证。
+
+### JMB39x 槽位没有显示硬盘
+
+先手动执行:
+
+```powershell
+smartctl -a -d jmb39x,0 /dev/sdb
+```
+
+将 `/dev/sdb` 替换为 `smartctl --scan` 中对应的阵列设备。如果手动命令能输出硬盘信息,页面刷新后也应能展示。

+ 12 - 0
frontend/index.html

@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Windows 监控管理</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1623 - 0
frontend/package-lock.json


+ 15 - 0
frontend/package.json

@@ -0,0 +1,15 @@
+{
+  "scripts": {
+    "dev": "vite --host 127.0.0.1",
+    "build": "vite build",
+    "preview": "vite preview --host 127.0.0.1"
+  },
+  "dependencies": {
+    "@vitejs/plugin-vue": "^5.1.4",
+    "axios": "^1.7.7",
+    "element-plus": "^2.8.4",
+    "vue": "^3.5.12",
+    "vite": "^5.4.10"
+  },
+  "devDependencies": {}
+}

+ 161 - 0
frontend/src/App.vue

@@ -0,0 +1,161 @@
+<template>
+  <div class="app-shell">
+    <aside class="sidebar">
+      <div class="brand">Windows 监控</div>
+      <el-menu :default-active="activeView" background-color="#1f2937" text-color="#d1d5db" active-text-color="#fff" @select="activeView = $event">
+        <el-menu-item index="dashboard">仪表盘</el-menu-item>
+        <el-menu-item index="pending">待确认中心</el-menu-item>
+        <el-menu-item index="services">Windows 服务</el-menu-item>
+        <el-menu-item index="processes">Windows 进程</el-menu-item>
+        <el-menu-item index="tags">标签管理</el-menu-item>
+        <el-menu-item index="sensors">传感器信息</el-menu-item>
+        <el-menu-item index="smart">硬盘 SMART</el-menu-item>
+        <el-menu-item index="scans">扫描历史</el-menu-item>
+      </el-menu>
+    </aside>
+
+    <main class="main">
+      <div class="topbar">
+        <div>
+          <div class="page-title">{{ title }}</div>
+          <div class="muted">采集 Windows 服务和进程,确认可信状态,并整理给 AI 分析的数据。</div>
+        </div>
+        <el-button type="primary" :loading="scanning" @click="runScan">执行扫描</el-button>
+      </div>
+
+      <section v-if="activeView === 'dashboard'">
+        <div class="metrics">
+          <div class="metric"><div class="metric-label">服务总数</div><div class="metric-value">{{ dashboard.service_total || 0 }}</div></div>
+          <div class="metric"><div class="metric-label">进程总数</div><div class="metric-value">{{ dashboard.process_total || 0 }}</div></div>
+          <div class="metric"><div class="metric-label">待确认服务</div><div class="metric-value">{{ dashboard.pending_services || 0 }}</div></div>
+          <div class="metric"><div class="metric-label">待确认进程</div><div class="metric-value">{{ dashboard.pending_processes || 0 }}</div></div>
+          <div class="metric"><div class="metric-label">本次未出现</div><div class="metric-value">{{ (dashboard.missing_services || 0) + (dashboard.missing_processes || 0) }}</div></div>
+        </div>
+        <div class="panel">
+          <el-descriptions title="最近扫描" :column="2" border>
+            <el-descriptions-item label="状态">{{ dashboard.latest_scan?.status || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="开始时间">{{ dashboard.latest_scan?.started_at || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="服务数量">{{ dashboard.latest_scan?.services_found ?? '-' }}</el-descriptions-item>
+            <el-descriptions-item label="进程数量">{{ dashboard.latest_scan?.processes_found ?? '-' }}</el-descriptions-item>
+          </el-descriptions>
+        </div>
+      </section>
+
+      <section v-if="activeView === 'services'">
+        <ItemTable type="service" ref="serviceTable" />
+      </section>
+
+      <section v-if="activeView === 'processes'">
+        <ItemTable type="process" ref="processTable" />
+      </section>
+
+      <section v-if="activeView === 'pending'">
+        <el-tabs v-model="pendingTab">
+          <el-tab-pane label="待确认服务" name="services">
+            <ItemTable type="service" confirm-status="PENDING" ref="pendingServiceTable" />
+          </el-tab-pane>
+          <el-tab-pane label="待确认进程" name="processes">
+            <ItemTable type="process" confirm-status="PENDING" ref="pendingProcessTable" />
+          </el-tab-pane>
+        </el-tabs>
+      </section>
+
+      <section v-if="activeView === 'tags'">
+        <TagManager />
+      </section>
+
+      <section v-if="activeView === 'sensors'">
+        <SensorView />
+      </section>
+
+      <section v-if="activeView === 'smart'">
+        <SmartView />
+      </section>
+
+      <section v-if="activeView === 'scans'" class="panel">
+        <el-table :data="scans.items" border stripe>
+          <el-table-column prop="id" label="ID" width="80" />
+          <el-table-column prop="status" label="状态" width="110" />
+          <el-table-column prop="started_at" label="开始时间" min-width="180" />
+          <el-table-column prop="finished_at" label="完成时间" min-width="180" />
+          <el-table-column prop="services_found" label="服务" width="90" />
+          <el-table-column prop="processes_found" label="进程" width="90" />
+          <el-table-column prop="new_services" label="新增服务" width="100" />
+          <el-table-column prop="new_processes" label="新增进程" width="100" />
+          <el-table-column prop="error_message" label="错误" min-width="180" />
+        </el-table>
+      </section>
+    </main>
+  </div>
+</template>
+
+<script setup>
+import { computed, nextTick, onMounted, ref, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import { api } from './api'
+import ItemTable from './components/ItemTable.vue'
+import SensorView from './components/SensorView.vue'
+import SmartView from './components/SmartView.vue'
+import TagManager from './components/TagManager.vue'
+
+const activeView = ref('dashboard')
+const pendingTab = ref('services')
+const dashboard = ref({})
+const scans = ref({ items: [] })
+const scanning = ref(false)
+const serviceTable = ref(null)
+const processTable = ref(null)
+const pendingServiceTable = ref(null)
+const pendingProcessTable = ref(null)
+
+const title = computed(() => ({
+  dashboard: '仪表盘',
+  pending: '待确认中心',
+  services: 'Windows 服务',
+  processes: 'Windows 进程',
+  tags: '标签管理',
+  sensors: '传感器信息',
+  smart: '硬盘 SMART',
+  scans: '扫描历史',
+})[activeView.value])
+
+async function loadDashboard() {
+  const { data } = await api.get('/api/dashboard')
+  dashboard.value = data
+}
+
+async function loadScans() {
+  const { data } = await api.get('/api/scans')
+  scans.value = data
+}
+
+async function refreshCurrent() {
+  await loadDashboard()
+  if (activeView.value === 'scans') await loadScans()
+  await nextTick()
+  serviceTable.value?.load()
+  processTable.value?.load()
+  pendingServiceTable.value?.load()
+  pendingProcessTable.value?.load()
+}
+
+async function runScan() {
+  scanning.value = true
+  try {
+    const { data } = await api.post('/api/scans/run')
+    ElMessage.success(`扫描完成:服务 ${data.services_found},进程 ${data.processes_found}`)
+    await refreshCurrent()
+  } catch (error) {
+    ElMessage.error(error.response?.data?.detail || '扫描失败')
+  } finally {
+    scanning.value = false
+  }
+}
+
+watch(activeView, async (view) => {
+  if (view === 'dashboard') await loadDashboard()
+  if (view === 'scans') await loadScans()
+})
+
+onMounted(refreshCurrent)
+</script>

+ 18 - 0
frontend/src/api.js

@@ -0,0 +1,18 @@
+import axios from 'axios'
+
+export const api = axios.create({
+  baseURL: import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8000',
+  timeout: 120000,
+})
+
+export const statusOptions = [
+  { label: '待确认', value: 'PENDING', type: 'warning' },
+  { label: '可信', value: 'TRUSTED', type: 'success' },
+  { label: '可疑', value: 'SUSPICIOUS', type: 'danger' },
+  { label: '忽略', value: 'IGNORED', type: 'info' },
+  { label: '信息不足', value: 'NEED_MORE_INFO', type: 'primary' },
+]
+
+export function statusMeta(value) {
+  return statusOptions.find((item) => item.value === value) || statusOptions[0]
+}

+ 289 - 0
frontend/src/components/ItemTable.vue

@@ -0,0 +1,289 @@
+<template>
+  <div class="panel">
+    <div class="toolbar">
+      <div class="filters">
+        <el-input v-model="query.keyword" clearable placeholder="搜索名称、路径、命令行" style="width: 260px" @keyup.enter="load" />
+        <el-select v-model="query.confirm_status" clearable placeholder="确认状态" style="width: 140px" :disabled="Boolean(confirmStatus)">
+          <el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
+        </el-select>
+        <el-select v-model="query.present" clearable placeholder="出现状态" style="width: 140px">
+          <el-option label="本次出现" :value="true" />
+          <el-option label="本次未出现" :value="false" />
+        </el-select>
+        <el-button @click="load">查询</el-button>
+      </div>
+      <div class="filters">
+        <el-button @click="openPrompt('selected')" :disabled="!selected.length">复制选中给 AI</el-button>
+        <el-button @click="openPrompt('pending')">复制全部待确认给 AI</el-button>
+        <el-button @click="importDialog = true">导入 AI JSON</el-button>
+        <el-dropdown :disabled="!selected.length" @command="batchUpdate">
+          <el-button>批量标记</el-button>
+          <template #dropdown>
+            <el-dropdown-menu>
+              <el-dropdown-item command="TRUSTED">可信</el-dropdown-item>
+              <el-dropdown-item command="SUSPICIOUS">可疑</el-dropdown-item>
+              <el-dropdown-item command="IGNORED">忽略</el-dropdown-item>
+              <el-dropdown-item command="NEED_MORE_INFO">信息不足</el-dropdown-item>
+            </el-dropdown-menu>
+          </template>
+        </el-dropdown>
+      </div>
+    </div>
+
+    <el-table :data="rows.items" border stripe @selection-change="selected = $event">
+      <el-table-column type="selection" width="46" />
+      <el-table-column prop="name" label="名称" min-width="170" />
+      <el-table-column v-if="type === 'service'" prop="display_name" label="显示名称" min-width="180" />
+      <el-table-column prop="status" label="运行状态" width="120" />
+      <el-table-column label="确认状态" width="120">
+        <template #default="{ row }">
+          <el-tag :type="statusMeta(row.confirm_status).type">{{ statusMeta(row.confirm_status).label }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="标签" min-width="180">
+        <template #default="{ row }">
+          <el-tag
+            v-for="tag in row.tags || []"
+            :key="tag.id"
+            :type="tag.is_controllable ? 'success' : 'danger'"
+            style="margin: 2px"
+          >
+            {{ tag.name }}
+          </el-tag>
+          <span v-if="!row.tags?.length" class="muted">未设置</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="出现" width="100">
+        <template #default="{ row }">
+          <el-tag :type="row.is_present_now ? 'success' : 'info'">{{ row.is_present_now ? '本次出现' : '未出现' }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="路径/命令行" min-width="260">
+        <template #default="{ row }">
+          <div class="path">{{ row.binary_path || row.exe_path || row.cmdline || '-' }}</div>
+        </template>
+      </el-table-column>
+      <el-table-column prop="last_seen_at" label="最后出现" min-width="170" />
+      <el-table-column label="操作" width="420" fixed="right">
+        <template #default="{ row }">
+          <el-button size="small" @click="showDetail(row)">详情</el-button>
+          <el-button size="small" @click="openTags(row)">标签</el-button>
+          <el-button size="small" type="success" @click="singleUpdate(row, 'TRUSTED')">可信</el-button>
+          <el-button size="small" type="danger" @click="singleUpdate(row, 'SUSPICIOUS')">可疑</el-button>
+          <el-button size="small" @click="singleUpdate(row, 'IGNORED')">忽略</el-button>
+          <template v-if="row.can_control">
+            <template v-if="type === 'service' && row.is_present_now">
+              <el-button size="small" type="primary" @click="control(row, 'start')">启动</el-button>
+              <el-button size="small" type="warning" @click="control(row, 'stop')">停止</el-button>
+              <el-button size="small" type="primary" @click="control(row, 'restart')">重启</el-button>
+            </template>
+            <template v-if="type === 'process'">
+              <el-button size="small" type="primary" @click="control(row, 'start')">启动</el-button>
+              <el-button v-if="row.is_present_now" size="small" type="warning" @click="control(row, 'stop')">停止</el-button>
+            </template>
+          </template>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <div style="margin-top: 12px; display: flex; justify-content: flex-end">
+      <el-pagination
+        background
+        layout="total, sizes, prev, pager, next"
+        :total="rows.total"
+        :page-size="query.page_size"
+        :current-page="query.page"
+        @size-change="query.page_size = $event; load()"
+        @current-change="query.page = $event; load()"
+      />
+    </div>
+
+    <el-dialog v-model="detailDialog" title="详情" width="760px">
+      <div v-if="current" class="detail-grid">
+        <template v-for="(value, key) in current" :key="key">
+          <strong>{{ key }}</strong>
+          <span>{{ value ?? '-' }}</span>
+        </template>
+      </div>
+      <template #footer>
+        <el-input v-model="note" type="textarea" :rows="3" placeholder="人工说明" />
+        <div style="margin-top: 10px">
+          <el-button @click="detailDialog = false">关闭</el-button>
+          <el-button type="primary" @click="saveCurrent">保存说明和状态</el-button>
+          <el-select v-model="currentStatus" style="width: 140px">
+            <el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
+          </el-select>
+        </div>
+      </template>
+    </el-dialog>
+
+    <el-dialog v-model="promptDialog" title="AI 分析提示词" width="860px">
+      <el-input v-model="promptText" class="prompt-box" type="textarea" />
+      <template #footer>
+        <el-button @click="copyPrompt">复制提示词</el-button>
+      </template>
+    </el-dialog>
+
+    <el-dialog v-model="tagDialog" title="编辑标签" width="560px">
+      <el-select v-model="tagSelection" multiple filterable placeholder="选择标签" style="width: 100%">
+        <el-option
+          v-for="tag in allTags"
+          :key="tag.id"
+          :label="`${tag.name}${tag.is_controllable ? '' : '(不可控制)'}`"
+          :value="tag.id"
+        />
+      </el-select>
+      <template #footer>
+        <el-button @click="tagDialog = false">取消</el-button>
+        <el-button type="primary" @click="saveTags">保存</el-button>
+      </template>
+    </el-dialog>
+
+    <el-dialog v-model="importDialog" title="导入 AI JSON" width="760px">
+      <el-input v-model="importJson" class="prompt-box" type="textarea" placeholder="粘贴 AI 输出的纯 JSON 数组" />
+      <template #footer>
+        <el-button @click="importDialog = false">取消</el-button>
+        <el-button type="primary" @click="importAi">导入</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { api, statusMeta, statusOptions } from '../api'
+
+const props = defineProps({
+  type: { type: String, required: true },
+  confirmStatus: { type: String, default: '' },
+})
+
+const basePath = props.type === 'service' ? '/api/services' : '/api/processes'
+const rows = ref({ items: [], total: 0 })
+const selected = ref([])
+const detailDialog = ref(false)
+const promptDialog = ref(false)
+const importDialog = ref(false)
+const tagDialog = ref(false)
+const promptText = ref('')
+const importJson = ref('')
+const current = ref(null)
+const currentStatus = ref('PENDING')
+const note = ref('')
+const allTags = ref([])
+const tagSelection = ref([])
+const tagTarget = ref(null)
+const query = reactive({
+  keyword: '',
+  confirm_status: props.confirmStatus || '',
+  present: null,
+  page: 1,
+  page_size: 20,
+})
+
+async function load() {
+  query.confirm_status = props.confirmStatus || query.confirm_status
+  const { data } = await api.get(basePath, { params: query })
+  rows.value = data
+}
+
+async function loadTags() {
+  const { data } = await api.get('/api/tags')
+  allTags.value = data.items
+}
+
+async function singleUpdate(row, confirm_status) {
+  await api.patch(`${basePath}/${row.id}`, { confirm_status, user_note: row.user_note })
+  ElMessage.success('已更新')
+  await load()
+}
+
+async function batchUpdate(confirm_status) {
+  await api.patch(`${basePath}/batch`, {
+    ids: selected.value.map((row) => row.id),
+    confirm_status,
+  })
+  ElMessage.success('批量更新完成')
+  await load()
+}
+
+async function showDetail(row) {
+  const { data } = await api.get(`${basePath}/${row.id}`)
+  current.value = data
+  currentStatus.value = data.confirm_status
+  note.value = data.user_note || ''
+  detailDialog.value = true
+}
+
+function openTags(row) {
+  tagTarget.value = row
+  tagSelection.value = (row.tags || []).map((tag) => tag.id)
+  tagDialog.value = true
+}
+
+async function saveTags() {
+  await api.put(`${basePath}/${tagTarget.value.id}/tags`, { tag_ids: tagSelection.value })
+  tagDialog.value = false
+  ElMessage.success('标签已更新')
+  await load()
+}
+
+async function saveCurrent() {
+  await api.patch(`${basePath}/${current.value.id}`, {
+    confirm_status: currentStatus.value,
+    user_note: note.value,
+  })
+  detailDialog.value = false
+  ElMessage.success('已保存')
+  await load()
+}
+
+async function openPrompt(scope) {
+  const payload = scope === 'selected'
+    ? { scope, ids: selected.value.map((row) => row.id) }
+    : { scope: 'pending' }
+  const { data } = await api.post(`${basePath}/ai-prompt`, payload)
+  promptText.value = `${data.prompt_text}\n\n人工核对表:\n\n${data.markdown_table}`
+  promptDialog.value = true
+}
+
+async function copyPrompt() {
+  await navigator.clipboard.writeText(promptText.value)
+  ElMessage.success('已复制到剪贴板')
+}
+
+async function importAi() {
+  let parsed
+  try {
+    parsed = JSON.parse(importJson.value)
+  } catch {
+    ElMessage.error('JSON 格式不正确')
+    return
+  }
+  const items = Array.isArray(parsed) ? parsed : parsed.items
+  const { data } = await api.post(`${basePath}/import-ai`, { items })
+  ElMessage.success(`导入完成,更新 ${data.updated} 条`)
+  importDialog.value = false
+  importJson.value = ''
+  await load()
+}
+
+async function control(row, action) {
+  const actionLabel = { start: '启动', stop: '停止', restart: '重启' }[action]
+  await ElMessageBox.confirm(`确认${actionLabel}“${row.name}”?`, `${actionLabel}确认`, { type: 'warning' })
+  try {
+    await api.post(`${basePath}/${row.id}/${action}`)
+    ElMessage.success(`${actionLabel}命令已执行`)
+    await load()
+  } catch (error) {
+    ElMessage.error(error.response?.data?.detail || `${actionLabel}失败`)
+  }
+}
+
+defineExpose({ load })
+onMounted(async () => {
+  await loadTags()
+  await load()
+})
+</script>

+ 154 - 0
frontend/src/components/SensorView.vue

@@ -0,0 +1,154 @@
+<template>
+  <div>
+    <div class="toolbar">
+      <div class="filters">
+        <el-select v-model="refreshSeconds" style="width: 160px" @change="restartTimer">
+          <el-option label="不自动刷新" :value="0" />
+          <el-option label="每 2 秒" :value="2" />
+          <el-option label="每 5 秒" :value="5" />
+          <el-option label="每 10 秒" :value="10" />
+          <el-option label="每 30 秒" :value="30" />
+        </el-select>
+        <el-button type="primary" :loading="loading" @click="load">刷新</el-button>
+      </div>
+      <span class="muted">采集时间:{{ data.collected_at || '-' }}</span>
+    </div>
+
+    <div class="metrics">
+      <div class="metric">
+        <div class="metric-label">CPU 负载</div>
+        <div class="metric-value">{{ fmtPercent(data.cpu?.load_percent) }}</div>
+      </div>
+      <div class="metric">
+        <div class="metric-label">内存使用</div>
+        <div class="metric-value">{{ fmtPercent(data.memory?.percent) }}</div>
+      </div>
+      <div class="metric">
+        <div class="metric-label">CPU 核心</div>
+        <div class="metric-value">{{ data.cpu?.logical_cores || '-' }}</div>
+      </div>
+      <div class="metric">
+        <div class="metric-label">内存可用</div>
+        <div class="metric-value">{{ fmtBytes(data.memory?.available) }}</div>
+      </div>
+    </div>
+
+    <div class="panel" style="margin-bottom: 14px">
+      <el-descriptions title="CPU 与内存" :column="2" border>
+        <el-descriptions-item label="物理核心">{{ data.cpu?.physical_cores ?? '-' }}</el-descriptions-item>
+        <el-descriptions-item label="逻辑核心">{{ data.cpu?.logical_cores ?? '-' }}</el-descriptions-item>
+        <el-descriptions-item label="频率">{{ data.cpu?.frequency_mhz ? `${data.cpu.frequency_mhz} MHz` : '-' }}</el-descriptions-item>
+        <el-descriptions-item label="交换区">{{ fmtBytes(data.memory?.swap_used) }} / {{ fmtBytes(data.memory?.swap_total) }}</el-descriptions-item>
+        <el-descriptions-item label="每核心负载" :span="2">
+          <el-tag v-for="(value, index) in data.cpu?.per_core_percent || []" :key="index" style="margin: 2px">
+            核心 {{ index }}:{{ fmtPercent(value) }}
+          </el-tag>
+        </el-descriptions-item>
+      </el-descriptions>
+    </div>
+
+    <div class="panel" style="margin-bottom: 14px">
+      <div class="section-title">显卡</div>
+      <el-table :data="data.gpu || []" border stripe empty-text="未获取到显卡信息">
+        <el-table-column prop="source" label="来源" width="190" />
+        <el-table-column prop="name" label="名称" min-width="180" />
+        <el-table-column label="负载" width="120">
+          <template #default="{ row }">{{ fmtPercent(row.load_percent) }}</template>
+        </el-table-column>
+        <el-table-column label="显存负载" width="120">
+          <template #default="{ row }">{{ fmtPercent(row.memory_load_percent) }}</template>
+        </el-table-column>
+        <el-table-column label="温度" width="110">
+          <template #default="{ row }">{{ row.temperature_c == null ? '-' : `${row.temperature_c} C` }}</template>
+        </el-table-column>
+        <el-table-column label="显存" min-width="160">
+          <template #default="{ row }">
+            {{ row.memory_used_mb == null ? '-' : `${row.memory_used_mb} / ${row.memory_total_mb} MB` }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="error" label="错误" min-width="200" />
+      </el-table>
+    </div>
+
+    <div class="panel" style="margin-bottom: 14px">
+      <div class="section-title">温度</div>
+      <el-table :data="temperatureRows" border stripe empty-text="未获取到温度信息">
+        <el-table-column prop="source" label="来源" min-width="180" />
+        <el-table-column prop="name" label="名称" min-width="220" />
+        <el-table-column prop="hardware_type" label="类型" width="130" />
+        <el-table-column label="当前值" width="120">
+          <template #default="{ row }">{{ row.value == null ? '-' : `${row.value} ${row.unit || ''}` }}</template>
+        </el-table-column>
+        <el-table-column label="最大值" width="120">
+          <template #default="{ row }">{{ row.max ?? row.high ?? '-' }}</template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <div class="panel">
+      <div class="section-title">采集说明</div>
+      <div v-for="note in data.notes || []" :key="note" class="muted">{{ note }}</div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { api } from '../api'
+
+const data = ref({})
+const loading = ref(false)
+const refreshSeconds = ref(5)
+let timer = null
+
+const temperatureRows = computed(() => [
+  ...(data.value.temperatures || []),
+  ...(data.value.hardware_sensors || []).filter((row) => row.hardware_type === 'Temperature'),
+])
+
+function fmtPercent(value) {
+  return value == null ? '-' : `${Number(value).toFixed(1)}%`
+}
+
+function fmtBytes(value) {
+  if (value == null) return '-'
+  const units = ['B', 'KB', 'MB', 'GB', 'TB']
+  let size = Number(value)
+  let index = 0
+  while (size >= 1024 && index < units.length - 1) {
+    size /= 1024
+    index += 1
+  }
+  return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
+}
+
+async function load() {
+  loading.value = true
+  try {
+    const { data: result } = await api.get('/api/sensors')
+    data.value = result
+  } catch (error) {
+    ElMessage.error(error.response?.data?.detail || '获取传感器信息失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+function restartTimer() {
+  if (timer) clearInterval(timer)
+  timer = null
+  if (refreshSeconds.value > 0) {
+    timer = setInterval(load, refreshSeconds.value * 1000)
+  }
+}
+
+onMounted(async () => {
+  await load()
+  restartTimer()
+})
+
+onBeforeUnmount(() => {
+  if (timer) clearInterval(timer)
+})
+</script>

+ 125 - 0
frontend/src/components/SmartView.vue

@@ -0,0 +1,125 @@
+<template>
+  <div>
+    <div class="toolbar">
+      <div class="filters">
+        <el-checkbox v-model="includeJmb39x">探测 JMB39x 阵列槽位</el-checkbox>
+        <el-input-number v-model="jmb39xSlots" :min="0" :max="16" />
+        <el-button type="primary" :loading="loading" @click="load">刷新 SMART</el-button>
+      </div>
+      <span class="muted">采集时间:{{ data.collected_at || '-' }}</span>
+    </div>
+
+    <div v-if="!data.smartctl_available" class="panel" style="margin-bottom: 14px">
+      <el-alert type="warning" show-icon title="未找到 smartctl,请安装 smartmontools 并加入 PATH。" />
+    </div>
+
+    <div class="panel" style="margin-bottom: 14px">
+      <div class="section-title">smartctl --scan</div>
+      <el-table :data="data.scan?.devices || []" border stripe empty-text="未扫描到硬盘">
+        <el-table-column prop="name" label="设备" width="150" />
+        <el-table-column prop="device_type" label="类型" width="120" />
+        <el-table-column prop="comment" label="说明" min-width="260" />
+        <el-table-column prop="scan_line" label="原始行" min-width="280" />
+      </el-table>
+    </div>
+
+    <div class="panel">
+      <div class="section-title">SMART 信息</div>
+      <el-table :data="data.devices || []" border stripe empty-text="无 SMART 信息">
+        <el-table-column type="expand">
+          <template #default="{ row }">
+            <el-descriptions :column="2" border style="margin-bottom: 12px">
+              <el-descriptions-item label="设备">{{ row.device }}</el-descriptions-item>
+              <el-descriptions-item label="访问类型">{{ row.device_type || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="型号">{{ row.summary?.model || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="序列号">{{ row.summary?.serial_number || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="固件">{{ row.summary?.firmware || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="容量">{{ row.summary?.capacity || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="健康状态">{{ row.summary?.health || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="温度">{{ row.summary?.temperature_c == null ? '-' : `${row.summary.temperature_c} C` }}</el-descriptions-item>
+              <el-descriptions-item label="通电时间">{{ row.summary?.power_on_hours || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="通电次数">{{ row.summary?.power_cycles || '-' }}</el-descriptions-item>
+            </el-descriptions>
+
+            <el-table :data="row.summary?.attributes || []" border size="small" empty-text="没有 ATA 属性表">
+              <el-table-column prop="id" label="ID" width="70" />
+              <el-table-column prop="name" label="属性" min-width="190" />
+              <el-table-column prop="value" label="VALUE" width="90" />
+              <el-table-column prop="worst" label="WORST" width="90" />
+              <el-table-column prop="threshold" label="THRESH" width="90" />
+              <el-table-column prop="type" label="类型" width="100" />
+              <el-table-column prop="when_failed" label="失败" width="100" />
+              <el-table-column prop="raw" label="RAW" min-width="180" />
+            </el-table>
+
+            <el-collapse style="margin-top: 12px">
+              <el-collapse-item title="原始 smartctl 输出" name="raw">
+                <pre class="raw-output">{{ row.raw_output }}</pre>
+              </el-collapse-item>
+              <el-collapse-item v-if="row.summary?.warnings?.length" title="警告" name="warnings">
+                <div v-for="warning in row.summary.warnings" :key="warning" class="muted">{{ warning }}</div>
+              </el-collapse-item>
+            </el-collapse>
+          </template>
+        </el-table-column>
+        <el-table-column prop="device" label="设备" width="130" />
+        <el-table-column prop="device_type" label="类型" width="120" />
+        <el-table-column label="JMB39x 槽位" width="120">
+          <template #default="{ row }">{{ row.jmb39x_slot ?? '-' }}</template>
+        </el-table-column>
+        <el-table-column label="型号" min-width="180">
+          <template #default="{ row }">{{ row.summary?.model || '-' }}</template>
+        </el-table-column>
+        <el-table-column label="健康" width="130">
+          <template #default="{ row }">
+            <el-tag :type="healthType(row.summary?.health)">{{ row.summary?.health || '-' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="温度" width="100">
+          <template #default="{ row }">{{ row.summary?.temperature_c == null ? '-' : `${row.summary.temperature_c} C` }}</template>
+        </el-table-column>
+        <el-table-column label="序列号" min-width="150">
+          <template #default="{ row }">{{ row.summary?.serial_number || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="error" label="错误" min-width="220" />
+      </el-table>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { api } from '../api'
+
+const data = ref({})
+const loading = ref(false)
+const includeJmb39x = ref(true)
+const jmb39xSlots = ref(8)
+
+function healthType(value) {
+  const text = String(value || '').toUpperCase()
+  if (text.includes('PASSED') || text.includes('OK')) return 'success'
+  if (text.includes('FAILED') || text.includes('BAD')) return 'danger'
+  return 'info'
+}
+
+async function load() {
+  loading.value = true
+  try {
+    const { data: result } = await api.get('/api/smart/devices', {
+      params: {
+        include_jmb39x: includeJmb39x.value,
+        jmb39x_slots: jmb39xSlots.value,
+      },
+    })
+    data.value = result
+  } catch (error) {
+    ElMessage.error(error.response?.data?.detail || '获取 SMART 信息失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(load)
+</script>

+ 122 - 0
frontend/src/components/TagManager.vue

@@ -0,0 +1,122 @@
+<template>
+  <div class="panel">
+    <div class="toolbar">
+      <div class="filters">
+        <el-button type="primary" @click="openCreate">新增标签</el-button>
+        <el-button @click="load">刷新</el-button>
+      </div>
+    </div>
+
+    <el-table :data="tags" border stripe>
+      <el-table-column prop="name" label="标签名称" min-width="160" />
+      <el-table-column prop="description" label="说明" min-width="260" />
+      <el-table-column label="是否可以被控制" width="160">
+        <template #default="{ row }">
+          <el-tag :type="row.is_controllable ? 'success' : 'danger'">
+            {{ row.is_controllable ? '可以控制' : '不可控制' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="内置" width="90">
+        <template #default="{ row }">
+          <el-tag v-if="row.is_builtin" type="info">内置</el-tag>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="180" fixed="right">
+        <template #default="{ row }">
+          <el-button size="small" @click="openEdit(row)">编辑</el-button>
+          <el-button size="small" type="danger" :disabled="row.is_builtin" @click="remove(row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-dialog v-model="dialog" :title="form.id ? '编辑标签' : '新增标签'" width="520px">
+      <el-form label-width="130px">
+        <el-form-item label="标签名称">
+          <el-input v-model="form.name" />
+        </el-form-item>
+        <el-form-item label="说明">
+          <el-input v-model="form.description" type="textarea" :rows="3" />
+        </el-form-item>
+        <el-form-item label="是否可以控制">
+          <el-switch v-model="form.is_controllable" active-text="可以控制" inactive-text="不可控制" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialog = false">取消</el-button>
+        <el-button type="primary" @click="save">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { api } from '../api'
+
+const tags = ref([])
+const dialog = ref(false)
+const form = reactive({
+  id: null,
+  name: '',
+  description: '',
+  is_controllable: true,
+})
+
+async function load() {
+  const { data } = await api.get('/api/tags')
+  tags.value = data.items
+}
+
+function resetForm() {
+  form.id = null
+  form.name = ''
+  form.description = ''
+  form.is_controllable = true
+}
+
+function openCreate() {
+  resetForm()
+  dialog.value = true
+}
+
+function openEdit(row) {
+  form.id = row.id
+  form.name = row.name
+  form.description = row.description || ''
+  form.is_controllable = row.is_controllable
+  dialog.value = true
+}
+
+async function save() {
+  if (!form.name.trim()) {
+    ElMessage.warning('请输入标签名称')
+    return
+  }
+  const payload = {
+    name: form.name.trim(),
+    description: form.description,
+    is_controllable: form.is_controllable,
+  }
+  if (form.id) {
+    await api.patch(`/api/tags/${form.id}`, payload)
+  } else {
+    await api.post('/api/tags', payload)
+  }
+  dialog.value = false
+  ElMessage.success('已保存')
+  await load()
+}
+
+async function remove(row) {
+  await ElMessageBox.confirm(`确认删除标签“${row.name}”?`, '删除标签', { type: 'warning' })
+  await api.delete(`/api/tags/${row.id}`)
+  ElMessage.success('已删除')
+  await load()
+}
+
+defineExpose({ load })
+onMounted(load)
+</script>

+ 7 - 0
frontend/src/main.js

@@ -0,0 +1,7 @@
+import { createApp } from 'vue'
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import './styles.css'
+import App from './App.vue'
+
+createApp(App).use(ElementPlus).mount('#app')

+ 153 - 0
frontend/src/styles.css

@@ -0,0 +1,153 @@
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  color: #1f2937;
+  background: #f6f7f9;
+  font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
+}
+
+.app-shell {
+  min-height: 100vh;
+  display: flex;
+}
+
+.sidebar {
+  width: 220px;
+  flex: 0 0 220px;
+  background: #1f2937;
+  color: #fff;
+  padding: 18px 12px;
+}
+
+.brand {
+  font-size: 18px;
+  font-weight: 700;
+  padding: 6px 10px 18px;
+}
+
+.main {
+  flex: 1;
+  min-width: 0;
+  padding: 22px;
+}
+
+.topbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 18px;
+}
+
+.page-title {
+  font-size: 24px;
+  font-weight: 700;
+}
+
+.metrics {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+  gap: 12px;
+  margin-bottom: 18px;
+}
+
+.metric {
+  background: #fff;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  padding: 16px;
+}
+
+.metric-label {
+  color: #6b7280;
+  font-size: 13px;
+}
+
+.metric-value {
+  font-size: 28px;
+  font-weight: 700;
+  margin-top: 8px;
+}
+
+.toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 12px;
+  flex-wrap: wrap;
+}
+
+.filters {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.panel {
+  background: #fff;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  padding: 14px;
+}
+
+.muted {
+  color: #6b7280;
+}
+
+.path {
+  max-width: 520px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.detail-grid {
+  display: grid;
+  grid-template-columns: 120px minmax(0, 1fr);
+  gap: 10px 14px;
+  word-break: break-word;
+}
+
+.prompt-box {
+  width: 100%;
+  min-height: 280px;
+  font-family: Consolas, monospace;
+}
+
+.section-title {
+  font-size: 16px;
+  font-weight: 700;
+  margin-bottom: 12px;
+}
+
+.raw-output {
+  max-height: 420px;
+  overflow: auto;
+  padding: 12px;
+  margin: 0;
+  border: 1px solid #e5e7eb;
+  border-radius: 6px;
+  background: #0f172a;
+  color: #e5e7eb;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+@media (max-width: 760px) {
+  .app-shell {
+    display: block;
+  }
+
+  .sidebar {
+    width: 100%;
+  }
+
+  .main {
+    padding: 14px;
+  }
+}

+ 10 - 0
frontend/vite.config.js

@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig({
+  plugins: [vue()],
+  server: {
+    host: '127.0.0.1',
+    port: 5173,
+  },
+})

+ 47 - 0
task.md

@@ -0,0 +1,47 @@
+# Windows 服务与进程监控管理系统任务跟踪
+
+## 目标
+
+开发一个本地 Web 管理系统:后端采集 Windows 服务与进程信息并保存到 SQLite,前端提供扫描、查看、确认、批量处理、AI 分析提示词复制和 AI JSON 结果导入能力。
+
+## 任务清单
+
+- [x] 创建任务跟踪文档
+- [x] 搭建 FastAPI 后端项目结构
+- [x] 设计并初始化 SQLite 数据表
+- [x] 实现 Windows 服务采集与进程采集
+- [x] 实现扫描记录和首次/后续扫描逻辑
+- [x] 实现仪表盘、历史、服务、进程、确认状态更新接口
+- [x] 实现 AI 提示词生成接口
+- [x] 实现 AI JSON 分析结果导入接口
+- [x] 搭建 Vue 3 + Element Plus 前端项目结构
+- [x] 实现仪表盘与扫描入口
+- [x] 实现服务列表、详情、状态更新和批量操作
+- [x] 实现进程列表、详情、状态更新和批量操作
+- [x] 实现待确认中心、AI 提示词复制、AI JSON 导入
+- [x] 编写接口说明文档
+- [x] 完成基本运行验证
+- [x] 增加实时传感器信息接口,展示 CPU、内存、显卡负载和温度等信息
+- [x] 增加前端传感器菜单,并支持页面刷新频率设置
+- [x] 增加 smartctl 硬盘 SMART 信息接口
+- [x] 增加 JMB39x USB 阵列硬盘柜 SMART 槽位探测支持
+- [x] 增加前端硬盘 SMART 菜单
+- [x] 编写 Windows 部署文档
+- [x] 增加标签表、服务/进程标签关联和默认标签
+- [x] 增加标签管理接口和前端标签管理页面
+- [x] 支持编辑 Windows 服务和进程标签
+- [x] 增加 Windows 服务启动、停止、重新启动接口和操作按钮
+- [x] 增加进程启动、停止接口和操作按钮
+- [x] 根据确认状态和不可控标签隐藏控制操作
+- [x] AI 提示词增加标签说明,并包含系统已有标签信息
+
+## 进度日志
+
+- 2026-05-03:初始化任务文档。
+- 2026-05-03:完成后端核心扫描、数据库、接口和前端主要页面。
+- 2026-05-03:完成接口文档,安装依赖,验证 Python 编译、前端构建、仪表盘/扫描/列表/AI 提示词接口。
+- 2026-05-03:开始增加实时传感器和硬盘 SMART 功能。
+- 2026-05-03:完成传感器接口与页面、SMART 接口与页面、JMB39x 槽位探测支持、部署文档。
+- 2026-05-03:验证 Python 编译、前端构建、传感器接口、smartctl 扫描和普通 SMART 读取通过。
+- 2026-05-09:开始增加标签、服务/进程控制和 AI 标签上下文功能。
+- 2026-05-09:完成标签管理、服务/进程标签编辑、服务/进程控制接口和前端按钮、AI 标签上下文,验证编译、构建和核心接口;补充系统核心进程保护。