1.后台新增移动端顶级接口文档,md格式显示

This commit is contained in:
2026-05-14 10:59:00 +08:00
parent 932a433613
commit 90ec085cd5
8 changed files with 381 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\docs;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request;
/**
* 后台只读展示《36字花-移动端接口设计草案》Markdown并提供下载。
*/
class Doc36ZiHuaMobileApi extends Backend
{
private const DOC_RELATIVE = 'docs' . DIRECTORY_SEPARATOR . '36字花-移动端接口设计草案.md';
public function content(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$path = $this->docAbsolutePath();
if (!is_file($path)) {
return $this->error(__('Document file not found'));
}
$raw = file_get_contents($path);
if ($raw === false) {
return $this->error(__('Failed to read document'));
}
return $this->success('', [
'markdown' => $raw,
'filename' => '36字花-移动端接口设计草案.md',
]);
}
public function download(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$path = $this->docAbsolutePath();
if (!is_file($path)) {
return $this->error(__('Document file not found'));
}
$body = file_get_contents($path);
if ($body === false) {
return $this->error(__('Failed to read document'));
}
$utf8Name = '36字花-移动端接口设计草案.md';
$asciiFallback = '36zihua-mobile-api-design-draft.md';
$disposition = sprintf(
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
$asciiFallback,
rawurlencode($utf8Name)
);
return new Response(200, [
'Content-Type' => 'text/markdown; charset=UTF-8',
'Content-Disposition' => $disposition,
'Cache-Control' => 'private, max-age=0, must-revalidate',
], $body);
}
private function docAbsolutePath(): string
{
return rtrim(base_path(), DIRECTORY_SEPARATOR . '/') . DIRECTORY_SEPARATOR . self::DOC_RELATIVE;
}
}

View File

@@ -96,4 +96,6 @@ return [
'Please input correct username' => 'Please enter the correct username', 'Please input correct username' => 'Please enter the correct username',
'Please enter a valid commission rate for non-top role group' => 'Non-top role groups require a commission rate between 0 and 100 (%)', 'Please enter a valid commission rate for non-top role group' => 'Non-top role groups require a commission rate between 0 and 100 (%)',
'Group Name Arr' => 'Group Name Arr', 'Group Name Arr' => 'Group Name Arr',
'Document file not found' => 'Document file not found',
'Failed to read document' => 'Failed to read document',
]; ];

View File

@@ -115,4 +115,6 @@ return [
'Please input correct username' => '请输入正确的用户名', 'Please input correct username' => '请输入正确的用户名',
'Please enter a valid commission rate for non-top role group' => '非顶级角色组须填写 0100 的分红比例(%)', 'Please enter a valid commission rate for non-top role group' => '非顶级角色组须填写 0100 的分红比例(%)',
'Group Name Arr' => '分组名称数组', 'Group Name Arr' => '分组名称数组',
'Document file not found' => '文档文件不存在',
'Failed to read document' => '读取文档失败',
]; ];

View File

@@ -124,4 +124,12 @@ return [
// 其它中文按钮文案 // 其它中文按钮文案
'期号开关' => 'Period toggle', '期号开关' => 'Period toggle',
'手动创建下一期' => 'Create next period manually', '手动创建下一期' => 'Create next period manually',
// Markdown 文档目录
'Markdown文档' => 'Markdown documents',
// 文档36字花移动端接口草案
'36字花移动端接口文档' => '36 Zihua mobile API design draft',
'拉取正文' => 'Load document body',
'下载 Markdown' => 'Download Markdown',
]; ];

View File

@@ -19,6 +19,7 @@
"element-plus": "2.9.1", "element-plus": "2.9.1",
"font-awesome": "4.7.0", "font-awesome": "4.7.0",
"lodash-es": "4.17.21", "lodash-es": "4.17.21",
"marked": "9.1.6",
"mitt": "3.0.1", "mitt": "3.0.1",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"pinia": "2.3.0", "pinia": "2.3.0",

View File

