频道自动补全: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
+28
View File
@@ -474,6 +474,16 @@ func (SchemaMigration) TableName() string {
return "schema_migrations" return "schema_migrations"
} }
type ChannelRecord struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement"`
ChannelID string `gorm:"column:channel_id;type:varchar(255);not null;uniqueIndex"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
}
func (ChannelRecord) TableName() string {
return "channels"
}
// LLMProviderRecord 保存 LLM API 配置,支持多个 AI 提供商 // LLMProviderRecord 保存 LLM API 配置,支持多个 AI 提供商
type LLMProviderRecord struct { type LLMProviderRecord struct {
Name string `gorm:"column:name;primaryKey;size:64;not null"` // 配置名称,如 "default"、"openai"、"ark" 等 Name string `gorm:"column:name;primaryKey;size:64;not null"` // 配置名称,如 "default"、"openai"、"ark" 等
@@ -700,6 +710,7 @@ func (s *Store) migrate() error {
{label: "nodeinfo", model: &NodeInfoRecord{}}, {label: "nodeinfo", model: &NodeInfoRecord{}},
{label: "map_report", model: &MapReportRecord{}}, {label: "map_report", model: &MapReportRecord{}},
{label: "text_message", model: &TextMessageRecord{}}, {label: "text_message", model: &TextMessageRecord{}},
{label: "channels", model: &ChannelRecord{}},
{label: "position", model: &PositionRecord{}}, {label: "position", model: &PositionRecord{}},
{label: "telemetry", model: &TelemetryRecord{}}, {label: "telemetry", model: &TelemetryRecord{}},
{label: "routing", model: &RoutingRecord{}}, {label: "routing", model: &RoutingRecord{}},
@@ -897,6 +908,7 @@ type DBMigration struct {
var dbMigrations = []DBMigration{ var dbMigrations = []DBMigration{
{Version: 1, Up: migrateDB1}, {Version: 1, Up: migrateDB1},
{Version: 2, Up: migrateDB2},
} }
func migrateDB1(tx *gorm.DB, driver string) error { func migrateDB1(tx *gorm.DB, driver string) error {
@@ -908,6 +920,19 @@ func migrateDB1(tx *gorm.DB, driver string) error {
return nil return nil
} }
func migrateDB2(tx *gorm.DB, driver string) error {
var sql string
if driver == config.DriverSQLite {
sql = "INSERT OR IGNORE INTO channels (channel_id, created_at) SELECT DISTINCT channel_id, datetime('now') FROM text_message WHERE channel_id IS NOT NULL AND channel_id != ''"
} else {
sql = "INSERT IGNORE INTO channels (channel_id, created_at) SELECT DISTINCT channel_id, NOW() FROM text_message WHERE channel_id IS NOT NULL AND channel_id != ''"
}
if err := tx.Exec(sql).Error; err != nil {
return fmt.Errorf("backfill channels: %w", err)
}
return nil
}
func runDBMigrations(tx *gorm.DB, driver string) error { func runDBMigrations(tx *gorm.DB, driver string) error {
if !tx.Migrator().HasTable(&SchemaMigration{}) { if !tx.Migrator().HasTable(&SchemaMigration{}) {
if err := tx.Migrator().CreateTable(&SchemaMigration{}); err != nil { if err := tx.Migrator().CreateTable(&SchemaMigration{}); err != nil {
@@ -1080,6 +1105,9 @@ func (s *Store) InsertTextMessage(record map[string]any, clientInfo MQTTClientIn
if err := s.db.Create(message).Error; err != nil { if err := s.db.Create(message).Error; err != nil {
return fmt.Errorf("insert text_message from %s: %w", message.FromID, err) return fmt.Errorf("insert text_message from %s: %w", message.FromID, err)
} }
if message.ChannelID != nil && *message.ChannelID != "" {
s.db.FirstOrCreate(&ChannelRecord{}, ChannelRecord{ChannelID: *message.ChannelID})
}
return nil return nil
} }
+5
View File
@@ -387,3 +387,8 @@ func (s *Store) listAppendRows(opts ListOptions, dest any) *gorm.DB {
} }
return q.Find(dest) return q.Find(dest)
} }
func (s *Store) ListChannels() ([]ChannelRecord, error) {
var rows []ChannelRecord
return rows, s.db.Order("channel_id ASC").Find(&rows).Error
}
+12
View File
@@ -139,6 +139,18 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str
rows, err := store.ListTextMessages(opts) rows, err := store.ListTextMessages(opts)
writeListResponse(c, rows, opts, err, textMessageDTO) writeListResponse(c, rows, opts, err, textMessageDTO)
}) })
r.GET("/channels", func(c *gin.Context) {
rows, err := store.ListChannels()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items := make([]gin.H, 0, len(rows))
for _, row := range rows {
items = append(items, gin.H{"channel_id": row.ChannelID})
}
c.JSON(http.StatusOK, gin.H{"items": items})
})
r.GET("/discard-details", func(c *gin.Context) { r.GET("/discard-details", func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := parseListOptions(c)
if !ok { if !ok {
+4 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' 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 AdminBlockingManagement from './components/AdminBlockingManagement.vue'
import AdminBot from './components/AdminBot.vue' import AdminBot from './components/AdminBot.vue'
import AdminBotDirect from './components/AdminBotDirect.vue' import AdminBotDirect from './components/AdminBotDirect.vue'
@@ -57,6 +57,7 @@ const nodePage = ref(1)
const nodePageSize = 25 const nodePageSize = 25
const nodeTotal = ref(0) const nodeTotal = ref(0)
const messages = ref<TextMessage[]>([]) const messages = ref<TextMessage[]>([])
const channels = ref<string[]>([])
const chatPageSize = 20 const chatPageSize = 20
const chatLoadingOlder = ref(false) const chatLoadingOlder = ref(false)
const chatHasMore = ref(true) const chatHasMore = ref(true)
@@ -682,6 +683,7 @@ onMounted(() => {
loadMapSource() loadMapSource()
refresh() refresh()
refreshTimer = window.setInterval(() => refresh(false), 5000) refreshTimer = window.setInterval(() => refresh(false), 5000)
getChannels().then(res => { channels.value = res.items.map(i => i.channel_id) }).catch(() => {})
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -811,6 +813,7 @@ onBeforeUnmount(() => {
<section class="workspace"> <section class="workspace">
<ChatPanel <ChatPanel
v-model:channelFilter="channelFilter" v-model:channelFilter="channelFilter"
:channels="channels"
:messages="filteredMessages" :messages="filteredMessages"
:nodes-by-id="nodesById" :nodes-by-id="nodesById"
:selected-node-id="selectedNodeId" :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') 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> { export function getHelpContent(): Promise<HelpContentResponse> {
return getJSON<HelpContentResponse>('/api/help') return getJSON<HelpContentResponse>('/api/help')
} }
+63 -1
View File
@@ -10,6 +10,7 @@ const props = defineProps<{
hasMoreMessages: boolean hasMoreMessages: boolean
isAdmin: boolean isAdmin: boolean
channelFilter: string channelFilter: string
channels: string[]
}>() }>()
type GroupedTextMessage = TextMessage & { mergedCount: number; mergedMessages: TextMessage[] } type GroupedTextMessage = TextMessage & { mergedCount: number; mergedMessages: TextMessage[] }
@@ -30,6 +31,56 @@ const topThreshold = 8
const bottomThreshold = 40 const bottomThreshold = 40
const scrollOverflowAllowance = 1 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 groupedMessages = computed<GroupedTextMessage[]>(() => {
const groups = new Map<string, GroupedTextMessage>() const groups = new Map<string, GroupedTextMessage>()
for (const message of props.messages) { for (const message of props.messages) {
@@ -201,7 +252,10 @@ onUpdated(() => {
class="chat-filter-input" class="chat-filter-input"
:value="channelFilter" :value="channelFilter"
placeholder="筛选频道" placeholder="筛选频道"
@input="emit('update:channelFilter', ($event.target as HTMLInputElement).value)" @input="onFilterInput"
@focus="onFilterFocus"
@blur="onFilterBlur"
@keydown="onFilterKeydown"
/> />
<button <button
v-if="channelFilter.trim()" v-if="channelFilter.trim()"
@@ -209,6 +263,14 @@ onUpdated(() => {
class="chat-filter-clear" class="chat-filter-clear"
@click="emit('update:channelFilter', '')" @click="emit('update:channelFilter', '')"
>清除</button> >清除</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>
</div> </div>
+31
View File
@@ -359,6 +359,7 @@ h3 {
} }
.chat-filter { .chat-filter {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -402,6 +403,36 @@ h3 {
background: var(--color-primary-soft); 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 { .chat-item {
display: grid; display: grid;
gap: 6px; gap: 6px;