up
This commit is contained in:
@@ -67,6 +67,7 @@ const mapReportsLoading = ref(false)
|
|||||||
const mapReportTotal = ref(0)
|
const mapReportTotal = ref(0)
|
||||||
const mapSources = ref<PublicMapTileSource[]>([fallbackMapSource])
|
const mapSources = ref<PublicMapTileSource[]>([fallbackMapSource])
|
||||||
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
|
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
|
||||||
|
const nodeFilter = ref('')
|
||||||
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 }
|
||||||
@@ -90,10 +91,74 @@ const nodesById = computed<NodeInfoById>(() => {
|
|||||||
return Object.fromEntries(map)
|
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 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 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, selectedItem]
|
||||||
: items
|
: items
|
||||||
|
|
||||||
@@ -578,13 +643,28 @@ onBeforeUnmount(() => {
|
|||||||
<a class="topbar-link" href="/admin">管理</a>
|
<a class="topbar-link" href="/admin">管理</a>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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="/signed">签到列表</a>
|
||||||
<a class="topbar-link" href="/help">使用帮助</a>
|
<a class="topbar-link" href="/help">使用帮助</a>
|
||||||
<a class="topbar-link" href="/admin">管理</a>
|
<a class="topbar-link" href="/admin">管理</a>
|
||||||
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
<!-- <button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||||
|
-->
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -633,7 +713,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<section class="workspace">
|
<section class="workspace">
|
||||||
<ChatPanel
|
<ChatPanel
|
||||||
:messages="messages"
|
:messages="filteredMessages"
|
||||||
:nodes-by-id="nodesById"
|
:nodes-by-id="nodesById"
|
||||||
:selected-node-id="selectedNodeId"
|
:selected-node-id="selectedNodeId"
|
||||||
:loading-older="chatLoadingOlder"
|
:loading-older="chatLoadingOlder"
|
||||||
@@ -662,13 +742,14 @@ onBeforeUnmount(() => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<NodeListPanel
|
<NodeListPanel
|
||||||
:nodes="pagedNodeInfo"
|
:nodes="filteredPagedNodeInfo"
|
||||||
:selected-node-id="selectedNodeId"
|
:selected-node-id="selectedNodeId"
|
||||||
:page="nodePage"
|
:page="nodePage"
|
||||||
:page-size="nodePageSize"
|
:page-size="nodePageSize"
|
||||||
:total="nodeTotal"
|
:total="filteredNodeTotal"
|
||||||
:loading="nodePageLoading || loading"
|
:loading="nodePageLoading || loading"
|
||||||
:is-admin="!!adminUser"
|
:is-admin="!!adminUser"
|
||||||
|
:filter-active="!!normalizedNodeFilter"
|
||||||
@select-node="selectNode"
|
@select-node="selectNode"
|
||||||
@page-change="loadNodePage"
|
@page-change="loadNodePage"
|
||||||
@delete-node="requestDeleteNode"
|
@delete-node="requestDeleteNode"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const props = defineProps<{
|
|||||||
total: number
|
total: number
|
||||||
loading: boolean
|
loading: boolean
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
|
filterActive?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -133,10 +134,15 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<button :disabled="loading || !canPrev" @click="emit('page-change', page - 1)">上一页</button>
|
<template v-if="filterActive">
|
||||||
<span>第 {{ page }} / {{ totalPages }} 页</span>
|
<span>已筛选,共 {{ total }} 条</span>
|
||||||
<span>每页 {{ pageSize }} 条</span>
|
</template>
|
||||||
<button :disabled="loading || !canNext" @click="emit('page-change', page + 1)">下一页</button>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ a {
|
|||||||
.topbar-actions {
|
.topbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
@@ -208,6 +208,16 @@ h3 {
|
|||||||
font-weight: 700;
|
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 {
|
.status-pill.ok {
|
||||||
color: color-mix(in srgb, var(--color-success) 68%, var(--color-heading));
|
color: color-mix(in srgb, var(--color-success) 68%, var(--color-heading));
|
||||||
background: var(--color-success-soft);
|
background: var(--color-success-soft);
|
||||||
@@ -280,6 +290,49 @@ h3 {
|
|||||||
background: var(--color-surface-soft);
|
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 {
|
.chat-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
Reference in New Issue
Block a user