@@ -85,6 +85,12 @@ export default {
'Newly added tasks will never start because they are blocked by failed tasks!(Web terminal)', 'Newly added tasks will never start because they are blocked by failed tasks!(Web terminal)',
'Failed to modify the source command, Please try again manually': 'Failed to modify the source command. Please try again manually.', 'Failed to modify the source command, Please try again manually': 'Failed to modify the source command. Please try again manually.',
}, },
docs36ziMobileApi: {
download: 'Download Markdown',
load_fail: 'Failed to load document',
doc_title: '36 Zihua Mobile API Design Draft',
nav_outline: 'Outline',
},
vite: { vite: {
Later: '稍后', Later: '稍后',
'Restart hot update': '重启热更新', 'Restart hot update': '重启热更新',

View File

@@ -85,6 +85,12 @@ export default {
'Newly added tasks will never start because they are blocked by failed tasks': '新添加的任务永远不会开始因为被失败的任务阻塞WEB终端', 'Newly added tasks will never start because they are blocked by failed tasks': '新添加的任务永远不会开始因为被失败的任务阻塞WEB终端',
'Failed to modify the source command, Please try again manually': '修改源的命令执行失败,请手动重试。', 'Failed to modify the source command, Please try again manually': '修改源的命令执行失败,请手动重试。',
}, },
docs36ziMobileApi: {
download: '下载 Markdown',
load_fail: '文档加载失败',
doc_title: '36字花移动端接口设计草案',
nav_outline: '目录',
},
vite: { vite: {
Later: '稍后', Later: '稍后',
'Restart hot update': '重启热更新', 'Restart hot update': '重启热更新',

View File

@@ -0,0 +1,279 @@
<template>
<div class="default-main doc-md-page" v-loading="loading">
<el-card shadow="never">
<template #header>
<div class="doc-md-header">
<span class="doc-md-title">{{ pageTitle }}</span>
<el-button type="primary" :loading="downloading" @click="onDownload">{{ t('docs36ziMobileApi.download') }}</el-button>
</div>
</template>
<div v-if="loadError" class="color-danger">{{ loadError }}</div>
<el-row v-else :gutter="16" class="doc-md-row">
<el-col v-if="toc.length" :xs="24" :sm="8" :md="7" :lg="6" class="doc-md-nav-col">
<el-affix :offset="88">
<div class="doc-nav-panel">
<div class="doc-nav-title">{{ t('docs36ziMobileApi.nav_outline') }}</div>
<el-scrollbar max-height="calc(100vh - 200px)">
<ul class="doc-nav-list">
<li
v-for="item in toc"
:key="item.id"
class="doc-nav-item"
:class="'doc-nav-level-' + item.level"
>
<a href="#" class="doc-nav-link" @click.prevent="scrollToHeading(item.id)">{{
item.text
}}</a>
</li>
</ul>
</el-scrollbar>
</div>
</el-affix>
</el-col>
<el-col :xs="24" :sm="toc.length ? 16 : 24" :md="toc.length ? 17 : 24" :lg="toc.length ? 18 : 24">
<div ref="bodyRef" class="ba-markdown doc-md-body" v-html="html"></div>
</el-col>
</el-row>
</el-card>
</div>
</template>
<script setup lang="ts">
import { marked } from 'marked'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { getUrl } from '/@/utils/axios'
import createAxios from '/@/utils/axios'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useConfig } from '/@/stores/config'
const { t } = useI18n()
const adminInfo = useAdminInfo()
const config = useConfig()
const bodyRef = ref<HTMLElement | null>(null)
const loading = ref(true)
const downloading = ref(false)
const markdown = ref('')
const loadError = ref('')
const downloadFilename = ref('36字花-移动端接口设计草案.md')
marked.use({ breaks: true })
interface TocItem {
level: number
text: string
id: string
}
const toc = computed((): TocItem[] => {
const md = markdown.value
if (!md) {
return []
}
const lines = md.split(/\r?\n/)
const out: TocItem[] = []
let idx = 0
for (const line of lines) {
const trimmed = line.trim()
const m = /^(#{1,4})\s+(.+)$/.exec(trimmed)
if (!m) {
continue
}
const level = m[1].length
let text = m[2].trim()
text = text.replace(/\s+#+\s*$/, '').trim()
out.push({ level, text, id: 'md-nav-' + String(idx) })
idx += 1
}
return out
})
const html = computed(() => {
if (!markdown.value) {
return ''
}
return String(marked.parse(markdown.value))
})
const pageTitle = computed(() => t('docs36ziMobileApi.doc_title'))
function applyHeadingIds() {
const root = bodyRef.value
const items = toc.value
if (!root || !items.length) {
return
}
const hs = root.querySelectorAll('h1, h2, h3, h4')
const n = Math.min(items.length, hs.length)
for (let i = 0; i < n; i++) {
const el = hs.item(i)
el.id = items[i].id
}
}
watch(
() => [html.value, markdown.value],
() => {
void nextTick(() => {
applyHeadingIds()
})
}
)
async function loadContent() {
loading.value = true
loadError.value = ''
try {
const res = await createAxios(
{
url: '/admin/docs.Doc36ZiHuaMobileApi/content',
method: 'get',
},
{ loading: false }
)
if (res.code === 1 && res.data && typeof res.data.markdown === 'string') {
markdown.value = res.data.markdown
if (typeof res.data.filename === 'string' && res.data.filename) {
downloadFilename.value = res.data.filename
}
} else {
loadError.value = res.msg || t('docs36ziMobileApi.load_fail')
}
} catch {
loadError.value = t('docs36ziMobileApi.load_fail')
} finally {
loading.value = false
void nextTick(() => {
applyHeadingIds()
})
}
}
function scrollToHeading(id: string) {
const el = document.getElementById(id)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
function triggerBlobDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
async function onDownload() {
downloading.value = true
try {
const token = adminInfo.getToken()
const fullUrl = getUrl() + '/admin/docs.Doc36ZiHuaMobileApi/download'
const response = await fetch(fullUrl, {
method: 'GET',
headers: {
batoken: token,
server: 'true',
'think-lang': config.lang.defaultLang,
},
})
if (!response.ok) {
loadError.value = t('docs36ziMobileApi.load_fail')
return
}
const ctype = response.headers.get('content-type') || ''
if (ctype.includes('application/json')) {
const j = await response.json()
loadError.value = typeof j.msg === 'string' && j.msg ? j.msg : t('docs36ziMobileApi.load_fail')
return
}
const blob = await response.blob()
triggerBlobDownload(blob, downloadFilename.value)
} catch {
loadError.value = t('docs36ziMobileApi.load_fail')
} finally {
downloading.value = false
}
}
onMounted(() => {
void loadContent()
})
</script>
<style scoped lang="scss">
.doc-md-page {
.doc-md-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.doc-md-title {
font-weight: 600;
}
.doc-md-row {
align-items: flex-start;
}
.doc-md-nav-col {
margin-bottom: 16px;
@media (min-width: 768px) {
margin-bottom: 0;
}
}
.doc-nav-panel {
padding: 12px 12px 8px;
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--el-border-radius-base);
background: var(--ba-bg-color-overlay);
}
.doc-nav-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.doc-nav-list {
list-style: none;
margin: 0;
padding: 0 0 8px;
}
.doc-nav-item {
margin: 0;
padding: 4px 0;
line-height: 1.35;
}
.doc-nav-level-1 {
padding-left: 0;
}
.doc-nav-level-2 {
padding-left: 10px;
}
.doc-nav-level-3 {
padding-left: 20px;
}
.doc-nav-level-4 {
padding-left: 30px;
}
.doc-nav-link {
color: var(--el-text-color-regular);
text-decoration: none;
font-size: 13px;
word-break: break-word;
cursor: pointer;
&:hover {
color: var(--el-color-primary);
}
}
.doc-md-body {
max-width: 1100px;
}
}
</style>