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 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',
'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 enter a valid commission rate for non-top role group' => '非顶级角色组须填写 0100 的分红比例(%)',
'Group Name Arr' => '分组名称数组',
'Document file not found' => '文档文件不存在',
'Failed to read document' => '读取文档失败',
];

View File

@@ -124,4 +124,12 @@ return [
// 其它中文按钮文案
'期号开关' => 'Period toggle',
'手动创建下一期' => '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",
"font-awesome": "4.7.0",
"lodash-es": "4.17.21",
"marked": "9.1.6",
"mitt": "3.0.1",
"nprogress": "0.2.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)',
'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: {
Later: '稍后',
'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终端',
'Failed to modify the source command, Please try again manually': '修改源的命令执行失败,请手动重试。',
},
docs36ziMobileApi: {
download: '下载 Markdown',
load_fail: '文档加载失败',
doc_title: '36字花移动端接口设计草案',
nav_outline: '目录',
},
vite: {
Later: '稍后',
'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>