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