Merge branch 'main' of https://git.lmve.net/kevin/meshtastic_mqtt_server
# Conflicts: # meshmap_frontend/src/App.vue # web.go
This commit is contained in:
@@ -418,7 +418,7 @@ onMounted(() => {
|
||||
<h2>屏蔽管理</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="empty">管理节点、IP/CIDR、违禁词三类屏蔽规则。当前页面只维护规则,不改变 MQTT 转发行为。</p>
|
||||
<p class="empty">管理节点、IP/CIDR、违禁词三类屏蔽规则。</p>
|
||||
</div>
|
||||
|
||||
<div class="panel admin-status-panel">
|
||||
|
||||
@@ -190,10 +190,8 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
border: 1px solid rgba(37, 99, 235, 0.14);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.16), transparent 32%),
|
||||
linear-gradient(135deg, #ffffff 0%, #f8fbff 52%, #eef6ff 100%);
|
||||
border: 1px solid var(--color-border);
|
||||
background: linear-gradient(135deg, var(--color-surface) 0%, var(--color-surface-soft) 100%);
|
||||
}
|
||||
|
||||
.control-header {
|
||||
@@ -210,19 +208,19 @@ onBeforeUnmount(() => {
|
||||
.control-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #cbd5e1;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
color: #475569;
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
background: color-mix(in srgb, var(--color-surface) 84%, transparent);
|
||||
}
|
||||
|
||||
.control-badge.active {
|
||||
border-color: rgba(22, 163, 74, 0.32);
|
||||
color: #15803d;
|
||||
background: #dcfce7;
|
||||
border-color: color-mix(in srgb, var(--color-success) 36%, white);
|
||||
color: color-mix(in srgb, var(--color-success) 72%, var(--color-heading));
|
||||
background: var(--color-success-soft);
|
||||
}
|
||||
|
||||
.control-body {
|
||||
@@ -235,10 +233,10 @@ onBeforeUnmount(() => {
|
||||
|
||||
.control-copy,
|
||||
.switch-card {
|
||||
border: 1px solid rgba(203, 213, 225, 0.78);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.06);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.control-copy {
|
||||
@@ -247,13 +245,13 @@ onBeforeUnmount(() => {
|
||||
|
||||
.control-copy h3 {
|
||||
margin: 0 0 0.45rem;
|
||||
color: #0f172a;
|
||||
color: var(--color-heading);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.control-copy p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
color: var(--color-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@@ -269,20 +267,20 @@ onBeforeUnmount(() => {
|
||||
gap: 1rem;
|
||||
min-height: 108px;
|
||||
padding: 1rem;
|
||||
color: #334155;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, background-color 0.16s ease;
|
||||
}
|
||||
|
||||
.switch-card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.09);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.switch-card.enabled {
|
||||
border-color: rgba(22, 163, 74, 0.35);
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
|
||||
border-color: color-mix(in srgb, var(--color-success) 42%, white);
|
||||
background: var(--color-success-soft);
|
||||
}
|
||||
|
||||
.switch-card.saving {
|
||||
@@ -303,12 +301,12 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.switch-text strong {
|
||||
color: #0f172a;
|
||||
color: var(--color-heading);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.switch-text small {
|
||||
color: #64748b;
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -319,9 +317,9 @@ onBeforeUnmount(() => {
|
||||
width: 54px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
background: #cbd5e1;
|
||||
box-shadow: inset 0 2px 4px rgba(15, 23, 42, 0.14);
|
||||
transition: background 0.15s ease;
|
||||
background: var(--color-border-strong);
|
||||
box-shadow: inset 0 2px 4px rgba(47, 52, 50, 0.12);
|
||||
transition: background-color 0.16s ease;
|
||||
}
|
||||
|
||||
.switch-toggle::after {
|
||||
@@ -333,12 +331,12 @@ onBeforeUnmount(() => {
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.24);
|
||||
transition: transform 0.15s ease;
|
||||
box-shadow: 0 4px 10px rgba(47, 52, 50, 0.18);
|
||||
transition: transform 0.16s ease;
|
||||
}
|
||||
|
||||
.switch-card.enabled .switch-toggle {
|
||||
background: linear-gradient(135deg, #16a34a, #22c55e);
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.switch-card.enabled .switch-toggle::after {
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
<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,
|
||||
proxy_enabled: true,
|
||||
})
|
||||
|
||||
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,
|
||||
proxy_enabled: item.proxy_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
const drafts = ref<Record<number, MapTileSourcePayload>>({})
|
||||
|
||||
function resetNewSource() {
|
||||
newSource.value = {
|
||||
name: '',
|
||||
url_template: '',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
max_zoom: 19,
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
proxy_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<label class="switch-card"><input v-model="newSource.proxy_enabled" 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>
|
||||
<label class="switch-card"><input v-model="drafts[item.id].proxy_enabled" type="checkbox" /> <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%;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 11px;
|
||||
color: var(--color-heading);
|
||||
font: inherit;
|
||||
background: var(--color-surface);
|
||||
outline: none;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.map-source-page :deep(input:focus) {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
.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, var(--color-surface) 0%, var(--color-surface-soft) 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 var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
background: color-mix(in srgb, var(--color-surface) 84%, transparent);
|
||||
}
|
||||
|
||||
.hero-stats strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: color-mix(in srgb, var(--color-primary) 72%, var(--color-heading));
|
||||
font-size: 22px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero-stats span,
|
||||
.source-meta span,
|
||||
.template-tip,
|
||||
.source-url {
|
||||
color: var(--color-muted);
|
||||
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: var(--color-text);
|
||||
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 var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 11px;
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
background: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.template-tip {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.map-source-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
background: var(--color-surface);
|
||||
box-shadow: inset 4px 0 0 var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.map-source-card.default {
|
||||
box-shadow: inset 4px 0 0 var(--color-success);
|
||||
}
|
||||
|
||||
.map-source-card.disabled {
|
||||
background: var(--color-surface-soft);
|
||||
box-shadow: inset 4px 0 0 var(--color-border-strong);
|
||||
}
|
||||
|
||||
.source-title-row h3 {
|
||||
margin: 0;
|
||||
color: var(--color-heading);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.source-url {
|
||||
max-width: 860px;
|
||||
margin: 6px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
border-radius: 999px;
|
||||
padding: 7px 12px;
|
||||
color: color-mix(in srgb, var(--color-primary) 72%, var(--color-heading));
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.status-pill.ok {
|
||||
color: color-mix(in srgb, var(--color-success) 72%, var(--color-heading));
|
||||
background: var(--color-success-soft);
|
||||
}
|
||||
|
||||
.status-pill.disabled {
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.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: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
background: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.source-meta strong {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border: 1px dashed var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
color: var(--color-muted);
|
||||
text-align: center;
|
||||
background: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
@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>
|
||||
@@ -582,21 +582,20 @@ onBeforeUnmount(() => {
|
||||
.mqtt-forward-page :deep(input),
|
||||
.mqtt-forward-page :deep(select) {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 11px;
|
||||
color: #0f172a;
|
||||
color: var(--color-heading);
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
background: var(--color-surface);
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.mqtt-forward-page :deep(input:focus),
|
||||
.mqtt-forward-page :deep(select:focus) {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
.mqtt-hero,
|
||||
@@ -610,7 +609,7 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #eff6ff 100%);
|
||||
background: linear-gradient(135deg, var(--color-surface) 0%, var(--color-surface-soft) 100%);
|
||||
}
|
||||
|
||||
.mqtt-hero h2 {
|
||||
@@ -624,23 +623,23 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.hero-stats div {
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
background: color-mix(in srgb, var(--color-surface) 84%, transparent);
|
||||
}
|
||||
|
||||
.hero-stats strong {
|
||||
display: block;
|
||||
color: #1d4ed8;
|
||||
color: color-mix(in srgb, var(--color-primary) 72%, var(--color-heading));
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.hero-stats span,
|
||||
.endpoint-line,
|
||||
.runtime-grid span {
|
||||
color: #64748b;
|
||||
color: var(--color-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -674,7 +673,7 @@ onBeforeUnmount(() => {
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: #334155;
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -687,9 +686,9 @@ onBeforeUnmount(() => {
|
||||
.edit-section,
|
||||
.forwarder-card,
|
||||
.topics-box {
|
||||
border: 1px solid #dbe4ef;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.broker-card {
|
||||
@@ -702,16 +701,16 @@ onBeforeUnmount(() => {
|
||||
|
||||
.broker-card legend {
|
||||
padding: 0 8px;
|
||||
color: #334155;
|
||||
color: var(--color-text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #fff 100%);
|
||||
background: linear-gradient(180deg, var(--color-surface-soft) 0%, var(--color-surface) 100%);
|
||||
}
|
||||
|
||||
.target-card {
|
||||
background: linear-gradient(180deg, #f8fffb 0%, #fff 100%);
|
||||
background: linear-gradient(180deg, var(--color-success-soft) 0%, var(--color-surface) 100%);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
@@ -726,13 +725,13 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #dbe4ef;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 11px;
|
||||
color: #334155;
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
background: #f8fafc;
|
||||
background: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.switch-card input,
|
||||
@@ -743,34 +742,34 @@ onBeforeUnmount(() => {
|
||||
.forwarder-card {
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
box-shadow: inset 4px 0 0 #dbeafe;
|
||||
box-shadow: inset 4px 0 0 var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.forwarder-title h3 {
|
||||
color: #0f172a;
|
||||
color: var(--color-heading);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
border-radius: 999px;
|
||||
padding: 7px 12px;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
color: color-mix(in srgb, var(--color-warning) 72%, var(--color-heading));
|
||||
background: var(--color-warning-soft);
|
||||
}
|
||||
|
||||
.status-pill.ok {
|
||||
color: #166534;
|
||||
background: #dcfce7;
|
||||
color: color-mix(in srgb, var(--color-success) 72%, var(--color-heading));
|
||||
background: var(--color-success-soft);
|
||||
}
|
||||
|
||||
.status-pill.warn {
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
color: color-mix(in srgb, var(--color-warning) 72%, var(--color-heading));
|
||||
background: var(--color-warning-soft);
|
||||
}
|
||||
|
||||
.status-pill.disabled {
|
||||
color: #475569;
|
||||
background: #e2e8f0;
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.runtime-grid {
|
||||
@@ -781,23 +780,23 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.runtime-grid div {
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
background: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.runtime-grid strong {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #0f172a;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
.inline-error {
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 36%, white);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
color: color-mix(in srgb, var(--color-danger) 74%, var(--color-heading));
|
||||
background: var(--color-danger-soft);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -831,24 +830,10 @@ onBeforeUnmount(() => {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-button.ghost {
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #bfdbfe;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.admin-button.secondary {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.admin-button.danger {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.topics-box {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
background: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.topic-row {
|
||||
@@ -856,25 +841,25 @@ onBeforeUnmount(() => {
|
||||
grid-template-columns: minmax(180px, 1.6fr) minmax(90px, 0.7fr) minmax(150px, 1fr) repeat(2, minmax(120px, 1fr)) minmax(90px, 0.7fr) minmax(90px, 0.7fr) auto auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.topic-row.new-topic {
|
||||
border: 1px dashed #93c5fd;
|
||||
border-radius: 14px;
|
||||
border: 1px dashed color-mix(in srgb, var(--color-primary) 54%, white);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem;
|
||||
background: #eff6ff;
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
color: #64748b;
|
||||
color: var(--color-muted);
|
||||
text-align: center;
|
||||
background: #f8fafc;
|
||||
background: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
|
||||
@@ -26,6 +26,7 @@ const menuX = ref(0)
|
||||
const menuY = ref(0)
|
||||
const topThreshold = 8
|
||||
const bottomThreshold = 40
|
||||
const scrollOverflowAllowance = 1
|
||||
|
||||
const groupedMessages = computed<GroupedTextMessage[]>(() => {
|
||||
const groups = new Map<string, GroupedTextMessage>()
|
||||
@@ -102,25 +103,29 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
closeMessageMenu()
|
||||
const el = panelRef.value
|
||||
function loadOlderFromCurrentScroll(el: HTMLElement) {
|
||||
if (
|
||||
!el ||
|
||||
props.loadingOlder ||
|
||||
!props.hasMoreMessages ||
|
||||
props.messages.length === 0 ||
|
||||
groupedMessages.value.length === 0 ||
|
||||
restoreScrollHeight != null
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (el.scrollTop <= topThreshold) {
|
||||
restoreScrollHeight = el.scrollHeight
|
||||
restoreScrollTop = el.scrollTop
|
||||
restoreMessageCount = props.messages.length
|
||||
emit('load-older')
|
||||
restoreScrollHeight = el.scrollHeight
|
||||
restoreScrollTop = el.scrollTop
|
||||
restoreMessageCount = groupedMessages.value.length
|
||||
emit('load-older')
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
closeMessageMenu()
|
||||
const el = panelRef.value
|
||||
if (!el || el.scrollTop > topThreshold) {
|
||||
return
|
||||
}
|
||||
loadOlderFromCurrentScroll(el)
|
||||
}
|
||||
|
||||
onBeforeUpdate(() => {
|
||||
@@ -138,6 +143,9 @@ onMounted(async () => {
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
didInitialScroll = true
|
||||
if (el.scrollHeight <= el.clientHeight + scrollOverflowAllowance) {
|
||||
loadOlderFromCurrentScroll(el)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -153,7 +161,7 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
if (restoreScrollHeight != null) {
|
||||
if (props.messages.length > restoreMessageCount) {
|
||||
if (groupedMessages.value.length > restoreMessageCount) {
|
||||
el.scrollTop = el.scrollHeight - restoreScrollHeight + restoreScrollTop
|
||||
clearRestoreState()
|
||||
return
|
||||
@@ -167,6 +175,10 @@ onUpdated(() => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
didInitialScroll = true
|
||||
}
|
||||
|
||||
if (el.scrollHeight <= el.clientHeight + scrollOverflowAllowance) {
|
||||
loadOlderFromCurrentScroll(el)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import L from 'leaflet'
|
||||
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<{
|
||||
items: MapRenderable[]
|
||||
@@ -10,9 +11,13 @@ const props = withDefaults(defineProps<{
|
||||
isAdmin: boolean
|
||||
autoFit?: boolean
|
||||
loading?: boolean
|
||||
mapSource?: PublicMapTileSource
|
||||
mapSources?: PublicMapTileSource[]
|
||||
}>(), {
|
||||
autoFit: true,
|
||||
loading: false,
|
||||
mapSource: () => fallbackMapSource,
|
||||
mapSources: () => [fallbackMapSource],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -21,6 +26,7 @@ const emit = defineEmits<{
|
||||
'delete-node': [nodeId: string]
|
||||
'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null }]
|
||||
'bounds-change': [payload: MapBoundsChangePayload]
|
||||
'map-source-change': [sourceId: number]
|
||||
}>()
|
||||
|
||||
const mapEl = ref<HTMLElement | null>(null)
|
||||
@@ -29,8 +35,11 @@ const menuX = ref(0)
|
||||
const menuY = ref(0)
|
||||
const lastRaisedNodeId = ref<string | null>(null)
|
||||
let map: L.Map | null = null
|
||||
let tileLayer: L.TileLayer | null = null
|
||||
let markerLayer: L.LayerGroup | null = null
|
||||
const markersByKey = new Map<string, L.Marker>()
|
||||
const overlapShuffleOrders = new Map<string, string[]>()
|
||||
const shuffledSelectedNodeIds = new Set<string>()
|
||||
let hasFitBounds = false
|
||||
|
||||
const minMapZoom = 3
|
||||
@@ -55,15 +64,12 @@ onMounted(async () => {
|
||||
maxBoundsViscosity: 1.0,
|
||||
worldCopyJump: false,
|
||||
}).setView(defaultMapCenter, defaultMapZoom)
|
||||
L.tileLayer('https://tile.openstreetmap.jp/{z}/{x}/{y}.png', {
|
||||
minZoom: minMapZoom,
|
||||
maxZoom: 19,
|
||||
noWrap: true,
|
||||
bounds: worldBounds,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
}).addTo(map)
|
||||
map.attributionControl.setPrefix(false)
|
||||
applyTileLayer()
|
||||
map.on('click', () => {
|
||||
closeNodeMenu()
|
||||
overlapShuffleOrders.clear()
|
||||
shuffledSelectedNodeIds.clear()
|
||||
emit('clear-node')
|
||||
})
|
||||
map.on('moveend', emitBoundsChange)
|
||||
@@ -77,8 +83,11 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
map?.remove()
|
||||
map = null
|
||||
tileLayer = null
|
||||
markerLayer = null
|
||||
markersByKey.clear()
|
||||
overlapShuffleOrders.clear()
|
||||
shuffledSelectedNodeIds.clear()
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -87,6 +96,32 @@ watch(
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.mapSource,
|
||||
() => applyTileLayer(),
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function selectMapSource(sourceId: number) {
|
||||
emit('map-source-change', sourceId)
|
||||
}
|
||||
|
||||
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() {
|
||||
menuNode.value = null
|
||||
}
|
||||
@@ -149,6 +184,7 @@ function renderMarkers(forceFit: boolean) {
|
||||
}
|
||||
const bounds = L.latLngBounds([])
|
||||
const visibleMarkerKeys = new Set<string>()
|
||||
const overlapGroups = buildOverlapGroups(props.items)
|
||||
|
||||
for (const item of props.items) {
|
||||
const markerKey = mapMarkerKey(item)
|
||||
@@ -166,8 +202,14 @@ function renderMarkers(forceFit: boolean) {
|
||||
}
|
||||
|
||||
const node = item
|
||||
const selected = node.node_id === props.selectedNodeId
|
||||
const rawSelected = node.node_id === props.selectedNodeId
|
||||
const shuffledSelected = rawSelected && shuffledSelectedNodeIds.has(node.node_id)
|
||||
const selected = rawSelected && !shuffledSelected
|
||||
const overlapGroupKey = nodeOverlapGroupKey(node, overlapGroups)
|
||||
const overlapGroup = overlapGroupKey ? overlapGroups.get(overlapGroupKey) : undefined
|
||||
const overlapIndex = overlapGroup ? nodeOverlapIndex(node, overlapGroup) : 0
|
||||
const raised = selected || node.node_id === lastRaisedNodeId.value
|
||||
const zIndexOffset = raised ? 1000 : overlapIndex
|
||||
const nodeIcon = L.divIcon({
|
||||
className: `node-marker${selected ? ' selected' : ''}`,
|
||||
html: `<span style="--node-color: ${nodeColor(node.node_id)}">${escapeHTML(node.label || 'N')}</span>`,
|
||||
@@ -180,7 +222,7 @@ function renderMarkers(forceFit: boolean) {
|
||||
marker = L.marker([node.latitude, node.longitude], {
|
||||
icon: nodeIcon,
|
||||
title: node.label,
|
||||
zIndexOffset: raised ? 1000 : 0,
|
||||
zIndexOffset,
|
||||
})
|
||||
marker.bindPopup(buildNodePopupHTML(node), { maxWidth: 320, className: 'node-detail-popup' })
|
||||
marker.addTo(markerLayer)
|
||||
@@ -188,7 +230,7 @@ function renderMarkers(forceFit: boolean) {
|
||||
} else {
|
||||
marker.setLatLng([node.latitude, node.longitude])
|
||||
marker.setIcon(nodeIcon)
|
||||
marker.setZIndexOffset(raised ? 1000 : 0)
|
||||
marker.setZIndexOffset(zIndexOffset)
|
||||
marker.options.title = node.label
|
||||
marker.getElement()?.setAttribute('title', node.label)
|
||||
const popup = marker.getPopup()
|
||||
@@ -203,8 +245,18 @@ function renderMarkers(forceFit: boolean) {
|
||||
marker.off('contextmenu')
|
||||
marker.on('click', (event) => {
|
||||
L.DomEvent.stopPropagation(event)
|
||||
lastRaisedNodeId.value = node.node_id
|
||||
closeNodeMenu()
|
||||
if (node.node_id === props.selectedNodeId) {
|
||||
if (moveSelectedNodeBehindOverlap(node, overlapGroups)) {
|
||||
shuffledSelectedNodeIds.add(node.node_id)
|
||||
marker?.closePopup()
|
||||
emit('clear-node')
|
||||
renderMarkers(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
shuffledSelectedNodeIds.clear()
|
||||
lastRaisedNodeId.value = node.node_id
|
||||
emit('select-node', node.node_id)
|
||||
})
|
||||
marker.on('contextmenu', (event) => openNodeMenu(node, event))
|
||||
@@ -235,6 +287,126 @@ function mapMarkerKey(item: MapRenderable): string {
|
||||
return `node:${item.node_id}`
|
||||
}
|
||||
|
||||
function buildOverlapGroups(items: MapRenderable[]): Map<string, string[]> {
|
||||
const groups = new Map<string, string[]>()
|
||||
if (!map) {
|
||||
return groups
|
||||
}
|
||||
|
||||
const capsules = items
|
||||
.filter((item): item is MapNode => item.type !== 'cluster')
|
||||
.map((node) => ({ node, bounds: nodeCapsuleBounds(node) }))
|
||||
const visited = new Set<string>()
|
||||
|
||||
for (const capsule of capsules) {
|
||||
if (visited.has(capsule.node.node_id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const stack = [capsule]
|
||||
const group: string[] = []
|
||||
visited.add(capsule.node.node_id)
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()
|
||||
if (!current) {
|
||||
continue
|
||||
}
|
||||
group.push(current.node.node_id)
|
||||
|
||||
for (const candidate of capsules) {
|
||||
if (visited.has(candidate.node.node_id)) {
|
||||
continue
|
||||
}
|
||||
if (capsuleBoundsOverlap(current.bounds, candidate.bounds)) {
|
||||
visited.add(candidate.node.node_id)
|
||||
stack.push(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (group.length >= 2) {
|
||||
const key = overlapGroupKey(group)
|
||||
const existingOrder = overlapShuffleOrders.get(key) ?? []
|
||||
const activeIds = new Set(group)
|
||||
const ordered = existingOrder.filter((nodeId) => activeIds.has(nodeId))
|
||||
for (const nodeId of group) {
|
||||
if (!ordered.includes(nodeId)) {
|
||||
ordered.push(nodeId)
|
||||
}
|
||||
}
|
||||
overlapShuffleOrders.set(key, ordered)
|
||||
groups.set(key, ordered)
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of overlapShuffleOrders.keys()) {
|
||||
if (!groups.has(key)) {
|
||||
overlapShuffleOrders.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function nodeOverlapGroupKey(node: MapNode, overlapGroups: Map<string, string[]>): string | null {
|
||||
for (const [key, nodeIds] of overlapGroups) {
|
||||
if (nodeIds.includes(node.node_id)) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function nodeOverlapIndex(node: MapNode, group: string[]): number {
|
||||
const index = group.indexOf(node.node_id)
|
||||
return index === -1 ? 0 : index
|
||||
}
|
||||
|
||||
function moveSelectedNodeBehindOverlap(node: MapNode, overlapGroups: Map<string, string[]>): boolean {
|
||||
const groupKey = nodeOverlapGroupKey(node, overlapGroups)
|
||||
if (!groupKey) {
|
||||
return false
|
||||
}
|
||||
const group = overlapGroups.get(groupKey)
|
||||
if (!group || group.length < 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nextOrder = [node.node_id, ...group.filter((nodeId) => nodeId !== node.node_id)]
|
||||
overlapShuffleOrders.set(groupKey, nextOrder)
|
||||
lastRaisedNodeId.value = null
|
||||
return true
|
||||
}
|
||||
|
||||
function overlapGroupKey(nodeIds: string[]): string {
|
||||
return [...nodeIds].sort().join('|')
|
||||
}
|
||||
|
||||
function nodeCapsuleBounds(node: MapNode): { left: number; right: number; top: number; bottom: number } {
|
||||
const point = map!.latLngToLayerPoint([node.latitude, node.longitude])
|
||||
const width = nodeCapsuleWidth(node)
|
||||
const height = 22
|
||||
return {
|
||||
left: point.x - width / 2,
|
||||
right: point.x + width / 2,
|
||||
top: point.y - height / 2,
|
||||
bottom: point.y + height / 2,
|
||||
}
|
||||
}
|
||||
|
||||
function nodeCapsuleWidth(node: MapNode): number {
|
||||
const label = node.label || 'N'
|
||||
return Math.max(34, Math.ceil(label.length * 6 + 10))
|
||||
}
|
||||
|
||||
function capsuleBoundsOverlap(
|
||||
left: { left: number; right: number; top: number; bottom: number },
|
||||
right: { left: number; right: number; top: number; bottom: number },
|
||||
): boolean {
|
||||
return left.left <= right.right && left.right >= right.left && left.top <= right.bottom && left.bottom >= right.top
|
||||
}
|
||||
|
||||
function buildClusterMarker(cluster: MapClusterNode): L.Marker {
|
||||
const size = clusterIconSize(cluster.count)
|
||||
const marker = L.marker([cluster.latitude, cluster.longitude], {
|
||||
@@ -342,15 +514,15 @@ function nodeColor(nodeId: string): string {
|
||||
}
|
||||
|
||||
const hueRanges = [
|
||||
[35, 75],
|
||||
[95, 165],
|
||||
[185, 250],
|
||||
[265, 315],
|
||||
[42, 68],
|
||||
[92, 136],
|
||||
[188, 218],
|
||||
[330, 354],
|
||||
]
|
||||
const range = hueRanges[hash % hueRanges.length]
|
||||
const hue = range[0] + (hash % (range[1] - range[0]))
|
||||
const saturation = 68 + (hash % 18)
|
||||
const lightness = 32 + (hash % 10)
|
||||
const saturation = 24 + (hash % 14)
|
||||
const lightness = 42 + (hash % 12)
|
||||
return `hsl(${hue} ${saturation}% ${lightness}%)`
|
||||
}
|
||||
|
||||
@@ -371,6 +543,43 @@ function escapeHTML(value: string): string {
|
||||
<template>
|
||||
<section class="map-panel panel">
|
||||
<div ref="mapEl" class="map-container"></div>
|
||||
<div
|
||||
class="map-source-control"
|
||||
@click.stop
|
||||
@mousedown.stop
|
||||
@dblclick.stop
|
||||
@wheel.stop
|
||||
>
|
||||
<button class="map-source-icon" type="button" aria-label="切换地图图源">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 18.5l-3 -1.5l-6 3v-13l6 -3l6 3l6 -3v7.5" />
|
||||
<path d="M9 4v13" />
|
||||
<path d="M15 7v5.5" />
|
||||
<path d="M21.121 20.121a3 3 0 1 0 -4.242 0c.418 .419 1.125 1.045 2.121 1.879c1.051 -.89 1.759 -1.516 2.121 -1.879" />
|
||||
<path d="M19 18v.01" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="map-source-popover">
|
||||
<div class="map-source-drawer-header">
|
||||
<span>地图图源</span>
|
||||
</div>
|
||||
<div v-if="mapSources.length > 1" class="map-source-options">
|
||||
<button
|
||||
v-for="source in mapSources"
|
||||
:key="source.id"
|
||||
class="map-source-option"
|
||||
:class="{ active: source.id === mapSource.id }"
|
||||
type="button"
|
||||
@click="selectMapSource(source.id)"
|
||||
>
|
||||
<span class="map-source-option-name">{{ source.name }}</span>
|
||||
<span v-if="source.id === mapSource.id" class="map-source-option-check">当前</span>
|
||||
</button>
|
||||
</div>
|
||||
<span v-else class="map-source-control-pill">{{ mapSource.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div v-if="loading" class="map-empty">正在加载当前区域坐标...</div>
|
||||
<div v-else-if="items.length === 0" class="map-empty">暂无可显示坐标的节点</div> -->
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
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, loadEnabledMapSources } from '../mapSource'
|
||||
import ConfirmDeleteModal from './ConfirmDeleteModal.vue'
|
||||
import NodeTrajectoryMap from './NodeTrajectoryMap.vue'
|
||||
|
||||
@@ -15,12 +16,25 @@ const mapReport = ref<MapReport | null>(null)
|
||||
const messages = ref<TextMessage[]>([])
|
||||
const positions = ref<PositionRecord[]>([])
|
||||
const telemetry = ref<TelemetryRecord[]>([])
|
||||
const mapSources = ref<PublicMapTileSource[]>([fallbackMapSource])
|
||||
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
|
||||
const loading = ref(true)
|
||||
const chatLoadingOlder = ref(false)
|
||||
const chatHasMore = ref(true)
|
||||
const telemetryLoading = ref(false)
|
||||
const trajectoryLoading = ref(false)
|
||||
const trajectoryError = ref('')
|
||||
const trajectoryTruncated = ref(false)
|
||||
const error = ref('')
|
||||
const chatPageSize = 20
|
||||
const telemetryPageSize = 25
|
||||
const trajectoryPageSize = 500
|
||||
const maxTrajectoryPoints = 5000
|
||||
const telemetryPage = ref(1)
|
||||
const trajectoryStartDate = ref(toDateInputValue())
|
||||
const trajectoryEndDate = ref(toDateInputValue())
|
||||
const chatHistoryRef = ref<HTMLElement | null>(null)
|
||||
const scrollOverflowAllowance = 1
|
||||
type GroupedTextMessage = TextMessage & { mergedCount: number; mergedMessages: TextMessage[] }
|
||||
type PendingDeleteAction =
|
||||
| { kind: 'delete-message'; message: GroupedTextMessage }
|
||||
@@ -94,6 +108,27 @@ function formatTime(value: string): string {
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
function toDateInputValue(date = new Date()): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function localDateRange(startDate: string, endDate: string): { since: string; until: string } | null {
|
||||
if (!startDate || !endDate) {
|
||||
trajectoryError.value = '请选择开始日期和结束日期'
|
||||
return null
|
||||
}
|
||||
const safeStartDate = startDate <= endDate ? startDate : endDate
|
||||
const safeEndDate = startDate <= endDate ? endDate : startDate
|
||||
trajectoryStartDate.value = safeStartDate
|
||||
trajectoryEndDate.value = safeEndDate
|
||||
const since = new Date(`${safeStartDate}T00:00:00.000`)
|
||||
const until = new Date(`${safeEndDate}T23:59:59.999`)
|
||||
return { since: since.toISOString(), until: until.toISOString() }
|
||||
}
|
||||
|
||||
function metricEntries(value: string | null): Array<[string, unknown]> {
|
||||
if (!value) {
|
||||
return []
|
||||
@@ -175,6 +210,79 @@ async function optional<T>(request: Promise<T>): Promise<T | null> {
|
||||
}
|
||||
}
|
||||
|
||||
function canTelemetryPrev(): boolean {
|
||||
return telemetryPage.value > 1
|
||||
}
|
||||
|
||||
function canTelemetryNext(): boolean {
|
||||
return telemetry.value.length === telemetryPageSize
|
||||
}
|
||||
|
||||
async function loadTelemetryPage() {
|
||||
telemetryLoading.value = true
|
||||
try {
|
||||
const response = await getTelemetry(telemetryPageSize, (telemetryPage.value - 1) * telemetryPageSize, props.nodeId)
|
||||
telemetry.value = response.items
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
telemetryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changeTelemetryPage(nextPage: number) {
|
||||
telemetryPage.value = Math.max(1, nextPage)
|
||||
loadTelemetryPage()
|
||||
}
|
||||
|
||||
async function loadTrajectoryRange() {
|
||||
const range = localDateRange(trajectoryStartDate.value, trajectoryEndDate.value)
|
||||
if (!range) {
|
||||
return
|
||||
}
|
||||
|
||||
trajectoryLoading.value = true
|
||||
trajectoryError.value = ''
|
||||
trajectoryTruncated.value = false
|
||||
positions.value = []
|
||||
try {
|
||||
const items: PositionRecord[] = []
|
||||
for (let offset = 0; offset < maxTrajectoryPoints; offset += trajectoryPageSize) {
|
||||
const response = await getPositions(trajectoryPageSize, offset, {
|
||||
nodeId: props.nodeId,
|
||||
since: range.since,
|
||||
until: range.until,
|
||||
})
|
||||
items.push(...response.items)
|
||||
if (response.items.length < trajectoryPageSize) {
|
||||
break
|
||||
}
|
||||
if (items.length >= maxTrajectoryPoints) {
|
||||
trajectoryTruncated.value = true
|
||||
break
|
||||
}
|
||||
}
|
||||
positions.value = items.slice(0, maxTrajectoryPoints)
|
||||
} catch (err) {
|
||||
trajectoryError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
trajectoryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyRecentTrajectory(days: number) {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(end.getDate() - days + 1)
|
||||
trajectoryStartDate.value = toDateInputValue(start)
|
||||
trajectoryEndDate.value = toDateInputValue(end)
|
||||
loadTrajectoryRange()
|
||||
}
|
||||
|
||||
function applyTodayTrajectory() {
|
||||
applyRecentTrajectory(1)
|
||||
}
|
||||
|
||||
async function loadInitialMessages() {
|
||||
const response = await getTextMessages(chatPageSize, 0, props.nodeId)
|
||||
messages.value = toChronological(response.items)
|
||||
@@ -183,24 +291,30 @@ async function loadInitialMessages() {
|
||||
const el = chatHistoryRef.value
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
await loadMoreUntilScrollable(el)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlderMessages() {
|
||||
const el = chatHistoryRef.value
|
||||
await loadOlderMessagesFromCurrentScroll(el)
|
||||
}
|
||||
|
||||
async function loadOlderMessagesFromCurrentScroll(el: HTMLElement | null) {
|
||||
if (chatLoadingOlder.value || !chatHasMore.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const el = chatHistoryRef.value
|
||||
const previousScrollHeight = el?.scrollHeight ?? 0
|
||||
const previousScrollTop = el?.scrollTop ?? 0
|
||||
const previousGroupedMessageCount = groupedMessages.value.length
|
||||
chatLoadingOlder.value = true
|
||||
try {
|
||||
const response = await getTextMessages(chatPageSize, messages.value.length, props.nodeId)
|
||||
messages.value = mergeMessages(messages.value, toChronological(response.items))
|
||||
chatHasMore.value = response.items.length === chatPageSize
|
||||
await nextTick()
|
||||
if (el) {
|
||||
if (el && groupedMessages.value.length > previousGroupedMessageCount) {
|
||||
el.scrollTop = el.scrollHeight - previousScrollHeight + previousScrollTop
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -210,6 +324,16 @@ async function loadOlderMessages() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreUntilScrollable(el: HTMLElement) {
|
||||
while (chatHasMore.value && el.scrollHeight <= el.clientHeight + scrollOverflowAllowance) {
|
||||
const previousGroupedMessageCount = groupedMessages.value.length
|
||||
await loadOlderMessagesFromCurrentScroll(el)
|
||||
if (groupedMessages.value.length <= previousGroupedMessageCount) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeMessageMenu() {
|
||||
menuMessage.value = null
|
||||
}
|
||||
@@ -367,21 +491,36 @@ function handleChatScroll() {
|
||||
loadOlderMessages()
|
||||
}
|
||||
|
||||
async function loadMapSource() {
|
||||
const sources = await loadEnabledMapSources()
|
||||
mapSources.value = sources
|
||||
mapSource.value = sources[0] ?? fallbackMapSource
|
||||
}
|
||||
|
||||
function selectMapSource(sourceId: number) {
|
||||
const source = mapSources.value.find((item) => item.id === sourceId)
|
||||
if (source) {
|
||||
mapSource.value = source
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetails() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
trajectoryError.value = ''
|
||||
telemetryPage.value = 1
|
||||
try {
|
||||
const [nodeData, reportData, positionData, telemetryData] = await Promise.all([
|
||||
const [nodeData, reportData] = await Promise.all([
|
||||
optional(getNodeInfoById(props.nodeId)),
|
||||
optional(getMapReportById(props.nodeId)),
|
||||
getPositions(500, 0, props.nodeId),
|
||||
getTelemetry(200, 0, props.nodeId),
|
||||
])
|
||||
nodeInfo.value = nodeData
|
||||
mapReport.value = reportData
|
||||
positions.value = positionData.items
|
||||
telemetry.value = telemetryData.items
|
||||
await loadInitialMessages()
|
||||
await Promise.all([
|
||||
loadTrajectoryRange(),
|
||||
loadTelemetryPage(),
|
||||
loadInitialMessages(),
|
||||
])
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
@@ -392,6 +531,7 @@ async function loadDetails() {
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', closeMessageMenu)
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
loadMapSource()
|
||||
loadDetails()
|
||||
})
|
||||
|
||||
@@ -492,7 +632,28 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<span class="badge">{{ positions.length }}</span>
|
||||
</div>
|
||||
<NodeTrajectoryMap :positions="positions" />
|
||||
<div class="trajectory-toolbar">
|
||||
<label class="trajectory-date-field">
|
||||
<span>开始日期</span>
|
||||
<input v-model="trajectoryStartDate" type="date" :disabled="trajectoryLoading" @change="loadTrajectoryRange" />
|
||||
</label>
|
||||
<label class="trajectory-date-field">
|
||||
<span>结束日期</span>
|
||||
<input v-model="trajectoryEndDate" type="date" :disabled="trajectoryLoading" @change="loadTrajectoryRange" />
|
||||
</label>
|
||||
<button type="button" :disabled="trajectoryLoading" @click="applyTodayTrajectory">今天</button>
|
||||
<button type="button" :disabled="trajectoryLoading" @click="applyRecentTrajectory(3)">最近三天</button>
|
||||
<button type="button" :disabled="trajectoryLoading" @click="applyRecentTrajectory(7)">最近七天</button>
|
||||
</div>
|
||||
<p v-if="trajectoryError" class="error trajectory-status">{{ trajectoryError }}</p>
|
||||
<p v-else-if="trajectoryTruncated" class="trajectory-status">轨迹点较多,仅显示前 {{ maxTrajectoryPoints }} 条,请缩小日期范围。</p>
|
||||
<p v-else-if="trajectoryLoading" class="trajectory-status">正在加载轨迹...</p>
|
||||
<NodeTrajectoryMap
|
||||
:positions="positions"
|
||||
:map-source="mapSource"
|
||||
:map-sources="mapSources"
|
||||
@map-source-change="selectMapSource"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -502,8 +663,9 @@ onBeforeUnmount(() => {
|
||||
<p class="eyebrow">Telemetry</p>
|
||||
<h2>遥测数据:{{ nodeTitle }}</h2>
|
||||
</div>
|
||||
<span class="badge">{{ telemetry.length }}</span>
|
||||
<span class="badge">本页 {{ telemetry.length }}</span>
|
||||
</div>
|
||||
<div v-if="telemetryLoading" class="admin-loading">正在加载遥测数据...</div>
|
||||
<div class="node-table-wrap">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
@@ -530,6 +692,12 @@ onBeforeUnmount(() => {
|
||||
</table>
|
||||
<div v-if="telemetry.length === 0" class="empty">暂无遥测数据</div>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button :disabled="telemetryLoading || !canTelemetryPrev()" @click="changeTelemetryPage(telemetryPage - 1)">上一页</button>
|
||||
<span>第 {{ telemetryPage }} 页</span>
|
||||
<span>每页 {{ telemetryPageSize }} 条</span>
|
||||
<button :disabled="telemetryLoading || !canTelemetryNext()" @click="changeTelemetryPage(telemetryPage + 1)">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
|
||||
@@ -2,16 +2,44 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import L from 'leaflet'
|
||||
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[]
|
||||
mapSource?: PublicMapTileSource
|
||||
mapSources?: PublicMapTileSource[]
|
||||
}>(), {
|
||||
mapSource: () => fallbackMapSource,
|
||||
mapSources: () => [fallbackMapSource],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'map-source-change': [sourceId: number]
|
||||
}>()
|
||||
|
||||
const mapEl = ref<HTMLElement | null>(null)
|
||||
let map: L.Map | null = null
|
||||
let tileLayer: L.TileLayer | null = null
|
||||
let layer: L.LayerGroup | null = null
|
||||
|
||||
function selectMapSource(sourceId: number) {
|
||||
emit('map-source-change', sourceId)
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!map || !layer) {
|
||||
return
|
||||
@@ -28,10 +56,10 @@ function renderTrajectory() {
|
||||
}
|
||||
|
||||
if (points.length > 1) {
|
||||
L.polyline(points, { color: '#2563eb', weight: 4, opacity: 0.8 }).addTo(layer)
|
||||
L.polyline(points, { color: '#7d8f9a', weight: 4, opacity: 0.78 }).addTo(layer)
|
||||
}
|
||||
L.circleMarker(points[0], { radius: 6, color: '#16a34a', fillColor: '#22c55e', fillOpacity: 0.9 }).bindPopup('起点').addTo(layer)
|
||||
L.circleMarker(points[points.length - 1], { radius: 6, color: '#dc2626', fillColor: '#ef4444', fillOpacity: 0.9 }).bindPopup('终点').addTo(layer)
|
||||
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)
|
||||
map.fitBounds(L.latLngBounds(points), { padding: [24, 24], maxZoom: 14 })
|
||||
}
|
||||
|
||||
@@ -49,10 +77,8 @@ onMounted(async () => {
|
||||
maxBoundsViscosity: 1.0,
|
||||
worldCopyJump: false,
|
||||
}).setView([0, 0], 2)
|
||||
L.tileLayer('https://tile.openstreetmap.jp/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
}).addTo(map)
|
||||
map.attributionControl.setPrefix(false)
|
||||
applyTileLayer()
|
||||
layer = L.layerGroup().addTo(map)
|
||||
renderTrajectory()
|
||||
})
|
||||
@@ -60,6 +86,7 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
map?.remove()
|
||||
map = null
|
||||
tileLayer = null
|
||||
layer = null
|
||||
})
|
||||
|
||||
@@ -68,8 +95,53 @@ watch(
|
||||
() => renderTrajectory(),
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.mapSource,
|
||||
() => applyTileLayer(),
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="mapEl" class="trajectory-map"></div>
|
||||
<div class="trajectory-map-shell">
|
||||
<div ref="mapEl" class="trajectory-map"></div>
|
||||
<div
|
||||
class="map-source-control"
|
||||
@click.stop
|
||||
@mousedown.stop
|
||||
@dblclick.stop
|
||||
@wheel.stop
|
||||
>
|
||||
<button class="map-source-icon" type="button" aria-label="切换地图图源">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 18.5l-3 -1.5l-6 3v-13l6 -3l6 3l6 -3v7.5" />
|
||||
<path d="M9 4v13" />
|
||||
<path d="M15 7v5.5" />
|
||||
<path d="M21.121 20.121a3 3 0 1 0 -4.242 0c.418 .419 1.125 1.045 2.121 1.879c1.051 -.89 1.759 -1.516 2.121 -1.879" />
|
||||
<path d="M19 18v.01" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="map-source-popover">
|
||||
<div class="map-source-drawer-header">
|
||||
<span>地图图源</span>
|
||||
</div>
|
||||
<div v-if="mapSources.length > 1" class="map-source-options">
|
||||
<button
|
||||
v-for="source in mapSources"
|
||||
:key="source.id"
|
||||
class="map-source-option"
|
||||
:class="{ active: source.id === mapSource.id }"
|
||||
type="button"
|
||||
@click="selectMapSource(source.id)"
|
||||
>
|
||||
<span class="map-source-option-name">{{ source.name }}</span>
|
||||
<span v-if="source.id === mapSource.id" class="map-source-option-check">当前</span>
|
||||
</button>
|
||||
</div>
|
||||
<span v-else class="map-source-control-pill">{{ mapSource.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user