first commit

This commit is contained in:
nnbcccscdscdsc
2026-03-31 20:41:08 +08:00
commit 771829d99d
44 changed files with 7663 additions and 0 deletions

View 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

View 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()

View 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()

View File

16
backend/config/asgi.py Normal file
View 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()

View 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
View 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
View 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
View 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()

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class MonitoringConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "monitoring"

View 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()

View 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"),
]

View 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
View 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
View File

@@ -0,0 +1 @@
* text=auto eol=lf

39
frontend/.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

48
frontend/README.md Normal file
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

26
frontend/eslint.config.ts Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

44
frontend/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

187
frontend/src/App.vue Normal file
View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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')

View 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

View 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
View 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
}

View 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>

View 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>

View 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>

View 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>

View 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
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View 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
View 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))
},
},
})