This commit is contained in:
2026-06-19 18:51:28 +08:00
parent 5aae81c831
commit 01b0ad1999
3 changed files with 153 additions and 13 deletions
+89 -8
View File
@@ -67,6 +67,7 @@ const mapReportsLoading = ref(false)
const mapReportTotal = ref(0)
const mapSources = ref<PublicMapTileSource[]>([fallbackMapSource])
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
const nodeFilter = ref('')
const pendingDeleteAction = ref<PendingDeleteAction | null>(null)
type DeletableTextMessage = TextMessage & { mergedCount?: number; mergedMessages?: TextMessage[] }
type NodeActionRequest = { nodeId: string; nodeNum: number | null; message?: DeletableTextMessage }
@@ -90,10 +91,74 @@ const nodesById = computed<NodeInfoById>(() => {
return Object.fromEntries(map)
})
const normalizedNodeFilter = computed(() => nodeFilter.value.trim().toLowerCase())
function nodeMatchesFilterByInfo(node: NodeInfo | null | undefined, keyword: string): boolean {
if (!keyword) {
return true
}
if (!node) {
return false
}
const fields = [node.node_id, node.long_name, node.short_name, node.hw_model, node.role]
return fields.some((value) => (value ?? '').toString().toLowerCase().includes(keyword))
}
function nodeIdMatchesFilter(nodeId: string | null | undefined): boolean {
const keyword = normalizedNodeFilter.value
if (!keyword) {
return true
}
if (!nodeId) {
return false
}
if (nodeId.toLowerCase().includes(keyword)) {
return true
}
return nodeMatchesFilterByInfo(nodesById.value[nodeId] ?? null, keyword)
}
function mapReportMatchesFilter(item: MapViewportPoint, keyword: string): boolean {
if (!keyword) {
return true
}
const fields = [item.node_id, item.long_name, item.short_name, item.hw_model, item.role]
if (fields.some((value) => (value ?? '').toString().toLowerCase().includes(keyword))) {
return true
}
return nodeMatchesFilterByInfo(nodesById.value[item.node_id] ?? null, keyword)
}
const filteredMessages = computed<TextMessage[]>(() => {
if (!normalizedNodeFilter.value) {
return messages.value
}
return messages.value.filter((message) => nodeIdMatchesFilter(message.from_id))
})
const filteredPagedNodeInfo = computed<NodeInfo[]>(() => {
const keyword = normalizedNodeFilter.value
if (!keyword) {
return pagedNodeInfo.value
}
// 过滤启用时,跨整个 nodeInfoSource(最多 500 条)匹配,分页交给前端忽略
return nodeInfoSource.value.filter((node) => nodeMatchesFilterByInfo(node, keyword))
})
const filteredNodeTotal = computed(() => {
if (!normalizedNodeFilter.value) {
return nodeTotal.value
}
return filteredPagedNodeInfo.value.length
})
const mapItems = computed<MapRenderable[]>(() => {
const items = mapViewportItems.value
const keyword = normalizedNodeFilter.value
const items = keyword
? mapViewportItems.value.filter((item) => isMapViewportPoint(item) && mapReportMatchesFilter(item, keyword))
: mapViewportItems.value
const selectedItem = selectedMapPoint.value
const renderItems = selectedItem && selectedItem.type === 'point' && !items.some((item) => item.type === 'point' && item.node_id === selectedItem.node_id)
const renderItems = selectedItem && selectedItem.type === 'point' && (!keyword || mapReportMatchesFilter(selectedItem, keyword)) && !items.some((item) => item.type === 'point' && item.node_id === selectedItem.node_id)
? [...items, selectedItem]
: items
@@ -578,13 +643,28 @@ onBeforeUnmount(() => {
<a class="topbar-link" href="/admin">管理</a>
</template>
<template v-else>
<span class="counter">节点 {{ nodeTotal }} · 已加载消息 {{ messages.length }} · 坐标 {{ mapItems.length }} / {{ mapReportTotal }}{{ mapViewportMode === 'clusters' ? ' · 已聚合' : '' }}{{ mapReportsLoading ? ' · 坐标加载中...' : '' }}</span>
<span class="counter">节点 {{ normalizedNodeFilter ? `${filteredNodeTotal} / ${nodeTotal}` : nodeTotal }} · 已加载消息 {{ normalizedNodeFilter ? `${filteredMessages.length} / ${messages.length}` : messages.length }} · 坐标 {{ mapItems.length }} / {{ mapReportTotal }}{{ mapViewportMode === 'clusters' ? ' · 已聚合' : '' }}{{ mapReportsLoading ? ' · 坐标加载中...' : '' }}{{ normalizedNodeFilter ? ' · 已筛选' : '' }}</span>
<div class="topbar-filter">
<input
type="search"
class="topbar-filter-input"
v-model="nodeFilter"
placeholder="筛选节点"
/>
<button
v-if="normalizedNodeFilter"
type="button"
class="topbar-filter-clear"
@click="nodeFilter = ''"
>清除</button>
</div>
<a class="topbar-link" href="/signed">签到列表</a>
<a class="topbar-link" href="/help">使用帮助</a>
<a class="topbar-link" href="/admin">管理</a>
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
<!-- <button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
-->
</template>
</div>
</header>
@@ -633,7 +713,7 @@ onBeforeUnmount(() => {
<section class="workspace">
<ChatPanel
:messages="messages"
:messages="filteredMessages"
:nodes-by-id="nodesById"
:selected-node-id="selectedNodeId"
:loading-older="chatLoadingOlder"
@@ -662,13 +742,14 @@ onBeforeUnmount(() => {
</section>
<NodeListPanel
:nodes="pagedNodeInfo"
:nodes="filteredPagedNodeInfo"
:selected-node-id="selectedNodeId"
:page="nodePage"
:page-size="nodePageSize"
:total="nodeTotal"
:total="filteredNodeTotal"
:loading="nodePageLoading || loading"
:is-admin="!!adminUser"
:filter-active="!!normalizedNodeFilter"
@select-node="selectNode"
@page-change="loadNodePage"
@delete-node="requestDeleteNode"
@@ -10,6 +10,7 @@ const props = defineProps<{
total: number
loading: boolean
isAdmin: boolean
filterActive?: boolean
}>()
const emit = defineEmits<{
@@ -133,10 +134,15 @@ onBeforeUnmount(() => {
</div>
<div class="pagination">
<button :disabled="loading || !canPrev" @click="emit('page-change', page - 1)">上一页</button>
<span> {{ page }} / {{ totalPages }} </span>
<span>每页 {{ pageSize }} </span>
<button :disabled="loading || !canNext" @click="emit('page-change', page + 1)">下一页</button>
<template v-if="filterActive">
<span>已筛选 {{ total }} </span>
</template>
<template v-else>
<button :disabled="loading || !canPrev" @click="emit('page-change', page - 1)">上一页</button>
<span> {{ page }} / {{ totalPages }} </span>
<span>每页 {{ pageSize }} </span>
<button :disabled="loading || !canNext" @click="emit('page-change', page + 1)">下一页</button>
</template>
</div>
</section>
</template>
+54 -1
View File
@@ -90,7 +90,7 @@ a {
.topbar-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 10px;
}
@@ -208,6 +208,16 @@ h3 {
font-weight: 700;
}
.topbar-actions > .counter {
flex-shrink: 0;
white-space: nowrap;
}
.topbar-actions > .topbar-link,
.topbar-actions > button {
flex-shrink: 0;
}
.status-pill.ok {
color: color-mix(in srgb, var(--color-success) 68%, var(--color-heading));
background: var(--color-success-soft);
@@ -280,6 +290,49 @@ h3 {
background: var(--color-surface-soft);
}
.topbar-filter {
display: flex;
align-items: center;
gap: 6px;
flex: 1 1 0;
min-width: 0;
}
.topbar-filter-input {
flex: 1;
min-width: 0;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
padding: 8px 10px;
color: var(--color-heading);
background: var(--color-surface);
outline: none;
font-size: 13px;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
}
.topbar-filter-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.topbar-filter-clear {
flex-shrink: 0;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
padding: 7px 10px;
color: var(--color-heading);
background: var(--color-surface);
font-size: 12px;
font-weight: 700;
}
.topbar-filter-clear:hover {
border-color: var(--color-primary);
color: var(--color-primary-hover);
background: var(--color-primary-soft);
}
.chat-item {
display: grid;
gap: 6px;