7 Commits
Author SHA1 Message Date
kevin 34e50af068 Merge pull request '修复 install.sh 全新部署无法启动:web 配置块误缩进在 database 段下,默认口令触发启动守卫' (#3) from dsh/meshtastic_mqtt_server:fix/install-sh-web-block-indent into main
Reviewed-on: kevin/meshtastic_mqtt_server#3
2026-08-21 21:33:03 +08:00
dsh 83f7f551fc 修复 install.sh 全新部署无法启动:web 配置块误缩进在 database 段下,随机管理员口令被解析器丢弃,程序以默认口令 admin 补全并触发非回环启动守卫拒绝;已修正缩进并补全 port_enabled/socket_enabled 字段 2026-08-21 21:30:04 +08:00
kevin 89e56a4048 修复聚合状态下无法删除所有显示的节点:视口接口聚合模式只返回聚合点(无 node_id),删除前按当前视野 bounds 分页拉取全部节点 ID 再逐个删除,前端 v0.3.3 2026-08-21 21:16:17 +08:00
kevin b817a82cad Merge pull request '修复节点详情页加载慢:text_message.from_id 加 varchar(191)+索引' (#2) from dsh/meshtastic_mqtt_server:fix/text-message-from-id-index into main
Reviewed-on: kevin/meshtastic_mqtt_server#2
2026-08-20 19:33:40 +08:00
dsh d33ec0075d 修复节点详情页加载慢:text_message.from_id 加 varchar(191)+索引,避免百万行全表扫描(与 channel_id DBv1 处理一致) 2026-08-20 19:32:08 +08:00
kevin 5d174caabb Merge pull request '修复签到墙日历与列表数量不一致:点选筛选按服务器时区构造区间' (#1) from dsh/meshtastic_mqtt_server:fix/signed-calendar-timezone into main
Reviewed-on: kevin/meshtastic_mqtt_server#1
2026-08-20 18:17:03 +08:00
dsh 6fddcd11b5 修复签到墙日历与列表数量不一致:日历按服务器本地日期分组,点选某天时用服务器时区构造筛选区间(从 sign_time 动态推导偏移),任意浏览器时区下日历数字与列表条数一致 2026-08-20 18:16:06 +08:00
4 changed files with 102 additions and 29 deletions
+3 -1
View File
@@ -95,8 +95,10 @@ database:
path: ${DATA_DIR}/${SERVICE_NAME}.db path: ${DATA_DIR}/${SERVICE_NAME}.db
mysql: mysql:
dsn: "" dsn: ""
web: web:
enabled: true enabled: true
port_enabled: true
socket_enabled: true
host: 0.0.0.0 host: 0.0.0.0
port: 8080 port: 8080
socket_path: ${SOCKET_PATH} socket_path: ${SOCKET_PATH}
+31 -1
View File
@@ -435,7 +435,7 @@ func (MapReportRecord) TableName() string {
type TextMessageRecord struct { type TextMessageRecord struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
FromID string `gorm:"column:from_id;not null"` FromID string `gorm:"column:from_id;not null;type:varchar(191);index:idx_text_message_from_id,priority:1"`
FromNum int64 `gorm:"column:from_num;not null;index:idx_text_message_from_num_created_at,priority:1"` FromNum int64 `gorm:"column:from_num;not null;index:idx_text_message_from_num_created_at,priority:1"`
Text *string `gorm:"column:text"` Text *string `gorm:"column:text"`
PayloadHex *string `gorm:"column:payload_hex"` PayloadHex *string `gorm:"column:payload_hex"`
@@ -742,6 +742,9 @@ func (s *Store) migrate() error {
if err := migrateBotNodePSK(tx, migrator, s.driver); err != nil { if err := migrateBotNodePSK(tx, migrator, s.driver); err != nil {
return err return err
} }
if err := migrateTextMessageFromIDIndex(tx, migrator, s.driver); err != nil {
return err
}
if err := migrateBotDirectMessages(tx, migrator); err != nil { if err := migrateBotDirectMessages(tx, migrator); err != nil {
return err return err
} }
@@ -768,6 +771,33 @@ func (s *Store) migrate() error {
}) })
} }
// migrateTextMessageFromIDIndex 为 text_message.from_id 建立索引。
// 该列历史版本为 longtext(无法直接建索引),节点详情页/聊天按 from_id 过滤时
// 会全表扫描(百万级行,耗时数秒)。与 channel_id 的 DBv1 处理一致:
// MySQL 先把列改为 VARCHAR(191) 再建完整索引;SQLite 直接建索引即可。
// 幂等:索引已存在时跳过(老库可能已有手工建的前缀索引)。
func migrateTextMessageFromIDIndex(tx *gorm.DB, migrator gorm.Migrator, driver string) error {
if !migrator.HasTable(&TextMessageRecord{}) {
return nil
}
if migrator.HasIndex(&TextMessageRecord{}, "idx_text_message_from_id") {
return nil
}
if driver == config.DriverSQLite {
if err := tx.Exec("CREATE INDEX idx_text_message_from_id ON text_message(from_id)").Error; err != nil {
return fmt.Errorf("create text_message.from_id index: %w", err)
}
return nil
}
if err := tx.Exec("ALTER TABLE text_message MODIFY COLUMN from_id VARCHAR(191) NOT NULL").Error; err != nil {
return fmt.Errorf("alter text_message.from_id to varchar: %w", err)
}
if err := tx.Exec("ALTER TABLE text_message ADD KEY idx_text_message_from_id (from_id)").Error; err != nil {
return fmt.Errorf("add text_message.from_id index: %w", err)
}
return nil
}
func migrateBotNodePSK(tx *gorm.DB, migrator gorm.Migrator, driver string) error { func migrateBotNodePSK(tx *gorm.DB, migrator gorm.Migrator, driver string) error {
if !migrator.HasTable(&BotNodeRecord{}) { if !migrator.HasTable(&BotNodeRecord{}) {
return nil return nil
+19 -7
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, getChannels, getHealth, getMapReportViewport, getNodeInfo, getPositions, getTextMessages, purgeNode } from './api' import { adminLogout, createNodeBlockingRule, deleteNode, deleteTextMessage, getAdminMe, getChannels, getHealth, getMapReportViewport, getMapReports, 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'
@@ -476,20 +476,32 @@ function requestPurgeNode(nodeId: string) {
pendingDeleteAction.value = { kind: 'purge-node', nodeId } pendingDeleteAction.value = { kind: 'purge-node', nodeId }
} }
function requestDeleteDisplayedNodes() { async function requestDeleteDisplayedNodes() {
// 收集当前地图上正在显示的节点 ID(仅 type=node,跳过聚合点),并按筛选后的视图顺序去重。 // 收集当前地图上正在显示的节点 ID(仅 type=node,跳过聚合点),并按筛选后的视图顺序去重。
const nodeIds = Array.from( const nodeIds = new Set(
new Set(
mapItems.value mapItems.value
.filter((item): item is Extract<MapRenderable, { type: 'node' }> => item.type === 'node') .filter((item): item is Extract<MapRenderable, { type: 'node' }> => item.type === 'node')
.map((item) => item.node_id), .map((item) => item.node_id),
),
) )
if (nodeIds.length === 0) { // 聚合状态下视口接口只返回聚合点(无 node_id),改按当前视野 bounds 分页拉取全部节点 ID。
if (mapViewportMode.value === 'clusters' && currentMapBounds.value) {
const pageSize = 500
for (let offset = 0; ; offset += pageSize) {
const response = await getMapReports(pageSize, offset, currentMapBounds.value)
for (const report of response.items) {
nodeIds.add(report.node_id)
}
const loaded = offset + response.items.length
if (response.items.length === 0 || loaded >= (response.total ?? loaded)) {
break
}
}
}
if (nodeIds.size === 0) {
error.value = '当前地图上没有可删除的节点。' error.value = '当前地图上没有可删除的节点。'
return return
} }
pendingDeleteAction.value = { kind: 'delete-displayed-nodes', nodeIds } pendingDeleteAction.value = { kind: 'delete-displayed-nodes', nodeIds: Array.from(nodeIds) }
} }
function requestDeleteAndBlockNode(payload: NodeActionRequest) { function requestDeleteAndBlockNode(payload: NodeActionRequest) {
+35 -6
View File
@@ -13,11 +13,29 @@ const total = ref(0)
const calendarMonth = ref(new Date()) const calendarMonth = ref(new Date())
const dailyCounts = ref<Record<string, number>>({}) const dailyCounts = ref<Record<string, number>>({})
const selectedDate = ref('') const selectedDate = ref('')
// 服务器时区偏移(如 "-04:00"/"+08:00"/"Z"):从接口返回的 sign_time 中推导。
// 日历按服务器本地日期分组,点选某天时也必须用同一时区构造筛选区间,
// 否则浏览器时区与服务器时区不同会导致日历数字与列表条数不一致。
const serverOffset = ref<string | null>(null)
const weekdays = ['日', '一', '二', '三', '四', '五', '六'] const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const todayDate = formatDateKey(new Date()) const todayDate = formatDateKey(new Date())
const calendarDays = computed(() => monthDays(calendarMonth.value)) const calendarDays = computed(() => monthDays(calendarMonth.value))
function extractServerOffset(value: string): string | null {
const match = /(Z|[+-]\d{2}:\d{2})$/.exec(value)
return match ? match[1] : null
}
function serverLocalISO(dateKey: string, hour: number, minute: number, second: number, millisecond: number): string {
const hh = String(hour).padStart(2, '0')
const mm = String(minute).padStart(2, '0')
const ss = String(second).padStart(2, '0')
const ms = String(millisecond).padStart(3, '0')
const offset = serverOffset.value ?? ''
return new Date(`${dateKey}T${hh}:${mm}:${ss}.${ms}${offset}`).toISOString()
}
function formatTime(value: string): string { function formatTime(value: string): string {
return new Date(value).toLocaleString() return new Date(value).toLocaleString()
} }
@@ -51,10 +69,12 @@ function monthDays(value: Date): Array<string | null> {
} }
function dateRangeForDay(value: string): { since: string; until: string } { function dateRangeForDay(value: string): { since: string; until: string } {
const [year, month, day] = value.split('-').map(Number) // 用服务器时区构造当天 00:00 ~ 23:59:59.999 的区间,
const start = new Date(year, month - 1, day) // 与后端按服务器本地日期分组的日历保持一致(任意浏览器时区下均一致)。
const end = new Date(year, month - 1, day, 23, 59, 59, 999) return {
return { since: start.toISOString(), until: end.toISOString() } since: serverLocalISO(value, 0, 0, 0, 0),
until: serverLocalISO(value, 23, 59, 59, 999),
}
} }
function canPrev(): boolean { function canPrev(): boolean {
@@ -71,8 +91,12 @@ async function loadCalendar() {
try { try {
const year = calendarMonth.value.getFullYear() const year = calendarMonth.value.getFullYear()
const month = calendarMonth.value.getMonth() const month = calendarMonth.value.getMonth()
const since = new Date(year, month, 1).toISOString() // 按服务器时区取该月首日 00:00 与末日 23:59:59.999 的区间。
const until = new Date(year, month + 1, 1).toISOString() const firstDay = `${year}-${String(month + 1).padStart(2, '0')}-01`
const lastDayOfMonth = new Date(year, month + 1, 0).getDate()
const lastDay = `${year}-${String(month + 1).padStart(2, '0')}-${String(lastDayOfMonth).padStart(2, '0')}`
const since = serverLocalISO(firstDay, 0, 0, 0, 0)
const until = serverLocalISO(lastDay, 23, 59, 59, 999)
const response = await getSignDailyCounts({ since, until }) const response = await getSignDailyCounts({ since, until })
dailyCounts.value = Object.fromEntries(response.items.map((item) => [item.date, item.count])) dailyCounts.value = Object.fromEntries(response.items.map((item) => [item.date, item.count]))
} catch (err) { } catch (err) {
@@ -93,6 +117,11 @@ async function loadRecords(nextPage = page.value) {
records.value = response.items records.value = response.items
total.value = response.total ?? response.offset + response.items.length total.value = response.total ?? response.offset + response.items.length
page.value = safePage page.value = safePage
// 从返回记录推导服务器时区偏移;首次获知后刷新日历(保证日历区间与列表筛选同钟)。
if (serverOffset.value === null && records.value.length > 0 && records.value[0].sign_time) {
serverOffset.value = extractServerOffset(records.value[0].sign_time)
loadCalendar()
}
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : String(err) error.value = err instanceof Error ? err.message : String(err)
} finally { } finally {