新增图源修改功能
This commit is contained in:
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mapTileSourceRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URLTemplate string `json:"url_template"`
|
||||||
|
Attribution string `json:"attribution"`
|
||||||
|
MaxZoom int `json:"max_zoom"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerMapSourceRoutes(r gin.IRouter, store *store) {
|
||||||
|
r.GET("/map-source/default", func(c *gin.Context) {
|
||||||
|
row, err := store.GetDefaultMapTileSource()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"item": publicMapTileSourceDTO(*row)})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerAdminMapSourceRoutes(r gin.IRouter, store *store) {
|
||||||
|
r.GET("/map-source", func(c *gin.Context) {
|
||||||
|
opts, ok := parseListOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := store.ListMapTileSources(opts)
|
||||||
|
if err != nil {
|
||||||
|
writeListResponse(c, rows, opts, err, mapTileSourceDTO)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := store.CountMapTileSources(opts)
|
||||||
|
writeListResponseWithTotal(c, rows, opts, total, err, mapTileSourceDTO)
|
||||||
|
})
|
||||||
|
r.POST("/map-source", func(c *gin.Context) {
|
||||||
|
var req mapTileSourceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := store.CreateMapTileSource(mapTileSourceInputFromRequest(req))
|
||||||
|
writeMapTileSourceMutationResponse(c, http.StatusCreated, row, err)
|
||||||
|
})
|
||||||
|
r.PUT("/map-source/:id", func(c *gin.Context) {
|
||||||
|
id, ok := parseMapTileSourceID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req mapTileSourceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := store.UpdateMapTileSource(id, mapTileSourceInputFromRequest(req))
|
||||||
|
writeMapTileSourceMutationResponse(c, http.StatusOK, row, err)
|
||||||
|
})
|
||||||
|
r.DELETE("/map-source/:id", func(c *gin.Context) {
|
||||||
|
id, ok := parseMapTileSourceID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeMapTileSourceDeleteResponse(c, store.DeleteMapTileSource(id))
|
||||||
|
})
|
||||||
|
r.POST("/map-source/:id/default", func(c *gin.Context) {
|
||||||
|
id, ok := parseMapTileSourceID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := store.SetDefaultMapTileSource(id)
|
||||||
|
writeMapTileSourceMutationResponse(c, http.StatusOK, row, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapTileSourceInputFromRequest(req mapTileSourceRequest) mapTileSourceInput {
|
||||||
|
return mapTileSourceInput{
|
||||||
|
Name: req.Name,
|
||||||
|
URLTemplate: req.URLTemplate,
|
||||||
|
Attribution: req.Attribution,
|
||||||
|
MaxZoom: req.MaxZoom,
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
IsDefault: req.IsDefault,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMapTileSourceID(c *gin.Context) (uint64, bool) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source id"})
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTileSourceRecord, err error) {
|
||||||
|
if errors.Is(err, errMapTileSourceAlreadyExists) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "map source already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, errMapTileSourceCannotDeleteDefault) || errors.Is(err, errMapTileSourceCannotDisableDefault) || errors.Is(err, errMapTileSourceDefaultMustBeEnabled) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"item": mapTileSourceDTO(*row)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMapTileSourceDeleteResponse(c *gin.Context, err error) {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, errMapTileSourceCannotDeleteDefault) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapTileSourceDTO(row mapTileSourceRecord) gin.H {
|
||||||
|
return gin.H{"id": row.ID, "name": row.Name, "url_template": row.URLTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom, "enabled": row.Enabled, "is_default": row.IsDefault, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicMapTileSourceDTO(row mapTileSourceRecord) gin.H {
|
||||||
|
return gin.H{"id": row.ID, "name": row.Name, "url_template": row.URLTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom}
|
||||||
|
}
|
||||||
@@ -115,6 +115,22 @@ func (runtimeSettingRecord) TableName() string {
|
|||||||
return "runtime_settings"
|
return "runtime_settings"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mapTileSourceRecord struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
Name string `gorm:"column:name;not null;uniqueIndex"`
|
||||||
|
URLTemplate string `gorm:"column:url_template;not null;uniqueIndex"`
|
||||||
|
Attribution string `gorm:"column:attribution"`
|
||||||
|
MaxZoom int `gorm:"column:max_zoom;not null"`
|
||||||
|
Enabled bool `gorm:"column:enabled;not null;index"`
|
||||||
|
IsDefault bool `gorm:"column:is_default;not null;index"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mapTileSourceRecord) TableName() string {
|
||||||
|
return "map_tile_sources"
|
||||||
|
}
|
||||||
|
|
||||||
type discardDetailsRecord struct {
|
type discardDetailsRecord struct {
|
||||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
Topic string `gorm:"column:topic"`
|
Topic string `gorm:"column:topic"`
|
||||||
@@ -415,6 +431,7 @@ func (s *store) migrate() error {
|
|||||||
{label: "login_log", model: &loginLogRecord{}},
|
{label: "login_log", model: &loginLogRecord{}},
|
||||||
{label: "help_content", model: &helpContentRecord{}},
|
{label: "help_content", model: &helpContentRecord{}},
|
||||||
{label: "runtime_settings", model: &runtimeSettingRecord{}},
|
{label: "runtime_settings", model: &runtimeSettingRecord{}},
|
||||||
|
{label: "map_tile_sources", model: &mapTileSourceRecord{}},
|
||||||
{label: "discard_details", model: &discardDetailsRecord{}},
|
{label: "discard_details", model: &discardDetailsRecord{}},
|
||||||
{label: "node_blocking", model: &nodeBlockingRecord{}},
|
{label: "node_blocking", model: &nodeBlockingRecord{}},
|
||||||
{label: "ip_blocking", model: &ipBlockingRecord{}},
|
{label: "ip_blocking", model: &ipBlockingRecord{}},
|
||||||
@@ -446,7 +463,7 @@ func (s *store) migrate() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return (&store{db: tx, driver: s.driver}).EnsureDefaultMapTileSource()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ func TestOpenStoreCreatesTables(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
for _, table := range []string{"users", "login_log", "runtime_settings", "discard_details", "node_blocking", "ip_blocking", "forbidden_word_blocking", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} {
|
for _, table := range []string{"users", "login_log", "runtime_settings", "map_tile_sources", "discard_details", "node_blocking", "ip_blocking", "forbidden_word_blocking", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} {
|
||||||
var name string
|
var name string
|
||||||
if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil {
|
if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil {
|
||||||
t.Fatalf("%s table missing: %v", table, err)
|
t.Fatalf("%s table missing: %v", table, err)
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ module meshtastic_mqtt_server
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/mochi-mqtt/server/v2 v2.7.9
|
github.com/mochi-mqtt/server/v2 v2.7.9
|
||||||
|
github.com/yuin/goldmark v1.8.2
|
||||||
|
golang.org/x/crypto v0.48.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/mysql v1.6.0
|
gorm.io/driver/mysql v1.6.0
|
||||||
@@ -20,7 +24,6 @@ require (
|
|||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
@@ -39,7 +42,6 @@ require (
|
|||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
@@ -50,10 +52,8 @@ require (
|
|||||||
github.com/rs/xid v1.4.0 // indirect
|
github.com/rs/xid v1.4.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultMapTileSourceName = "OpenStreetMap Japan"
|
||||||
|
defaultMapTileSourceURLTemplate = "https://tile.openstreetmap.jp/{z}/{x}/{y}.png"
|
||||||
|
defaultMapTileSourceAttribution = "© OpenStreetMap contributors"
|
||||||
|
defaultMapTileSourceMaxZoom = 19
|
||||||
|
maxMapTileSourceURLLength = 2048
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errMapTileSourceAlreadyExists = errors.New("map source already exists")
|
||||||
|
errMapTileSourceCannotDeleteDefault = errors.New("default map source cannot be deleted")
|
||||||
|
errMapTileSourceCannotDisableDefault = errors.New("default map source cannot be disabled")
|
||||||
|
errMapTileSourceDefaultMustBeEnabled = errors.New("default map source must be enabled")
|
||||||
|
)
|
||||||
|
|
||||||
|
type mapTileSourceInput struct {
|
||||||
|
Name string
|
||||||
|
URLTemplate string
|
||||||
|
Attribution string
|
||||||
|
MaxZoom int
|
||||||
|
Enabled bool
|
||||||
|
IsDefault bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) ListMapTileSources(opts listOptions) ([]mapTileSourceRecord, error) {
|
||||||
|
opts = normalizeListOptions(opts)
|
||||||
|
var rows []mapTileSourceRecord
|
||||||
|
q := s.db.Model(&mapTileSourceRecord{}).
|
||||||
|
Order("is_default DESC").
|
||||||
|
Order("updated_at DESC").
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(opts.Limit).
|
||||||
|
Offset(opts.Offset)
|
||||||
|
return rows, q.Find(&rows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) CountMapTileSources(opts listOptions) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
return total, s.db.Model(&mapTileSourceRecord{}).Count(&total).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) {
|
||||||
|
var row mapTileSourceRecord
|
||||||
|
err := s.db.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Take(&row).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
fallback := defaultMapTileSourceRecord()
|
||||||
|
return &fallback, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) CreateMapTileSource(input mapTileSourceInput) (*mapTileSourceRecord, error) {
|
||||||
|
row, err := mapTileSourceFromInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if row.IsDefault && !row.Enabled {
|
||||||
|
return nil, errMapTileSourceDefaultMustBeEnabled
|
||||||
|
}
|
||||||
|
if err := s.ensureMapTileSourceUnique(0, row.Name, row.URLTemplate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if row.IsDefault {
|
||||||
|
if err := tx.Model(&mapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Create(row).Error
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTileSourceRecord, error) {
|
||||||
|
if id == 0 {
|
||||||
|
return nil, fmt.Errorf("map source id is required")
|
||||||
|
}
|
||||||
|
row, err := mapTileSourceFromInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var updated mapTileSourceRecord
|
||||||
|
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var existing mapTileSourceRecord
|
||||||
|
if err := tx.Where("id = ?", id).Take(&existing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing.IsDefault && !row.Enabled {
|
||||||
|
return errMapTileSourceCannotDisableDefault
|
||||||
|
}
|
||||||
|
if row.IsDefault && !row.Enabled {
|
||||||
|
return errMapTileSourceDefaultMustBeEnabled
|
||||||
|
}
|
||||||
|
if !row.IsDefault && existing.IsDefault {
|
||||||
|
row.IsDefault = true
|
||||||
|
}
|
||||||
|
if err := ensureMapTileSourceUniqueTx(tx, id, row.Name, row.URLTemplate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if row.IsDefault {
|
||||||
|
if err := tx.Model(&mapTileSourceRecord{}).Where("id <> ? AND is_default = ?", id, true).Update("is_default", false).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updates := map[string]any{
|
||||||
|
"name": row.Name,
|
||||||
|
"url_template": row.URLTemplate,
|
||||||
|
"attribution": row.Attribution,
|
||||||
|
"max_zoom": row.MaxZoom,
|
||||||
|
"enabled": row.Enabled,
|
||||||
|
"is_default": row.IsDefault,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}
|
||||||
|
if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Where("id = ?", id).Take(&updated).Error
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) DeleteMapTileSource(id uint64) error {
|
||||||
|
if id == 0 {
|
||||||
|
return fmt.Errorf("map source id is required")
|
||||||
|
}
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var row mapTileSourceRecord
|
||||||
|
if err := tx.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if row.IsDefault {
|
||||||
|
return errMapTileSourceCannotDeleteDefault
|
||||||
|
}
|
||||||
|
result := tx.Where("id = ?", id).Delete(&mapTileSourceRecord{})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) SetDefaultMapTileSource(id uint64) (*mapTileSourceRecord, error) {
|
||||||
|
if id == 0 {
|
||||||
|
return nil, fmt.Errorf("map source id is required")
|
||||||
|
}
|
||||||
|
var row mapTileSourceRecord
|
||||||
|
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !row.Enabled {
|
||||||
|
return errMapTileSourceDefaultMustBeEnabled
|
||||||
|
}
|
||||||
|
if err := tx.Model(&mapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Where("id = ?", id).Take(&row).Error
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) EnsureDefaultMapTileSource() error {
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&mapTileSourceRecord{}).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
row := defaultMapTileSourceRecord()
|
||||||
|
return tx.Create(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaults []mapTileSourceRecord
|
||||||
|
if err := tx.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Find(&defaults).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(defaults) > 0 {
|
||||||
|
return tx.Model(&mapTileSourceRecord{}).Where("id <> ? AND is_default = ?", defaults[0].ID, true).Update("is_default", false).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
var enabled mapTileSourceRecord
|
||||||
|
err := tx.Where("enabled = ?", true).Order("id ASC").Take(&enabled).Error
|
||||||
|
if err == nil {
|
||||||
|
return tx.Model(&mapTileSourceRecord{}).Where("id = ?", enabled.ID).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
row := defaultMapTileSourceRecord()
|
||||||
|
var existing mapTileSourceRecord
|
||||||
|
err = tx.Where("name = ? OR url_template = ?", row.Name, row.URLTemplate).Order("id ASC").Take(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
return tx.Model(&mapTileSourceRecord{}).Where("id = ?", existing.ID).Updates(map[string]any{"enabled": true, "is_default": true, "updated_at": time.Now()}).Error
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&row).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultMapTileSourceRecord() mapTileSourceRecord {
|
||||||
|
return mapTileSourceRecord{
|
||||||
|
Name: defaultMapTileSourceName,
|
||||||
|
URLTemplate: defaultMapTileSourceURLTemplate,
|
||||||
|
Attribution: defaultMapTileSourceAttribution,
|
||||||
|
MaxZoom: defaultMapTileSourceMaxZoom,
|
||||||
|
Enabled: true,
|
||||||
|
IsDefault: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapTileSourceFromInput(input mapTileSourceInput) (*mapTileSourceRecord, error) {
|
||||||
|
name := strings.TrimSpace(input.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("map source name is required")
|
||||||
|
}
|
||||||
|
urlTemplate, err := normalizeMapTileSourceURLTemplate(input.URLTemplate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
maxZoom := input.MaxZoom
|
||||||
|
if maxZoom == 0 {
|
||||||
|
maxZoom = defaultMapTileSourceMaxZoom
|
||||||
|
}
|
||||||
|
if maxZoom < 1 || maxZoom > 30 {
|
||||||
|
return nil, fmt.Errorf("max zoom must be between 1 and 30")
|
||||||
|
}
|
||||||
|
return &mapTileSourceRecord{
|
||||||
|
Name: name,
|
||||||
|
URLTemplate: urlTemplate,
|
||||||
|
Attribution: strings.TrimSpace(input.Attribution),
|
||||||
|
MaxZoom: maxZoom,
|
||||||
|
Enabled: input.Enabled,
|
||||||
|
IsDefault: input.IsDefault,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMapTileSourceURLTemplate(value string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "", fmt.Errorf("map source url template is required")
|
||||||
|
}
|
||||||
|
if len(value) > maxMapTileSourceURLLength {
|
||||||
|
return "", fmt.Errorf("map source url template is too long")
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if unicode.IsControl(r) || unicode.IsSpace(r) {
|
||||||
|
return "", fmt.Errorf("map source url template must not contain whitespace or control characters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, placeholder := range []string{"{z}", "{x}", "{y}"} {
|
||||||
|
if strings.Count(value, placeholder) != 1 {
|
||||||
|
return "", fmt.Errorf("map source url template must contain %s exactly once", placeholder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("map source url template is invalid")
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return "", fmt.Errorf("map source url template must use http or https")
|
||||||
|
}
|
||||||
|
if parsed.Host == "" {
|
||||||
|
return "", fmt.Errorf("map source url template host is required")
|
||||||
|
}
|
||||||
|
if parsed.User != nil {
|
||||||
|
return "", fmt.Errorf("map source url template must not contain credentials")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) ensureMapTileSourceUnique(id uint64, name, urlTemplate string) error {
|
||||||
|
return ensureMapTileSourceUniqueTx(s.db, id, name, urlTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureMapTileSourceUniqueTx(tx *gorm.DB, id uint64, name, urlTemplate string) error {
|
||||||
|
var existing mapTileSourceRecord
|
||||||
|
q := tx.Where("name = ? OR url_template = ?", name, urlTemplate)
|
||||||
|
if id != 0 {
|
||||||
|
q = q.Where("id <> ?", id)
|
||||||
|
}
|
||||||
|
err := q.Take(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
return errMapTileSourceAlreadyExists
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMapTileSourceDefaultSeeded(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
row, err := st.GetDefaultMapTileSource()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDefaultMapTileSource() error = %v", err)
|
||||||
|
}
|
||||||
|
if row.Name != defaultMapTileSourceName || row.URLTemplate != defaultMapTileSourceURLTemplate || !row.Enabled || !row.IsDefault {
|
||||||
|
t.Fatalf("default map source = %+v, want built-in default", row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateMapTileSourceValidation(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://tiles.example.com/{z}/{x}.png", MaxZoom: 19, Enabled: true}); err == nil {
|
||||||
|
t.Fatal("CreateMapTileSource() missing placeholder error = nil, want error")
|
||||||
|
}
|
||||||
|
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "javascript:alert(1)/{z}/{x}/{y}", MaxZoom: 19, Enabled: true}); err == nil {
|
||||||
|
t.Fatal("CreateMapTileSource() invalid scheme error = nil, want error")
|
||||||
|
}
|
||||||
|
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://user:pass@tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 19, Enabled: true}); err == nil {
|
||||||
|
t.Fatal("CreateMapTileSource() credentials error = nil, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapTileSourceDuplicateAndDefaultRules(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
first, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles2.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) {
|
||||||
|
t.Fatalf("duplicate name error = %v, want errMapTileSourceAlreadyExists", err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom 2", URLTemplate: first.URLTemplate, MaxZoom: 18, Enabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) {
|
||||||
|
t.Fatalf("duplicate url error = %v, want errMapTileSourceAlreadyExists", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := st.SetDefaultMapTileSource(first.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetDefaultMapTileSource() error = %v", err)
|
||||||
|
}
|
||||||
|
if !updated.IsDefault {
|
||||||
|
t.Fatalf("updated default = %+v, want is_default", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldDefault, err := st.GetDefaultMapTileSource()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDefaultMapTileSource() error = %v", err)
|
||||||
|
}
|
||||||
|
if oldDefault.ID != first.ID {
|
||||||
|
t.Fatalf("default id = %d, want %d", oldDefault.ID, first.ID)
|
||||||
|
}
|
||||||
|
if _, err := st.UpdateMapTileSource(first.ID, mapTileSourceInput{Name: first.Name, URLTemplate: first.URLTemplate, Attribution: first.Attribution, MaxZoom: first.MaxZoom, Enabled: false, IsDefault: true}); !errors.Is(err, errMapTileSourceCannotDisableDefault) {
|
||||||
|
t.Fatalf("disable default error = %v, want errMapTileSourceCannotDisableDefault", err)
|
||||||
|
}
|
||||||
|
if err := st.DeleteMapTileSource(first.ID); !errors.Is(err, errMapTileSourceCannotDeleteDefault) {
|
||||||
|
t.Fatalf("delete default error = %v, want errMapTileSourceCannotDeleteDefault", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
|||||||
import AdminHelpEdit from './components/AdminHelpEdit.vue'
|
import AdminHelpEdit from './components/AdminHelpEdit.vue'
|
||||||
import AdminLogin from './components/AdminLogin.vue'
|
import AdminLogin from './components/AdminLogin.vue'
|
||||||
import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
||||||
|
import AdminMapSource from './components/AdminMapSource.vue'
|
||||||
import AdminMqttForward from './components/AdminMqttForward.vue'
|
import AdminMqttForward from './components/AdminMqttForward.vue'
|
||||||
import AdminUsers from './components/AdminUsers.vue'
|
import AdminUsers from './components/AdminUsers.vue'
|
||||||
import ChatPanel from './components/ChatPanel.vue'
|
import ChatPanel from './components/ChatPanel.vue'
|
||||||
@@ -15,7 +16,8 @@ import HelpPage from './components/HelpPage.vue'
|
|||||||
import MeshMap from './components/MeshMap.vue'
|
import MeshMap from './components/MeshMap.vue'
|
||||||
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
||||||
import NodeListPanel from './components/NodeListPanel.vue'
|
import NodeListPanel from './components/NodeListPanel.vue'
|
||||||
import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types'
|
import { fallbackMapSource, loadDefaultMapSource } from './mapSource'
|
||||||
|
import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, NodeInfo, NodeInfoById, PositionRecord, PublicMapTileSource, TextMessage } from './types'
|
||||||
|
|
||||||
const currentPath = window.location.pathname
|
const currentPath = window.location.pathname
|
||||||
const adminPath = currentPath
|
const adminPath = currentPath
|
||||||
@@ -50,6 +52,7 @@ const currentMapBounds = ref<MapBoundsQuery | null>(null)
|
|||||||
const currentMapZoom = ref(2)
|
const currentMapZoom = ref(2)
|
||||||
const mapReportsLoading = ref(false)
|
const mapReportsLoading = ref(false)
|
||||||
const mapReportTotal = ref(0)
|
const mapReportTotal = ref(0)
|
||||||
|
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
|
||||||
const pendingDeleteAction = ref<PendingDeleteAction | null>(null)
|
const pendingDeleteAction = ref<PendingDeleteAction | null>(null)
|
||||||
type DeletableTextMessage = TextMessage & { mergedCount?: number; mergedMessages?: TextMessage[] }
|
type DeletableTextMessage = TextMessage & { mergedCount?: number; mergedMessages?: TextMessage[] }
|
||||||
type NodeActionRequest = { nodeId: string; nodeNum: number | null; message?: DeletableTextMessage }
|
type NodeActionRequest = { nodeId: string; nodeNum: number | null; message?: DeletableTextMessage }
|
||||||
@@ -292,6 +295,10 @@ async function refresh(showLoading = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMapSource() {
|
||||||
|
mapSource.value = await loadDefaultMapSource()
|
||||||
|
}
|
||||||
|
|
||||||
async function checkAdminSession() {
|
async function checkAdminSession() {
|
||||||
adminChecking.value = true
|
adminChecking.value = true
|
||||||
try {
|
try {
|
||||||
@@ -463,6 +470,7 @@ onMounted(() => {
|
|||||||
if (isDetailedPage || isHelpPage) {
|
if (isDetailedPage || isHelpPage) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
loadMapSource()
|
||||||
refresh()
|
refresh()
|
||||||
refreshTimer = window.setInterval(() => refresh(false), 5000)
|
refreshTimer = window.setInterval(() => refresh(false), 5000)
|
||||||
})
|
})
|
||||||
@@ -493,6 +501,7 @@ onBeforeUnmount(() => {
|
|||||||
<a href="/admin/users" :class="{ active: adminPath === '/admin/users' }">用户管理</a>
|
<a href="/admin/users" :class="{ active: adminPath === '/admin/users' }">用户管理</a>
|
||||||
<a href="/admin/blocking_management" :class="{ active: adminPath === '/admin/blocking_management' }">屏蔽管理</a>
|
<a href="/admin/blocking_management" :class="{ active: adminPath === '/admin/blocking_management' }">屏蔽管理</a>
|
||||||
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
|
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
|
||||||
|
<a href="/admin/map_source" :class="{ active: adminPath === '/admin/map_source' }">地图图源</a>
|
||||||
<a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a>
|
<a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a>
|
||||||
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
|
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
|
||||||
<a href="/admin/discard_details" :class="{ active: adminPath === '/admin/discard_details' }">丢弃数据</a>
|
<a href="/admin/discard_details" :class="{ active: adminPath === '/admin/discard_details' }">丢弃数据</a>
|
||||||
@@ -532,6 +541,7 @@ onBeforeUnmount(() => {
|
|||||||
<AdminUsers v-if="adminPath === '/admin/users'" :user="adminUser" />
|
<AdminUsers v-if="adminPath === '/admin/users'" :user="adminUser" />
|
||||||
<AdminBlockingManagement v-else-if="adminPath === '/admin/blocking_management'" />
|
<AdminBlockingManagement v-else-if="adminPath === '/admin/blocking_management'" />
|
||||||
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
|
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
|
||||||
|
<AdminMapSource v-else-if="adminPath === '/admin/map_source'" />
|
||||||
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
|
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
|
||||||
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
||||||
<AdminDiscardDetails v-else-if="adminPath === '/admin/discard_details'" />
|
<AdminDiscardDetails v-else-if="adminPath === '/admin/discard_details'" />
|
||||||
@@ -570,6 +580,7 @@ onBeforeUnmount(() => {
|
|||||||
:is-admin="!!adminUser"
|
:is-admin="!!adminUser"
|
||||||
:auto-fit="false"
|
:auto-fit="false"
|
||||||
:loading="mapReportsLoading"
|
:loading="mapReportsLoading"
|
||||||
|
:map-source="mapSource"
|
||||||
@bounds-change="handleMapBoundsChange"
|
@bounds-change="handleMapBoundsChange"
|
||||||
@select-node="selectedNodeId = $event"
|
@select-node="selectedNodeId = $event"
|
||||||
@clear-node="selectedNodeId = null"
|
@clear-node="selectedNodeId = null"
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import type {
|
|||||||
ListResponse,
|
ListResponse,
|
||||||
MapBoundsQuery,
|
MapBoundsQuery,
|
||||||
MapReport,
|
MapReport,
|
||||||
|
MapTileSource,
|
||||||
|
MapTileSourcePayload,
|
||||||
|
MapTileSourceResponse,
|
||||||
MapViewportResponse,
|
MapViewportResponse,
|
||||||
MQTTForwarder,
|
MQTTForwarder,
|
||||||
MQTTForwarderPayload,
|
MQTTForwarderPayload,
|
||||||
@@ -29,6 +32,7 @@ import type {
|
|||||||
NodeBlockingRulePayload,
|
NodeBlockingRulePayload,
|
||||||
NodeInfo,
|
NodeInfo,
|
||||||
PositionRecord,
|
PositionRecord,
|
||||||
|
PublicMapTileSourceResponse,
|
||||||
TelemetryRecord,
|
TelemetryRecord,
|
||||||
TextMessage,
|
TextMessage,
|
||||||
} from './types'
|
} from './types'
|
||||||
@@ -125,6 +129,10 @@ export function getMapReportViewport(bounds: MapBoundsQuery, zoom: number, limit
|
|||||||
return getJSON<MapViewportResponse>(`/api/map-reports/viewport?${params.toString()}`)
|
return getJSON<MapViewportResponse>(`/api/map-reports/viewport?${params.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDefaultMapSource(): Promise<PublicMapTileSourceResponse> {
|
||||||
|
return getJSON<PublicMapTileSourceResponse>('/api/map-source/default')
|
||||||
|
}
|
||||||
|
|
||||||
export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise<ListResponse<TextMessage>> {
|
export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise<ListResponse<TextMessage>> {
|
||||||
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeId))
|
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeId))
|
||||||
}
|
}
|
||||||
@@ -201,6 +209,26 @@ export function getAdminLoginLogs(limit = 100, offset = 0): Promise<AdminLoginLo
|
|||||||
return getJSON<AdminLoginLogsResponse>(`/api/admin/log/login?limit=${limit}&offset=${offset}`)
|
return getJSON<AdminLoginLogsResponse>(`/api/admin/log/login?limit=${limit}&offset=${offset}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAdminMapSources(limit = 100, offset = 0): Promise<ListResponse<MapTileSource>> {
|
||||||
|
return getJSON<ListResponse<MapTileSource>>(listPath('/api/admin/map-source', limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminMapSource(payload: MapTileSourcePayload): Promise<MapTileSourceResponse> {
|
||||||
|
return postJSON<MapTileSourceResponse>('/api/admin/map-source', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAdminMapSource(id: number, payload: MapTileSourcePayload): Promise<MapTileSourceResponse> {
|
||||||
|
return putJSON<MapTileSourceResponse>(`/api/admin/map-source/${id}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAdminMapSource(id: number): Promise<{ status: string }> {
|
||||||
|
return deleteJSON<{ status: string }>(`/api/admin/map-source/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDefaultAdminMapSource(id: number): Promise<MapTileSourceResponse> {
|
||||||
|
return postJSON<MapTileSourceResponse>(`/api/admin/map-source/${id}/default`)
|
||||||
|
}
|
||||||
|
|
||||||
export function getNodeBlockingRules(limit = 100, offset = 0): Promise<ListResponse<NodeBlockingRule>> {
|
export function getNodeBlockingRules(limit = 100, offset = 0): Promise<ListResponse<NodeBlockingRule>> {
|
||||||
return getJSON<ListResponse<NodeBlockingRule>>(listPath('/api/admin/blocking/nodes', limit, offset))
|
return getJSON<ListResponse<NodeBlockingRule>>(listPath('/api/admin/blocking/nodes', limit, offset))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,545 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { createAdminMapSource, deleteAdminMapSource, getAdminMapSources, setDefaultAdminMapSource, updateAdminMapSource } from '../api'
|
||||||
|
import type { MapTileSource, MapTileSourcePayload } from '../types'
|
||||||
|
|
||||||
|
const items = ref<MapTileSource[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 25
|
||||||
|
|
||||||
|
const newSource = ref<MapTileSourcePayload>({
|
||||||
|
name: '',
|
||||||
|
url_template: 'https://tile.openstreetmap.jp/{z}/{x}/{y}.png',
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
max_zoom: 19,
|
||||||
|
enabled: true,
|
||||||
|
is_default: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const canPrev = () => page.value > 1
|
||||||
|
const canNext = () => items.value.length === pageSize
|
||||||
|
const enabledCount = computed(() => items.value.filter((item) => item.enabled).length)
|
||||||
|
const defaultSource = computed(() => items.value.find((item) => item.is_default) ?? null)
|
||||||
|
|
||||||
|
function editableCopy(item: MapTileSource): MapTileSourcePayload {
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
url_template: item.url_template,
|
||||||
|
attribution: item.attribution,
|
||||||
|
max_zoom: item.max_zoom,
|
||||||
|
enabled: item.enabled,
|
||||||
|
is_default: item.is_default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const drafts = ref<Record<number, MapTileSourcePayload>>({})
|
||||||
|
|
||||||
|
function resetNewSource() {
|
||||||
|
newSource.value = {
|
||||||
|
name: '',
|
||||||
|
url_template: '',
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
max_zoom: 19,
|
||||||
|
enabled: true,
|
||||||
|
is_default: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePayload(payload: MapTileSourcePayload): string {
|
||||||
|
if (!payload.name.trim()) {
|
||||||
|
return '请输入图源名称'
|
||||||
|
}
|
||||||
|
const url = payload.url_template.trim()
|
||||||
|
if (!url) {
|
||||||
|
return '请输入图源 URL 模板'
|
||||||
|
}
|
||||||
|
for (const placeholder of ['{z}', '{x}', '{y}']) {
|
||||||
|
if (!url.includes(placeholder)) {
|
||||||
|
return `URL 模板必须包含 ${placeholder}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(payload.max_zoom) || payload.max_zoom < 1 || payload.max_zoom > 30) {
|
||||||
|
return '最大缩放级别必须是 1 到 30 之间的整数'
|
||||||
|
}
|
||||||
|
if (payload.is_default && !payload.enabled) {
|
||||||
|
return '默认图源必须启用'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshItems() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const response = await getAdminMapSources(pageSize, (page.value - 1) * pageSize)
|
||||||
|
items.value = response.items
|
||||||
|
drafts.value = Object.fromEntries(response.items.map((item) => [item.id, editableCopy(item)]))
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(nextPage: number) {
|
||||||
|
page.value = Math.max(1, nextPage)
|
||||||
|
refreshItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSource() {
|
||||||
|
const validation = validatePayload(newSource.value)
|
||||||
|
if (validation) {
|
||||||
|
error.value = validation
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
await createAdminMapSource({ ...newSource.value })
|
||||||
|
message.value = '图源已添加'
|
||||||
|
resetNewSource()
|
||||||
|
page.value = 1
|
||||||
|
await refreshItems()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSource(item: MapTileSource) {
|
||||||
|
const draft = drafts.value[item.id]
|
||||||
|
if (!draft) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const validation = validatePayload(draft)
|
||||||
|
if (validation) {
|
||||||
|
error.value = validation
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
await updateAdminMapSource(item.id, { ...draft })
|
||||||
|
message.value = '图源已保存'
|
||||||
|
await refreshItems()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDefaultSource(item: MapTileSource) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
await setDefaultAdminMapSource(item.id)
|
||||||
|
message.value = '默认图源已更新'
|
||||||
|
await refreshItems()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSource(item: MapTileSource) {
|
||||||
|
if (!window.confirm(`确定要删除图源「${item.name}」吗?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
await deleteAdminMapSource(item.id)
|
||||||
|
message.value = '图源已删除'
|
||||||
|
if (items.value.length === 1 && page.value > 1) {
|
||||||
|
page.value -= 1
|
||||||
|
}
|
||||||
|
await refreshItems()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshItems)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="map-source-page">
|
||||||
|
<div class="map-source-hero panel">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<p class="eyebrow">Map source</p>
|
||||||
|
<h2>地图图源</h2>
|
||||||
|
<p class="muted">集中维护 Leaflet 瓦片图源。URL 模板必须包含 <code>{z}</code>、<code>{x}</code>、<code>{y}</code>。</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-stats">
|
||||||
|
<div>
|
||||||
|
<strong>{{ items.length }}</strong>
|
||||||
|
<span>当前图源</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ enabledCount }}</strong>
|
||||||
|
<span>已启用</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ defaultSource?.name || '-' }}</strong>
|
||||||
|
<span>默认图源</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel map-source-create-panel">
|
||||||
|
<div class="panel-heading compact">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Create</p>
|
||||||
|
<h2>新增图源</h2>
|
||||||
|
</div>
|
||||||
|
<button class="admin-button ghost" type="button" @click="refreshItems" :disabled="loading">{{ loading ? '刷新中...' : '刷新数据' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="map-source-form" @submit.prevent="createSource">
|
||||||
|
<label class="field">名称<input v-model="newSource.name" placeholder="OpenStreetMap Japan" /></label>
|
||||||
|
<label class="field url-field">URL 模板<input v-model="newSource.url_template" placeholder="https://tile.example.com/{z}/{x}/{y}.png" /></label>
|
||||||
|
<label class="field attribution-field">Attribution<input v-model="newSource.attribution" placeholder="© OpenStreetMap contributors" /></label>
|
||||||
|
<label class="field zoom-field">最大缩放<input v-model.number="newSource.max_zoom" type="number" min="1" max="30" /></label>
|
||||||
|
<label class="switch-card"><input v-model="newSource.enabled" type="checkbox" /> <span>启用</span></label>
|
||||||
|
<label class="switch-card"><input v-model="newSource.is_default" type="checkbox" /> <span>设为默认</span></label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="admin-button" type="submit" :disabled="loading">添加图源</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="template-tip">示例:<code>https://tile.openstreetmap.jp/{z}/{x}/{y}.png</code></p>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-if="message" class="success">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel map-source-list-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Sources</p>
|
||||||
|
<h2>图源列表</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge">{{ items.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="items.length === 0" class="empty-state">暂无地图图源,先在上方添加一个配置。</div>
|
||||||
|
|
||||||
|
<article v-for="item in items" :key="item.id" class="map-source-card" :class="{ default: item.is_default, disabled: !item.enabled }">
|
||||||
|
<header class="source-card-title">
|
||||||
|
<div>
|
||||||
|
<div class="source-title-row">
|
||||||
|
<h3>{{ item.name }}</h3>
|
||||||
|
<span v-if="item.is_default" class="status-pill ok">默认</span>
|
||||||
|
<span v-else-if="item.enabled" class="status-pill">启用</span>
|
||||||
|
<span v-else class="status-pill disabled">停用</span>
|
||||||
|
</div>
|
||||||
|
<p class="source-url">{{ item.url_template }}</p>
|
||||||
|
</div>
|
||||||
|
<button v-if="!item.is_default" class="admin-button ghost" :disabled="loading || !item.enabled" @click="setDefaultSource(item)">设为默认</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="drafts[item.id]" class="source-edit-grid">
|
||||||
|
<label class="field">名称<input v-model="drafts[item.id].name" /></label>
|
||||||
|
<label class="field url-field">URL 模板<input v-model="drafts[item.id].url_template" /></label>
|
||||||
|
<label class="field attribution-field">Attribution<input v-model="drafts[item.id].attribution" /></label>
|
||||||
|
<label class="field zoom-field">最大缩放<input v-model.number="drafts[item.id].max_zoom" type="number" min="1" max="30" /></label>
|
||||||
|
<label class="switch-card"><input v-model="drafts[item.id].enabled" type="checkbox" :disabled="item.is_default" /> <span>启用图源</span></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="source-meta">
|
||||||
|
<div><span>ID</span><strong>{{ item.id }}</strong></div>
|
||||||
|
<div><span>最大缩放</span><strong>{{ item.max_zoom }}</strong></div>
|
||||||
|
<div><span>Attribution</span><strong>{{ item.attribution || '-' }}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="admin-button" :disabled="loading" @click="saveSource(item)">保存</button>
|
||||||
|
<button class="admin-button danger" :disabled="loading || item.is_default" @click="removeSource(item)">删除</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<button :disabled="loading || !canPrev()" @click="changePage(page - 1)">上一页</button>
|
||||||
|
<span>第 {{ page }} 页</span>
|
||||||
|
<span>每页 {{ pageSize }} 条</span>
|
||||||
|
<button :disabled="loading || !canNext()" @click="changePage(page + 1)">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.map-source-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-page :deep(input) {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
color: #0f172a;
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-page :deep(input:focus) {
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-page :deep(input[type='checkbox']) {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-hero,
|
||||||
|
.map-source-create-panel,
|
||||||
|
.map-source-list-panel {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #eff6ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats div {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #dbeafe;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 22px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats span,
|
||||||
|
.source-meta span,
|
||||||
|
.template-tip,
|
||||||
|
.source-url {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading,
|
||||||
|
.source-card-title,
|
||||||
|
.source-title-row,
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading,
|
||||||
|
.source-card-title {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading.compact {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-form,
|
||||||
|
.source-edit-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1fr) minmax(320px, 2fr) minmax(220px, 1.4fr) minmax(100px, 0.5fr) auto auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-field {
|
||||||
|
min-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-field {
|
||||||
|
min-width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-card {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 39px;
|
||||||
|
border: 1px solid #dbe4ef;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-tip {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-card {
|
||||||
|
border: 1px solid #dbe4ef;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: inset 4px 0 0 #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-card.default {
|
||||||
|
box-shadow: inset 4px 0 0 #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-card.disabled {
|
||||||
|
background: #f8fafc;
|
||||||
|
box-shadow: inset 4px 0 0 #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-title-row h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-url {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.ok {
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.disabled {
|
||||||
|
color: #475569;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-edit-grid {
|
||||||
|
grid-template-columns: minmax(180px, 1fr) minmax(320px, 2fr) minmax(220px, 1.4fr) minmax(100px, 0.5fr) auto;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(70px, 0.4fr) minmax(100px, 0.5fr) minmax(220px, 2fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-meta div {
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-meta strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.map-source-hero,
|
||||||
|
.panel-heading,
|
||||||
|
.source-card-title {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats,
|
||||||
|
.map-source-form,
|
||||||
|
.source-edit-grid,
|
||||||
|
.source-meta {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-field,
|
||||||
|
.attribution-field {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.hero-stats,
|
||||||
|
.map-source-form,
|
||||||
|
.source-edit-grid,
|
||||||
|
.source-meta {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import type { MapBoundsChangePayload, MapClusterNode, MapNode, MapRenderable } from '../types'
|
import { fallbackMapSource } from '../mapSource'
|
||||||
|
import type { MapBoundsChangePayload, MapClusterNode, MapNode, MapRenderable, PublicMapTileSource } from '../types'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
items: MapRenderable[]
|
items: MapRenderable[]
|
||||||
@@ -10,9 +11,11 @@ const props = withDefaults(defineProps<{
|
|||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
autoFit?: boolean
|
autoFit?: boolean
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
mapSource?: PublicMapTileSource
|
||||||
}>(), {
|
}>(), {
|
||||||
autoFit: true,
|
autoFit: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
mapSource: () => fallbackMapSource,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -29,6 +32,7 @@ const menuX = ref(0)
|
|||||||
const menuY = ref(0)
|
const menuY = ref(0)
|
||||||
const lastRaisedNodeId = ref<string | null>(null)
|
const lastRaisedNodeId = ref<string | null>(null)
|
||||||
let map: L.Map | null = null
|
let map: L.Map | null = null
|
||||||
|
let tileLayer: L.TileLayer | null = null
|
||||||
let markerLayer: L.LayerGroup | null = null
|
let markerLayer: L.LayerGroup | null = null
|
||||||
const markersByKey = new Map<string, L.Marker>()
|
const markersByKey = new Map<string, L.Marker>()
|
||||||
let hasFitBounds = false
|
let hasFitBounds = false
|
||||||
@@ -55,13 +59,7 @@ onMounted(async () => {
|
|||||||
maxBoundsViscosity: 1.0,
|
maxBoundsViscosity: 1.0,
|
||||||
worldCopyJump: false,
|
worldCopyJump: false,
|
||||||
}).setView(defaultMapCenter, defaultMapZoom)
|
}).setView(defaultMapCenter, defaultMapZoom)
|
||||||
L.tileLayer('https://tile.openstreetmap.jp/{z}/{x}/{y}.png', {
|
applyTileLayer()
|
||||||
minZoom: minMapZoom,
|
|
||||||
maxZoom: 19,
|
|
||||||
noWrap: true,
|
|
||||||
bounds: worldBounds,
|
|
||||||
attribution: '© OpenStreetMap contributors',
|
|
||||||
}).addTo(map)
|
|
||||||
map.on('click', () => {
|
map.on('click', () => {
|
||||||
closeNodeMenu()
|
closeNodeMenu()
|
||||||
emit('clear-node')
|
emit('clear-node')
|
||||||
@@ -77,6 +75,7 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener('keydown', handleKeydown)
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
map?.remove()
|
map?.remove()
|
||||||
map = null
|
map = null
|
||||||
|
tileLayer = null
|
||||||
markerLayer = null
|
markerLayer = null
|
||||||
markersByKey.clear()
|
markersByKey.clear()
|
||||||
})
|
})
|
||||||
@@ -87,6 +86,28 @@ watch(
|
|||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.mapSource,
|
||||||
|
() => applyTileLayer(),
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function applyTileLayer() {
|
||||||
|
if (!map) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (tileLayer) {
|
||||||
|
tileLayer.remove()
|
||||||
|
}
|
||||||
|
tileLayer = L.tileLayer(props.mapSource.url_template, {
|
||||||
|
minZoom: minMapZoom,
|
||||||
|
maxZoom: props.mapSource.max_zoom || fallbackMapSource.max_zoom,
|
||||||
|
noWrap: true,
|
||||||
|
bounds: worldBounds,
|
||||||
|
attribution: props.mapSource.attribution || fallbackMapSource.attribution,
|
||||||
|
}).addTo(map)
|
||||||
|
}
|
||||||
|
|
||||||
function closeNodeMenu() {
|
function closeNodeMenu() {
|
||||||
menuNode.value = null
|
menuNode.value = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { createNodeBlockingRule, deleteNode, deleteTextMessage, getMapReportById, getNodeInfoById, getPositions, getTelemetry, getTextMessages } from '../api'
|
import { createNodeBlockingRule, deleteNode, deleteTextMessage, getMapReportById, getNodeInfoById, getPositions, getTelemetry, getTextMessages } from '../api'
|
||||||
import type { MapReport, NodeInfo, PositionRecord, TelemetryRecord, TextMessage } from '../types'
|
import type { MapReport, NodeInfo, PositionRecord, PublicMapTileSource, TelemetryRecord, TextMessage } from '../types'
|
||||||
|
import { fallbackMapSource, loadDefaultMapSource } from '../mapSource'
|
||||||
import ConfirmDeleteModal from './ConfirmDeleteModal.vue'
|
import ConfirmDeleteModal from './ConfirmDeleteModal.vue'
|
||||||
import NodeTrajectoryMap from './NodeTrajectoryMap.vue'
|
import NodeTrajectoryMap from './NodeTrajectoryMap.vue'
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ const mapReport = ref<MapReport | null>(null)
|
|||||||
const messages = ref<TextMessage[]>([])
|
const messages = ref<TextMessage[]>([])
|
||||||
const positions = ref<PositionRecord[]>([])
|
const positions = ref<PositionRecord[]>([])
|
||||||
const telemetry = ref<TelemetryRecord[]>([])
|
const telemetry = ref<TelemetryRecord[]>([])
|
||||||
|
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const chatLoadingOlder = ref(false)
|
const chatLoadingOlder = ref(false)
|
||||||
const chatHasMore = ref(true)
|
const chatHasMore = ref(true)
|
||||||
@@ -367,6 +369,10 @@ function handleChatScroll() {
|
|||||||
loadOlderMessages()
|
loadOlderMessages()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMapSource() {
|
||||||
|
mapSource.value = await loadDefaultMapSource()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDetails() {
|
async function loadDetails() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -392,6 +398,7 @@ async function loadDetails() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('click', closeMessageMenu)
|
window.addEventListener('click', closeMessageMenu)
|
||||||
window.addEventListener('keydown', handleKeydown)
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
loadMapSource()
|
||||||
loadDetails()
|
loadDetails()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -492,7 +499,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<span class="badge">{{ positions.length }}</span>
|
<span class="badge">{{ positions.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<NodeTrajectoryMap :positions="positions" />
|
<NodeTrajectoryMap :positions="positions" :map-source="mapSource" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,34 @@
|
|||||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import type { PositionRecord } from '../types'
|
import { fallbackMapSource } from '../mapSource'
|
||||||
|
import type { PositionRecord, PublicMapTileSource } from '../types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
positions: PositionRecord[]
|
positions: PositionRecord[]
|
||||||
}>()
|
mapSource?: PublicMapTileSource
|
||||||
|
}>(), {
|
||||||
|
mapSource: () => fallbackMapSource,
|
||||||
|
})
|
||||||
|
|
||||||
const mapEl = ref<HTMLElement | null>(null)
|
const mapEl = ref<HTMLElement | null>(null)
|
||||||
let map: L.Map | null = null
|
let map: L.Map | null = null
|
||||||
|
let tileLayer: L.TileLayer | null = null
|
||||||
let layer: L.LayerGroup | null = null
|
let layer: L.LayerGroup | null = null
|
||||||
|
|
||||||
|
function applyTileLayer() {
|
||||||
|
if (!map) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (tileLayer) {
|
||||||
|
tileLayer.remove()
|
||||||
|
}
|
||||||
|
tileLayer = L.tileLayer(props.mapSource.url_template, {
|
||||||
|
maxZoom: props.mapSource.max_zoom || fallbackMapSource.max_zoom,
|
||||||
|
attribution: props.mapSource.attribution || fallbackMapSource.attribution,
|
||||||
|
}).addTo(map)
|
||||||
|
}
|
||||||
|
|
||||||
function renderTrajectory() {
|
function renderTrajectory() {
|
||||||
if (!map || !layer) {
|
if (!map || !layer) {
|
||||||
return
|
return
|
||||||
@@ -49,10 +67,7 @@ onMounted(async () => {
|
|||||||
maxBoundsViscosity: 1.0,
|
maxBoundsViscosity: 1.0,
|
||||||
worldCopyJump: false,
|
worldCopyJump: false,
|
||||||
}).setView([0, 0], 2)
|
}).setView([0, 0], 2)
|
||||||
L.tileLayer('https://tile.openstreetmap.jp/{z}/{x}/{y}.png', {
|
applyTileLayer()
|
||||||
maxZoom: 19,
|
|
||||||
attribution: '© OpenStreetMap contributors',
|
|
||||||
}).addTo(map)
|
|
||||||
layer = L.layerGroup().addTo(map)
|
layer = L.layerGroup().addTo(map)
|
||||||
renderTrajectory()
|
renderTrajectory()
|
||||||
})
|
})
|
||||||
@@ -60,6 +75,7 @@ onMounted(async () => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
map?.remove()
|
map?.remove()
|
||||||
map = null
|
map = null
|
||||||
|
tileLayer = null
|
||||||
layer = null
|
layer = null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -68,6 +84,12 @@ watch(
|
|||||||
() => renderTrajectory(),
|
() => renderTrajectory(),
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.mapSource,
|
||||||
|
() => applyTileLayer(),
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { getDefaultMapSource } from './api'
|
||||||
|
import type { PublicMapTileSource } from './types'
|
||||||
|
|
||||||
|
export const fallbackMapSource: PublicMapTileSource = {
|
||||||
|
id: 0,
|
||||||
|
name: 'OpenStreetMap Japan',
|
||||||
|
url_template: 'https://tile.openstreetmap.jp/{z}/{x}/{y}.png',
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
max_zoom: 19,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDefaultMapSource(): Promise<PublicMapTileSource> {
|
||||||
|
try {
|
||||||
|
const response = await getDefaultMapSource()
|
||||||
|
return response.item
|
||||||
|
} catch {
|
||||||
|
return fallbackMapSource
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,38 @@ export interface MapBoundsChangePayload {
|
|||||||
zoom: number
|
zoom: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicMapTileSource {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
url_template: string
|
||||||
|
attribution: string
|
||||||
|
max_zoom: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapTileSource extends PublicMapTileSource {
|
||||||
|
enabled: boolean
|
||||||
|
is_default: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapTileSourcePayload {
|
||||||
|
name: string
|
||||||
|
url_template: string
|
||||||
|
attribution: string
|
||||||
|
max_zoom: number
|
||||||
|
enabled: boolean
|
||||||
|
is_default: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapTileSourceResponse {
|
||||||
|
item: MapTileSource
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicMapTileSourceResponse {
|
||||||
|
item: PublicMapTileSource
|
||||||
|
}
|
||||||
|
|
||||||
export interface MapViewportPoint extends MapReport {
|
export interface MapViewportPoint extends MapReport {
|
||||||
type: 'point'
|
type: 'point'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
|
|||||||
registerNodeInfoRoutes(r, store, "/nodeinfo")
|
registerNodeInfoRoutes(r, store, "/nodeinfo")
|
||||||
registerNodeInfoRoutes(r, store, "/nodes")
|
registerNodeInfoRoutes(r, store, "/nodes")
|
||||||
registerMapReportRoutes(r, store)
|
registerMapReportRoutes(r, store)
|
||||||
|
registerMapSourceRoutes(r, store)
|
||||||
registerHelpRoutes(r, store)
|
registerHelpRoutes(r, store)
|
||||||
r.GET("/text-messages", func(c *gin.Context) {
|
r.GET("/text-messages", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := parseListOptions(c)
|
||||||
@@ -186,6 +187,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
|||||||
registerAdminBlockingRoutes(protected, store, blocking)
|
registerAdminBlockingRoutes(protected, store, blocking)
|
||||||
registerAdminMQTTForwardRoutes(protected, store, forwarder)
|
registerAdminMQTTForwardRoutes(protected, store, forwarder)
|
||||||
registerAdminRuntimeSettingsRoutes(protected, store, settings)
|
registerAdminRuntimeSettingsRoutes(protected, store, settings)
|
||||||
|
registerAdminMapSourceRoutes(protected, store)
|
||||||
registerAdminHelpRoutes(protected, store)
|
registerAdminHelpRoutes(protected, store)
|
||||||
protected.GET("/me", func(c *gin.Context) {
|
protected.GET("/me", func(c *gin.Context) {
|
||||||
claims := c.MustGet("admin_claims").(*sessionClaims)
|
claims := c.MustGet("admin_claims").(*sessionClaims)
|
||||||
|
|||||||
Reference in New Issue
Block a user