first commit
This commit is contained in:
15
SampleCData/config/omnisocket_demo.yaml
Normal file
15
SampleCData/config/omnisocket_demo.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
transport:
|
||||||
|
server_addr: "127.0.0.1:10909"
|
||||||
|
relay_via: ""
|
||||||
|
bind_ip: ""
|
||||||
|
bind_device: ""
|
||||||
|
|
||||||
|
video_receiver:
|
||||||
|
peer_id: "peer-a-video"
|
||||||
|
buffer_bytes: 1048576
|
||||||
|
|
||||||
|
video_sender:
|
||||||
|
peer_id: "peer-b-video"
|
||||||
|
target_peer: "peer-a-video"
|
||||||
|
frame_bytes: 65536
|
||||||
|
frame_interval_ms: 33
|
||||||
64
SampleCData/omnisocket_video_receiver.py
Normal file
64
SampleCData/omnisocket_video_receiver.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Minimal video-plane sample that receives frames with recv_into()."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
try:
|
||||||
|
from omnisocket import MSG_TYPE_BINARY, Session, VIDEO_DEFAULTS
|
||||||
|
except ImportError:
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent / "python"))
|
||||||
|
from omnisocket import MSG_TYPE_BINARY, Session, VIDEO_DEFAULTS
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
config_path = Path(__file__).resolve().parent / "config" / "omnisocket_demo.yaml"
|
||||||
|
if not config_path.exists():
|
||||||
|
return {}
|
||||||
|
with config_path.open("r", encoding="utf-8") as file:
|
||||||
|
return yaml.safe_load(file) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
config = load_config()
|
||||||
|
transport_cfg = config.get("transport", {})
|
||||||
|
video_cfg = config.get("video_receiver", {})
|
||||||
|
|
||||||
|
session = Session()
|
||||||
|
session.connect(
|
||||||
|
server_addr=str(transport_cfg.get("server_addr", "127.0.0.1:10909")),
|
||||||
|
peer_id=str(video_cfg.get("peer_id", "peer-a-video")),
|
||||||
|
relay_via=str(transport_cfg.get("relay_via", "")),
|
||||||
|
bind_ip=str(transport_cfg.get("bind_ip", "")),
|
||||||
|
bind_device=str(transport_cfg.get("bind_device", "")),
|
||||||
|
**VIDEO_DEFAULTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
buffer = bytearray(int(video_cfg.get("buffer_bytes", 65536)))
|
||||||
|
frame_count = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
meta = session.recv_into(buffer, timeout_ms=1000)
|
||||||
|
if meta is None:
|
||||||
|
continue
|
||||||
|
if meta["msg_type"] != MSG_TYPE_BINARY:
|
||||||
|
print(f"ignore non-binary message: {meta}")
|
||||||
|
continue
|
||||||
|
frame = bytes(buffer[: meta["body_len"]])
|
||||||
|
sequence = int.from_bytes(frame[:8], "big") if len(frame) >= 8 else -1
|
||||||
|
print(
|
||||||
|
f"received frame={frame_count} remote_seq={sequence} "
|
||||||
|
f"bytes={meta['body_len']} from={meta['from']}"
|
||||||
|
)
|
||||||
|
frame_count += 1
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
66
SampleCData/omnisocket_video_sender.py
Normal file
66
SampleCData/omnisocket_video_sender.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Minimal video-plane sample that sends fixed-size binary frames over KCP."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
try:
|
||||||
|
from omnisocket import Session, VIDEO_DEFAULTS
|
||||||
|
except ImportError:
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent / "python"))
|
||||||
|
from omnisocket import Session, VIDEO_DEFAULTS
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
config_path = Path(__file__).resolve().parent / "config" / "omnisocket_demo.yaml"
|
||||||
|
if not config_path.exists():
|
||||||
|
return {}
|
||||||
|
with config_path.open("r", encoding="utf-8") as file:
|
||||||
|
return yaml.safe_load(file) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
config = load_config()
|
||||||
|
transport_cfg = config.get("transport", {})
|
||||||
|
video_cfg = config.get("video_sender", {})
|
||||||
|
|
||||||
|
server_addr = str(transport_cfg.get("server_addr", "127.0.0.1:10909"))
|
||||||
|
relay_via = str(transport_cfg.get("relay_via", ""))
|
||||||
|
bind_ip = str(transport_cfg.get("bind_ip", ""))
|
||||||
|
bind_device = str(transport_cfg.get("bind_device", ""))
|
||||||
|
peer_id = str(video_cfg.get("peer_id", "peer-b-video"))
|
||||||
|
target_peer = str(video_cfg.get("target_peer", "peer-a-video"))
|
||||||
|
frame_bytes = int(video_cfg.get("frame_bytes", 30720))
|
||||||
|
frame_interval_ms = int(video_cfg.get("frame_interval_ms", 66))
|
||||||
|
|
||||||
|
session = Session()
|
||||||
|
session.connect(
|
||||||
|
server_addr=server_addr,
|
||||||
|
peer_id=peer_id,
|
||||||
|
relay_via=relay_via,
|
||||||
|
bind_ip=bind_ip,
|
||||||
|
bind_device=bind_device,
|
||||||
|
**VIDEO_DEFAULTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
sequence = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
frame = sequence.to_bytes(8, "big") + os.urandom(max(0, frame_bytes - 8))
|
||||||
|
session.send(to=target_peer, data=frame)
|
||||||
|
print(f"sent frame={sequence} bytes={len(frame)}")
|
||||||
|
sequence += 1
|
||||||
|
time.sleep(frame_interval_ms / 1000.0)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
backend/config/__init__.py
Normal file
0
backend/config/__init__.py
Normal file
16
backend/config/asgi.py
Normal file
16
backend/config/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for config project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
89
backend/config/settings.py
Normal file
89
backend/config/settings.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
SECRET_KEY = 'django-insecure-pk4scm@ifo%mao6l=j0@-$_v+pg-43^hj4a!199^)zivz-_8xu'
|
||||||
|
DEBUG = True
|
||||||
|
ALLOWED_HOSTS = ["*"]
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'corsheaders',
|
||||||
|
'rest_framework',
|
||||||
|
'channels',
|
||||||
|
'monitoring',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'config.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'config.wsgi.application'
|
||||||
|
ASGI_APPLICATION = 'config.asgi.application'
|
||||||
|
CHANNEL_LAYERS = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'channels.layers.InMemoryChannelLayer',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'zh-hans'
|
||||||
|
TIME_ZONE = 'Asia/Shanghai'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
CORS_ALLOW_ALL_ORIGINS = True
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
7
backend/config/urls.py
Normal file
7
backend/config/urls.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('api/', include('monitoring.urls')),
|
||||||
|
]
|
||||||
16
backend/config/wsgi.py
Normal file
16
backend/config/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for config project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
22
backend/manage.py
Executable file
22
backend/manage.py
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
1
backend/monitoring/__init__.py
Normal file
1
backend/monitoring/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
7
backend/monitoring/apps.py
Normal file
7
backend/monitoring/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MonitoringConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "monitoring"
|
||||||
|
|
||||||
460
backend/monitoring/services.py
Normal file
460
backend/monitoring/services.py
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
WORKSPACE_ROOT = PROJECT_ROOT.parent
|
||||||
|
JPEG_FRAME_DIR = WORKSPACE_ROOT / "RobotDataShow" / "jpeg-frames"
|
||||||
|
# GPS 数据 JSON 文件路径
|
||||||
|
GEOSTREAM_JSON_PATH = WORKSPACE_ROOT / "GeoStream" / "gps_latest.json"
|
||||||
|
GEOSTREAM_STALE_SECONDS = 15
|
||||||
|
SAMPLE_CDATA_DIR = PROJECT_ROOT / "SampleCData"
|
||||||
|
OMNISOCKET_CONFIG_PATH = SAMPLE_CDATA_DIR / "config" / "omnisocket_demo.yaml"
|
||||||
|
VIDEO_SOURCE_MODE = os.getenv("VIDEO_SOURCE_MODE", "auto").strip().lower()
|
||||||
|
OMNISOCKET_FRAME_FRESH_SECONDS = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def utc_iso_now() -> str:
|
||||||
|
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
class OmniSocketVideoReceiver:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._started = False
|
||||||
|
self._session = None
|
||||||
|
self._session_cls = None
|
||||||
|
self._binary_msg_type = None
|
||||||
|
self._video_defaults: dict[str, Any] = {}
|
||||||
|
self._latest_frame: bytes | None = None
|
||||||
|
self._latest_received_at = 0.0
|
||||||
|
self._latest_sequence: int | None = None
|
||||||
|
self._frames_received = 0
|
||||||
|
self._last_error = ""
|
||||||
|
self._load_backend()
|
||||||
|
|
||||||
|
def _load_backend(self) -> None:
|
||||||
|
# 服务启动时先尝试导入一次 Python/C 扩展。
|
||||||
|
try:
|
||||||
|
self._import_backend()
|
||||||
|
except Exception as error: # pragma: no cover - 可选的运行时依赖
|
||||||
|
self._last_error = f"omnisocket import failed: {error}"
|
||||||
|
|
||||||
|
def _import_backend(self) -> None:
|
||||||
|
# 优先使用已经安装到当前 Python 环境里的 omnisocket。
|
||||||
|
# 如果导入失败,再尝试样例目录下的本地 python 路径。
|
||||||
|
try:
|
||||||
|
from omnisocket import MSG_TYPE_BINARY, Session, VIDEO_DEFAULTS # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
python_dir = SAMPLE_CDATA_DIR / "python"
|
||||||
|
if python_dir.exists():
|
||||||
|
sys.path.insert(0, str(python_dir))
|
||||||
|
from omnisocket import MSG_TYPE_BINARY, Session, VIDEO_DEFAULTS # type: ignore
|
||||||
|
|
||||||
|
self._binary_msg_type = MSG_TYPE_BINARY
|
||||||
|
self._session_cls = Session
|
||||||
|
self._video_defaults = dict(VIDEO_DEFAULTS)
|
||||||
|
|
||||||
|
def ensure_started(self) -> None:
|
||||||
|
# 当第一次请求帧或状态时,再懒启动后台接收线程。
|
||||||
|
if VIDEO_SOURCE_MODE == "sample":
|
||||||
|
return
|
||||||
|
if self._session_cls is None or self._binary_msg_type is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if self._started:
|
||||||
|
return
|
||||||
|
self._started = True
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run,
|
||||||
|
name="omnisocket-video-receiver",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def _load_config(self) -> dict[str, Any]:
|
||||||
|
# 这里保持和 SampleCData/omnisocket_video_receiver.py 一样的配置结构:
|
||||||
|
# transport + video_receiver。
|
||||||
|
# 即使配置文件不存在,也允许回退到默认值继续运行。
|
||||||
|
config: dict[str, Any] = {}
|
||||||
|
if OMNISOCKET_CONFIG_PATH.exists():
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
import yaml # type: ignore
|
||||||
|
|
||||||
|
with OMNISOCKET_CONFIG_PATH.open("r", encoding="utf-8") as file:
|
||||||
|
config = yaml.safe_load(file) or {}
|
||||||
|
except ImportError:
|
||||||
|
# 如果当前环境没有 PyYAML,就用一个足够支撑当前 demo 配置的简化解析器。
|
||||||
|
config = self._load_simple_yaml_config(OMNISOCKET_CONFIG_PATH)
|
||||||
|
except Exception as error: # pragma: no cover - 可选依赖
|
||||||
|
self._last_error = f"config load failed: {error}"
|
||||||
|
|
||||||
|
transport_cfg = dict(config.get("transport", {}))
|
||||||
|
video_cfg = dict(config.get("video_receiver", {}))
|
||||||
|
|
||||||
|
transport_cfg["server_addr"] = os.getenv(
|
||||||
|
"OMNISOCKET_SERVER_ADDR",
|
||||||
|
str(transport_cfg.get("server_addr", "127.0.0.1:10909")),
|
||||||
|
)
|
||||||
|
transport_cfg["relay_via"] = os.getenv(
|
||||||
|
"OMNISOCKET_RELAY_VIA",
|
||||||
|
str(transport_cfg.get("relay_via", "")),
|
||||||
|
)
|
||||||
|
transport_cfg["bind_ip"] = os.getenv(
|
||||||
|
"OMNISOCKET_BIND_IP",
|
||||||
|
str(transport_cfg.get("bind_ip", "")),
|
||||||
|
)
|
||||||
|
transport_cfg["bind_device"] = os.getenv(
|
||||||
|
"OMNISOCKET_BIND_DEVICE",
|
||||||
|
str(transport_cfg.get("bind_device", "")),
|
||||||
|
)
|
||||||
|
video_cfg["peer_id"] = os.getenv(
|
||||||
|
"OMNISOCKET_VIDEO_PEER_ID",
|
||||||
|
str(video_cfg.get("peer_id", "peer-a-video")),
|
||||||
|
)
|
||||||
|
video_cfg["buffer_bytes"] = int(
|
||||||
|
os.getenv(
|
||||||
|
"OMNISOCKET_BUFFER_BYTES",
|
||||||
|
str(video_cfg.get("buffer_bytes", 1024 * 1024)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"transport": transport_cfg,
|
||||||
|
"video_receiver": video_cfg,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _load_simple_yaml_config(self, path: Path) -> dict[str, Any]:
|
||||||
|
parsed: dict[str, Any] = {}
|
||||||
|
current_section: str | None = None
|
||||||
|
|
||||||
|
with path.open("r", encoding="utf-8") as file:
|
||||||
|
for raw_line in file:
|
||||||
|
line = raw_line.split("#", 1)[0].rstrip()
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not line.startswith(" "):
|
||||||
|
if not line.endswith(":"):
|
||||||
|
raise ValueError(f"invalid top-level yaml line: {raw_line.strip()}")
|
||||||
|
current_section = line[:-1].strip()
|
||||||
|
parsed[current_section] = {}
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current_section is None:
|
||||||
|
raise ValueError(f"yaml key outside section: {raw_line.strip()}")
|
||||||
|
|
||||||
|
stripped = line.strip()
|
||||||
|
if ":" not in stripped:
|
||||||
|
raise ValueError(f"invalid yaml key line: {raw_line.strip()}")
|
||||||
|
|
||||||
|
key, value = stripped.split(":", 1)
|
||||||
|
parsed[current_section][key.strip()] = self._parse_simple_yaml_scalar(value.strip())
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
def _parse_simple_yaml_scalar(self, value: str) -> Any:
|
||||||
|
if value in {'""', "''"}:
|
||||||
|
return ""
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
||||||
|
return value[1:-1]
|
||||||
|
if value.lower() == "true":
|
||||||
|
return True
|
||||||
|
if value.lower() == "false":
|
||||||
|
return False
|
||||||
|
if value and value.lstrip("-").isdigit():
|
||||||
|
return int(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _connect_session(self):
|
||||||
|
# 这里和样例接收器一致:创建 Session(),然后使用 transport/video 配置建立连接。
|
||||||
|
assert self._session_cls is not None
|
||||||
|
|
||||||
|
config = self._load_config()
|
||||||
|
transport_cfg = config.get("transport", {})
|
||||||
|
video_cfg = config.get("video_receiver", {})
|
||||||
|
|
||||||
|
session = self._session_cls()
|
||||||
|
session.connect(
|
||||||
|
server_addr=str(transport_cfg.get("server_addr", "127.0.0.1:10909")),
|
||||||
|
peer_id=str(video_cfg.get("peer_id", "peer-a-video")),
|
||||||
|
relay_via=str(transport_cfg.get("relay_via", "")),
|
||||||
|
bind_ip=str(transport_cfg.get("bind_ip", "")),
|
||||||
|
bind_device=str(transport_cfg.get("bind_device", "")),
|
||||||
|
**self._video_defaults,
|
||||||
|
)
|
||||||
|
return session, int(video_cfg.get("buffer_bytes", 1024 * 1024))
|
||||||
|
|
||||||
|
def _extract_jpeg_frame(self, frame: bytes) -> bytes | None:
|
||||||
|
# 同时兼容两种帧格式:
|
||||||
|
# 1. 纯 JPEG 二进制
|
||||||
|
# 2. 前 8 字节是序号,后面才是真正的 JPEG 数据
|
||||||
|
if frame.startswith(b"\xff\xd8"):
|
||||||
|
return frame
|
||||||
|
if len(frame) > 8 and frame[8:10] == b"\xff\xd8":
|
||||||
|
return frame[8:]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract_sequence(self, frame: bytes) -> int | None:
|
||||||
|
if len(frame) >= 8 and not frame.startswith(b"\xff\xd8"):
|
||||||
|
return int.from_bytes(frame[:8], "big")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
# 后台持续接收循环:
|
||||||
|
# connect -> recv_into(buffer) -> 按 body_len 截出有效内容 -> 把最新 JPEG 帧缓存在内存里
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
session, buffer_bytes = self._connect_session()
|
||||||
|
self._session = session
|
||||||
|
self._last_error = ""
|
||||||
|
buffer = bytearray(buffer_bytes)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# 从 OmniSocket / C 侧收一帧原始二进制数据到缓冲区
|
||||||
|
meta = session.recv_into(buffer, timeout_ms=1000)
|
||||||
|
if meta is None:
|
||||||
|
continue
|
||||||
|
if meta.get("msg_type") != self._binary_msg_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 真正收到的有效部分切出来,形成当前这一帧
|
||||||
|
frame = bytes(buffer[: meta["body_len"]])
|
||||||
|
jpeg_frame = self._extract_jpeg_frame(frame)
|
||||||
|
if jpeg_frame is None:
|
||||||
|
self._last_error = "received non-JPEG binary frame"
|
||||||
|
continue
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
# 这里只保留最新的一张 JPEG 帧,供 Web 接口直接返回给前端。
|
||||||
|
self._latest_frame = jpeg_frame
|
||||||
|
self._latest_received_at = time.time()
|
||||||
|
self._latest_sequence = self._extract_sequence(frame)
|
||||||
|
self._frames_received += 1
|
||||||
|
except Exception as error: # pragma: no cover - 运行时集成路径
|
||||||
|
self._last_error = str(error)
|
||||||
|
time.sleep(2)
|
||||||
|
finally:
|
||||||
|
if self._session is not None:
|
||||||
|
try:
|
||||||
|
self._session.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._session = None
|
||||||
|
|
||||||
|
def get_latest_frame(self) -> bytes | None:
|
||||||
|
# 把内存里最新的一张真实 JPEG 帧暴露给 Django 视图层。
|
||||||
|
# 如果这张帧已经过旧,就返回 None,让上层回退到本地模拟帧。
|
||||||
|
self.ensure_started()
|
||||||
|
with self._lock:
|
||||||
|
if self._latest_frame is None:
|
||||||
|
return None
|
||||||
|
if time.time() - self._latest_received_at > OMNISOCKET_FRAME_FRESH_SECONDS:
|
||||||
|
return None
|
||||||
|
return self._latest_frame
|
||||||
|
|
||||||
|
def get_status(self) -> dict[str, Any]:
|
||||||
|
self.ensure_started()
|
||||||
|
config = self._load_config()
|
||||||
|
transport_cfg = config.get("transport", {})
|
||||||
|
video_cfg = config.get("video_receiver", {})
|
||||||
|
with self._lock:
|
||||||
|
has_recent_frame = self._latest_frame is not None and (
|
||||||
|
time.time() - self._latest_received_at <= OMNISOCKET_FRAME_FRESH_SECONDS
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"backend_ready": self._session_cls is not None,
|
||||||
|
"mode": VIDEO_SOURCE_MODE,
|
||||||
|
"connected": self._session is not None,
|
||||||
|
"has_recent_frame": has_recent_frame,
|
||||||
|
"frames_received": self._frames_received,
|
||||||
|
"latest_sequence": self._latest_sequence,
|
||||||
|
"last_error": self._last_error,
|
||||||
|
"config_path": str(OMNISOCKET_CONFIG_PATH),
|
||||||
|
"server_addr": str(transport_cfg.get("server_addr", "")),
|
||||||
|
"relay_via": str(transport_cfg.get("relay_via", "")),
|
||||||
|
"peer_id": str(video_cfg.get("peer_id", "")),
|
||||||
|
"buffer_bytes": int(video_cfg.get("buffer_bytes", 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class VideoFrameService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._frame_paths = sorted(JPEG_FRAME_DIR.glob("*.jpg"))
|
||||||
|
self._index = 0
|
||||||
|
self._receiver = OmniSocketVideoReceiver()
|
||||||
|
|
||||||
|
def get_status(self) -> dict[str, Any]:
|
||||||
|
receiver_status = self._receiver.get_status()
|
||||||
|
receiver_frame = self._receiver.get_latest_frame()
|
||||||
|
|
||||||
|
# 如果已经收到了真实视频帧,就把当前状态标记为实时模式,给前端显示。
|
||||||
|
if receiver_frame is not None:
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"source_mode": "omnisocket-jpeg-live",
|
||||||
|
"frame_count": receiver_status["frames_received"],
|
||||||
|
"fps": 30,
|
||||||
|
"frame_dir": str(JPEG_FRAME_DIR),
|
||||||
|
"source_detail": f"peer stream active, frames={receiver_status['frames_received']}",
|
||||||
|
"receiver": receiver_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 强制实时模式时,如果还没收到真实帧,就明确告诉前端“正在等待”,
|
||||||
|
# 不再悄悄回退到本地样例图,避免调试时误以为链路已接通。
|
||||||
|
if VIDEO_SOURCE_MODE == "omnisocket":
|
||||||
|
wait_detail = receiver_status["last_error"] or (
|
||||||
|
"等待 OmniSocket 实时 JPEG 帧,"
|
||||||
|
f"接收端 peer_id={receiver_status['peer_id']},"
|
||||||
|
f"服务器={receiver_status['server_addr']}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"source_mode": "omnisocket-waiting",
|
||||||
|
"frame_count": receiver_status["frames_received"],
|
||||||
|
"fps": 30,
|
||||||
|
"frame_dir": str(JPEG_FRAME_DIR),
|
||||||
|
"source_detail": wait_detail,
|
||||||
|
"receiver": receiver_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"available": bool(self._frame_paths),
|
||||||
|
"source_mode": "sample-jpeg-frame-loop" if self._frame_paths else "unavailable",
|
||||||
|
"frame_count": len(self._frame_paths),
|
||||||
|
"fps": 30,
|
||||||
|
"frame_dir": str(JPEG_FRAME_DIR),
|
||||||
|
"source_detail": receiver_status["last_error"] or "fallback to local sample frames",
|
||||||
|
"receiver": receiver_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_next_frame(self) -> bytes:
|
||||||
|
# 优先返回从 Python/C 视频接收器拿到的最新真实 JPEG 帧。
|
||||||
|
receiver_frame = self._receiver.get_latest_frame()
|
||||||
|
if receiver_frame is not None:
|
||||||
|
return receiver_frame
|
||||||
|
|
||||||
|
# 强制实时模式下,如果还没收到真实帧,直接报错,
|
||||||
|
# 这样前端就能明确知道实时链路还没接通。
|
||||||
|
if VIDEO_SOURCE_MODE == "omnisocket":
|
||||||
|
raise RuntimeError(
|
||||||
|
"OmniSocket 实时 JPEG 帧暂未就绪,请检查 server_addr、peer_id、"
|
||||||
|
"target_peer,以及视频发送端是否已经启动。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 如果当前没有真实帧,就回退到本地演示 JPEG 文件,保证页面仍然可用。
|
||||||
|
if not self._frame_paths:
|
||||||
|
raise FileNotFoundError(f"No JPEG frames found in {JPEG_FRAME_DIR}")
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
frame_path = self._frame_paths[self._index]
|
||||||
|
self._index = (self._index + 1) % len(self._frame_paths)
|
||||||
|
|
||||||
|
return frame_path.read_bytes()
|
||||||
|
|
||||||
|
def iter_mjpeg(self, fps: float = 6.0) -> Iterator[bytes]:
|
||||||
|
frame_interval = 1.0 / max(1.0, min(fps, 30.0))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
frame = self.get_next_frame()
|
||||||
|
header = (
|
||||||
|
b"--frame\r\n"
|
||||||
|
b"Content-Type: image/jpeg\r\n"
|
||||||
|
+ f"Content-Length: {len(frame)}\r\n\r\n".encode("ascii")
|
||||||
|
)
|
||||||
|
yield header + frame + b"\r\n"
|
||||||
|
time.sleep(frame_interval)
|
||||||
|
|
||||||
|
|
||||||
|
class GpsDataService:
|
||||||
|
def get_latest(self) -> dict[str, Any]:
|
||||||
|
# 优先使用由 GeoStream C 解析器生成的最新数据包。
|
||||||
|
payload = self._read_geostream_payload()
|
||||||
|
if payload is not None:
|
||||||
|
payload["source_mode"] = "geostream-json"
|
||||||
|
payload["updated_at"] = utc_iso_now()
|
||||||
|
return payload
|
||||||
|
|
||||||
|
# 当没有最新的 GPS 文件可用时,退回到使用一个移动的演示点位。
|
||||||
|
return self._build_simulated_payload()
|
||||||
|
|
||||||
|
def _read_geostream_payload(self) -> dict[str, Any] | None:
|
||||||
|
# Django 后端目前还不直接解析串口数据流。
|
||||||
|
# 它只读取由 GeoStream/parse_gps.c 生成的 JSON 文件。
|
||||||
|
if not GEOSTREAM_JSON_PATH.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 忽略过期的文件,以便 UI 可以退回到使用模拟数据,
|
||||||
|
# 而不是把旧位置当作实时位置来显示。
|
||||||
|
age_seconds = time.time() - GEOSTREAM_JSON_PATH.stat().st_mtime
|
||||||
|
if age_seconds > GEOSTREAM_STALE_SECONDS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with GEOSTREAM_JSON_PATH.open("r", encoding="utf-8") as file:
|
||||||
|
return json.load(file)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _build_simulated_payload(self) -> dict[str, Any]:
|
||||||
|
# 为本地 UI 开发构建一个平滑的伪造轨迹。
|
||||||
|
tick = time.time() / 12.0
|
||||||
|
latitude = 31.2304 + math.sin(tick) * 0.0014
|
||||||
|
longitude = 121.4737 + math.cos(tick) * 0.0018
|
||||||
|
|
||||||
|
return {
|
||||||
|
"has_fix": True,
|
||||||
|
"utc_time": datetime.now(UTC).strftime("%H:%M:%S"),
|
||||||
|
"latitude": round(latitude, 6),
|
||||||
|
"longitude": round(longitude, 6),
|
||||||
|
"satellites": 14 + int((math.sin(tick * 0.7) + 1.0) * 2),
|
||||||
|
"altitude_m": round(6.5 + math.cos(tick * 0.5) * 1.2, 2),
|
||||||
|
"coordinate_system": "WGS84",
|
||||||
|
"source_sentence": "SIMULATED",
|
||||||
|
"raw_coordinate_format": "decimal degrees",
|
||||||
|
"source_mode": "simulated",
|
||||||
|
"updated_at": utc_iso_now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkTelemetryService:
|
||||||
|
def get_latest(self) -> dict[str, Any]:
|
||||||
|
tick = time.time()
|
||||||
|
|
||||||
|
latency_ms = 28 + math.sin(tick / 4.0) * 6
|
||||||
|
jitter_ms = 3 + math.cos(tick / 5.0) * 1.4
|
||||||
|
tx_kbps = 780 + math.sin(tick / 6.0) * 160
|
||||||
|
rx_kbps = 720 + math.cos(tick / 7.0) * 140
|
||||||
|
packet_loss_pct = max(0.0, 0.35 + math.sin(tick / 9.0) * 0.25)
|
||||||
|
signal_dbm = -53 - abs(math.sin(tick / 8.0) * 7)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"peer_status": "online",
|
||||||
|
"latency_ms": round(latency_ms, 1),
|
||||||
|
"jitter_ms": round(jitter_ms, 1),
|
||||||
|
"packet_loss_pct": round(packet_loss_pct, 2),
|
||||||
|
"tx_kbps": int(tx_kbps),
|
||||||
|
"rx_kbps": int(rx_kbps),
|
||||||
|
"signal_dbm": round(signal_dbm, 1),
|
||||||
|
"transport": "OmniSocket / simulated",
|
||||||
|
"source_mode": "simulated",
|
||||||
|
"updated_at": utc_iso_now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
video_service = VideoFrameService()
|
||||||
|
gps_service = GpsDataService()
|
||||||
|
network_service = NetworkTelemetryService()
|
||||||
13
backend/monitoring/urls.py
Normal file
13
backend/monitoring/urls.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("dashboard/", views.dashboard_snapshot, name="dashboard-snapshot"),
|
||||||
|
path("gps/latest/", views.gps_latest, name="gps-latest"),
|
||||||
|
path("network/latest/", views.network_latest, name="network-latest"),
|
||||||
|
path("video/status/", views.video_status, name="video-status"),
|
||||||
|
path("video/frame/", views.video_frame, name="video-frame"),
|
||||||
|
path("video/stream/", views.video_stream, name="video-stream"),
|
||||||
|
]
|
||||||
78
backend/monitoring/views.py
Normal file
78
backend/monitoring/views.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.http import HttpResponse, StreamingHttpResponse
|
||||||
|
from rest_framework.decorators import api_view
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from .services import gps_service, network_service, video_service
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
def dashboard_snapshot(request):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"gps": gps_service.get_latest(),
|
||||||
|
"network": network_service.get_latest(),
|
||||||
|
"video": video_service.get_status(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
def gps_latest(request):
|
||||||
|
return Response(gps_service.get_latest())
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
def network_latest(request):
|
||||||
|
return Response(network_service.get_latest())
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
def video_status(request):
|
||||||
|
return Response(video_service.get_status())
|
||||||
|
|
||||||
|
|
||||||
|
def video_frame(request):
|
||||||
|
status = video_service.get_status()
|
||||||
|
if not status["available"]:
|
||||||
|
return HttpResponse(
|
||||||
|
status.get("source_detail") or f"JPEG frame directory not found: {status['frame_dir']}",
|
||||||
|
status=503,
|
||||||
|
content_type="text/plain; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame = video_service.get_next_frame()
|
||||||
|
except (FileNotFoundError, RuntimeError) as error:
|
||||||
|
return HttpResponse(
|
||||||
|
str(error),
|
||||||
|
status=503,
|
||||||
|
content_type="text/plain; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = HttpResponse(frame, content_type="image/jpeg")
|
||||||
|
response["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def video_stream(request):
|
||||||
|
status = video_service.get_status()
|
||||||
|
if not status["available"]:
|
||||||
|
return HttpResponse(
|
||||||
|
status.get("source_detail") or f"JPEG frame directory not found: {status['frame_dir']}",
|
||||||
|
status=503,
|
||||||
|
content_type="text/plain; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
fps = float(request.GET.get("fps", status["fps"]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
fps = float(status["fps"])
|
||||||
|
|
||||||
|
response = StreamingHttpResponse(
|
||||||
|
video_service.iter_mjpeg(fps=fps),
|
||||||
|
content_type="multipart/x-mixed-replace; boundary=frame",
|
||||||
|
)
|
||||||
|
response["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
|
return response
|
||||||
8
frontend/.editorconfig
Normal file
8
frontend/.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
end_of_line = lf
|
||||||
|
max_line_length = 100
|
||||||
1
frontend/.gitattributes
vendored
Normal file
1
frontend/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
39
frontend/.gitignore
vendored
Normal file
39
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
*.timestamp-*-*.mjs
|
||||||
10
frontend/.oxlintrc.json
Normal file
10
frontend/.oxlintrc.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||||
|
"env": {
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/.prettierrc.json
Normal file
6
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
48
frontend/README.md
Normal file
48
frontend/README.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# frontend
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|
||||||
|
## Recommended Browser Setup
|
||||||
|
|
||||||
|
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||||
|
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||||
|
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||||
|
- Firefox:
|
||||||
|
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||||
|
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lint with [ESLint](https://eslint.org/)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
1
frontend/env.d.ts
vendored
Normal file
1
frontend/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
26
frontend/eslint.config.ts
Normal file
26
frontend/eslint.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||||
|
import skipFormatting from 'eslint-config-prettier/flat'
|
||||||
|
|
||||||
|
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||||
|
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||||
|
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||||
|
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||||
|
|
||||||
|
export default defineConfigWithVueTs(
|
||||||
|
{
|
||||||
|
name: 'app/files-to-lint',
|
||||||
|
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||||
|
},
|
||||||
|
|
||||||
|
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||||
|
|
||||||
|
...pluginVue.configs['flat/essential'],
|
||||||
|
vueTsConfigs.recommended,
|
||||||
|
|
||||||
|
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||||
|
|
||||||
|
skipFormatting,
|
||||||
|
)
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Vite App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5032
frontend/package-lock.json
generated
Normal file
5032
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
frontend/package.json
Normal file
44
frontend/package.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build",
|
||||||
|
"lint": "run-s lint:*",
|
||||||
|
"lint:oxlint": "oxlint . --fix",
|
||||||
|
"lint:eslint": "eslint . --fix --cache",
|
||||||
|
"format": "prettier --write --experimental-cli src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.31",
|
||||||
|
"vue-router": "^5.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
"@types/node": "^24.12.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"eslint": "^10.1.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-oxlint": "~1.57.0",
|
||||||
|
"eslint-plugin-vue": "~10.8.0",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"npm-run-all2": "^8.0.4",
|
||||||
|
"oxlint": "~1.57.0",
|
||||||
|
"prettier": "3.8.1",
|
||||||
|
"typescript": "~6.0.0",
|
||||||
|
"vite": "^8.0.3",
|
||||||
|
"vite-plugin-vue-devtools": "^8.1.1",
|
||||||
|
"vue-tsc": "^3.2.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
187
frontend/src/App.vue
Normal file
187
frontend/src/App.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink, RouterView } from 'vue-router'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/', label: '总览' },
|
||||||
|
{ to: '/video', label: '视频流' },
|
||||||
|
{ to: '/map', label: '地图定位' },
|
||||||
|
{ to: '/network', label: '网络状态' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<p class="brand-mark">RCC</p>
|
||||||
|
<div>
|
||||||
|
<strong>Robot Command Center</strong>
|
||||||
|
<span>机器人竞赛指挥台</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
class="nav-link"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="page-body">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(91, 122, 255, 0.18), transparent 24%),
|
||||||
|
radial-gradient(circle at top right, rgba(77, 212, 172, 0.13), transparent 22%),
|
||||||
|
linear-gradient(180deg, #08101d 0%, #050914 58%, #02040a 100%);
|
||||||
|
color: #f5f7fb;
|
||||||
|
font-family:
|
||||||
|
'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(*) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(a) {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
width: min(1440px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 22px 0 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(8, 14, 26, 0.82);
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||||
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.panel) {
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(12, 20, 36, 0.84);
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||||
|
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.24);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, #5b7aff, #4dd4ac);
|
||||||
|
color: #06101d;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong,
|
||||||
|
.brand span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand span {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #a9b6cf;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||||
|
background: rgba(13, 22, 40, 0.78);
|
||||||
|
color: #dfe6f8;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
background 0.2s ease,
|
||||||
|
border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: rgba(25, 38, 66, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.router-link-exact-active {
|
||||||
|
background: linear-gradient(135deg, #5b7aff, #7bc4ff);
|
||||||
|
color: #08101d;
|
||||||
|
border-color: transparent;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-body {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.topbar {
|
||||||
|
position: static;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.app-shell {
|
||||||
|
width: min(100%, calc(100% - 20px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
433
frontend/src/components/GpsMapPanel.vue
Normal file
433
frontend/src/components/GpsMapPanel.vue
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { GpsTelemetry } from '@/types'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
AMap?: any
|
||||||
|
_AMapSecurityConfig?: {
|
||||||
|
securityJsCode: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
gps: GpsTelemetry | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'robot_command_center_amap'
|
||||||
|
|
||||||
|
const keyInput = ref('')
|
||||||
|
const securityCodeInput = ref('')
|
||||||
|
const statusText = ref('等待加载高德地图。')
|
||||||
|
const amapCoordinateText = ref('暂无')
|
||||||
|
const mapElement = ref<HTMLDivElement | null>(null)
|
||||||
|
const mapRunning = ref(false)
|
||||||
|
|
||||||
|
let loadPromise: Promise<any> | null = null
|
||||||
|
let mapInstance: any = null
|
||||||
|
let marker: any = null
|
||||||
|
let infoWindow: any = null
|
||||||
|
|
||||||
|
function readSavedCredentials() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCredentials() {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
key: keyInput.value.trim(),
|
||||||
|
securityJsCode: securityCodeInput.value.trim(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number) {
|
||||||
|
return value.toFixed(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAmapScript(key: string, securityJsCode: string) {
|
||||||
|
if (window.AMap) {
|
||||||
|
return window.AMap
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadPromise) {
|
||||||
|
return loadPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
window._AMapSecurityConfig = { securityJsCode }
|
||||||
|
|
||||||
|
loadPromise = new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = `https://webapi.amap.com/maps?v=2.0&key=${encodeURIComponent(key)}`
|
||||||
|
script.async = true
|
||||||
|
script.onload = () => resolve(window.AMap)
|
||||||
|
script.onerror = () => reject(new Error('高德地图脚本加载失败,请检查 Key / jscode 和网络。'))
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
|
||||||
|
return loadPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMap() {
|
||||||
|
if (mapInstance || !mapElement.value || !window.AMap) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mapInstance = new window.AMap.Map(mapElement.value, {
|
||||||
|
viewMode: '3D',
|
||||||
|
zoom: 15,
|
||||||
|
center: [121.4737, 31.2304],
|
||||||
|
mapStyle: 'amap://styles/normal',
|
||||||
|
})
|
||||||
|
|
||||||
|
marker = new window.AMap.Marker({
|
||||||
|
anchor: 'bottom-center',
|
||||||
|
title: 'Robot GPS',
|
||||||
|
})
|
||||||
|
|
||||||
|
infoWindow = new window.AMap.InfoWindow({
|
||||||
|
offset: new window.AMap.Pixel(0, -28),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopMap() {
|
||||||
|
mapRunning.value = false
|
||||||
|
amapCoordinateText.value = '已停止'
|
||||||
|
|
||||||
|
if (infoWindow) {
|
||||||
|
infoWindow.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (marker) {
|
||||||
|
marker.setMap(null)
|
||||||
|
marker = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapInstance) {
|
||||||
|
if (typeof mapInstance.destroy === 'function') {
|
||||||
|
mapInstance.destroy()
|
||||||
|
}
|
||||||
|
mapInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
infoWindow = null
|
||||||
|
|
||||||
|
if (mapElement.value) {
|
||||||
|
mapElement.value.innerHTML = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
statusText.value = '已停止高德地图加载与坐标转换。需要时再点击“加载地图”即可。'
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMap(gps: GpsTelemetry | null) {
|
||||||
|
if (!mapRunning.value || !mapInstance || !window.AMap) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gps?.has_fix || gps.latitude == null || gps.longitude == null) {
|
||||||
|
amapCoordinateText.value = '暂无'
|
||||||
|
marker?.setMap(null)
|
||||||
|
infoWindow?.close()
|
||||||
|
statusText.value = gps ? 'GPS 在线,但当前还没有有效定位。' : '等待 GPS 数据。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawLatitude = gps.latitude
|
||||||
|
const rawLongitude = gps.longitude
|
||||||
|
|
||||||
|
window.AMap.convertFrom([rawLongitude, rawLatitude], 'gps', (status: string, result: any) => {
|
||||||
|
if (status !== 'complete' || !result?.locations?.length) {
|
||||||
|
statusText.value = 'GPS 坐标转换失败。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = result.locations[0]
|
||||||
|
const lng = typeof point.getLng === 'function' ? point.getLng() : point.lng
|
||||||
|
const lat = typeof point.getLat === 'function' ? point.getLat() : point.lat
|
||||||
|
|
||||||
|
amapCoordinateText.value = `${formatNumber(lat)}, ${formatNumber(lng)}`
|
||||||
|
marker.setPosition([lng, lat])
|
||||||
|
marker.setMap(mapInstance)
|
||||||
|
|
||||||
|
infoWindow.setContent(
|
||||||
|
[
|
||||||
|
'<div style="min-width: 240px; padding: 6px 2px; line-height: 1.75; font-size: 13px; color: #152033;">',
|
||||||
|
'<div style="margin-bottom: 8px; font-size: 14px; font-weight: 700; color: #0f172a;">Robot GPS 定位</div>',
|
||||||
|
`<div><span style="color: #667085;">原始 WGS84:</span> <strong style="color: #0f172a;">${formatNumber(rawLatitude)}, ${formatNumber(rawLongitude)}</strong></div>`,
|
||||||
|
`<div><span style="color: #667085;">高德 GCJ-02:</span> <strong style="color: #0f172a;">${formatNumber(lat)}, ${formatNumber(lng)}</strong></div>`,
|
||||||
|
`<div><span style="color: #667085;">UTC 时间:</span> <strong style="color: #0f172a;">${gps.utc_time || '--:--:--'}</strong></div>`,
|
||||||
|
`<div><span style="color: #667085;">卫星数:</span> <strong style="color: #0f172a;">${gps.satellites ?? '未知'}</strong></div>`,
|
||||||
|
`<div><span style="color: #667085;">海拔:</span> <strong style="color: #0f172a;">${gps.altitude_m ?? '未知'} m</strong></div>`,
|
||||||
|
'</div>',
|
||||||
|
].join(''),
|
||||||
|
)
|
||||||
|
infoWindow.open(mapInstance, [lng, lat])
|
||||||
|
mapInstance.setZoomAndCenter(17, [lng, lat])
|
||||||
|
statusText.value = `地图已刷新,数据源:${gps.source_mode}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMap() {
|
||||||
|
const key = keyInput.value.trim()
|
||||||
|
const securityJsCode = securityCodeInput.value.trim()
|
||||||
|
|
||||||
|
if (!key || !securityJsCode) {
|
||||||
|
statusText.value = '请先填写高德 Key 和安全密钥 jscode。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
statusText.value = '正在加载高德地图...'
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadAmapScript(key, securityJsCode)
|
||||||
|
ensureMap()
|
||||||
|
saveCredentials()
|
||||||
|
mapRunning.value = true
|
||||||
|
statusText.value = '地图已加载。'
|
||||||
|
updateMap(props.gps)
|
||||||
|
} catch (error) {
|
||||||
|
statusText.value = error instanceof Error ? error.message : '地图加载失败。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawCoordinateText = computed(() => {
|
||||||
|
if (!props.gps?.has_fix || props.gps.latitude == null || props.gps.longitude == null) {
|
||||||
|
return '暂无有效定位'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatNumber(props.gps.latitude)}, ${formatNumber(props.gps.longitude)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const metaText = computed(() => {
|
||||||
|
if (!props.gps) {
|
||||||
|
return '暂无'
|
||||||
|
}
|
||||||
|
const satellites = props.gps.satellites ?? '未知'
|
||||||
|
const altitude = props.gps.altitude_m == null ? '未知' : `${props.gps.altitude_m} m`
|
||||||
|
return `${satellites} 颗 / ${altitude}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedAtText = computed(() => {
|
||||||
|
if (!props.gps?.updated_at) {
|
||||||
|
return '暂无'
|
||||||
|
}
|
||||||
|
return new Date(props.gps.updated_at).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const saved = readSavedCredentials()
|
||||||
|
if (saved) {
|
||||||
|
keyInput.value = saved.key ?? ''
|
||||||
|
securityCodeInput.value = saved.securityJsCode ?? ''
|
||||||
|
if (keyInput.value && securityCodeInput.value) {
|
||||||
|
statusText.value = '已恢复高德配置。高德地图不会自动加载,请按需点击“加载地图”。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.gps,
|
||||||
|
(gps) => {
|
||||||
|
updateMap(gps)
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel map-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">GPS</p>
|
||||||
|
<h2>地图定位</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge">{{ gps?.source_mode ?? 'loading' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="intro">
|
||||||
|
这里复用了你原来 `GeoStream/gps_map.html` 的高德地图思路。后端优先读取
|
||||||
|
`GeoStream/gps_latest.json`,所以你运行 `parse_gps.c` 生成数据后,这里会直接接上。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="credentials">
|
||||||
|
<input v-model="keyInput" type="text" placeholder="高德 Web 端 Key" />
|
||||||
|
<input v-model="securityCodeInput" type="text" placeholder="安全密钥 jscode" />
|
||||||
|
<button type="button" @click="startMap">加载地图</button>
|
||||||
|
<button type="button" class="secondary" @click="stopMap">停止加载</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status">{{ statusText }}</div>
|
||||||
|
|
||||||
|
<div class="details">
|
||||||
|
<div class="detail-card">
|
||||||
|
<span>原始 WGS84</span>
|
||||||
|
<strong>{{ rawCoordinateText }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card">
|
||||||
|
<span>高德 GCJ-02</span>
|
||||||
|
<strong>{{ amapCoordinateText }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card">
|
||||||
|
<span>UTC 时间</span>
|
||||||
|
<strong>{{ gps?.utc_time ?? '--:--:--' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card">
|
||||||
|
<span>卫星 / 海拔</span>
|
||||||
|
<strong>{{ metaText }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card">
|
||||||
|
<span>坐标系</span>
|
||||||
|
<strong>{{ gps?.coordinate_system ?? 'WGS84' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card">
|
||||||
|
<span>最近刷新</span>
|
||||||
|
<strong>{{ updatedAtText }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="mapElement" class="map-canvas" :class="{ stopped: !mapRunning }">
|
||||||
|
<div v-if="!mapRunning" class="map-placeholder">
|
||||||
|
高德地图当前未加载。点击上方“加载地图”后才会开始请求地图与坐标转换服务。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.map-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: #f5a524;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(245, 165, 36, 0.15);
|
||||||
|
color: #ffd48a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro,
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #d5dbee;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credentials {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 140px 140px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credentials input,
|
||||||
|
.credentials button {
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.28);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(7, 14, 26, 0.78);
|
||||||
|
color: #f5f7fb;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credentials button {
|
||||||
|
cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, #ffb347, #ff8f5a);
|
||||||
|
color: #10151f;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credentials button.secondary {
|
||||||
|
background: rgba(7, 14, 26, 0.78);
|
||||||
|
color: #f5f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(7, 14, 26, 0.78);
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #8d99b3;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card strong {
|
||||||
|
font-size: 17px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-canvas {
|
||||||
|
position: relative;
|
||||||
|
min-height: 420px;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.28);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(255, 179, 71, 0.16), transparent 28%),
|
||||||
|
linear-gradient(180deg, #0b1220 0%, #070b14 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-canvas.stopped {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-placeholder {
|
||||||
|
width: min(560px, calc(100% - 40px));
|
||||||
|
padding: 20px 22px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||||
|
background: rgba(7, 14, 26, 0.84);
|
||||||
|
color: #d5dbee;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.credentials,
|
||||||
|
.details {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
151
frontend/src/components/NetworkPanel.vue
Normal file
151
frontend/src/components/NetworkPanel.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { NetworkTelemetry } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
network: NetworkTelemetry | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const updatedAt = computed(() => {
|
||||||
|
if (!props.network?.updated_at) {
|
||||||
|
return '暂无'
|
||||||
|
}
|
||||||
|
return new Date(props.network.updated_at).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel network-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Network</p>
|
||||||
|
<h2>链路状态</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge">{{ network?.peer_status ?? 'loading' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>延迟</span>
|
||||||
|
<strong>{{ network?.latency_ms ?? '--' }} ms</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>抖动</span>
|
||||||
|
<strong>{{ network?.jitter_ms ?? '--' }} ms</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>丢包率</span>
|
||||||
|
<strong>{{ network?.packet_loss_pct ?? '--' }} %</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>信号强度</span>
|
||||||
|
<strong>{{ network?.signal_dbm ?? '--' }} dBm</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>发送速率</span>
|
||||||
|
<strong>{{ network?.tx_kbps ?? '--' }} kbps</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>接收速率</span>
|
||||||
|
<strong>{{ network?.rx_kbps ?? '--' }} kbps</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
<p><strong>来源:</strong>{{ network?.transport ?? '暂无' }} / {{ network?.source_mode ?? '暂无' }}</p>
|
||||||
|
<p><strong>刷新:</strong>{{ updatedAt }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.network-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: #4dd4ac;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(40, 199, 111, 0.16);
|
||||||
|
color: #63e6a9;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(7, 14, 26, 0.78);
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #8d99b3;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card strong {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(7, 14, 26, 0.78);
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||||
|
color: #d5dbee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary p + p {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
228
frontend/src/components/VideoPanel.vue
Normal file
228
frontend/src/components/VideoPanel.vue
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { buildVideoFrameUrl } from '@/lib/api'
|
||||||
|
import type { VideoStatus } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
video: VideoStatus | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const frameUrl = ref(buildVideoFrameUrl(0))
|
||||||
|
const currentFps = computed(() => props.video?.fps ?? 30)
|
||||||
|
const canRequestFrames = computed(() => props.video == null || props.video.available)
|
||||||
|
const modeLabel = computed(() => {
|
||||||
|
if (!props.video) {
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
if (props.video.source_mode === 'omnisocket-jpeg-live') {
|
||||||
|
return `${props.video.fps} FPS 实时接收`
|
||||||
|
}
|
||||||
|
if (props.video.source_mode === 'omnisocket-waiting') {
|
||||||
|
return '等待 OmniSocket 实时帧'
|
||||||
|
}
|
||||||
|
if (props.video.source_mode === 'sample-jpeg-frame-loop') {
|
||||||
|
return `${props.video.fps} FPS 本地演示`
|
||||||
|
}
|
||||||
|
return `${props.video.fps} FPS`
|
||||||
|
})
|
||||||
|
|
||||||
|
let frameTimer: number | null = null
|
||||||
|
let frameKey = 0
|
||||||
|
|
||||||
|
function refreshFrame() {
|
||||||
|
if (!canRequestFrames.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
frameKey += 1
|
||||||
|
frameUrl.value = buildVideoFrameUrl(frameKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startFrameLoop() {
|
||||||
|
if (frameTimer != null) {
|
||||||
|
window.clearInterval(frameTimer)
|
||||||
|
frameTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canRequestFrames.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshFrame()
|
||||||
|
const intervalMs = Math.max(33, Math.round(1000 / currentFps.value))
|
||||||
|
frameTimer = window.setInterval(() => {
|
||||||
|
refreshFrame()
|
||||||
|
}, intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startFrameLoop()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (frameTimer != null) {
|
||||||
|
window.clearInterval(frameTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([currentFps, canRequestFrames], () => {
|
||||||
|
startFrameLoop()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel video-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Video</p>
|
||||||
|
<h2>JPEG 视频流</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge" :class="{ bad: !video?.available }">
|
||||||
|
{{ video?.source_mode ?? 'loading' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="video-shell">
|
||||||
|
<img
|
||||||
|
v-if="canRequestFrames"
|
||||||
|
class="video-frame"
|
||||||
|
:src="frameUrl"
|
||||||
|
alt="Robot jpeg frame stream"
|
||||||
|
/>
|
||||||
|
<div v-else class="video-placeholder">
|
||||||
|
正在等待 OmniSocket 实时 JPEG 帧接入...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>帧源</span>
|
||||||
|
<strong>{{ video?.frame_count ?? '--' }} 张 JPEG</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>当前模式</span>
|
||||||
|
<strong>{{ modeLabel }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
这里始终按固定频率逐张请求 Django 返回的单帧 JPEG,不依赖 MJPEG。只要后端已经收到
|
||||||
|
OmniSocket 里的真实 JPEG 帧,这个组件就会直接显示实时画面。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="hint subtle">
|
||||||
|
当前帧源状态:{{ video?.source_detail ?? '暂无' }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.video-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: #5b7aff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(40, 199, 111, 0.16);
|
||||||
|
color: #63e6a9;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.bad {
|
||||||
|
background: rgba(255, 107, 107, 0.18);
|
||||||
|
color: #ffb4b4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-shell {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.28);
|
||||||
|
background: linear-gradient(180deg, #09111f 0%, #050812 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-frame {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #02050d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-placeholder {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
padding: 24px;
|
||||||
|
color: #a8b4ce;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.7;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(91, 122, 255, 0.14), transparent 42%),
|
||||||
|
#02050d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(7, 14, 26, 0.78);
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #8d99b3;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card strong {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
color: #8d99b3;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint.subtle {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
66
frontend/src/composables/useMonitoringData.ts
Normal file
66
frontend/src/composables/useMonitoringData.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import { fetchDashboardSnapshot } from '@/lib/api'
|
||||||
|
import type { GpsTelemetry, NetworkTelemetry, VideoStatus } from '@/types'
|
||||||
|
|
||||||
|
type UseMonitoringDataOptions = {
|
||||||
|
refreshIntervalMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
|
||||||
|
const gps = ref<GpsTelemetry | null>(null)
|
||||||
|
const network = ref<NetworkTelemetry | null>(null)
|
||||||
|
const video = ref<VideoStatus | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const refreshIntervalMs = Math.max(200, options.refreshIntervalMs ?? 2000)
|
||||||
|
|
||||||
|
let refreshTimer: number | null = null
|
||||||
|
|
||||||
|
async function refreshDashboard() {
|
||||||
|
try {
|
||||||
|
const snapshot = await fetchDashboardSnapshot()
|
||||||
|
gps.value = snapshot.gps
|
||||||
|
network.value = snapshot.network
|
||||||
|
video.value = snapshot.video
|
||||||
|
errorMessage.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '数据加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerStatus = computed(() => {
|
||||||
|
if (errorMessage.value) {
|
||||||
|
return errorMessage.value
|
||||||
|
}
|
||||||
|
if (loading.value) {
|
||||||
|
return '正在连接 Django 后端并加载监控数据...'
|
||||||
|
}
|
||||||
|
return '页面已连接 Django 后端。GPS 与网络状态按当前页面策略轮询更新,视频区域单独按目标 30FPS 请求单帧 JPEG。'
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshDashboard().catch(() => undefined)
|
||||||
|
refreshTimer = window.setInterval(() => {
|
||||||
|
refreshDashboard().catch(() => undefined)
|
||||||
|
}, refreshIntervalMs)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshTimer != null) {
|
||||||
|
window.clearInterval(refreshTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
gps,
|
||||||
|
network,
|
||||||
|
video,
|
||||||
|
loading,
|
||||||
|
errorMessage,
|
||||||
|
headerStatus,
|
||||||
|
refreshDashboard,
|
||||||
|
}
|
||||||
|
}
|
||||||
21
frontend/src/lib/api.ts
Normal file
21
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { DashboardSnapshot } from '@/types'
|
||||||
|
|
||||||
|
const envBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined
|
||||||
|
|
||||||
|
export const API_BASE = (envBaseUrl?.trim() || 'http://127.0.0.1:8001').replace(/\/$/, '')
|
||||||
|
|
||||||
|
async function fetchJson<T>(path: string): Promise<T> {
|
||||||
|
const response = await fetch(`${API_BASE}${path}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`请求失败: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDashboardSnapshot() {
|
||||||
|
return fetchJson<DashboardSnapshot>('/api/dashboard/')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVideoFrameUrl(frameKey: number) {
|
||||||
|
return `${API_BASE}/api/video/frame/?frame=${frameKey}&t=${Date.now()}`
|
||||||
|
}
|
||||||
12
frontend/src/main.ts
Normal file
12
frontend/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
34
frontend/src/router/index.ts
Normal file
34
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
import DashboardView from '@/views/DashboardView.vue'
|
||||||
|
import MapView from '@/views/MapView.vue'
|
||||||
|
import NetworkView from '@/views/NetworkView.vue'
|
||||||
|
import VideoView from '@/views/VideoView.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: DashboardView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/video',
|
||||||
|
name: 'video',
|
||||||
|
component: VideoView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/map',
|
||||||
|
name: 'map',
|
||||||
|
component: MapView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/network',
|
||||||
|
name: 'network',
|
||||||
|
component: NetworkView,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
12
frontend/src/stores/counter.ts
Normal file
12
frontend/src/stores/counter.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const useCounterStore = defineStore('counter', () => {
|
||||||
|
const count = ref(0)
|
||||||
|
const doubleCount = computed(() => count.value * 2)
|
||||||
|
function increment() {
|
||||||
|
count.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count, doubleCount, increment }
|
||||||
|
})
|
||||||
55
frontend/src/types.ts
Normal file
55
frontend/src/types.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
export interface GpsTelemetry {
|
||||||
|
has_fix: boolean
|
||||||
|
utc_time: string
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
satellites: number | null
|
||||||
|
altitude_m: number | null
|
||||||
|
coordinate_system: string
|
||||||
|
source_sentence: string
|
||||||
|
raw_coordinate_format: string
|
||||||
|
source_mode: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkTelemetry {
|
||||||
|
peer_status: string
|
||||||
|
latency_ms: number
|
||||||
|
jitter_ms: number
|
||||||
|
packet_loss_pct: number
|
||||||
|
tx_kbps: number
|
||||||
|
rx_kbps: number
|
||||||
|
signal_dbm: number
|
||||||
|
transport: string
|
||||||
|
source_mode: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoStatus {
|
||||||
|
available: boolean
|
||||||
|
source_mode: string
|
||||||
|
frame_count: number
|
||||||
|
fps: number
|
||||||
|
frame_dir: string
|
||||||
|
source_detail?: string
|
||||||
|
receiver?: {
|
||||||
|
backend_ready: boolean
|
||||||
|
mode: string
|
||||||
|
connected: boolean
|
||||||
|
has_recent_frame: boolean
|
||||||
|
frames_received: number
|
||||||
|
latest_sequence: number | null
|
||||||
|
last_error: string
|
||||||
|
config_path: string
|
||||||
|
server_addr?: string
|
||||||
|
relay_via?: string
|
||||||
|
peer_id?: string
|
||||||
|
buffer_bytes?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSnapshot {
|
||||||
|
gps: GpsTelemetry
|
||||||
|
network: NetworkTelemetry
|
||||||
|
video: VideoStatus
|
||||||
|
}
|
||||||
93
frontend/src/views/DashboardView.vue
Normal file
93
frontend/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import GpsMapPanel from '@/components/GpsMapPanel.vue'
|
||||||
|
import NetworkPanel from '@/components/NetworkPanel.vue'
|
||||||
|
import VideoPanel from '@/components/VideoPanel.vue'
|
||||||
|
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||||
|
|
||||||
|
const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-shell">
|
||||||
|
<header class="hero">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Overview</p>
|
||||||
|
<h1>机器人竞赛指挥台</h1>
|
||||||
|
</div>
|
||||||
|
<p class="hero-text">
|
||||||
|
当前版本已经接通三块核心能力:JPEG 视频流、GPS 地图定位、网络状态展示。后面接真实
|
||||||
|
C 数据源时,前端页面不需要大改。
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="banner" :class="{ error: !!errorMessage }">
|
||||||
|
{{ headerStatus }}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<VideoPanel :video="video" />
|
||||||
|
<GpsMapPanel :gps="gps" />
|
||||||
|
<NetworkPanel :network="network" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(320px, 520px);
|
||||||
|
gap: 20px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #8da2fb;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(34px, 5vw, 64px);
|
||||||
|
line-height: 1.04;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-text {
|
||||||
|
margin: 0;
|
||||||
|
color: #c8d2e8;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(11, 19, 35, 0.84);
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||||
|
color: #d5dbee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner.error {
|
||||||
|
color: #ffd0d0;
|
||||||
|
border-color: rgba(255, 107, 107, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
74
frontend/src/views/MapView.vue
Normal file
74
frontend/src/views/MapView.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import GpsMapPanel from '@/components/GpsMapPanel.vue'
|
||||||
|
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||||
|
|
||||||
|
const { gps, errorMessage, headerStatus } = useMonitoringData({
|
||||||
|
refreshIntervalMs: 500,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-shell">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Map</p>
|
||||||
|
<h1>地图定位页面</h1>
|
||||||
|
</div>
|
||||||
|
<p class="description">
|
||||||
|
这里整合了 `GeoStream` 的 GPS 展示逻辑。只要原来的 GPS 模块继续写
|
||||||
|
`gps_latest.json`,这个页面就能直接显示实时定位。
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="banner" :class="{ error: !!errorMessage }">
|
||||||
|
{{ headerStatus }}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<GpsMapPanel :gps="gps" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
color: #f5a524;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(28px, 4vw, 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description,
|
||||||
|
.banner {
|
||||||
|
margin: 0;
|
||||||
|
color: #d5dbee;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(11, 19, 35, 0.84);
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner.error {
|
||||||
|
color: #ffd0d0;
|
||||||
|
border-color: rgba(255, 107, 107, 0.28);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
72
frontend/src/views/NetworkView.vue
Normal file
72
frontend/src/views/NetworkView.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import NetworkPanel from '@/components/NetworkPanel.vue'
|
||||||
|
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||||
|
|
||||||
|
const { network, errorMessage, headerStatus } = useMonitoringData()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-shell">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Network</p>
|
||||||
|
<h1>网络状态页面</h1>
|
||||||
|
</div>
|
||||||
|
<p class="description">
|
||||||
|
当前先展示模拟网络遥测数据,后续只需要把后端采集函数替换成真实 C 输出,就能保留同样的渲染界面。
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="banner" :class="{ error: !!errorMessage }">
|
||||||
|
{{ headerStatus }}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NetworkPanel :network="network" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
color: #4dd4ac;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(28px, 4vw, 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description,
|
||||||
|
.banner {
|
||||||
|
margin: 0;
|
||||||
|
color: #d5dbee;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(11, 19, 35, 0.84);
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner.error {
|
||||||
|
color: #ffd0d0;
|
||||||
|
border-color: rgba(255, 107, 107, 0.28);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
69
frontend/src/views/VideoView.vue
Normal file
69
frontend/src/views/VideoView.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import VideoPanel from '@/components/VideoPanel.vue'
|
||||||
|
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||||
|
|
||||||
|
const { video, errorMessage, headerStatus } = useMonitoringData()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-shell">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Video</p>
|
||||||
|
<h1>视频流页面</h1>
|
||||||
|
</div>
|
||||||
|
<p class="description">这个页面专门用于看逐帧 JPEG 画面。前端会按固定频率请求单张 JPEG,后端每次返回一帧。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="banner" :class="{ error: !!errorMessage }">
|
||||||
|
{{ headerStatus }}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<VideoPanel :video="video" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
color: #8da2fb;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(28px, 4vw, 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description,
|
||||||
|
.banner {
|
||||||
|
margin: 0;
|
||||||
|
color: #d5dbee;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(11, 19, 35, 0.84);
|
||||||
|
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner.error {
|
||||||
|
color: #ffd0d0;
|
||||||
|
border-color: rgba(255, 107, 107, 0.28);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
18
frontend/tsconfig.app.json
Normal file
18
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Extra safety for array and object lookups, but may have false positives.
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
||||||
|
// Path mapping for cleaner imports.
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/tsconfig.json
Normal file
11
frontend/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
27
frontend/tsconfig.node.json
Normal file
27
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||||
|
{
|
||||||
|
"extends": "@tsconfig/node24/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"playwright.config.*",
|
||||||
|
"eslint.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||||
|
// Bundler mode provides a smoother developer experience.
|
||||||
|
"module": "preserve",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
|
||||||
|
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
|
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
frontend/vite.config.ts
Normal file
18
frontend/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueDevTools(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user