频道自动补全:channels 表(DBv2 回填去重) + GET /api/channels + 聊天卡片输入框下拉补全(键盘导航+点击选中)

This commit is contained in:
2026-08-07 19:55:32 +08:00
parent 0e622329a7
commit 62b6ff44d1
7 changed files with 147 additions and 2 deletions
+4 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { adminLogout, createNodeBlockingRule, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReportViewport, getNodeInfo, getPositions, getTextMessages, purgeNode } from './api'
import { adminLogout, createNodeBlockingRule, deleteNode, deleteTextMessage, getAdminMe, getChannels, getHealth, getMapReportViewport, getNodeInfo, getPositions, getTextMessages, purgeNode } from './api'
import AdminBlockingManagement from './components/AdminBlockingManagement.vue'
import AdminBot from './components/AdminBot.vue'
import AdminBotDirect from './components/AdminBotDirect.vue'
@@ -57,6 +57,7 @@ const nodePage = ref(1)
const nodePageSize = 25
const nodeTotal = ref(0)
const messages = ref<TextMessage[]>([])
const channels = ref<string[]>([])
const chatPageSize = 20
const chatLoadingOlder = ref(false)
const chatHasMore = ref(true)
@@ -682,6 +683,7 @@ onMounted(() => {
loadMapSource()
refresh()
refreshTimer = window.setInterval(() => refresh(false), 5000)
getChannels().then(res => { channels.value = res.items.map(i => i.channel_id) }).catch(() => {})
})
onBeforeUnmount(() => {
@@ -811,6 +813,7 @@ onBeforeUnmount(() => {
<section class="workspace">
<ChatPanel
v-model:channelFilter="channelFilter"
:channels="channels"
:messages="filteredMessages"
:nodes-by-id="nodesById"
:selected-node-id="selectedNodeId"
+4
View File
@@ -135,6 +135,10 @@ export function getBackendVersion(): Promise<{ version: string; commit: string }
return getJSON<{ version: string; commit: string }>('/api/version')
}
export function getChannels(): Promise<{ items: { channel_id: string }[] }> {
return getJSON<{ items: { channel_id: string }[] }>('/api/channels')
}
export function getHelpContent(): Promise<HelpContentResponse> {
return getJSON<HelpContentResponse>('/api/help')
}
+63 -1
View File
@@ -10,6 +10,7 @@ const props = defineProps<{
hasMoreMessages: boolean
isAdmin: boolean
channelFilter: string
channels: string[]
}>()
type GroupedTextMessage = TextMessage & { mergedCount: number; mergedMessages: TextMessage[] }
@@ -30,6 +31,56 @@ const topThreshold = 8
const bottomThreshold = 40
const scrollOverflowAllowance = 1
const showSuggestions = ref(false)
const selectedSuggestion = ref(-1)
const suggestions = computed(() => {
const q = props.channelFilter.trim().toLowerCase()
if (!q) return []
return props.channels
.filter(ch => ch.toLowerCase().includes(q))
.slice(0, 10)
})
function onFilterInput(event: Event) {
const value = (event.target as HTMLInputElement).value
emit('update:channelFilter', value)
selectedSuggestion.value = -1
showSuggestions.value = value.trim().length > 0 && suggestions.value.length > 0
}
function onFilterFocus() {
if (props.channelFilter.trim() && suggestions.value.length > 0) {
showSuggestions.value = true
}
}
function onFilterBlur() {
setTimeout(() => { showSuggestions.value = false }, 150)
}
function onFilterKeydown(event: KeyboardEvent) {
if (!showSuggestions.value || suggestions.value.length === 0) return
if (event.key === 'ArrowDown') {
event.preventDefault()
selectedSuggestion.value = Math.min(selectedSuggestion.value + 1, suggestions.value.length - 1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
selectedSuggestion.value = Math.max(selectedSuggestion.value - 1, -1)
} else if (event.key === 'Enter' && selectedSuggestion.value >= 0) {
event.preventDefault()
emit('update:channelFilter', suggestions.value[selectedSuggestion.value])
showSuggestions.value = false
} else if (event.key === 'Escape') {
showSuggestions.value = false
}
}
function selectSuggestion(ch: string) {
emit('update:channelFilter', ch)
showSuggestions.value = false
}
const groupedMessages = computed<GroupedTextMessage[]>(() => {
const groups = new Map<string, GroupedTextMessage>()
for (const message of props.messages) {
@@ -201,7 +252,10 @@ onUpdated(() => {
class="chat-filter-input"
:value="channelFilter"
placeholder="筛选频道"
@input="emit('update:channelFilter', ($event.target as HTMLInputElement).value)"
@input="onFilterInput"
@focus="onFilterFocus"
@blur="onFilterBlur"
@keydown="onFilterKeydown"
/>
<button
v-if="channelFilter.trim()"
@@ -209,6 +263,14 @@ onUpdated(() => {
class="chat-filter-clear"
@click="emit('update:channelFilter', '')"
>清除</button>
<ul v-if="showSuggestions && suggestions.length > 0" class="chat-filter-suggestions">
<li
v-for="(ch, idx) in suggestions"
:key="ch"
:class="{ active: idx === selectedSuggestion }"
@mousedown.prevent="selectSuggestion(ch)"
>{{ ch }}</li>
</ul>
</div>
</div>
+31
View File
@@ -359,6 +359,7 @@ h3 {
}
.chat-filter {
position: relative;
display: flex;
align-items: center;
gap: 6px;
@@ -402,6 +403,36 @@ h3 {
background: var(--color-primary-soft);
}
.chat-filter-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 20;
margin: 0;
padding: 0;
list-style: none;
background: var(--color-surface);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-height: 200px;
overflow-y: auto;
}
.chat-filter-suggestions li {
padding: 6px 10px;
font-size: 13px;
cursor: pointer;
color: var(--color-heading);
}
.chat-filter-suggestions li:hover,
.chat-filter-suggestions li.active {
background: var(--color-primary-soft);
color: var(--color-primary-hover);
}
.chat-item {
display: grid;
gap: 6px;