commit fac8ab74702204edec9f88ae81ad4bb33afbc055 Author: admin Date: Thu Apr 30 15:51:48 2026 +0800 feat: KVM虚拟化管理平台初始版本 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71a057f --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +.env +node_modules/ +dist/ +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..b63b747 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# KVM 虚拟化管理平台 + +基于 FastAPI + Vue 3 + Element Plus 的 KVM 虚拟机管理平台,通过 libvirt API 管理虚拟机。 + +## 技术栈 + +- **后端**: FastAPI + libvirt Python API +- **前端**: Vue 3 + Element Plus + Vite +- **虚拟化**: QEMU/KVM + libvirt + +## 功能 + +- 🖥️ 虚拟机管理(创建/启动/停止/删除/快照) +- 📊 资源监控(CPU/内存/磁盘/网络) +- 💾 存储池管理 +- 🌐 网络管理 +- 📋 控制台访问(noVNC) +- 📸 快照管理 +- 🔐 用户认证 + +## 项目结构 + +``` +kvm-manager/ +├── backend/ +│ ├── app/ +│ │ ├── main.py +│ │ ├── config.py +│ │ ├── database.py +│ │ ├── models.py +│ │ ├── auth.py +│ │ └── routers/ +│ │ ├── vm.py +│ │ ├── storage.py +│ │ ├── network.py +│ │ ├── snapshot.py +│ │ └── monitor.py +│ ├── requirements.txt +│ └── Dockerfile +├── frontend/ +│ ├── src/ +│ │ ├── views/ +│ │ ├── components/ +│ │ ├── api/ +│ │ ├── router/ +│ │ └── App.vue +│ ├── package.json +│ └── vite.config.js +├── docker-compose.yml +└── README.md +``` + +## 快速开始 + +```bash +# 后端 +cd backend +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 8004 + +# 前端 +cd frontend +npm install +npm run dev +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f93b65f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libvirt-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8004 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8004"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..dbbbea3 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,17 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + APP_NAME: str = "KVM Manager" + APP_VERSION: str = "1.0.0" + LIBVIRT_URI: str = "qemu:///system" + API_PREFIX: str = "/api" + SECRET_KEY: str = "kvm-manager-secret-key-change-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/backend/app/libvirt_conn.py b/backend/app/libvirt_conn.py new file mode 100644 index 0000000..6d95dae --- /dev/null +++ b/backend/app/libvirt_conn.py @@ -0,0 +1,78 @@ +"""libvirt 连接管理 - 使用连接池模式""" +import libvirt +from contextlib import contextmanager +from app.config import settings +import logging + +logger = logging.getLogger(__name__) + + +class LibvirtConnection: + """libvirt 连接管理器(只读连接保持,写操作用短连接)""" + + def __init__(self): + self._conn = None + + def _connect(self): + """建立新的 libvirt 连接""" + try: + conn = libvirt.openReadOnly(settings.LIBVIRT_URI) + if conn is None: + raise ConnectionError(f"无法连接到 libvirt: {settings.LIBVIRT_URI}") + return conn + except libvirt.libvirtError as e: + logger.error(f"libvirt 连接错误: {e}") + raise + + def _connect_rw(self): + """建立可读写连接""" + try: + conn = libvirt.open(settings.LIBVIRT_URI) + if conn is None: + raise ConnectionError(f"无法连接到 libvirt (RW): {settings.LIBVIRT_URI}") + return conn + except libvirt.libvirtError as e: + logger.error(f"libvirt RW 连接错误: {e}") + raise + + @property + def conn(self): + """获取只读连接(带自动重连)""" + try: + if self._conn is None or not self._conn.isAlive(): + self._conn = self._connect() + except Exception: + self._conn = self._connect() + return self._conn + + @contextmanager + def get_rw(self): + """获取读写连接(短连接,用完关闭)""" + conn = None + try: + conn = self._connect_rw() + yield conn + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass + + def get_host_info(self): + """获取宿主机信息""" + c = self.conn + return { + "hostname": c.getHostname(), + "hypervisor": c.getType(), + "libvirt_version": c.getLibVersion(), + "hypervisor_version": c.getVersion(), + "cpu_model": c.getInfo()[5] if len(c.getInfo()) > 5 else "Unknown", + "cpu_cores": c.getInfo()[2], + "memory_total": c.getInfo()[1], # KB + "cpu_speed": c.getInfo()[3], # MHz + } + + +# 全局单例 +libvirt_conn = LibvirtConnection() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..6aaa7ac --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,50 @@ +"""FastAPI 主应用""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.config import settings +from app.routers import vm, storage, network, snapshot, monitor + +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description="KVM 虚拟化管理平台 API", +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 注册路由 +app.include_router(vm.router, prefix=f"{settings.API_PREFIX}/vm", tags=["虚拟机管理"]) +app.include_router(storage.router, prefix=f"{settings.API_PREFIX}/storage", tags=["存储管理"]) +app.include_router(network.router, prefix=f"{settings.API_PREFIX}/network", tags=["网络管理"]) +app.include_router(snapshot.router, prefix=f"{settings.API_PREFIX}/snapshot", tags=["快照管理"]) +app.include_router(monitor.router, prefix=f"{settings.API_PREFIX}/monitor", tags=["资源监控"]) + + +@app.get("/") +async def root(): + return {"name": settings.APP_NAME, "version": settings.APP_VERSION} + + +@app.get(f"{settings.API_PREFIX}/host") +async def host_info(): + """获取宿主机信息""" + from app.libvirt_conn import libvirt_conn + return libvirt_conn.get_host_info() + + +@app.get("/health") +async def health(): + """健康检查""" + from app.libvirt_conn import libvirt_conn + try: + conn = libvirt_conn.conn + return {"status": "ok", "libvirt": conn.isAlive()} + except Exception as e: + return {"status": "error", "message": str(e)} diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/monitor.py b/backend/app/routers/monitor.py new file mode 100644 index 0000000..9e50b03 --- /dev/null +++ b/backend/app/routers/monitor.py @@ -0,0 +1,207 @@ +"""资源监控路由""" +from fastapi import APIRouter, HTTPException +from app.libvirt_conn import libvirt_conn +import libvirt +import time +import threading + +router = APIRouter() + +# 简易内存缓存 +_stats_cache = {} +_cache_lock = threading.Lock() + + +@router.get("/overview") +async def monitor_overview(): + """宿主机总览监控""" + conn = libvirt_conn.conn + + # 宿主机信息 + host_info = conn.getInfo() + hostname = conn.getHostname() + + # CPU 使用率(通过 node info) + cpu_stats = conn.getCPUStats(-1, 0) # 全局 CPU 统计 + cpu_total = cpu_stats.get("user", 0) + cpu_stats.get("system", 0) + cpu_stats.get("idle", 0) + cpu_used = cpu_stats.get("user", 0) + cpu_stats.get("system", 0) + cpu_percent = round(cpu_used / cpu_total * 100, 1) if cpu_total > 0 else 0 + + # 内存 + memory_total_kb = host_info[1] + # 获取可用内存 + try: + with open("/proc/meminfo", "r") as f: + meminfo = {} + for line in f: + parts = line.split() + if len(parts) >= 2: + meminfo[parts[0].rstrip(":")] = int(parts[1]) + + mem_total_mb = meminfo.get("MemTotal", 0) // 1024 + mem_available_mb = meminfo.get("MemAvailable", 0) // 1024 + mem_used_mb = mem_total_mb - mem_available_mb + mem_percent = round(mem_used_mb / mem_total_mb * 100, 1) if mem_total_mb > 0 else 0 + except Exception: + mem_total_mb = memory_total_kb // 1024 + mem_used_mb = 0 + mem_available_mb = mem_total_mb + mem_percent = 0 + + # 虚拟机统计 + domains = conn.listAllDomains(0) + running = sum(1 for d in domains if d.isActive()) + stopped = len(domains) - running + + return { + "hostname": hostname, + "cpu": { + "cores": host_info[2], + "speed_mhz": host_info[3], + "usage_percent": cpu_percent, + }, + "memory": { + "total_mb": mem_total_mb, + "used_mb": mem_used_mb, + "available_mb": mem_available_mb, + "usage_percent": mem_percent, + }, + "vms": { + "total": len(domains), + "running": running, + "stopped": stopped, + }, + } + + +@router.get("/vm/{name}") +async def monitor_vm(name: str): + """获取虚拟机实时监控数据""" + conn = libvirt_conn.conn + try: + dom = conn.lookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在") + + if not dom.isActive(): + return {"name": name, "state": "stopped", "cpu_percent": 0, "memory": {}} + + # CPU 百分比 + cpu_percent = _get_vm_cpu_percent(dom) + + # 内存 + mem_stats = {} + try: + raw = dom.memoryStats() + mem_stats = { + "rss_mb": raw.get("rss", 0) // 1024 if "rss" in raw else 0, + "actual_mb": raw.get("actual", 0) // 1024 if "actual" in raw else 0, + "available_mb": raw.get("available", 0) // 1024 if "available" in raw else 0, + "usage_percent": round( + raw.get("rss", 0) / raw.get("actual", 1) * 100, 1 + ) if "rss" in raw and "actual" in raw else 0, + } + except Exception: + pass + + # 磁盘IO + disk_stats = _get_vm_disk_stats(dom) + + # 网络IO + net_stats = _get_vm_net_stats(dom) + + return { + "name": name, + "state": "running", + "cpu_percent": cpu_percent, + "memory": mem_stats, + "disk": disk_stats, + "network": net_stats, + } + + +def _get_vm_cpu_percent(dom) -> float: + """计算虚拟机 CPU 使用率""" + cache_key = f"cpu_{dom.name()}" + + try: + # 第一次采样 + info1 = dom.info() + cpu_time1 = info1[2] + t1 = time.time() + + # 从缓存获取上一次数据 + with _cache_lock: + prev = _stats_cache.get(cache_key) + + if prev: + cpu_time0, t0 = prev + elapsed = t1 - t0 + cpu_diff = cpu_time1 - cpu_time0 + # CPU时间单位是纳秒 + cpu_percent = round((cpu_diff / 1e9) / elapsed * 100, 1) + cpu_percent = min(cpu_percent, 100.0) + else: + cpu_percent = 0.0 + + # 更新缓存 + with _cache_lock: + _stats_cache[cache_key] = (cpu_time1, t1) + + return cpu_percent + except Exception: + return 0.0 + + +def _get_vm_disk_stats(dom) -> list: + """获取虚拟机磁盘IO""" + from lxml import etree + stats = [] + try: + xml = etree.fromstring(dom.XMLDesc(0).encode()) + for disk in xml.findall(".//disk[@device='disk']"): + target = disk.find("target") + if target is not None: + dev = target.get("dev", "") + try: + s = dom.blockStats(dev) + stats.append({ + "dev": dev, + "read_bytes": s[1], + "write_bytes": s[3], + "read_requests": s[0], + "write_requests": s[2], + }) + except Exception: + pass + except Exception: + pass + return stats + + +def _get_vm_net_stats(dom) -> list: + """获取虚拟机网络IO""" + from lxml import etree + stats = [] + try: + xml = etree.fromstring(dom.XMLDesc(0).encode()) + for iface in xml.findall(".//interface"): + target = iface.find("target") + if target is not None: + dev = target.get("dev", "") + try: + s = dom.interfaceStats(dev) + stats.append({ + "dev": dev, + "rx_bytes": s[0], + "tx_bytes": s[4], + "rx_packets": s[1], + "tx_packets": s[5], + "rx_errors": s[2], + "tx_errors": s[6], + }) + except Exception: + pass + except Exception: + pass + return stats diff --git a/backend/app/routers/network.py b/backend/app/routers/network.py new file mode 100644 index 0000000..ec1010d --- /dev/null +++ b/backend/app/routers/network.py @@ -0,0 +1,175 @@ +"""网络管理路由""" +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from typing import Optional, List +from lxml import etree + +from app.libvirt_conn import libvirt_conn +import libvirt + +router = APIRouter() + + +class NetworkCreate(BaseModel): + name: str = Field(..., description="网络名称") + mode: str = Field("nat", description="模式: nat/bridge/isolated") + subnet: str = Field("192.168.100.0/24", description="子网") + bridge: Optional[str] = Field(None, description="桥接网卡名(mode=bridge时必填)") + dhcp_start: Optional[str] = Field(None, description="DHCP起始IP") + dhcp_end: Optional[str] = Field(None, description="DHCP结束IP") + + +@router.get("/list") +async def list_networks(): + """列出所有网络""" + conn = libvirt_conn.conn + networks = conn.listAllNetworks(0) + result = [] + for net in networks: + xml = etree.fromstring(net.XMLDesc(0).encode()) + + # 解析网络信息 + forward = xml.find("forward") + mode = forward.get("mode", "isolated") if forward is not None else "isolated" + + ip_elem = xml.find("ip") + address = ip_elem.get("address", "") if ip_elem is not None else "" + netmask = ip_elem.get("netmask", "") if ip_elem is not None else "" + + bridge = xml.find("bridge") + bridge_name = bridge.get("name", "") if bridge is not None else "" + + # DHCP范围 + dhcp_range = None + dhcp = xml.find(".//dhcp") + if dhcp is not None: + r = dhcp.find("range") + if r is not None: + dhcp_range = { + "start": r.get("start", ""), + "end": r.get("end", ""), + } + + # 活跃租约 + leases = [] + try: + for lease in net.DHCPLeases(): + leases.append({ + "ip": lease.get("ipaddr", ""), + "mac": lease.get("mac", ""), + "hostname": lease.get("hostname", ""), + "expiry": lease.get("expirytime", 0), + }) + except Exception: + pass + + result.append({ + "name": net.name(), + "active": net.isActive() == 1, + "persistent": net.isPersistent() == 1, + "autostart": net.autostart() == 1, + "mode": mode, + "address": address, + "netmask": netmask, + "bridge": bridge_name, + "dhcp": dhcp_range, + "leases": leases, + }) + return {"networks": result, "total": len(result)} + + +@router.get("/detail/{name}") +async def get_network(name: str): + """获取网络详情""" + conn = libvirt_conn.conn + try: + net = conn.networkLookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"网络 '{name}' 不存在") + + xml_str = net.XMLDesc(0) + return {"name": name, "xml": xml_str, "active": net.isActive() == 1} + + +@router.post("/create") +async def create_network(net: NetworkCreate): + """创建网络""" + if net.mode == "bridge" and not net.bridge: + raise HTTPException(status_code=400, detail="桥接模式必须指定桥接网卡") + + if net.mode == "bridge": + xml = f""" + {net.name} + + +""" + else: + # NAT或隔离模式 + import ipaddress + network = ipaddress.ip_network(net.subnet, strict=False) + gateway = str(network.network_address + 1) + + dhcp_xml = "" + if net.mode == "nat": + start = net.dhcp_start or str(network.network_address + 2) + end = net.dhcp_end or str(network.network_address + 254) + dhcp_xml = f""" + + + """ + + forward_xml = f"" if net.mode == "nat" else "" + netmask = str(network.netmask) + + xml = f""" + {net.name} + {forward_xml} + + {dhcp_xml} + +""" + + with libvirt_conn.get_rw() as rw_conn: + try: + n = rw_conn.networkDefineXML(xml) + n.setAutostart(1) + n.create() + return {"message": f"网络 '{net.name}' 创建成功"} + except libvirt.libvirtError as e: + raise HTTPException(status_code=500, detail=f"创建网络失败: {str(e)}") + + +@router.delete("/delete/{name}") +async def delete_network(name: str): + """删除网络""" + with libvirt_conn.get_rw() as rw_conn: + try: + net = rw_conn.networkLookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"网络 '{name}' 不存在") + + if net.isActive(): + net.destroy() + net.undefine() + return {"message": f"网络 '{name}' 已删除"} + + +@router.post("/action/{name}") +async def network_action(name: str, action: str): + """网络操作: start/stop""" + with libvirt_conn.get_rw() as rw_conn: + try: + net = rw_conn.networkLookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"网络 '{name}' 不存在") + + try: + if action == "start": + net.create() + elif action == "stop": + net.destroy() + else: + raise HTTPException(status_code=400, detail=f"不支持的操作: {action}") + return {"message": f"网络 '{name}' {action} 成功"} + except libvirt.libvirtError as e: + raise HTTPException(status_code=500, detail=f"操作失败: {str(e)}") diff --git a/backend/app/routers/snapshot.py b/backend/app/routers/snapshot.py new file mode 100644 index 0000000..47a3360 --- /dev/null +++ b/backend/app/routers/snapshot.py @@ -0,0 +1,114 @@ +"""快照管理路由""" +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from typing import Optional +from lxml import etree + +from app.libvirt_conn import libvirt_conn +import libvirt + +router = APIRouter() + + +class SnapshotCreate(BaseModel): + name: str = Field(..., description="快照名称") + description: Optional[str] = Field(None, description="快照描述") + + +@router.get("/list/{vm_name}") +async def list_snapshots(vm_name: str): + """列出虚拟机的所有快照""" + conn = libvirt_conn.conn + try: + dom = conn.lookupByName(vm_name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"虚拟机 '{vm_name}' 不存在") + + snapshots = [] + try: + for snap in dom.listAllSnapshots(0): + xml = etree.fromstring(snap.getXMLDesc().encode()) + desc = xml.find("description") + state = xml.find("state") + creation = xml.find("creationTime") + + snapshots.append({ + "name": snap.getName(), + "state": state.text if state is not None else "", + "description": desc.text if desc is not None else "", + "creation_time": int(creation.text) if creation is not None else 0, + "is_current": snap.isCurrent() == 1, + }) + except libvirt.libvirtError: + pass # 没有快照 + + return {"vm": vm_name, "snapshots": snapshots, "total": len(snapshots)} + + +@router.post("/create/{vm_name}") +async def create_snapshot(vm_name: str, snap: SnapshotCreate): + """创建快照""" + with libvirt_conn.get_rw() as rw_conn: + try: + dom = rw_conn.lookupByName(vm_name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"虚拟机 '{vm_name}' 不存在") + + desc_xml = f"{snap.description}" if snap.description else "" + xml = f""" + {snap.name} + {desc_xml} +""" + + try: + dom.snapshotCreateXML(xml, 0) + return {"message": f"快照 '{snap.name}' 创建成功"} + except libvirt.libvirtError as e: + raise HTTPException(status_code=500, detail=f"创建快照失败: {str(e)}") + + +@router.post("/revert/{vm_name}/{snap_name}") +async def revert_snapshot(vm_name: str, snap_name: str): + """恢复快照""" + with libvirt_conn.get_rw() as rw_conn: + try: + dom = rw_conn.lookupByName(vm_name) + snap = dom.snapshotLookupByName(snap_name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail="虚拟机或快照不存在") + + try: + dom.revertToSnapshot(snap) + return {"message": f"已恢复到快照 '{snap_name}'"} + except libvirt.libvirtError as e: + raise HTTPException(status_code=500, detail=f"恢复快照失败: {str(e)}") + + +@router.delete("/delete/{vm_name}/{snap_name}") +async def delete_snapshot(vm_name: str, snap_name: str): + """删除快照""" + with libvirt_conn.get_rw() as rw_conn: + try: + dom = rw_conn.lookupByName(vm_name) + snap = dom.snapshotLookupByName(snap_name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail="虚拟机或快照不存在") + + try: + snap.delete(0) + return {"message": f"快照 '{snap_name}' 已删除"} + except libvirt.libvirtError as e: + raise HTTPException(status_code=500, detail=f"删除快照失败: {str(e)}") + + +@router.get("/detail/{vm_name}/{snap_name}") +async def get_snapshot_detail(vm_name: str, snap_name: str): + """获取快照详情""" + conn = libvirt_conn.conn + try: + dom = conn.lookupByName(vm_name) + snap = dom.snapshotLookupByName(snap_name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail="虚拟机或快照不存在") + + return {"name": snap_name, "vm": vm_name, "xml": snap.getXMLDesc()} diff --git a/backend/app/routers/storage.py b/backend/app/routers/storage.py new file mode 100644 index 0000000..c61e02b --- /dev/null +++ b/backend/app/routers/storage.py @@ -0,0 +1,180 @@ +"""存储池管理路由""" +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from typing import Optional +from lxml import etree +import os + +from app.libvirt_conn import libvirt_conn +import libvirt + +router = APIRouter() + + +class PoolCreate(BaseModel): + name: str = Field(..., description="存储池名称") + path: str = Field(..., description="存储池路径") + type: str = Field("dir", description="存储池类型: dir/fs/logical") + + +class VolCreate(BaseModel): + name: str = Field(..., description="卷名称") + capacity_gb: int = Field(20, description="容量(GB)") + format: str = Field("qcow2", description="格式: qcow2/raw") + + +@router.get("/pools") +async def list_pools(): + """列出所有存储池""" + conn = libvirt_conn.conn + pools = conn.listAllStoragePools(0) + result = [] + for pool in pools: + info = pool.info() + xml = etree.fromstring(pool.XMLDesc(0).encode()) + target = xml.find(".//target/path") + result.append({ + "name": pool.name(), + "state": ["inactive", "building", "running", "degraded", "inaccessible"][info[0]] if info[0] < 5 else "unknown", + "capacity_gb": round(info[1] / (1024**3), 2), + "allocation_gb": round(info[2] / (1024**3), 2), + "available_gb": round(info[3] / (1024**3), 2), + "path": target.text if target is not None else "", + "autostart": pool.autostart() == 1, + "persistent": pool.isPersistent() == 1, + }) + return {"pools": result, "total": len(result)} + + +@router.get("/pool/{name}") +async def get_pool(name: str): + """获取存储池详情""" + conn = libvirt_conn.conn + try: + pool = conn.storagePoolLookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"存储池 '{name}' 不存在") + + info = pool.info() + xml = etree.fromstring(pool.XMLDesc(0).encode()) + + # 获取卷列表 + volumes = [] + try: + for vol_name in pool.listVolumes(): + vol = pool.storageVolLookupByName(vol_name) + vol_info = vol.info() + volumes.append({ + "name": vol_name, + "path": vol.path(), + "type": vol_info[0], + "capacity_gb": round(vol_info[1] / (1024**3), 2), + "allocation_gb": round(vol_info[2] / (1024**3), 2), + }) + except Exception: + pass + + target = xml.find(".//target/path") + return { + "name": pool.name(), + "state": ["inactive", "building", "running", "degraded", "inaccessible"][info[0]] if info[0] < 5 else "unknown", + "capacity_gb": round(info[1] / (1024**3), 2), + "allocation_gb": round(info[2] / (1024**3), 2), + "available_gb": round(info[3] / (1024**3), 2), + "path": target.text if target is not None else "", + "autostart": pool.autostart() == 1, + "volumes": volumes, + "volume_count": len(volumes), + } + + +@router.post("/pool/create") +async def create_pool(pool: PoolCreate): + """创建存储池""" + with libvirt_conn.get_rw() as rw_conn: + xml = f""" + {pool.name} + + {pool.path} + +""" + try: + os.makedirs(pool.path, exist_ok=True) + p = rw_conn.storagePoolDefineXML(xml, 0) + p.setAutostart(1) + p.create(0) + return {"message": f"存储池 '{pool.name}' 创建成功"} + except libvirt.libvirtError as e: + raise HTTPException(status_code=500, detail=f"创建存储池失败: {str(e)}") + + +@router.delete("/pool/{name}") +async def delete_pool(name: str): + """删除存储池""" + with libvirt_conn.get_rw() as rw_conn: + try: + pool = rw_conn.storagePoolLookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"存储池 '{name}' 不存在") + + if pool.info()[0] == libvirt.VIR_STORAGE_POOL_RUNNING: + pool.destroy() + + pool.undefine() + return {"message": f"存储池 '{name}' 已删除"} + + +@router.post("/pool/{name}/volume") +async def create_volume(name: str, vol: VolCreate): + """在存储池中创建卷""" + with libvirt_conn.get_rw() as rw_conn: + try: + pool = rw_conn.storagePoolLookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"存储池 '{name}' 不存在") + + vol_xml = f""" + {vol.name} + {vol.capacity_gb} + 1 + + + +""" + try: + pool.createXML(vol_xml, 0) + return {"message": f"卷 '{vol.name}' 创建成功"} + except libvirt.libvirtError as e: + raise HTTPException(status_code=500, detail=f"创建卷失败: {str(e)}") + + +@router.delete("/pool/{pool_name}/volume/{vol_name}") +async def delete_volume(pool_name: str, vol_name: str): + """删除卷""" + with libvirt_conn.get_rw() as rw_conn: + try: + pool = rw_conn.storagePoolLookupByName(pool_name) + vol = pool.storageVolLookupByName(vol_name) + vol.delete(0) + return {"message": f"卷 '{vol_name}' 已删除"} + except libvirt.libvirtError as e: + raise HTTPException(status_code=500, detail=f"删除卷失败: {str(e)}") + + +@router.get("/isos") +async def list_isos(): + """列出可用的ISO镜像""" + iso_dirs = ["/var/lib/libvirt/iso", "/isos", "/mnt/isos"] + isos = [] + for d in iso_dirs: + if os.path.isdir(d): + for f in os.listdir(d): + if f.lower().endswith(".iso"): + fp = os.path.join(d, f) + size = os.path.getsize(fp) + isos.append({ + "name": f, + "path": fp, + "size_gb": round(size / (1024**3), 2), + }) + return {"isos": isos, "total": len(isos)} diff --git a/backend/app/routers/vm.py b/backend/app/routers/vm.py new file mode 100644 index 0000000..29a7c69 --- /dev/null +++ b/backend/app/routers/vm.py @@ -0,0 +1,387 @@ +"""虚拟机管理路由""" +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from typing import Optional, List +from lxml import etree +import os + +from app.libvirt_conn import libvirt_conn +from app.utils import parse_vm_info, generate_vm_xml +import libvirt + +router = APIRouter() + + +# ===== 请求模型 ===== + +class VMCreate(BaseModel): + name: str = Field(..., description="虚拟机名称") + memory_mb: int = Field(2048, description="内存大小(MB)") + vcpus: int = Field(2, description="CPU核心数") + disk_gb: int = Field(20, description="磁盘大小(GB)") + pool_name: str = Field("default", description="存储池名称") + iso_path: Optional[str] = Field(None, description="ISO安装镜像路径") + network: str = Field("default", description="网络名称") + description: Optional[str] = Field(None, description="描述") + + +class VMAction(BaseModel): + action: str = Field(..., description="操作: start/stop/restart/pause/resume/force_stop") + + +class VMClone(BaseModel): + new_name: str = Field(..., description="新虚拟机名称") + + +# ===== API ===== + +@router.get("/list") +async def list_vms(): + """获取所有虚拟机列表""" + conn = libvirt_conn.conn + domains = conn.listAllDomains(0) + vms = [] + for dom in domains: + try: + vm_info = parse_vm_info(dom) + vms.append(vm_info) + except Exception as e: + vms.append({ + "name": dom.name(), + "uuid": dom.UUIDString(), + "state": "error", + "error": str(e), + }) + return {"vms": vms, "total": len(vms)} + + +@router.get("/detail/{name}") +async def get_vm_detail(name: str): + """获取虚拟机详情""" + conn = libvirt_conn.conn + try: + dom = conn.lookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在") + + info = parse_vm_info(dom) + + # 运行中的虚拟机获取更多动态信息 + if info["state"] == "running": + try: + # CPU 时间 + _, _, cpu_time, _ = dom.info() + info["cpu_time_ns"] = cpu_time + except Exception: + pass + + # 内存使用 + try: + mem_stats = dom.memoryStats() + info["memory_stats"] = mem_stats + except Exception: + pass + + # 块设备统计 + try: + block_stats = [] + for disk in info.get("disks", []): + if disk.get("dev"): + stats = dom.blockStats(disk["dev"]) + block_stats.append({ + "dev": disk["dev"], + "read_bytes": stats[1], + "write_bytes": stats[3], + "read_requests": stats[0], + "write_requests": stats[2], + }) + info["block_stats"] = block_stats + except Exception: + pass + + # 网络统计 + try: + net_stats = [] + for i, iface in enumerate(info.get("interfaces", [])): + if iface.get("mac"): + stats = dom.interfaceStats(iface["mac"]) + net_stats.append({ + "mac": iface["mac"], + "rx_bytes": stats[0], + "tx_bytes": stats[4], + "rx_packets": stats[1], + "tx_packets": stats[5], + }) + info["net_stats"] = net_stats + except Exception: + pass + + return info + + +@router.post("/create") +async def create_vm(vm: VMCreate): + """创建虚拟机""" + conn = libvirt_conn.conn + + # 检查名称是否已存在 + try: + conn.lookupByName(vm.name) + raise HTTPException(status_code=400, detail=f"虚拟机 '{vm.name}' 已存在") + except libvirt.libvirtError: + pass # 不存在,继续创建 + + with libvirt_conn.get_rw() as rw_conn: + try: + # 确定磁盘路径 + pool = rw_conn.storagePoolLookupByName(vm.pool_name) + pool_info = pool.info() + pool_xml = etree.fromstring(pool.XMLDesc(0).encode()) + target = pool_xml.find(".//target/path") + pool_path = target.text if target is not None else "/var/lib/libvirt/images" + + disk_path = os.path.join(pool_path, f"{vm.name}.qcow2") + + # 创建 qcow2 磁盘 + # 创建卷的 XML + vol_xml = f""" + {vm.name}.qcow2 + {vm.disk_gb} + 1 + + + + 0644 + + +""" + pool.createXML(vol_xml, 0) + + # 生成虚拟机XML + vm_xml = generate_vm_xml( + name=vm.name, + memory_mb=vm.memory_mb, + vcpus=vm.vcpus, + disk_path=disk_path, + disk_size_gb=vm.disk_gb, + iso_path=vm.iso_path, + network=vm.network, + ) + + # 定义并启动 + dom = rw_conn.defineXML(vm_xml) + dom.create() + + return {"message": f"虚拟机 '{vm.name}' 创建成功", "name": vm.name} + + except libvirt.libvirtError as e: + raise HTTPException(status_code=500, detail=f"创建虚拟机失败: {str(e)}") + + +@router.post("/action/{name}") +async def vm_action(name: str, action: VMAction): + """虚拟机操作""" + conn = libvirt_conn.conn + try: + dom = conn.lookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在") + + with libvirt_conn.get_rw() as rw_conn: + try: + rw_dom = rw_conn.lookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在") + + act = action.action + try: + if act == "start": + rw_dom.create() + msg = f"虚拟机 '{name}' 已启动" + elif act == "stop": + rw_dom.shutdown() + msg = f"虚拟机 '{name}' 正在关闭" + elif act == "force_stop": + rw_dom.destroy() + msg = f"虚拟机 '{name}' 已强制关闭" + elif act == "restart": + rw_dom.reboot(libvirt.VIR_DOMAIN_REBOOT_DEFAULT) + msg = f"虚拟机 '{name}' 正在重启" + elif act == "pause": + rw_dom.suspend() + msg = f"虚拟机 '{name}' 已暂停" + elif act == "resume": + rw_dom.resume() + msg = f"虚拟机 '{name}' 已恢复" + else: + raise HTTPException(status_code=400, detail=f"不支持的操作: {act}") + return {"message": msg} + except libvirt.libvirtError as e: + raise HTTPException(status_code=500, detail=f"操作失败: {str(e)}") + + +@router.delete("/delete/{name}") +async def delete_vm(name: str, force: bool = False): + """删除虚拟机""" + with libvirt_conn.get_rw() as rw_conn: + try: + dom = rw_conn.lookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在") + + state, _ = dom.info()[0:2] + if state == libvirt.VIR_DOMAIN_RUNNING: + if force: + dom.destroy() + else: + raise HTTPException( + status_code=400, + detail=f"虚拟机 '{name}' 正在运行,请先关闭或使用 force=true" + ) + + # 获取磁盘路径 + xml_desc = dom.XMLDesc(0) + tree = etree.fromstring(xml_desc.encode()) + disk_files = [] + for disk in tree.findall(".//disk[@device='disk']/source"): + f = disk.get("file") + if f: + disk_files.append(f) + + # 取消定义 + dom.undefine() + + # 删除磁盘文件 + for f in disk_files: + try: + os.remove(f) + except Exception: + pass + + return {"message": f"虚拟机 '{name}' 已删除", "removed_disks": disk_files} + + +@router.post("/clone/{name}") +async def clone_vm(name: str, clone: VMClone): + """克隆虚拟机""" + with libvirt_conn.get_rw() as rw_conn: + try: + dom = rw_conn.lookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在") + + # 检查新名称是否已存在 + try: + rw_conn.lookupByName(clone.new_name) + raise HTTPException(status_code=400, detail=f"虚拟机 '{clone.new_name}' 已存在") + except libvirt.libvirtError: + pass + + try: + # 获取源虚拟机 XML + xml_desc = dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE) + tree = etree.fromstring(xml_desc.encode()) + + # 修改名称 + tree.find("name").text = clone.new_name + + # 修改 UUID(删除让libvirt自动生成) + uuid_elem = tree.find("uuid") + if uuid_elem is not None: + tree.remove(uuid_elem) + + # 修改磁盘路径 + import uuid as uuid_mod + new_uuid = str(uuid_mod.uuid4())[:8] + for disk in tree.findall(".//disk[@device='disk']"): + source = disk.find("source") + if source is not None: + old_path = source.get("file", "") + new_path = old_path.replace(f"{name}.qcow2", f"{clone.new_name}.qcow2") + source.set("file", new_path) + + # 修改 MAC 地址 + for mac in tree.findall(".//interface/mac"): + import random + mac_addr = "52:54:00:%02x:%02x:%02x" % ( + random.randint(0, 255), + random.randint(0, 255), + random.randint(0, 255), + ) + mac.set("address", mac_addr) + + # 复制磁盘 + old_disk_path = "" + new_disk_path = "" + for disk in tree.findall(".//disk[@device='disk']/source"): + old_disk_path = disk.get("file", "") + new_disk_path = old_disk_path.replace(f"{name}.qcow2", f"{clone.new_name}.qcow2") + + if old_disk_path and os.path.exists(old_disk_path): + import subprocess + subprocess.run( + ["qemu-img", "create", "-f", "qcow2", "-b", old_disk_path, "-F", "qcow2", new_disk_path], + check=True, + capture_output=True, + ) + + # 定义新虚拟机 + new_xml = etree.tostring(tree, encoding="unicode") + rw_conn.defineXML(new_xml) + + return {"message": f"虚拟机 '{name}' 已克隆为 '{clone.new_name}'"} + + except libvirt.libvirtError as e: + raise HTTPException(status_code=500, detail=f"克隆失败: {str(e)}") + + +@router.get("/xml/{name}") +async def get_vm_xml(name: str): + """获取虚拟机 XML 配置""" + conn = libvirt_conn.conn + try: + dom = conn.lookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在") + + return {"name": name, "xml": dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE)} + + +@router.put("/xml/{name}") +async def update_vm_xml(name: str, xml: dict): + """更新虚拟机 XML 配置""" + with libvirt_conn.get_rw() as rw_conn: + try: + dom = rw_conn.lookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在") + + xml_str = xml.get("xml", "") + if not xml_str: + raise HTTPException(status_code=400, detail="XML不能为空") + + try: + rw_conn.defineXML(xml_str) + return {"message": f"虚拟机 '{name}' 配置已更新"} + except libvirt.libvirtError as e: + raise HTTPException(status_code=500, detail=f"更新失败: {str(e)}") + + +@router.post("/migrate/{name}") +async def migrate_vm(name: str, dest_uri: str, live: bool = True): + """迁移虚拟机""" + with libvirt_conn.get_rw() as rw_conn: + try: + dom = rw_conn.lookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在") + + flags = libvirt.VIR_MIGRATE_LIVE if live else 0 + flags |= libvirt.VIR_MIGRATE_PERSIST_DEST + + try: + dest_conn = libvirt.open(dest_uri) + dom.migrate(dest_conn, flags, None, None, 0) + return {"message": f"虚拟机 '{name}' 已迁移到 {dest_uri}"} + except libvirt.libvirtError as e: + raise HTTPException(status_code=500, detail=f"迁移失败: {str(e)}") diff --git a/backend/app/utils.py b/backend/app/utils.py new file mode 100644 index 0000000..1c717ed --- /dev/null +++ b/backend/app/utils.py @@ -0,0 +1,247 @@ +"""虚拟机 XML 模板和工具函数""" +import uuid +import libvirt +from lxml import etree + + +def generate_vm_xml( + name: str, + memory_mb: int, + vcpus: int, + disk_path: str, + disk_size_gb: int = 20, + iso_path: str = None, + network: str = "default", + vnc_port: int = -1, + os_type: str = "hvm", + arch: str = "x86_64", + machine: str = "pc", +) -> str: + """生成虚拟机 XML 定义""" + + vm_uuid = str(uuid.uuid4()) + + # 基础 XML 结构 + xml_parts = f""" + {name} + {vm_uuid} + {memory_mb} + {memory_mb} + {vcpus} + + {os_type} + + + + + + + + + + + + + + destroy + restart + destroy + + /usr/bin/qemu-system-x86_64 + + + + + """ + + # 光驱(ISO安装) + if iso_path: + xml_parts += f""" + + + + + + """ + + # VNC + if vnc_port == -1: + vnc_port = 5900 # auto-allocate by libvirt + xml_parts += f""" + + + + + + + + + + """ + + # 网络 + xml_parts += f""" + + + + + + + + +""" + + return xml_parts + + +def parse_vm_info(dom) -> dict: + """从 libvirt domain 对象提取虚拟机信息""" + from app.libvirt_conn import libvirt_conn + + # 基本信息 + info = { + "id": dom.ID(), + "name": dom.name(), + "uuid": dom.UUIDString(), + "state": _get_state(dom), + "autostart": False, + } + + try: + info["autostart"] = dom.autostart() == 1 + except Exception: + pass + + # 解析 XML + xml_desc = dom.XMLDesc(0) + tree = etree.fromstring(xml_desc.encode()) + + # 内存和CPU + mem = tree.find(".//memory") + cur_mem = tree.find(".//currentMemory") + vcpu = tree.find(".//vcpu") + + mem_unit = mem.get("unit", "KiB") if mem is not None else "KiB" + mem_val = int(mem.text) if mem is not None else 0 + info["memory_mb"] = _to_mb(mem_val, mem_unit) + + cur_unit = cur_mem.get("unit", "KiB") if cur_mem is not None else "KiB" + cur_val = int(cur_mem.text) if cur_mem is not None else 0 + info["current_memory_mb"] = _to_mb(cur_val, cur_unit) + + info["vcpus"] = int(vcpu.text) if vcpu is not None else 1 + + # CPU type + cpu = tree.find(".//cpu") + if cpu is not None: + info["cpu_mode"] = cpu.get("mode", "unknown") + else: + info["cpu_mode"] = "unknown" + + # 磁盘 + disks = [] + for disk in tree.findall(".//disk"): + if disk.get("device") == "disk": + source = disk.find("source") + target = disk.find("target") + driver = disk.find("driver") + disk_info = { + "file": source.get("file", "") if source is not None else "", + "dev": target.get("dev", "") if target is not None else "", + "bus": target.get("bus", "") if target is not None else "", + "format": driver.get("type", "") if driver is not None else "", + } + disks.append(disk_info) + info["disks"] = disks + + # 网络 + interfaces = [] + for iface in tree.findall(".//interface"): + source = iface.find("source") + model = iface.find("model") + iface_info = { + "type": iface.get("type", ""), + "network": source.get("network", "") if source is not None else "", + "model": model.get("type", "") if model is not None else "", + } + # 如果运行中,获取MAC和IP + if info["state"] == "running": + mac = iface.find("mac") + if mac is not None: + iface_info["mac"] = mac.get("address", "") + # 尝试获取IP地址 + try: + ifaces = dom.interfaceAddresses( + libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, 0 + ) + if ifaces: + for ifname, ifdata in ifaces.items(): + if mac is not None and ifdata.get("hwaddr", "") == iface_info.get("mac", ""): + addrs = ifdata.get("addrs", []) + if addrs: + iface_info["ip"] = addrs[0].get("addr", "") + except Exception: + try: + ifaces = dom.interfaceAddresses( + libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE, 0 + ) + if ifaces: + for ifname, ifdata in ifaces.items(): + addrs = ifdata.get("addrs", []) + if addrs: + iface_info["ip"] = addrs[0].get("addr", "") + break + except Exception: + pass + interfaces.append(iface_info) + info["interfaces"] = interfaces + + # VNC + graphics = tree.find(".//graphics[@type='vnc']") + if graphics is not None: + info["vnc_port"] = int(graphics.get("port", -1)) + info["vnc_listen"] = graphics.get("listen", "127.0.0.1") + else: + info["vnc_port"] = -1 + info["vnc_listen"] = "" + + # OS info + os_type = tree.find(".//os/type") + info["os_type"] = os_type.text if os_type is not None else "hvm" + + return info + + +def _get_state(dom) -> str: + """获取虚拟机运行状态""" + raw = dom.info() + state = raw[0] + state_map = { + libvirt.VIR_DOMAIN_NOSTATE: "nostate", + libvirt.VIR_DOMAIN_RUNNING: "running", + libvirt.VIR_DOMAIN_BLOCKED: "blocked", + libvirt.VIR_DOMAIN_PAUSED: "paused", + libvirt.VIR_DOMAIN_SHUTDOWN: "shutdown", + libvirt.VIR_DOMAIN_SHUTOFF: "shutoff", + libvirt.VIR_DOMAIN_CRASHED: "crashed", + libvirt.VIR_DOMAIN_PMSUSPENDED: "suspended", + } + return state_map.get(state, "unknown") + + +def _to_mb(value, unit) -> int: + """转换为 MB""" + unit = unit.lower() + if unit in ("kib", "k", "kib"): + return value // 1024 + elif unit in ("mib", "m", "mib"): + return value + elif unit in ("gib", "g", "gib"): + return value * 1024 + elif unit in ("tib", "t"): + return value * 1024 * 1024 + elif unit == "b": + return value // (1024 * 1024) + return value // 1024 # default KiB diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..6fcccd4 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +libvirt-python==9.0.0 +python-multipart==0.0.6 +pydantic==2.5.2 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +aiofiles==23.2.1 +websockify==0.10.0 +lxml==4.9.3 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1762099 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.8' + +services: + backend: + build: ./backend + container_name: kvm-backend + restart: unless-stopped + network_mode: host + pid: host + privileged: true + volumes: + - /var/run/libvirt/libvirt-sock:/var/run/libvirt/libvirt-sock + - /var/lib/libvirt:/var/lib/libvirt + - /vol1:/vol1 + - /proc:/host_proc:ro + environment: + - LIBVIRT_URI=qemu:///system + command: uvicorn app.main:app --host 0.0.0.0 --port 8004 + + frontend: + build: ./frontend + container_name: kvm-frontend + restart: unless-stopped + ports: + - "8005:80" + depends_on: + - backend diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..174a0f6 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,5 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..223ac4f --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,34 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA 路由支持 + location / { + try_files $uri $uri/ /index.html; + } + + # API 代理 + location /api/ { + proxy_pass http://backend:8004/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300s; + } + + # 健康检查 + location /health { + proxy_pass http://backend:8004/health; + } + + # WebSocket (VNC) + location /websockify { + proxy_pass http://backend:8004/websockify; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..88609f2 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1660 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.15.2", + "echarts": "^6.0.0", + "element-plus": "^2.13.7", + "vue": "^3.5.32", + "vue-echarts": "^8.0.1", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.6", + "vite": "^8.0.10" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tybys/wasm-util/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", + "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz", + "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.33", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz", + "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz", + "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.33", + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.10", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz", + "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz", + "integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz", + "integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz", + "integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/runtime-core": "3.5.33", + "@vue/shared": "3.5.33", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz", + "integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "vue": "3.5.33" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/element-plus": { + "version": "2.13.7", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.7.tgz", + "integrity": "sha512-XdHATFZOyzVFL1DaHQ90IOJQSg9UnSAV+bhDW+YB5UoZ0Hxs50mwqjqfwXkuwpSag+VXXizVcErBR6Movo5daw==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", + "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-sfc": "3.5.33", + "@vue/runtime-dom": "3.5.33", + "@vue/server-renderer": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.7.tgz", + "integrity": "sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==", + "license": "MIT" + }, + "node_modules/vue-echarts": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-8.0.1.tgz", + "integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==", + "license": "MIT", + "peerDependencies": { + "echarts": "^6.0.0", + "vue": "^3.3.0" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..759efe5 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.15.2", + "echarts": "^6.0.0", + "element-plus": "^2.13.7", + "vue": "^3.5.32", + "vue-echarts": "^8.0.1", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.6", + "vite": "^8.0.10" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..ad4878c --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..e4ae62a --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,17 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api', + timeout: 30000, +}) + +// 响应拦截 +api.interceptors.response.use( + (res) => res.data, + (err) => { + console.error('API Error:', err.response?.data?.detail || err.message) + return Promise.reject(err) + } +) + +export default api diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue new file mode 100644 index 0000000..f91553d --- /dev/null +++ b/frontend/src/components/HelloWorld.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/layout/MainLayout.vue b/frontend/src/layout/MainLayout.vue new file mode 100644 index 0000000..92b53af --- /dev/null +++ b/frontend/src/layout/MainLayout.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..5ae638e --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,18 @@ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import 'element-plus/theme-chalk/dark/css-vars.css' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import router from './router' +import App from './App.vue' + +const app = createApp(App) + +// 注册所有图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(ElementPlus) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..a482c78 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,52 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { + path: '/', + component: () => import('../layout/MainLayout.vue'), + redirect: '/dashboard', + children: [ + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('../views/Dashboard.vue'), + meta: { title: '仪表盘' }, + }, + { + path: 'vms', + name: 'VMList', + component: () => import('../views/VMList.vue'), + meta: { title: '虚拟机' }, + }, + { + path: 'vm/:name', + name: 'VMDetail', + component: () => import('../views/VMDetail.vue'), + meta: { title: '虚拟机详情' }, + }, + { + path: 'storage', + name: 'Storage', + component: () => import('../views/Storage.vue'), + meta: { title: '存储管理' }, + }, + { + path: 'network', + name: 'Network', + component: () => import('../views/Network.vue'), + meta: { title: '网络管理' }, + }, + ], + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +router.beforeEach((to) => { + document.title = `${to.meta.title || 'KVM'} - KVM管理平台` +}) + +export default router diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..527d4fb --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,296 @@ +:root { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --border: #e5e4e7; + --code-bg: #f4f3ec; + --accent: #aa3bff; + --accent-bg: rgba(170, 59, 255, 0.1); + --accent-border: rgba(170, 59, 255, 0.5); + --social-bg: rgba(244, 243, 236, 0.5); + --shadow: + rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light dark; + color: var(--text); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (max-width: 1024px) { + font-size: 16px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #16171d; + --border: #2e303a; + --code-bg: #1f2028; + --accent: #c084fc; + --accent-bg: rgba(192, 132, 252, 0.15); + --accent-border: rgba(192, 132, 252, 0.5); + --social-bg: rgba(47, 48, 58, 0.5); + --shadow: + rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; + } + + #social .button-icon { + filter: invert(1) brightness(2); + } +} + +body { + margin: 0; +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} + +code, +.counter { + font-family: var(--mono); + display: inline-flex; + border-radius: 4px; + color: var(--text-h); +} + +code { + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); +} + +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#app { + width: 1126px; + max-width: 100%; + margin: 0 auto; + text-align: center; + border-inline: 1px solid var(--border); + min-height: 100svh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..db414ee --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,326 @@ + + + + + diff --git a/frontend/src/views/Network.vue b/frontend/src/views/Network.vue new file mode 100644 index 0000000..1c4afd2 --- /dev/null +++ b/frontend/src/views/Network.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/frontend/src/views/Storage.vue b/frontend/src/views/Storage.vue new file mode 100644 index 0000000..486e2a0 --- /dev/null +++ b/frontend/src/views/Storage.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/frontend/src/views/VMDetail.vue b/frontend/src/views/VMDetail.vue new file mode 100644 index 0000000..6ea5d03 --- /dev/null +++ b/frontend/src/views/VMDetail.vue @@ -0,0 +1,391 @@ + + + + + diff --git a/frontend/src/views/VMList.vue b/frontend/src/views/VMList.vue new file mode 100644 index 0000000..4c522b9 --- /dev/null +++ b/frontend/src/views/VMList.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..b127c94 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 3000, + host: '0.0.0.0', + proxy: { + '/api': { + target: 'http://localhost:8004', + changeOrigin: true, + }, + }, + }, +})