频道自动补全:channels 表(DBv2 回填去重) + GET /api/channels + 聊天卡片输入框下拉补全(键盘导航+点击选中)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user