Merge branch 'feature/web-listener-toggles'

This commit is contained in:
2026-06-16 19:50:28 +08:00
4 changed files with 207 additions and 46 deletions
+48 -7
View File
@@ -52,6 +52,8 @@ type mysqlConfig struct {
type webConfig struct { type webConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
PortEnabled bool `yaml:"port_enabled"`
SocketEnabled bool `yaml:"socket_enabled"`
Host string `yaml:"host"` Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
SocketPath string `yaml:"socket_path"` SocketPath string `yaml:"socket_path"`
@@ -106,6 +108,8 @@ type rawMySQLConfig struct {
type rawWebConfig struct { type rawWebConfig struct {
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
PortEnabled *bool `yaml:"port_enabled"`
SocketEnabled *bool `yaml:"socket_enabled"`
Host *string `yaml:"host"` Host *string `yaml:"host"`
Port *int `yaml:"port"` Port *int `yaml:"port"`
SocketPath *string `yaml:"socket_path"` SocketPath *string `yaml:"socket_path"`
@@ -143,6 +147,8 @@ func defaultConfig() *config {
}, },
Web: webConfig{ Web: webConfig{
Enabled: true, Enabled: true,
PortEnabled: true,
SocketEnabled: defaultWebSocketPath() != "",
Host: "0.0.0.0", Host: "0.0.0.0",
Port: 8080, Port: 8080,
SocketPath: defaultWebSocketPath(), SocketPath: defaultWebSocketPath(),
@@ -160,12 +166,20 @@ func defaultConfig() *config {
// defaultConfigDir 根据操作系统返回配置目录。 // defaultConfigDir 根据操作系统返回配置目录。
func defaultConfigDir() string { func defaultConfigDir() string {
if runtime.GOOS == "windows" { return defaultConfigDirForGOOS(runtime.GOOS)
}
func defaultConfigDirForGOOS(goos string) string {
if useRelativeDefaultPath(goos) {
return filepath.Join(".", "win", "etc", "mesh_mqtt_go") return filepath.Join(".", "win", "etc", "mesh_mqtt_go")
} }
return filepath.Join(string(filepath.Separator), "etc", "mesh_mqtt_go") return filepath.Join(string(filepath.Separator), "etc", "mesh_mqtt_go")
} }
func useRelativeDefaultPath(goos string) bool {
return goos == "windows" || goos == "darwin"
}
// defaultConfigPath 返回默认配置文件路径。 // defaultConfigPath 返回默认配置文件路径。
func defaultConfigPath() string { func defaultConfigPath() string {
return filepath.Join(defaultConfigDir(), configFileName) return filepath.Join(defaultConfigDir(), configFileName)
@@ -184,7 +198,7 @@ func defaultMapTileCacheDir() string {
} }
func defaultMapTileCacheDirForGOOS(goos string) string { func defaultMapTileCacheDirForGOOS(goos string) string {
if goos == "windows" { if useRelativeDefaultPath(goos) {
return filepath.Join(".", "win", "srv", "mesh_mqtt_go") return filepath.Join(".", "win", "srv", "mesh_mqtt_go")
} }
return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go") return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go")
@@ -194,19 +208,30 @@ func defaultWebSocketPathForGOOS(goos string) string {
if goos == "windows" { if goos == "windows" {
return "" return ""
} }
if useRelativeDefaultPath(goos) {
return filepath.Join(".", "win", "opt", "mesh_mqtt_go", "web.sock")
}
return filepath.Join(string(filepath.Separator), "opt", "mesh_mqtt_go", "web.sock") return filepath.Join(string(filepath.Separator), "opt", "mesh_mqtt_go", "web.sock")
} }
func clearWebSocketPathOnUnsupportedGOOS(cfg *config, goos string) bool { func clearWebSocketPathOnUnsupportedGOOS(cfg *config, goos string) bool {
if goos != "windows" || cfg.Web.SocketPath == "" { if goos != "windows" {
return false return false
} }
cfg.Web.SocketPath = "" changed := false
return true if cfg.Web.SocketPath != "" {
cfg.Web.SocketPath = ""
changed = true
}
if cfg.Web.SocketEnabled {
cfg.Web.SocketEnabled = false
changed = true
}
return changed
} }
func defaultSQLitePathForGOOS(goos string) string { func defaultSQLitePathForGOOS(goos string) string {
if goos == "windows" { if useRelativeDefaultPath(goos) {
return filepath.Join(".", "win", "etc", "mesh_mqtt_go", "mesh_mqtt_go.db") return filepath.Join(".", "win", "etc", "mesh_mqtt_go", "mesh_mqtt_go.db")
} }
return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go", "mesh_mqtt_go.db") return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go", "mesh_mqtt_go.db")
@@ -336,6 +361,16 @@ func normalizeConfig(raw rawConfig) (*config, bool) {
} else { } else {
cfg.Web.Enabled = *raw.Web.Enabled cfg.Web.Enabled = *raw.Web.Enabled
} }
if raw.Web.PortEnabled == nil {
changed = true
} else {
cfg.Web.PortEnabled = *raw.Web.PortEnabled
}
if raw.Web.SocketEnabled == nil {
changed = true
} else {
cfg.Web.SocketEnabled = *raw.Web.SocketEnabled
}
if raw.Web.Host == nil { if raw.Web.Host == nil {
changed = true changed = true
} else { } else {
@@ -407,9 +442,15 @@ func validateConfig(cfg *config) error {
return fmt.Errorf("invalid database.driver %q: must be sqlite or mysql", cfg.Database.Driver) return fmt.Errorf("invalid database.driver %q: must be sqlite or mysql", cfg.Database.Driver)
} }
if cfg.Web.Enabled { if cfg.Web.Enabled {
if cfg.Web.SocketPath == "" && (cfg.Web.Port <= 0 || cfg.Web.Port > 65535) { if !cfg.Web.PortEnabled && !cfg.Web.SocketEnabled {
return fmt.Errorf("web.port_enabled and web.socket_enabled cannot both be false when web is enabled")
}
if cfg.Web.PortEnabled && (cfg.Web.Port <= 0 || cfg.Web.Port > 65535) {
return fmt.Errorf("invalid web port %d: must be 1-65535", cfg.Web.Port) return fmt.Errorf("invalid web port %d: must be 1-65535", cfg.Web.Port)
} }
if cfg.Web.SocketEnabled && cfg.Web.SocketPath == "" {
return fmt.Errorf("web.socket_path is required when web.socket_enabled is true")
}
if cfg.Web.StaticDir == "" { if cfg.Web.StaticDir == "" {
return fmt.Errorf("web.static_dir is required when web is enabled") return fmt.Errorf("web.static_dir is required when web is enabled")
} }
+71 -15
View File
@@ -35,6 +35,13 @@ func TestLoadConfigCreatesDefaultFile(t *testing.T) {
if !cfg.Web.Enabled { if !cfg.Web.Enabled {
t.Fatalf("web enabled = false, want true") t.Fatalf("web enabled = false, want true")
} }
if !cfg.Web.PortEnabled {
t.Fatalf("web port enabled = false, want true")
}
wantSocketEnabled := defaultWebSocketPath() != ""
if cfg.Web.SocketEnabled != wantSocketEnabled {
t.Fatalf("web socket enabled = %t, want %t", cfg.Web.SocketEnabled, wantSocketEnabled)
}
if cfg.Web.Port != 8080 { if cfg.Web.Port != 8080 {
t.Fatalf("web port = %d, want 8080", cfg.Web.Port) t.Fatalf("web port = %d, want 8080", cfg.Web.Port)
} }
@@ -83,7 +90,7 @@ func TestLoadConfigFillsMissingFields(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
text := string(data) text := string(data)
for _, want := range []string{"host:", "tls:", "enabled:", "cert_file:", "key_file:", "meshtastic:", "psk:", "database:", "driver:", "sqlite:", "mysql:", "dsn:", "web:", "port:", "socket_path:", "static_dir:", "map_tile_cache_dir:"} { for _, want := range []string{"host:", "tls:", "enabled:", "cert_file:", "key_file:", "meshtastic:", "psk:", "database:", "driver:", "sqlite:", "mysql:", "dsn:", "web:", "port_enabled:", "socket_enabled:", "port:", "socket_path:", "static_dir:", "map_tile_cache_dir:"} {
if !strings.Contains(text, want) { if !strings.Contains(text, want) {
t.Fatalf("completed config missing %q in:\n%s", want, text) t.Fatalf("completed config missing %q in:\n%s", want, text)
} }
@@ -117,7 +124,7 @@ func TestLoadConfigPreservesExplicitWebFalse(t *testing.T) {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
content := "web:\n enabled: false\n host: 127.0.0.1\n port: 8081\n static_dir: ./public\n" content := "web:\n enabled: false\n port_enabled: false\n socket_enabled: false\n host: 127.0.0.1\n port: 8081\n static_dir: ./public\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil { if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -129,6 +136,9 @@ func TestLoadConfigPreservesExplicitWebFalse(t *testing.T) {
if cfg.Web.Enabled { if cfg.Web.Enabled {
t.Fatalf("web enabled = true, want explicit false") t.Fatalf("web enabled = true, want explicit false")
} }
if cfg.Web.PortEnabled || cfg.Web.SocketEnabled {
t.Fatalf("web listener enabled = %t/%t, want explicit false/false", cfg.Web.PortEnabled, cfg.Web.SocketEnabled)
}
if cfg.Web.Host != "127.0.0.1" || cfg.Web.Port != 8081 || cfg.Web.StaticDir != "./public" { if cfg.Web.Host != "127.0.0.1" || cfg.Web.Port != 8081 || cfg.Web.StaticDir != "./public" {
t.Fatalf("web config = %#v", cfg.Web) t.Fatalf("web config = %#v", cfg.Web)
} }
@@ -157,11 +167,29 @@ func TestLoadConfigMalformedYAMLDoesNotOverwrite(t *testing.T) {
} }
} }
func TestDefaultConfigDirForGOOS(t *testing.T) {
wantRelative := filepath.Join(".", "win", "etc", "mesh_mqtt_go")
for _, goos := range []string{"windows", "darwin"} {
path := defaultConfigDirForGOOS(goos)
if path != wantRelative {
t.Fatalf("%s config dir = %q, want %q", goos, path, wantRelative)
}
}
linuxPath := defaultConfigDirForGOOS("linux")
wantLinux := filepath.Join(string(filepath.Separator), "etc", "mesh_mqtt_go")
if linuxPath != wantLinux {
t.Fatalf("linux config dir = %q, want %q", linuxPath, wantLinux)
}
}
func TestDefaultMapTileCacheDirForGOOS(t *testing.T) { func TestDefaultMapTileCacheDirForGOOS(t *testing.T) {
windowsPath := defaultMapTileCacheDirForGOOS("windows") wantRelative := filepath.Join(".", "win", "srv", "mesh_mqtt_go")
wantWindows := filepath.Join(".", "win", "srv", "mesh_mqtt_go") for _, goos := range []string{"windows", "darwin"} {
if windowsPath != wantWindows { path := defaultMapTileCacheDirForGOOS(goos)
t.Fatalf("windows map tile cache dir = %q, want %q", windowsPath, wantWindows) if path != wantRelative {
t.Fatalf("%s map tile cache dir = %q, want %q", goos, path, wantRelative)
}
} }
linuxPath := defaultMapTileCacheDirForGOOS("linux") linuxPath := defaultMapTileCacheDirForGOOS("linux")
@@ -176,6 +204,12 @@ func TestDefaultWebSocketPathForGOOS(t *testing.T) {
t.Fatalf("windows web socket path = %q, want empty", windowsPath) t.Fatalf("windows web socket path = %q, want empty", windowsPath)
} }
darwinPath := defaultWebSocketPathForGOOS("darwin")
wantDarwin := filepath.Join(".", "win", "opt", "mesh_mqtt_go", "web.sock")
if darwinPath != wantDarwin {
t.Fatalf("darwin web socket path = %q, want %q", darwinPath, wantDarwin)
}
linuxPath := defaultWebSocketPathForGOOS("linux") linuxPath := defaultWebSocketPathForGOOS("linux")
want := filepath.Join(string(filepath.Separator), "opt", "mesh_mqtt_go", "web.sock") want := filepath.Join(string(filepath.Separator), "opt", "mesh_mqtt_go", "web.sock")
if linuxPath != want { if linuxPath != want {
@@ -192,6 +226,9 @@ func TestClearWebSocketPathOnUnsupportedGOOS(t *testing.T) {
if cfg.Web.SocketPath != "" { if cfg.Web.SocketPath != "" {
t.Fatalf("windows web socket path = %q, want empty", cfg.Web.SocketPath) t.Fatalf("windows web socket path = %q, want empty", cfg.Web.SocketPath)
} }
if cfg.Web.SocketEnabled {
t.Fatalf("windows web socket enabled = true, want false")
}
cfg.Web.SocketPath = "/opt/mesh_mqtt_go/web.sock" cfg.Web.SocketPath = "/opt/mesh_mqtt_go/web.sock"
if clearWebSocketPathOnUnsupportedGOOS(cfg, "linux") { if clearWebSocketPathOnUnsupportedGOOS(cfg, "linux") {
@@ -203,9 +240,12 @@ func TestClearWebSocketPathOnUnsupportedGOOS(t *testing.T) {
} }
func TestDefaultSQLitePathForGOOS(t *testing.T) { func TestDefaultSQLitePathForGOOS(t *testing.T) {
windowsPath := defaultSQLitePathForGOOS("windows") wantRelative := filepath.Join(".", "win", "etc", "mesh_mqtt_go", "mesh_mqtt_go.db")
if !strings.Contains(windowsPath, filepath.Join("win", "etc", "mesh_mqtt_go", "mesh_mqtt_go.db")) { for _, goos := range []string{"windows", "darwin"} {
t.Fatalf("windows sqlite path = %q", windowsPath) path := defaultSQLitePathForGOOS(goos)
if path != wantRelative {
t.Fatalf("%s sqlite path = %q, want %q", goos, path, wantRelative)
}
} }
linuxPath := defaultSQLitePathForGOOS("linux") linuxPath := defaultSQLitePathForGOOS("linux")
@@ -238,24 +278,38 @@ func TestValidateConfigDatabase(t *testing.T) {
func TestValidateConfigWeb(t *testing.T) { func TestValidateConfigWeb(t *testing.T) {
cfg := defaultConfig() cfg := defaultConfig()
cfg.Web.SocketPath = ""
cfg.Web.Port = 0 cfg.Web.Port = 0
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web port") { if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web port") {
t.Fatalf("invalid web port error = %v, want web port error", err) t.Fatalf("invalid web port error = %v, want web port error", err)
} }
cfg = defaultConfig() cfg = defaultConfig()
cfg.Web.SocketPath = filepath.Join(string(filepath.Separator), "tmp", "mesh_mqtt_go.sock") cfg.Web.PortEnabled = false
cfg.Web.Port = 0 cfg.Web.Port = 0
if err := validateConfig(cfg); err != nil { if err := validateConfig(cfg); err != nil {
t.Fatalf("web socket with invalid port error = %v, want nil", err) t.Fatalf("disabled web port with invalid port error = %v, want nil", err)
} }
cfg = defaultConfig() cfg = defaultConfig()
cfg.Web.SocketEnabled = false
cfg.Web.SocketPath = "" cfg.Web.SocketPath = ""
cfg.Web.Port = 0 if err := validateConfig(cfg); err != nil {
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web port") { t.Fatalf("disabled web socket with empty path error = %v, want nil", err)
t.Fatalf("invalid web port without socket error = %v, want web port error", err) }
cfg = defaultConfig()
cfg.Web.PortEnabled = false
cfg.Web.SocketEnabled = true
cfg.Web.SocketPath = ""
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.socket_path") {
t.Fatalf("missing web socket path error = %v, want web.socket_path error", err)
}
cfg = defaultConfig()
cfg.Web.PortEnabled = false
cfg.Web.SocketEnabled = false
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.port_enabled") {
t.Fatalf("disabled web listeners error = %v, want web.port_enabled error", err)
} }
cfg = defaultConfig() cfg = defaultConfig()
@@ -272,6 +326,8 @@ func TestValidateConfigWeb(t *testing.T) {
cfg = defaultConfig() cfg = defaultConfig()
cfg.Web.Enabled = false cfg.Web.Enabled = false
cfg.Web.PortEnabled = false
cfg.Web.SocketEnabled = false
cfg.Web.Port = 0 cfg.Web.Port = 0
cfg.Web.StaticDir = "" cfg.Web.StaticDir = ""
if err := validateConfig(cfg); err != nil { if err := validateConfig(cfg); err != nil {
+33 -18
View File
@@ -179,9 +179,11 @@ func parseArgs() (*config, error) {
flag.StringVar(&cfg.Database.SQLite.Path, "sqlite-path", cfg.Database.SQLite.Path, "SQLite database file path") flag.StringVar(&cfg.Database.SQLite.Path, "sqlite-path", cfg.Database.SQLite.Path, "SQLite database file path")
flag.StringVar(&cfg.Database.MySQL.DSN, "mysql-dsn", cfg.Database.MySQL.DSN, "MySQL database DSN") flag.StringVar(&cfg.Database.MySQL.DSN, "mysql-dsn", cfg.Database.MySQL.DSN, "MySQL database DSN")
flag.BoolVar(&cfg.Web.Enabled, "web", cfg.Web.Enabled, "Enable Gin web server") flag.BoolVar(&cfg.Web.Enabled, "web", cfg.Web.Enabled, "Enable Gin web server")
flag.BoolVar(&cfg.Web.PortEnabled, "web-port-enabled", cfg.Web.PortEnabled, "Enable web server on TCP host and port")
flag.BoolVar(&cfg.Web.SocketEnabled, "web-socket-enabled", cfg.Web.SocketEnabled, "Enable web server on Unix socket; unsupported on Windows")
flag.StringVar(&cfg.Web.Host, "web-host", cfg.Web.Host, "Web server listen host") flag.StringVar(&cfg.Web.Host, "web-host", cfg.Web.Host, "Web server listen host")
flag.IntVar(&cfg.Web.Port, "web-port", cfg.Web.Port, "Web server listen port") flag.IntVar(&cfg.Web.Port, "web-port", cfg.Web.Port, "Web server listen port")
flag.StringVar(&cfg.Web.SocketPath, "web-socket-path", cfg.Web.SocketPath, "Web server Unix socket path; empty uses host and port; unsupported on Windows") flag.StringVar(&cfg.Web.SocketPath, "web-socket-path", cfg.Web.SocketPath, "Web server Unix socket path; unsupported on Windows")
flag.StringVar(&cfg.Web.StaticDir, "web-static-dir", cfg.Web.StaticDir, "Web frontend static files directory") flag.StringVar(&cfg.Web.StaticDir, "web-static-dir", cfg.Web.StaticDir, "Web frontend static files directory")
flag.StringVar(&cfg.Web.MapTileCacheDir, "web-map-tile-cache-dir", cfg.Web.MapTileCacheDir, "Map tile disk cache root directory") flag.StringVar(&cfg.Web.MapTileCacheDir, "web-map-tile-cache-dir", cfg.Web.MapTileCacheDir, "Map tile disk cache root directory")
flag.StringVar(&cfg.Web.Admin.Username, "admin-username", cfg.Web.Admin.Username, "Web admin username") flag.StringVar(&cfg.Web.Admin.Username, "admin-username", cfg.Web.Admin.Username, "Web admin username")
@@ -245,31 +247,44 @@ func run(cfg *config) error {
} }
defer forwardManager.StopAll() defer forwardManager.StopAll()
var httpServer *http.Server var httpServers []*http.Server
errCh := make(chan error, 1) errCh := make(chan error, 2)
if cfg.Web.Enabled { if cfg.Web.Enabled {
sessions, err := newSessionManager(cfg.Web.Admin) sessions, err := newSessionManager(cfg.Web.Admin)
if err != nil { if err != nil {
return err return err
} }
mqttStatus := mqttRuntimeStatus{server: server, address: mqttAddr, tls: cfg.MQTT.TLS.Enabled, stats: messageStats, dbQueue: dbQueue} mqttStatus := mqttRuntimeStatus{server: server, address: mqttAddr, tls: cfg.MQTT.TLS.Enabled, stats: messageStats, dbQueue: dbQueue}
httpServer = newHTTPServer(cfg.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender) handler := newRouter(cfg.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender)
webAddress := httpServer.Addr webAddresses := []string{}
go func() { if cfg.Web.PortEnabled {
if cfg.Web.SocketPath != "" { httpServer := &http.Server{
Addr: net.JoinHostPort(cfg.Web.Host, strconv.Itoa(cfg.Web.Port)),
Handler: handler,
}
httpServers = append(httpServers, httpServer)
webAddresses = append(webAddresses, httpServer.Addr)
go func() {
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
}
if cfg.Web.SocketEnabled {
httpServer := &http.Server{Handler: handler}
httpServers = append(httpServers, httpServer)
webAddresses = append(webAddresses, cfg.Web.SocketPath)
go func() {
if err := serveHTTPUnixSocket(httpServer, cfg.Web.SocketPath); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := serveHTTPUnixSocket(httpServer, cfg.Web.SocketPath); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err errCh <- err
} }
return }()
}
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
if cfg.Web.SocketPath != "" {
webAddress = cfg.Web.SocketPath
} }
printJSON(map[string]any{"event": "web_started", "address": webAddress, "static_dir": cfg.Web.StaticDir}) webStarted := map[string]any{"event": "web_started", "addresses": webAddresses, "static_dir": cfg.Web.StaticDir}
if len(webAddresses) > 0 {
webStarted["address"] = webAddresses[0]
}
printJSON(webStarted)
} }
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
@@ -281,12 +296,12 @@ func run(cfg *config) error {
case runErr = <-errCh: case runErr = <-errCh:
} }
if httpServer != nil { for _, httpServer := range httpServers {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil && runErr == nil { if err := httpServer.Shutdown(ctx); err != nil && runErr == nil {
runErr = err runErr = err
} }
cancel()
} }
if err := server.Close(); err != nil && runErr == nil { if err := server.Close(); err != nil && runErr == nil {
runErr = err runErr = err
@@ -40,15 +40,58 @@ function applyTileLayer() {
}).addTo(map) }).addTo(map)
} }
function pointStyle(isStart: boolean, isEnd: boolean): L.CircleMarkerOptions {
if (isStart) {
return { radius: 7, color: '#7f9183', fillColor: '#9aaa95', fillOpacity: 0.88, weight: 2 }
}
if (isEnd) {
return { radius: 7, color: '#b4877f', fillColor: '#c59b93', fillOpacity: 0.88, weight: 2 }
}
return { radius: 3, color: '#7d8f9a', fillColor: '#9ab3c2', fillOpacity: 0.72, weight: 1 }
}
function buildPositionPopupHTML(position: PositionRecord, isStart: boolean, isEnd: boolean): string {
const title = isStart ? '起点' : isEnd ? '终点' : '轨迹点'
const latitude = position.latitude == null ? '-' : position.latitude.toFixed(6)
const longitude = position.longitude == null ? '-' : position.longitude.toFixed(6)
const altitude = position.altitude == null ? '-' : `${position.altitude} m`
const time = new Date(position.created_at).toLocaleString()
return `
<div class="trajectory-popup">
<strong>${escapeHTML(title)}</strong>
<dl>
<div><dt>纬度</dt><dd>${latitude}</dd></div>
<div><dt>经度</dt><dd>${longitude}</dd></div>
<div><dt>海拔</dt><dd>${altitude}</dd></div>
<div><dt>时间</dt><dd>${escapeHTML(time)}</dd></div>
</dl>
</div>
`
}
function escapeHTML(value: string): string {
return value.replace(/[&<>'"]/g, (char) => {
const entities: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
}
return entities[char]
})
}
function renderTrajectory() { function renderTrajectory() {
if (!map || !layer) { if (!map || !layer) {
return return
} }
layer.clearLayers() const trajectoryLayer = layer
const points = [...props.positions] trajectoryLayer.clearLayers()
const positions = [...props.positions]
.filter((position) => position.latitude != null && position.longitude != null) .filter((position) => position.latitude != null && position.longitude != null)
.reverse() .reverse()
.map((position) => [position.latitude as number, position.longitude as number] as L.LatLngTuple) const points = positions.map((position) => [position.latitude as number, position.longitude as number] as L.LatLngTuple)
if (points.length === 0) { if (points.length === 0) {
map.setView([0, 0], 2) map.setView([0, 0], 2)
@@ -56,10 +99,16 @@ function renderTrajectory() {
} }
if (points.length > 1) { if (points.length > 1) {
L.polyline(points, { color: '#7d8f9a', weight: 4, opacity: 0.78 }).addTo(layer) L.polyline(points, { color: '#7d8f9a', weight: 4, opacity: 0.78 }).addTo(trajectoryLayer)
} }
L.circleMarker(points[0], { radius: 6, color: '#7f9183', fillColor: '#9aaa95', fillOpacity: 0.88 }).bindPopup('起点').addTo(layer)
L.circleMarker(points[points.length - 1], { radius: 6, color: '#b4877f', fillColor: '#c59b93', fillOpacity: 0.88 }).bindPopup('终点').addTo(layer) positions.forEach((position, index) => {
const isStart = index === 0
const isEnd = index === positions.length - 1
L.circleMarker(points[index], pointStyle(isStart, isEnd))
.bindPopup(buildPositionPopupHTML(position, isStart, isEnd), { maxWidth: 280, className: 'trajectory-point-popup' })
.addTo(trajectoryLayer)
})
map.fitBounds(L.latLngBounds(points), { padding: [24, 24], maxZoom: 14 }) map.fitBounds(L.latLngBounds(points), { padding: [24, 24], maxZoom: 14 })
} }