1.新增菜单后台操作指南,方便管理员查看使用

This commit is contained in:
2026-05-30 17:48:55 +08:00
parent a4c8f623be
commit 90abab14a3
37 changed files with 678 additions and 1 deletions

View File

@@ -0,0 +1,26 @@
import request from '@/utils/http'
/**
* 后台操作指南 API
*/
export default {
/**
* 读取 Markdown 内容
*/
read() {
return request.get<Api.Common.ApiData>({
url: '/core/adminGuide/read'
})
},
/**
* 保存 Markdown 内容
*/
save(params: { content: string }) {
return request.post<Api.Common.ApiData>({
url: '/core/adminGuide/save',
data: params,
showSuccessMessage: true
})
}
}

View File

@@ -0,0 +1,20 @@
{
"title": "Admin Guide",
"toolbar": {
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",
"refresh": "Refresh"
},
"meta": {
"filePath": "File Path",
"updateTime": "Updated At"
},
"message": {
"loadFailed": "Failed to load admin guide",
"saveSuccess": "Saved successfully",
"saveFailed": "Failed to save",
"cancelConfirm": "You have unsaved changes. Cancel editing?",
"editRequired": "Please click Edit before saving"
}
}

View File

@@ -0,0 +1,20 @@
{
"title": "后台操作指南",
"toolbar": {
"edit": "编辑",
"save": "保存",
"cancel": "取消",
"refresh": "刷新"
},
"meta": {
"filePath": "文档路径",
"updateTime": "更新时间"
},
"message": {
"loadFailed": "加载操作指南失败",
"saveSuccess": "保存成功",
"saveFailed": "保存失败",
"cancelConfirm": "当前有未保存的修改,确定取消编辑吗?",
"editRequired": "请先点击编辑后再保存"
}
}

View File

@@ -14,7 +14,8 @@ const NO_CHANNEL_LAYOUT_PATHS = [
'/safeguard/database',
'/safeguard/server',
'/safeguard/cache',
'/safeguard/email-log'
'/safeguard/email-log',
'/admin_guide'
]
/** 日志页左侧首项为「全部」dept_id=0 表示不按渠道过滤 */

View File

@@ -0,0 +1,211 @@
<template>
<div class="art-full-height admin-guide-page">
<ElCard class="art-card-xs flex flex-col h-full mt-0" shadow="never">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<b>{{ $t('page.title') }}</b>
<div v-if="meta.filePath" class="mt-1 text-xs text-g-500">
{{ $t('page.meta.filePath') }}{{ meta.filePath }}
<span v-if="meta.updateTime" class="ml-3">
{{ $t('page.meta.updateTime') }}{{ meta.updateTime }}
</span>
</div>
</div>
<ElSpace wrap>
<ElButton
v-permission="'system:admin_guide:index:read'"
:loading="loading"
@click="loadContent"
>
<template #icon>
<ArtSvgIcon icon="ri:refresh-line" />
</template>
{{ $t('page.toolbar.refresh') }}
</ElButton>
<ElButton
v-if="!isEditing"
v-permission="'system:admin_guide:index:edit'"
type="primary"
@click="startEdit"
>
<template #icon>
<ArtSvgIcon icon="ri:pencil-line" />
</template>
{{ $t('page.toolbar.edit') }}
</ElButton>
<template v-if="isEditing">
<ElButton @click="handleCancel">
{{ $t('page.toolbar.cancel') }}
</ElButton>
<ElButton
v-permission="'system:admin_guide:index:save'"
type="primary"
:loading="saving"
@click="handleSave"
>
<template #icon>
<ArtSvgIcon icon="ri:save-line" />
</template>
{{ $t('page.toolbar.save') }}
</ElButton>
</template>
</ElSpace>
</div>
</template>
<div v-loading="loading" class="admin-guide-body flex-1 min-h-0 overflow-auto">
<SaMdEditor
v-if="isEditing"
v-model="editContent"
height="calc(100vh - 220px)"
min-height="480px"
/>
<MdPreview
v-else
:model-value="previewContent"
:theme="previewTheme"
preview-theme="github"
class="admin-guide-preview"
/>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessageBox } from 'element-plus'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css'
import SaMdEditor from '@/components/sai/sa-md-editor/index.vue'
import { useSettingStore } from '@/store/modules/setting'
import api from '@/api/system/admin_guide'
defineOptions({ name: 'SystemAdminGuide' })
const { t } = useI18n()
const settingStore = useSettingStore()
const loading = ref(false)
const saving = ref(false)
const isEditing = ref(false)
const previewContent = ref('')
const editContent = ref('')
const originalContent = ref('')
const meta = ref<{ filePath?: string; updateTime?: string }>({})
const previewTheme = computed(() => (settingStore.isDark ? 'dark' : 'light'))
/** 静态资源根地址public 目录,开发环境走代理地址) */
const getStaticFileBase = (): string => {
const apiUrl = import.meta.env.VITE_API_URL || ''
if (apiUrl.startsWith('http')) {
return apiUrl.replace(/\/$/, '')
}
const proxyUrl = import.meta.env.VITE_API_PROXY_URL || ''
if (proxyUrl) {
return String(proxyUrl).replace(/\/$/, '')
}
return ''
}
const resolveGuideImages = (content: string): string => {
const staticBase = getStaticFileBase()
if (!staticBase) {
return content
}
return content.replace(/!\[([^\]]*)\]\((\/docs\/picture\/[^)]+)\)/g, (_match, alt, path) => {
return `![${alt}](${staticBase}${path})`
})
}
const loadContent = async () => {
loading.value = true
try {
const res = await api.read()
const data = res as { content?: string; file_path?: string; update_time?: string }
const rawContent = data.content ?? ''
previewContent.value = resolveGuideImages(rawContent)
editContent.value = rawContent
originalContent.value = rawContent
meta.value = {
filePath: data.file_path,
updateTime: data.update_time
}
} catch {
// 错误由 http 工具统一处理
} finally {
loading.value = false
}
}
const startEdit = () => {
editContent.value = previewContent.value
originalContent.value = previewContent.value
isEditing.value = true
}
const handleCancel = async () => {
if (editContent.value !== originalContent.value) {
try {
await ElMessageBox.confirm(t('page.message.cancelConfirm'), {
type: 'warning'
})
} catch {
return
}
}
editContent.value = originalContent.value
isEditing.value = false
}
const handleSave = async () => {
if (!isEditing.value) {
return
}
saving.value = true
try {
const res = await api.save({ content: editContent.value })
const data = res as { content?: string; file_path?: string; update_time?: string }
const savedContent = data.content ?? editContent.value
editContent.value = savedContent
originalContent.value = savedContent
previewContent.value = resolveGuideImages(savedContent)
meta.value = {
filePath: data.file_path ?? meta.value.filePath,
updateTime: data.update_time ?? meta.value.updateTime
}
isEditing.value = false
} catch {
// 错误由 http 工具统一处理
} finally {
saving.value = false
}
}
onMounted(() => {
loadContent()
})
</script>
<style scoped lang="scss">
.admin-guide-page {
:deep(.el-card__body) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding-top: 12px;
}
}
.admin-guide-body {
width: 100%;
}
.admin-guide-preview {
padding: 8px 4px;
}
</style>