编辑帮助功能
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type helpContentRequest struct {
|
||||
Markdown string `json:"markdown"`
|
||||
}
|
||||
|
||||
func registerHelpRoutes(r gin.IRouter, store *store) {
|
||||
r.GET("/help", func(c *gin.Context) {
|
||||
item, err := latestHelpContentDTO(store)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": item})
|
||||
})
|
||||
}
|
||||
|
||||
func registerAdminHelpRoutes(r gin.IRouter, store *store) {
|
||||
r.GET("/help", func(c *gin.Context) {
|
||||
item, err := latestHelpContentDTO(store)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": item})
|
||||
})
|
||||
r.POST("/help", func(c *gin.Context) {
|
||||
var req helpContentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help content request"})
|
||||
return
|
||||
}
|
||||
claims := c.MustGet("admin_claims").(*sessionClaims)
|
||||
row, err := store.InsertHelpContent(req.Markdown, claims.Username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item, err := helpContentDTO(row.ID, row.Markdown, row.CreatedBy, &row.CreatedAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"item": item})
|
||||
})
|
||||
r.POST("/help/preview", func(c *gin.Context) {
|
||||
var req helpContentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help preview request"})
|
||||
return
|
||||
}
|
||||
html, err := renderHelpMarkdown(req.Markdown)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"html": html})
|
||||
})
|
||||
}
|
||||
|
||||
func latestHelpContentDTO(store *store) (gin.H, error) {
|
||||
row, err := store.GetLatestHelpContent()
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return helpContentDTO(0, defaultHelpMarkdown, "", nil)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return helpContentDTO(row.ID, row.Markdown, row.CreatedBy, &row.CreatedAt)
|
||||
}
|
||||
|
||||
func helpContentDTO(id uint64, markdown, createdBy string, createdAt *time.Time) (gin.H, error) {
|
||||
html, err := renderHelpMarkdown(markdown)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"id": ptrHelpID(id), "markdown": markdown, "html": html, "created_by": createdBy, "created_at": ptrTime(createdAt)}, nil
|
||||
}
|
||||
|
||||
func ptrHelpID(id uint64) any {
|
||||
if id == 0 {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func ptrTime(value *time.Time) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -91,6 +91,17 @@ func (loginLogRecord) TableName() string {
|
||||
return "login_log"
|
||||
}
|
||||
|
||||
type helpContentRecord struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Markdown string `gorm:"column:markdown;type:text;not null"`
|
||||
CreatedBy string `gorm:"column:created_by;index"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index"`
|
||||
}
|
||||
|
||||
func (helpContentRecord) TableName() string {
|
||||
return "help_content"
|
||||
}
|
||||
|
||||
type discardDetailsRecord struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Topic string `gorm:"column:topic"`
|
||||
@@ -389,6 +400,7 @@ func (s *store) migrate() error {
|
||||
}{
|
||||
{label: "users", model: &userRecord{}},
|
||||
{label: "login_log", model: &loginLogRecord{}},
|
||||
{label: "help_content", model: &helpContentRecord{}},
|
||||
{label: "discard_details", model: &discardDetailsRecord{}},
|
||||
{label: "node_blocking", model: &nodeBlockingRecord{}},
|
||||
{label: "ip_blocking", model: &ipBlockingRecord{}},
|
||||
|
||||
@@ -14,6 +14,7 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
@@ -30,6 +31,7 @@ require (
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -37,6 +39,7 @@ require (
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
@@ -47,6 +50,7 @@ require (
|
||||
github.com/rs/xid v1.4.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
@@ -46,6 +48,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -68,6 +72,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mochi-mqtt/server/v2 v2.7.9 h1:y0g4vrSLAag7T07l2oCzOa/+nKVLoazKEWAArwqBNYI=
|
||||
github.com/mochi-mqtt/server/v2 v2.7.9/go.mod h1:lZD3j35AVNqJL5cezlnSkuG05c0FCHSsfAKSPBOSbqc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -106,6 +112,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
|
||||
func renderHelpMarkdown(markdown string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
md := goldmark.New(goldmark.WithExtensions(extension.GFM))
|
||||
if err := md.Convert([]byte(markdown), &buf); err != nil {
|
||||
return "", fmt.Errorf("render markdown: %w", err)
|
||||
}
|
||||
policy := bluemonday.UGCPolicy()
|
||||
policy.RequireNoFollowOnLinks(false)
|
||||
return policy.Sanitize(buf.String()), nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxHelpMarkdownBytes = 200 * 1024
|
||||
|
||||
const defaultHelpMarkdown = `## 连接地址
|
||||
|
||||
将 Meshtastic 设备连接到本服务提供的 MQTT broker。
|
||||
|
||||
- 默认地址:**mesh.gat-iot.com**
|
||||
- 默认端口:**1883**
|
||||
- 用户名称:**meshdev**
|
||||
- 密码:**large4cats**
|
||||
|
||||
## 频道加密要求
|
||||
|
||||
为了让服务能够解析 Meshtastic MQTT payload,频道需要满足以下任一条件:
|
||||
|
||||
- 频道不加密。
|
||||
- 使用 Meshtastic 默认 PSK:**AQ==**。
|
||||
|
||||
如果使用自定义加密密钥,数据可能会被判定为无法解密并丢弃。
|
||||
|
||||
## 反馈问题
|
||||
|
||||
如果遇到 bug,请在 GitHub [提交 issue](https://github.com/wuwenfengmi1998/meshtastic_mqtt_server),或联系邮箱 [kevin@lmve.net](mailto:kevin@lmve.net)。`
|
||||
|
||||
func (s *store) GetLatestHelpContent() (*helpContentRecord, error) {
|
||||
var row helpContentRecord
|
||||
if err := s.db.Order("id DESC").Take(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) InsertHelpContent(markdown, createdBy string) (*helpContentRecord, error) {
|
||||
markdown = strings.TrimSpace(markdown)
|
||||
createdBy = strings.TrimSpace(createdBy)
|
||||
if markdown == "" {
|
||||
return nil, fmt.Errorf("markdown is required")
|
||||
}
|
||||
if len([]byte(markdown)) > maxHelpMarkdownBytes {
|
||||
return nil, fmt.Errorf("markdown exceeds %d bytes", maxHelpMarkdownBytes)
|
||||
}
|
||||
row := helpContentRecord{Markdown: markdown, CreatedBy: createdBy}
|
||||
if err := s.db.Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { adminLogout, createNodeBlockingRule, deleteNode, deleteTextMessage, get
|
||||
import AdminBlockingManagement from './components/AdminBlockingManagement.vue'
|
||||
import AdminDashboard from './components/AdminDashboard.vue'
|
||||
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
||||
import AdminHelpEdit from './components/AdminHelpEdit.vue'
|
||||
import AdminLogin from './components/AdminLogin.vue'
|
||||
import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
||||
import AdminMqttForward from './components/AdminMqttForward.vue'
|
||||
@@ -457,6 +458,7 @@ onBeforeUnmount(() => {
|
||||
<a href="/admin/users" :class="{ active: adminPath === '/admin/users' }">用户管理</a>
|
||||
<a href="/admin/blocking_management" :class="{ active: adminPath === '/admin/blocking_management' }">屏蔽管理</a>
|
||||
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</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/discard_details" :class="{ active: adminPath === '/admin/discard_details' }">丢弃数据</a>
|
||||
</nav>
|
||||
@@ -495,6 +497,7 @@ onBeforeUnmount(() => {
|
||||
<AdminUsers v-if="adminPath === '/admin/users'" :user="adminUser" />
|
||||
<AdminBlockingManagement v-else-if="adminPath === '/admin/blocking_management'" />
|
||||
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
|
||||
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
|
||||
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
||||
<AdminDiscardDetails v-else-if="adminPath === '/admin/discard_details'" />
|
||||
<AdminDashboard v-else />
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
ForbiddenWordBlockingRule,
|
||||
ForbiddenWordBlockingRulePayload,
|
||||
HealthStatus,
|
||||
HelpContentResponse,
|
||||
HelpPreviewResponse,
|
||||
IPBlockingRule,
|
||||
IPBlockingRulePayload,
|
||||
ListResponse,
|
||||
@@ -82,6 +84,10 @@ export function getHealth(): Promise<HealthStatus> {
|
||||
return getJSON<HealthStatus>('/api/health')
|
||||
}
|
||||
|
||||
export function getHelpContent(): Promise<HelpContentResponse> {
|
||||
return getJSON<HelpContentResponse>('/api/help')
|
||||
}
|
||||
|
||||
export function getNodeInfo(limit = 500, offset = 0): Promise<ListResponse<NodeInfo>> {
|
||||
return getJSON<ListResponse<NodeInfo>>(listPath('/api/nodeinfo', limit, offset))
|
||||
}
|
||||
@@ -157,6 +163,18 @@ export function getAdminMqttStatus(): Promise<AdminMqttStatus> {
|
||||
return getJSON<AdminMqttStatus>('/api/admin/mqtt/status')
|
||||
}
|
||||
|
||||
export function getAdminHelpContent(): Promise<HelpContentResponse> {
|
||||
return getJSON<HelpContentResponse>('/api/admin/help')
|
||||
}
|
||||
|
||||
export function saveAdminHelpContent(markdown: string): Promise<HelpContentResponse> {
|
||||
return postJSON<HelpContentResponse>('/api/admin/help', { markdown })
|
||||
}
|
||||
|
||||
export function previewAdminHelpContent(markdown: string): Promise<HelpPreviewResponse> {
|
||||
return postJSON<HelpPreviewResponse>('/api/admin/help/preview', { markdown })
|
||||
}
|
||||
|
||||
export function getAdminUsers(): Promise<AdminUsersResponse> {
|
||||
return getJSON<AdminUsersResponse>('/api/admin/users')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getAdminHelpContent, previewAdminHelpContent, saveAdminHelpContent } from '../api'
|
||||
import type { HelpContent } from '../types'
|
||||
|
||||
const markdown = ref('')
|
||||
const previewHtml = ref('')
|
||||
const latest = ref<HelpContent | null>(null)
|
||||
const loading = ref(false)
|
||||
const previewing = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const message = ref('')
|
||||
let previewTimer: number | undefined
|
||||
|
||||
function formatTime(value: string | null): string {
|
||||
return value ? new Date(value).toLocaleString() : '默认内容'
|
||||
}
|
||||
|
||||
async function loadHelpContent() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
try {
|
||||
const response = await getAdminHelpContent()
|
||||
latest.value = response.item
|
||||
markdown.value = response.item.markdown
|
||||
previewHtml.value = response.item.html
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function previewHelpContent() {
|
||||
previewing.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await previewAdminHelpContent(markdown.value)
|
||||
previewHtml.value = response.html
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
previewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePreview() {
|
||||
if (previewTimer !== undefined) {
|
||||
window.clearTimeout(previewTimer)
|
||||
}
|
||||
previewTimer = window.setTimeout(() => {
|
||||
previewHelpContent()
|
||||
}, 400)
|
||||
}
|
||||
|
||||
async function saveHelpContent() {
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
if (!markdown.value.trim()) {
|
||||
error.value = '帮助内容不能为空'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await saveAdminHelpContent(markdown.value)
|
||||
latest.value = response.item
|
||||
markdown.value = response.item.markdown
|
||||
previewHtml.value = response.item.html
|
||||
message.value = `帮助内容已保存为版本 #${response.item.id}`
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadHelpContent)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-dashboard">
|
||||
<div class="panel admin-status-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Help</p>
|
||||
<h2>帮助编辑</h2>
|
||||
</div>
|
||||
<div class="admin-actions">
|
||||
<button class="admin-button" @click="loadHelpContent" :disabled="loading || saving">{{ loading ? '加载中...' : '重新加载' }}</button>
|
||||
<button class="admin-button" @click="saveHelpContent" :disabled="loading || saving">{{ saving ? '保存中...' : '保存新版本' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="message" class="success">{{ message }}</p>
|
||||
<p class="help-version muted">
|
||||
当前版本:{{ latest?.id ? `#${latest.id}` : '默认内容' }} · 创建人:{{ latest?.created_by || '-' }} · 时间:{{ formatTime(latest?.created_at ?? null) }}
|
||||
</p>
|
||||
|
||||
<div class="help-editor-grid">
|
||||
<label class="help-editor-pane">
|
||||
<span>Markdown 内容</span>
|
||||
<textarea v-model="markdown" @input="schedulePreview" placeholder="请输入帮助内容 Markdown"></textarea>
|
||||
</label>
|
||||
<div class="help-editor-pane help-preview-pane">
|
||||
<div class="help-preview-header">
|
||||
<span>预览</span>
|
||||
<button class="admin-button" @click="previewHelpContent" :disabled="previewing">{{ previewing ? '预览中...' : '刷新预览' }}</button>
|
||||
</div>
|
||||
<div class="markdown-body help-preview" v-html="previewHtml"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,3 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getHelpContent } from '../api'
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const html = ref('')
|
||||
|
||||
async function loadHelpContent() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await getHelpContent()
|
||||
html.value = response.item.html
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadHelpContent)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="help-page">
|
||||
<div class="panel">
|
||||
@@ -9,32 +33,9 @@
|
||||
</div>
|
||||
|
||||
<div class="help-content">
|
||||
<section>
|
||||
<h3>连接地址</h3>
|
||||
<p>将 Meshtastic 设备连接到本服务提供的 MQTT broker。</p>
|
||||
<ul>
|
||||
<li>默认地址:<strong>mesh.gat-iot.com</strong></li>
|
||||
<li>默认端口:<strong>1883</strong></li>
|
||||
<li>用户名称:<strong>meshdev</strong></li>
|
||||
<li>密码:<strong>large4cats</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>频道加密要求</h3>
|
||||
<p>为了让服务能够解析 Meshtastic MQTT payload,频道需要满足以下任一条件:</p>
|
||||
<ul>
|
||||
<li>频道不加密。</li>
|
||||
<li>使用 Meshtastic 默认 PSK:<strong>AQ==</strong>。</li>
|
||||
</ul>
|
||||
<p>如果使用自定义加密密钥,数据可能会被判定为无法解密并丢弃。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>反馈问题</h3>
|
||||
<p>如果遇到 bug,请在 GitHub <a href="https://github.com/wuwenfengmi1998/meshtastic_mqtt_server" target="_blank" rel="noopener noreferrer">提交 issue</a>,或联系邮箱 <a href="mailto:kevin@lmve.net">kevin@lmve.net</a>。</p>
|
||||
</section>
|
||||
|
||||
<p v-if="loading" class="muted">正在加载帮助内容...</p>
|
||||
<p v-else-if="error" class="error">{{ error }}</p>
|
||||
<div v-else class="markdown-body" v-html="html"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -525,6 +525,59 @@ h3 {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
line-height: 1.65;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3 {
|
||||
margin: 0.8em 0 0.4em;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.markdown-body h1:first-child,
|
||||
.markdown-body h2:first-child,
|
||||
.markdown-body h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin: 0 0 0.8em;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
margin: 0 0 0.8em;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
color: #2563eb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
border-radius: 6px;
|
||||
padding: 2px 5px;
|
||||
background: #e2e8f0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
padding: 0;
|
||||
color: #e2e8f0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.detail-section-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
|
||||
@@ -716,6 +769,56 @@ h3 {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.help-version {
|
||||
margin: 12px 16px 0;
|
||||
}
|
||||
|
||||
.help-editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.help-editor-pane {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.help-editor-pane textarea {
|
||||
min-height: 520px;
|
||||
resize: vertical;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.help-preview-pane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.help-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.help-preview {
|
||||
min-height: 520px;
|
||||
overflow: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
color: #334155;
|
||||
font-weight: 400;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.admin-form button,
|
||||
.admin-actions button,
|
||||
.admin-button {
|
||||
@@ -954,7 +1057,8 @@ dd {
|
||||
|
||||
.workspace,
|
||||
.detail-grid,
|
||||
.detail-section-grid {
|
||||
.detail-section-grid,
|
||||
.help-editor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,26 @@ export interface HealthStatus {
|
||||
database: string
|
||||
}
|
||||
|
||||
export interface HelpContent {
|
||||
id: number | null
|
||||
markdown: string
|
||||
html: string
|
||||
created_by: string
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
export interface HelpContentResponse {
|
||||
item: HelpContent
|
||||
}
|
||||
|
||||
export interface HelpContentPayload {
|
||||
markdown: string
|
||||
}
|
||||
|
||||
export interface HelpPreviewResponse {
|
||||
html: string
|
||||
}
|
||||
|
||||
export interface NodeInfo {
|
||||
node_id: string
|
||||
node_num: number
|
||||
|
||||
@@ -72,6 +72,7 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
|
||||
registerNodeInfoRoutes(r, store, "/nodeinfo")
|
||||
registerNodeInfoRoutes(r, store, "/nodes")
|
||||
registerMapReportRoutes(r, store)
|
||||
registerHelpRoutes(r, store)
|
||||
r.GET("/text-messages", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
@@ -184,6 +185,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
||||
protected.Use(requireAdmin(sessions))
|
||||
registerAdminBlockingRoutes(protected, store, blocking)
|
||||
registerAdminMQTTForwardRoutes(protected, store, forwarder)
|
||||
registerAdminHelpRoutes(protected, store)
|
||||
protected.GET("/me", func(c *gin.Context) {
|
||||
claims := c.MustGet("admin_claims").(*sessionClaims)
|
||||
c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}})
|
||||
|
||||
Reference in New Issue
Block a user