@@ -0,0 +1,114 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type signRequest struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
LongName string `json:"long_name"`
|
||||||
|
ShortName string `json:"short_name"`
|
||||||
|
SignText string `json:"sign_text"`
|
||||||
|
SignTime string `json:"sign_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerAdminSignRoutes(r gin.IRouter, store *store) {
|
||||||
|
r.GET("/signs", func(c *gin.Context) {
|
||||||
|
opts, ok := parseListOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := store.ListSigns(opts)
|
||||||
|
if err != nil {
|
||||||
|
writeListResponse(c, rows, opts, err, signDTO)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := store.CountSigns(opts)
|
||||||
|
writeListResponseWithTotal(c, rows, opts, total, err, signDTO)
|
||||||
|
})
|
||||||
|
r.POST("/signs", func(c *gin.Context) {
|
||||||
|
var req signRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signTime, ok := parseSignRequestTime(c, req.SignTime)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := store.CreateSign(req.NodeID, nullableString(req.LongName), nullableString(req.ShortName), req.SignText, signTime)
|
||||||
|
writeSignMutationResponse(c, http.StatusCreated, row, err)
|
||||||
|
})
|
||||||
|
r.PUT("/signs/:id", func(c *gin.Context) {
|
||||||
|
id, ok := parseSignID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req signRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signTime, ok := parseSignRequestTime(c, req.SignTime)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := store.UpdateSign(id, req.NodeID, nullableString(req.LongName), nullableString(req.ShortName), req.SignText, signTime)
|
||||||
|
writeSignMutationResponse(c, http.StatusOK, row, err)
|
||||||
|
})
|
||||||
|
r.DELETE("/signs/:id", func(c *gin.Context) {
|
||||||
|
id, ok := parseSignID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := store.DeleteSign(id)
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "sign record not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSignID(c *gin.Context) (uint64, bool) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign id"})
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSignRequestTime(c *gin.Context, value string) (time.Time, bool) {
|
||||||
|
if value == "" {
|
||||||
|
return time.Time{}, true
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign_time: use RFC3339"})
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSignMutationResponse(c *gin.Context, status int, row *signRecord, err error) {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "sign record not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"item": signDTO(*row)})
|
||||||
|
}
|
||||||
@@ -153,6 +153,19 @@ func (discardDetailsRecord) TableName() string {
|
|||||||
return "discard_details"
|
return "discard_details"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type signRecord struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
NodeID string `gorm:"column:node_id;not null;index"`
|
||||||
|
LongName *string `gorm:"column:long_name"`
|
||||||
|
ShortName *string `gorm:"column:short_name"`
|
||||||
|
SignText string `gorm:"column:sign_text;type:text;not null"`
|
||||||
|
SignTime time.Time `gorm:"column:sign_time;not null;index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (signRecord) TableName() string {
|
||||||
|
return "signs"
|
||||||
|
}
|
||||||
|
|
||||||
type nodeBlockingRecord struct {
|
type nodeBlockingRecord struct {
|
||||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
NodeID string `gorm:"column:node_id;not null;uniqueIndex"`
|
NodeID string `gorm:"column:node_id;not null;uniqueIndex"`
|
||||||
@@ -528,6 +541,7 @@ func (s *store) migrate() error {
|
|||||||
{label: "runtime_settings", model: &runtimeSettingRecord{}},
|
{label: "runtime_settings", model: &runtimeSettingRecord{}},
|
||||||
{label: "map_tile_sources", model: &mapTileSourceRecord{}},
|
{label: "map_tile_sources", model: &mapTileSourceRecord{}},
|
||||||
{label: "discard_details", model: &discardDetailsRecord{}},
|
{label: "discard_details", model: &discardDetailsRecord{}},
|
||||||
|
{label: "signs", model: &signRecord{}},
|
||||||
{label: "node_blocking", model: &nodeBlockingRecord{}},
|
{label: "node_blocking", model: &nodeBlockingRecord{}},
|
||||||
{label: "ip_blocking", model: &ipBlockingRecord{}},
|
{label: "ip_blocking", model: &ipBlockingRecord{}},
|
||||||
{label: "forbidden_word_blocking", model: &forbiddenWordBlockingRecord{}},
|
{label: "forbidden_word_blocking", model: &forbiddenWordBlockingRecord{}},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import AdminLogin from './components/AdminLogin.vue'
|
|||||||
import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
||||||
import AdminMapSource from './components/AdminMapSource.vue'
|
import AdminMapSource from './components/AdminMapSource.vue'
|
||||||
import AdminMqttForward from './components/AdminMqttForward.vue'
|
import AdminMqttForward from './components/AdminMqttForward.vue'
|
||||||
|
import AdminSignManagement from './components/AdminSignManagement.vue'
|
||||||
import AdminUsers from './components/AdminUsers.vue'
|
import AdminUsers from './components/AdminUsers.vue'
|
||||||
import ChatPanel from './components/ChatPanel.vue'
|
import ChatPanel from './components/ChatPanel.vue'
|
||||||
import ConfirmDeleteModal from './components/ConfirmDeleteModal.vue'
|
import ConfirmDeleteModal from './components/ConfirmDeleteModal.vue'
|
||||||
@@ -18,6 +19,7 @@ import HelpPage from './components/HelpPage.vue'
|
|||||||
import MeshMap from './components/MeshMap.vue'
|
import MeshMap from './components/MeshMap.vue'
|
||||||
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
||||||
import NodeListPanel from './components/NodeListPanel.vue'
|
import NodeListPanel from './components/NodeListPanel.vue'
|
||||||
|
import SignedPage from './components/SignedPage.vue'
|
||||||
import { fallbackMapSource, loadEnabledMapSources } from './mapSource'
|
import { fallbackMapSource, loadEnabledMapSources } from './mapSource'
|
||||||
import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, MapViewportPoint, NodeInfo, NodeInfoById, PositionRecord, PublicMapTileSource, TextMessage } from './types'
|
import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, MapViewportPoint, NodeInfo, NodeInfoById, PositionRecord, PublicMapTileSource, TextMessage } from './types'
|
||||||
|
|
||||||
@@ -27,10 +29,12 @@ const isAdminPage = adminPath.startsWith('/admin')
|
|||||||
const isMqttForwardAdminPage = adminPath === '/admin/mqtt_forward' || adminPath === '/admin/mqtt_forward/'
|
const isMqttForwardAdminPage = adminPath === '/admin/mqtt_forward' || adminPath === '/admin/mqtt_forward/'
|
||||||
const isBotAdminPage = adminPath === '/admin/bot' || adminPath === '/admin/bot/'
|
const isBotAdminPage = adminPath === '/admin/bot' || adminPath === '/admin/bot/'
|
||||||
const isBotDirectAdminPage = adminPath === '/admin/bot/direct' || adminPath === '/admin/bot/direct/'
|
const isBotDirectAdminPage = adminPath === '/admin/bot/direct' || adminPath === '/admin/bot/direct/'
|
||||||
|
const isSignAdminPage = adminPath === '/admin/sign' || adminPath === '/admin/sign/'
|
||||||
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
||||||
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
||||||
const isDetailedPage = !!detailedNodeId
|
const isDetailedPage = !!detailedNodeId
|
||||||
const isHelpPage = currentPath === '/help'
|
const isHelpPage = currentPath === '/help'
|
||||||
|
const isSignedPage = currentPath === '/signed'
|
||||||
const adminUser = ref<AdminUser | null>(null)
|
const adminUser = ref<AdminUser | null>(null)
|
||||||
const adminChecking = ref(false)
|
const adminChecking = ref(false)
|
||||||
|
|
||||||
@@ -509,7 +513,7 @@ onMounted(() => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
checkAdminSession()
|
checkAdminSession()
|
||||||
if (isDetailedPage || isHelpPage) {
|
if (isDetailedPage || isHelpPage || isSignedPage) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadMapSource()
|
loadMapSource()
|
||||||
@@ -545,6 +549,7 @@ onBeforeUnmount(() => {
|
|||||||
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
|
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
|
||||||
<a href="/admin/bot" :class="{ active: isBotAdminPage }">机器人</a>
|
<a href="/admin/bot" :class="{ active: isBotAdminPage }">机器人</a>
|
||||||
<a href="/admin/bot/direct" :class="{ active: isBotDirectAdminPage }">机器人私聊</a>
|
<a href="/admin/bot/direct" :class="{ active: isBotDirectAdminPage }">机器人私聊</a>
|
||||||
|
<a href="/admin/sign" :class="{ active: isSignAdminPage }">签到管理</a>
|
||||||
<a href="/admin/map_source" :class="{ active: adminPath === '/admin/map_source' }">地图图源</a>
|
<a href="/admin/map_source" :class="{ active: adminPath === '/admin/map_source' }">地图图源</a>
|
||||||
<a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a>
|
<a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a>
|
||||||
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
|
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
|
||||||
@@ -564,6 +569,7 @@ onBeforeUnmount(() => {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
||||||
<span class="counter">节点 {{ nodeTotal }} · 已加载消息 {{ messages.length }} · 坐标 {{ mapItems.length }} / {{ mapReportTotal }}{{ mapViewportMode === 'clusters' ? ' · 已聚合' : '' }}{{ mapReportsLoading ? ' · 坐标加载中...' : '' }}</span>
|
<span class="counter">节点 {{ nodeTotal }} · 已加载消息 {{ messages.length }} · 坐标 {{ mapItems.length }} / {{ mapReportTotal }}{{ mapViewportMode === 'clusters' ? ' · 已聚合' : '' }}{{ mapReportsLoading ? ' · 坐标加载中...' : '' }}</span>
|
||||||
|
<a class="topbar-link" href="/signed">签到列表</a>
|
||||||
<a class="topbar-link" href="/help">使用帮助</a>
|
<a class="topbar-link" href="/help">使用帮助</a>
|
||||||
<a class="topbar-link" href="/admin">管理</a>
|
<a class="topbar-link" href="/admin">管理</a>
|
||||||
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||||
@@ -587,6 +593,7 @@ onBeforeUnmount(() => {
|
|||||||
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
|
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
|
||||||
<AdminBot v-else-if="isBotAdminPage" />
|
<AdminBot v-else-if="isBotAdminPage" />
|
||||||
<AdminBotDirect v-else-if="isBotDirectAdminPage" />
|
<AdminBotDirect v-else-if="isBotDirectAdminPage" />
|
||||||
|
<AdminSignManagement v-else-if="isSignAdminPage" />
|
||||||
<AdminMapSource v-else-if="adminPath === '/admin/map_source'" />
|
<AdminMapSource v-else-if="adminPath === '/admin/map_source'" />
|
||||||
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
|
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
|
||||||
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
||||||
@@ -604,6 +611,10 @@ onBeforeUnmount(() => {
|
|||||||
<HelpPage />
|
<HelpPage />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="isSignedPage">
|
||||||
|
<SignedPage />
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import type {
|
|||||||
PositionRecord,
|
PositionRecord,
|
||||||
PublicMapTileSourceResponse,
|
PublicMapTileSourceResponse,
|
||||||
PublicMapTileSourcesResponse,
|
PublicMapTileSourcesResponse,
|
||||||
|
SignRecord,
|
||||||
|
SignRecordPayload,
|
||||||
TelemetryRecord,
|
TelemetryRecord,
|
||||||
TextMessage,
|
TextMessage,
|
||||||
BotDirectMessage,
|
BotDirectMessage,
|
||||||
@@ -167,6 +169,26 @@ export function getTextMessages(limit = 100, offset = 0, nodeIdOrOptions: string
|
|||||||
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeIdOrOptions))
|
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeIdOrOptions))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSignRecords(limit = 100, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<SignRecord>> {
|
||||||
|
return getJSON<ListResponse<SignRecord>>(listPath('/api/signs', limit, offset, nodeIdOrOptions))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminSignRecords(limit = 100, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<SignRecord>> {
|
||||||
|
return getJSON<ListResponse<SignRecord>>(listPath('/api/admin/signs', limit, offset, nodeIdOrOptions))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminSignRecord(payload: SignRecordPayload): Promise<{ item: SignRecord }> {
|
||||||
|
return postJSON<{ item: SignRecord }>('/api/admin/signs', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAdminSignRecord(id: number, payload: SignRecordPayload): Promise<{ item: SignRecord }> {
|
||||||
|
return putJSON<{ item: SignRecord }>(`/api/admin/signs/${id}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAdminSignRecord(id: number): Promise<{ status: string }> {
|
||||||
|
return deleteJSON<{ status: string }>(`/api/admin/signs/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteTextMessage(id: number): Promise<{ status: string }> {
|
export function deleteTextMessage(id: number): Promise<{ status: string }> {
|
||||||
return deleteJSON<{ status: string }>(`/api/admin/text-messages/${id}`)
|
return deleteJSON<{ status: string }>(`/api/admin/text-messages/${id}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { createAdminSignRecord, deleteAdminSignRecord, getAdminSignRecords, updateAdminSignRecord } from '../api'
|
||||||
|
import type { SignRecord, SignRecordPayload } from '../types'
|
||||||
|
|
||||||
|
const pageSize = 25
|
||||||
|
const records = ref<SignRecord[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const total = ref(0)
|
||||||
|
const newNodeID = ref('')
|
||||||
|
const newLongName = ref('')
|
||||||
|
const newShortName = ref('')
|
||||||
|
const newSignText = ref('')
|
||||||
|
const newSignTime = ref(toDateTimeLocal(new Date().toISOString()))
|
||||||
|
const edits = ref<Record<number, SignRecordPayload>>({})
|
||||||
|
|
||||||
|
function formatTime(value: string): string {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateTimeLocal(value: string): string {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const offset = date.getTimezoneOffset() * 60000
|
||||||
|
return new Date(date.getTime() - offset).toISOString().slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRFC3339(value: string): string | undefined {
|
||||||
|
if (!value) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
throw new Error('签到时间格式无效')
|
||||||
|
}
|
||||||
|
return date.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPrev(): boolean {
|
||||||
|
return page.value > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function canNext(): boolean {
|
||||||
|
return page.value * pageSize < total.value || records.value.length === pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
function signPayload(nodeID: string, longName: string, shortName: string, signText: string, signTime: string): SignRecordPayload {
|
||||||
|
if (!nodeID.trim()) {
|
||||||
|
throw new Error('节点 ID 不能为空')
|
||||||
|
}
|
||||||
|
if (!signText.trim()) {
|
||||||
|
throw new Error('签到文本不能为空')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
node_id: nodeID.trim(),
|
||||||
|
long_name: longName.trim(),
|
||||||
|
short_name: shortName.trim(),
|
||||||
|
sign_text: signText.trim(),
|
||||||
|
sign_time: toRFC3339(signTime),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSigns(nextPage = page.value) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const safePage = Math.max(1, nextPage)
|
||||||
|
const response = await getAdminSignRecords(pageSize, (safePage - 1) * pageSize)
|
||||||
|
records.value = response.items
|
||||||
|
total.value = response.total ?? response.offset + response.items.length
|
||||||
|
page.value = safePage
|
||||||
|
edits.value = {}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSign() {
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
let payload: SignRecordPayload
|
||||||
|
try {
|
||||||
|
payload = signPayload(newNodeID.value, newLongName.value, newShortName.value, newSignText.value, newSignTime.value)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await createAdminSignRecord(payload)
|
||||||
|
newNodeID.value = ''
|
||||||
|
newLongName.value = ''
|
||||||
|
newShortName.value = ''
|
||||||
|
newSignText.value = ''
|
||||||
|
newSignTime.value = toDateTimeLocal(new Date().toISOString())
|
||||||
|
message.value = '签到记录已新增'
|
||||||
|
await refreshSigns(1)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(record: SignRecord) {
|
||||||
|
edits.value = {
|
||||||
|
...edits.value,
|
||||||
|
[record.id]: {
|
||||||
|
node_id: record.node_id,
|
||||||
|
long_name: record.long_name || '',
|
||||||
|
short_name: record.short_name || '',
|
||||||
|
sign_text: record.sign_text,
|
||||||
|
sign_time: toDateTimeLocal(record.sign_time),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit(id: number) {
|
||||||
|
const next = { ...edits.value }
|
||||||
|
delete next[id]
|
||||||
|
edits.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSign(record: SignRecord) {
|
||||||
|
const edit = edits.value[record.id]
|
||||||
|
if (!edit) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
let payload: SignRecordPayload
|
||||||
|
try {
|
||||||
|
payload = signPayload(edit.node_id, edit.long_name, edit.short_name, edit.sign_text, edit.sign_time || '')
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await updateAdminSignRecord(record.id, payload)
|
||||||
|
message.value = '签到记录已保存'
|
||||||
|
await refreshSigns()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSign(record: SignRecord) {
|
||||||
|
if (!window.confirm(`确定要删除节点 ${record.node_id} 的签到记录吗?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await deleteAdminSignRecord(record.id)
|
||||||
|
message.value = '签到记录已删除'
|
||||||
|
await refreshSigns(records.value.length === 1 ? page.value - 1 : page.value)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => refreshSigns())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-dashboard">
|
||||||
|
<div class="panel admin-status-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Sign</p>
|
||||||
|
<h2>签到管理</h2>
|
||||||
|
</div>
|
||||||
|
<button class="admin-button" :disabled="loading" @click="refreshSigns()">{{ loading ? '刷新中...' : '刷新签到' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="admin-form" @submit.prevent="createSign">
|
||||||
|
<label>
|
||||||
|
<span>节点 ID</span>
|
||||||
|
<input v-model="newNodeID" autocomplete="off" placeholder="!1234abcd" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Long Name</span>
|
||||||
|
<input v-model="newLongName" autocomplete="off" placeholder="Long Name" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Short Name</span>
|
||||||
|
<input v-model="newShortName" autocomplete="off" placeholder="Short" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>签到时间</span>
|
||||||
|
<input v-model="newSignTime" type="datetime-local" />
|
||||||
|
</label>
|
||||||
|
<label class="admin-form-wide">
|
||||||
|
<span>签到文本</span>
|
||||||
|
<input v-model="newSignText" autocomplete="off" placeholder="签到文本" />
|
||||||
|
</label>
|
||||||
|
<button class="admin-button" :disabled="loading" type="submit">新增签到</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-if="message" class="success">{{ message }}</p>
|
||||||
|
|
||||||
|
<div class="node-table-wrap">
|
||||||
|
<table class="node-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>节点 ID</th>
|
||||||
|
<th>Long Name</th>
|
||||||
|
<th>Short Name</th>
|
||||||
|
<th>签到文本</th>
|
||||||
|
<th>签到时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="record in records" :key="record.id">
|
||||||
|
<template v-if="edits[record.id]">
|
||||||
|
<td>{{ record.id }}</td>
|
||||||
|
<td><input v-model="edits[record.id].node_id" class="admin-table-input" /></td>
|
||||||
|
<td><input v-model="edits[record.id].long_name" class="admin-table-input" /></td>
|
||||||
|
<td><input v-model="edits[record.id].short_name" class="admin-table-input" /></td>
|
||||||
|
<td><input v-model="edits[record.id].sign_text" class="admin-table-input" /></td>
|
||||||
|
<td><input v-model="edits[record.id].sign_time" class="admin-table-input" type="datetime-local" /></td>
|
||||||
|
<td>
|
||||||
|
<button class="admin-button" :disabled="loading" @click="saveSign(record)">保存</button>
|
||||||
|
<button class="admin-button" :disabled="loading" @click="cancelEdit(record.id)">取消</button>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<td>{{ record.id }}</td>
|
||||||
|
<td>{{ record.node_id }}</td>
|
||||||
|
<td>{{ record.long_name || '-' }}</td>
|
||||||
|
<td>{{ record.short_name || '-' }}</td>
|
||||||
|
<td>{{ record.sign_text }}</td>
|
||||||
|
<td>{{ formatTime(record.sign_time) }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="admin-button" :disabled="loading" @click="startEdit(record)">编辑</button>
|
||||||
|
<button class="admin-button danger" :disabled="loading" @click="removeSign(record)">删除</button>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-if="records.length === 0" class="empty">{{ loading ? '加载中...' : '暂无签到记录' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<button class="admin-button" :disabled="!canPrev() || loading" @click="refreshSigns(page - 1)">上一页</button>
|
||||||
|
<span>第 {{ page }} 页 / 共 {{ total }} 条</span>
|
||||||
|
<button class="admin-button" :disabled="!canNext() || loading" @click="refreshSigns(page + 1)">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { getSignRecords } from '../api'
|
||||||
|
import type { SignRecord } from '../types'
|
||||||
|
|
||||||
|
const pageSize = 25
|
||||||
|
const records = ref<SignRecord[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
function formatTime(value: string): string {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPrev(): boolean {
|
||||||
|
return page.value > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function canNext(): boolean {
|
||||||
|
return page.value * pageSize < total.value || records.value.length === pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecords(nextPage = page.value) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const safePage = Math.max(1, nextPage)
|
||||||
|
const response = await getSignRecords(pageSize, (safePage - 1) * pageSize)
|
||||||
|
records.value = response.items
|
||||||
|
total.value = response.total ?? response.offset + response.items.length
|
||||||
|
page.value = safePage
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => loadRecords())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-dashboard">
|
||||||
|
<div class="panel admin-status-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Signed</p>
|
||||||
|
<h2>签到用户</h2>
|
||||||
|
</div>
|
||||||
|
<span class="counter">共 {{ total }} 条签到记录</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<div class="node-table-wrap">
|
||||||
|
<table class="node-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>节点 ID</th>
|
||||||
|
<th>Long Name</th>
|
||||||
|
<th>Short Name</th>
|
||||||
|
<th>签到文本</th>
|
||||||
|
<th>签到时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="record in records" :key="record.id">
|
||||||
|
<td>{{ record.node_id }}</td>
|
||||||
|
<td>{{ record.long_name || '-' }}</td>
|
||||||
|
<td>{{ record.short_name || '-' }}</td>
|
||||||
|
<td>{{ record.sign_text }}</td>
|
||||||
|
<td>{{ formatTime(record.sign_time) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-if="records.length === 0" class="empty">{{ loading ? '加载中...' : '暂无签到记录' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<button class="admin-button" :disabled="!canPrev() || loading" @click="loadRecords(page - 1)">上一页</button>
|
||||||
|
<span>第 {{ page }} 页</span>
|
||||||
|
<button class="admin-button" :disabled="!canNext() || loading" @click="loadRecords(page + 1)">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -149,6 +149,23 @@ export interface TextMessage {
|
|||||||
content_json: string
|
content_json: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SignRecord {
|
||||||
|
id: number
|
||||||
|
node_id: string
|
||||||
|
long_name: string | null
|
||||||
|
short_name: string | null
|
||||||
|
sign_text: string
|
||||||
|
sign_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignRecordPayload {
|
||||||
|
node_id: string
|
||||||
|
long_name: string
|
||||||
|
short_name: string
|
||||||
|
sign_text: string
|
||||||
|
sign_time?: string
|
||||||
|
}
|
||||||
|
|
||||||
// 机器人 PKI 私聊(bot_direct_messages 表)。direction 区分本地 bot 视角的进出方向。
|
// 机器人 PKI 私聊(bot_direct_messages 表)。direction 区分本地 bot 视角的进出方向。
|
||||||
export interface BotDirectMessage {
|
export interface BotDirectMessage {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *store) ListSigns(opts listOptions) ([]signRecord, error) {
|
||||||
|
opts = normalizeListOptions(opts)
|
||||||
|
var rows []signRecord
|
||||||
|
q := applySignFilters(s.db.Model(&signRecord{}), opts).
|
||||||
|
Order("sign_time DESC").
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(opts.Limit).
|
||||||
|
Offset(opts.Offset)
|
||||||
|
return rows, q.Find(&rows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) CountSigns(opts listOptions) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
q := applySignFilters(s.db.Model(&signRecord{}), opts)
|
||||||
|
return total, q.Count(&total).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GetSignByID(id uint64) (*signRecord, error) {
|
||||||
|
var row signRecord
|
||||||
|
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*signRecord, error) {
|
||||||
|
nodeID = strings.TrimSpace(nodeID)
|
||||||
|
signText = strings.TrimSpace(signText)
|
||||||
|
if nodeID == "" {
|
||||||
|
return nil, fmt.Errorf("node id is required")
|
||||||
|
}
|
||||||
|
if signText == "" {
|
||||||
|
return nil, fmt.Errorf("sign text is required")
|
||||||
|
}
|
||||||
|
if signTime.IsZero() {
|
||||||
|
signTime = time.Now()
|
||||||
|
}
|
||||||
|
row := signRecord{NodeID: nodeID, LongName: trimNullableString(longName), ShortName: trimNullableString(shortName), SignText: signText, SignTime: signTime}
|
||||||
|
if err := s.db.Create(&row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) UpdateSign(id uint64, nodeID string, longName, shortName *string, signText string, signTime time.Time) (*signRecord, error) {
|
||||||
|
if id == 0 {
|
||||||
|
return nil, fmt.Errorf("sign id is required")
|
||||||
|
}
|
||||||
|
nodeID = strings.TrimSpace(nodeID)
|
||||||
|
signText = strings.TrimSpace(signText)
|
||||||
|
if nodeID == "" {
|
||||||
|
return nil, fmt.Errorf("node id is required")
|
||||||
|
}
|
||||||
|
if signText == "" {
|
||||||
|
return nil, fmt.Errorf("sign text is required")
|
||||||
|
}
|
||||||
|
if signTime.IsZero() {
|
||||||
|
signTime = time.Now()
|
||||||
|
}
|
||||||
|
if _, err := s.GetSignByID(id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
updates := map[string]any{"node_id": nodeID, "long_name": trimNullableString(longName), "short_name": trimNullableString(shortName), "sign_text": signText, "sign_time": signTime}
|
||||||
|
if err := s.db.Model(&signRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetSignByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) DeleteSign(id uint64) error {
|
||||||
|
result := s.db.Where("id = ?", id).Delete(&signRecord{})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySignFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
||||||
|
if opts.NodeID != "" {
|
||||||
|
q = q.Where("node_id = ?", opts.NodeID)
|
||||||
|
}
|
||||||
|
if opts.Since != nil {
|
||||||
|
q = q.Where("sign_time >= ?", *opts.Since)
|
||||||
|
}
|
||||||
|
if opts.Until != nil {
|
||||||
|
q = q.Where("sign_time <= ?", *opts.Until)
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimNullableString(value *string) *string {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimSpace(*value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &trimmed
|
||||||
|
}
|
||||||
@@ -75,6 +75,19 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
|
|||||||
registerMapSourceRoutes(r, store)
|
registerMapSourceRoutes(r, store)
|
||||||
registerMapTileProxyRoutes(r, store, mapTileCacheDir)
|
registerMapTileProxyRoutes(r, store, mapTileCacheDir)
|
||||||
registerHelpRoutes(r, store)
|
registerHelpRoutes(r, store)
|
||||||
|
r.GET("/signs", func(c *gin.Context) {
|
||||||
|
opts, ok := parseListOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := store.ListSigns(opts)
|
||||||
|
if err != nil {
|
||||||
|
writeListResponse(c, rows, opts, err, signDTO)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := store.CountSigns(opts)
|
||||||
|
writeListResponseWithTotal(c, rows, opts, total, err, signDTO)
|
||||||
|
})
|
||||||
r.GET("/text-messages", func(c *gin.Context) {
|
r.GET("/text-messages", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := parseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -186,6 +199,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
|||||||
protected := r.Group("")
|
protected := r.Group("")
|
||||||
protected.Use(requireAdmin(sessions))
|
protected.Use(requireAdmin(sessions))
|
||||||
registerAdminBlockingRoutes(protected, store, blocking)
|
registerAdminBlockingRoutes(protected, store, blocking)
|
||||||
|
registerAdminSignRoutes(protected, store)
|
||||||
registerAdminMQTTForwardRoutes(protected, store, forwarder)
|
registerAdminMQTTForwardRoutes(protected, store, forwarder)
|
||||||
registerAdminRuntimeSettingsRoutes(protected, store, settings)
|
registerAdminRuntimeSettingsRoutes(protected, store, settings)
|
||||||
registerAdminMapSourceRoutes(protected, store)
|
registerAdminMapSourceRoutes(protected, store)
|
||||||
@@ -599,6 +613,10 @@ func mapReportClusterDTO(row mapReportClusterRecord) gin.H {
|
|||||||
return gin.H{"type": "cluster", "cluster_id": row.ClusterID, "latitude": row.Latitude, "longitude": row.Longitude, "count": row.Count}
|
return gin.H{"type": "cluster", "cluster_id": row.ClusterID, "latitude": row.Latitude, "longitude": row.Longitude, "count": row.Count}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func signDTO(row signRecord) gin.H {
|
||||||
|
return gin.H{"id": row.ID, "node_id": row.NodeID, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "sign_text": row.SignText, "sign_time": row.SignTime}
|
||||||
|
}
|
||||||
|
|
||||||
func textMessageDTO(row textMessageRecord) gin.H {
|
func textMessageDTO(row textMessageRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "packet_id": ptrInt64(row.PacketID), "text": ptrString(row.Text), "topic": row.Topic, "channel_id": ptrString(row.ChannelID), "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON}
|
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "packet_id": ptrInt64(row.PacketID), "text": ptrString(row.Text), "topic": row.Topic, "channel_id": ptrString(row.ChannelID), "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user