编辑帮助功能

This commit is contained in:
2026-06-05 19:05:04 +08:00
parent d962ccf9af
commit dd10d99b99
13 changed files with 492 additions and 27 deletions
+3
View File
@@ -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 />
+18
View File
@@ -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>
+27 -26
View File
@@ -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>
+105 -1
View File
@@ -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;
}
+20
View File
@@ -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