feat(DocReader): 新增 doc.gvsds.com 动态阅读器(markdown/HTML 渲染、系列侧边栏、URL 历史切换)
This commit is contained in:
1 parent
6b7d14c614
commit
2c50a8070b
4 files changed
+289
-2
No files matched your search
Generated
+13
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||
"axios": "^1.19.0",
|
||||
"marked": "^18.0.9",
|
||||
"pinia": "^4.0.2",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^5.2.0"
|
||||
@@ -1871,6 +1872,18 @@
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.9",
|
||||
"resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.9.tgz",
|
||||
"integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||
"axios": "^1.19.0",
|
||||
"marked": "^18.0.9",
|
||||
"pinia": "^4.0.2",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^5.2.0"
|
||||
|
||||
+15
-2
@@ -112,6 +112,13 @@ export const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/DocumentView.vue'),
|
||||
meta: { title: '文档中心' },
|
||||
},
|
||||
// === doc 子域动态阅读器(doc.gvsds.com/<uuid>) ===
|
||||
{
|
||||
path: ':uuid([0-9a-fA-F-]{32,36})',
|
||||
name: 'DocReader',
|
||||
component: () => import('@/views/DocReaderView.vue'),
|
||||
meta: { title: '文档阅读' },
|
||||
},
|
||||
// === ProJect 子目录(续) ===
|
||||
{
|
||||
path: 'ProJect/Unauthorized',
|
||||
@@ -195,14 +202,20 @@ const router = createRouter({
|
||||
},
|
||||
})
|
||||
|
||||
// 子域感知:invite.gvsds.com 根路径 → 招聘首页
|
||||
// 其余子域(www 等)根路径保持默认首页
|
||||
// 子域感知:
|
||||
// invite.gvsds.com 根路径 → 招聘首页
|
||||
// doc.gvsds.com 根路径 → 文档中心(DocumentView)
|
||||
// 其余子域(www 等)根路径保持默认首页
|
||||
router.beforeEach((to) => {
|
||||
const host = window.location.hostname || ''
|
||||
const isInvite = host === 'invite.gvsds.com' || host.startsWith('invite.')
|
||||
const isDoc = host === 'doc.gvsds.com' || host.startsWith('doc.')
|
||||
if (isInvite && to.path === '/') {
|
||||
return { path: '/Invite', replace: true }
|
||||
}
|
||||
if (isDoc && to.path === '/') {
|
||||
return { path: '/Document', replace: true }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DocReaderView — doc.gvsds.com 动态文档阅读器(GVSite 体系)
|
||||
*
|
||||
* 用法:
|
||||
* doc.gvsds.com/<uuid> → 系列目录(侧边栏章节)或单文档正文
|
||||
* 数据来自后端 doc 子域 JSON API:
|
||||
* /API/GetSerieDirectory/<uuid> 系列目录 / 单文档标记
|
||||
* /API/GetDoc/<uuid> 文章内容(DocFormat: markdown/html)
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import { marked } from 'marked'
|
||||
|
||||
interface Chapter {
|
||||
id: string
|
||||
title: string
|
||||
sections: unknown[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/** 深色状态跟随 html.dark(SiteLayout 统一切换,此处只读同步) */
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
let darkObserver: MutationObserver | null = null
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
const docFormat = ref<'markdown' | 'html'>('markdown')
|
||||
const chapters = ref<Chapter[]>([])
|
||||
const activeId = ref('')
|
||||
|
||||
const uuid = computed(() => (route.params.uuid as string) || '')
|
||||
|
||||
/** 正文 HTML:markdown 走 marked 渲染,html 原样输出 */
|
||||
const renderedHtml = computed(() => {
|
||||
if (docFormat.value === 'html') return content.value
|
||||
return marked.parse(content.value || '', { async: false }) as string
|
||||
})
|
||||
|
||||
/**
|
||||
* 章节排序与 GERP 编辑器一致:
|
||||
* - 以 `_` 开头的文章最前(如 __index)
|
||||
* - "第N章"按章节数字升序
|
||||
* - 其余按标题(zh-CN 字典序)
|
||||
*/
|
||||
function DocSortKey(title: string): [number, number, string] {
|
||||
if (title.startsWith('_')) return [0, 0, title]
|
||||
const m = title.match(/^第(\d+)章/)
|
||||
if (m) return [1, Number(m[1]), title]
|
||||
return [2, 0, title]
|
||||
}
|
||||
|
||||
function SortChapters(list: Chapter[]): Chapter[] {
|
||||
return [...list].sort((a, b) => {
|
||||
const [ra, na, ta] = DocSortKey(a.title)
|
||||
const [rb, nb, tb] = DocSortKey(b.title)
|
||||
if (ra !== rb) return ra - rb
|
||||
if (na !== nb) return na - nb
|
||||
return ta.localeCompare(tb, 'zh-CN')
|
||||
})
|
||||
}
|
||||
|
||||
/** 加载并渲染单篇文档 */
|
||||
async function loadDoc(id: string): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const { data } = await axios.get(`/API/GetDoc/${id}`)
|
||||
if (data?.error) {
|
||||
error.value = '文档不存在或已删除'
|
||||
return
|
||||
}
|
||||
content.value = data.DocContent || ''
|
||||
docFormat.value = data.DocFormat === 'html' ? 'html' : 'markdown'
|
||||
title.value = data.DocTitle || '未命名文档'
|
||||
// 章节 id 为去连字符 UUID,统一归一化便于侧边栏高亮匹配
|
||||
activeId.value = id.replace(/-/g, '')
|
||||
document.title = `${title.value} - doc.gvsds.com`
|
||||
} catch {
|
||||
error.value = '加载文章失败,请稍后再试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化:按 URL uuid 加载系列目录或单文档 */
|
||||
async function init(): Promise<void> {
|
||||
if (!uuid.value) {
|
||||
error.value = '缺少文档标识'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const { data } = await axios.get(`/API/GetSerieDirectory/${uuid.value}`)
|
||||
if (data?.error) {
|
||||
error.value = '未找到内容'
|
||||
return
|
||||
}
|
||||
title.value = data.title || ''
|
||||
document.title = `${title.value} - doc.gvsds.com`
|
||||
if (data.document) {
|
||||
// 单文档模式:侧边栏展示所属系列全部文章(后端返回 chapters),默认选中当前文档
|
||||
chapters.value = SortChapters(data.chapters || [])
|
||||
await loadDoc(uuid.value)
|
||||
} else {
|
||||
chapters.value = SortChapters(data.chapters || [])
|
||||
if (chapters.value.length) {
|
||||
await loadDoc(chapters.value[0].id)
|
||||
} else {
|
||||
error.value = '该系列暂无文档'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
error.value = '加载失败,请稍后再试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击侧边栏章节:更新 history URL(可前进/后退/分享),由 watch(uuid) 触发重新加载 */
|
||||
function selectChapter(c: Chapter): void {
|
||||
const path = `/${c.id}`
|
||||
if (route.path === path) {
|
||||
void loadDoc(c.id)
|
||||
return
|
||||
}
|
||||
void router.push(path)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 监听 html.dark 变化(SiteLayout 统一切换主题),本页只读同步
|
||||
darkObserver = new MutationObserver(() => {
|
||||
isDark.value = document.documentElement.classList.contains('dark')
|
||||
})
|
||||
darkObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
init()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
darkObserver?.disconnect()
|
||||
darkObserver = null
|
||||
})
|
||||
watch(uuid, () => {
|
||||
init()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-screen flex flex-col transition-colors duration-300"
|
||||
:class="isDark ? 'bg-neutral-900 text-neutral-200' : 'bg-neutral-100 text-neutral-800'"
|
||||
>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<!-- 侧边栏:系列章节(sticky 浮动,随滚动固定在 SiteLayout 顶栏下方) -->
|
||||
<aside
|
||||
v-if="chapters.length"
|
||||
class="w-64 shrink-0 hidden md:block p-3 space-y-1 self-start sticky top-16 max-h-[calc(100vh-4rem)] overflow-y-auto border-r"
|
||||
:class="isDark ? 'border-neutral-700' : 'border-neutral-200'"
|
||||
>
|
||||
<a
|
||||
v-for="c in chapters"
|
||||
:key="c.id"
|
||||
href="#"
|
||||
class="block px-3 py-2 rounded-md text-sm transition-colors"
|
||||
:class="[
|
||||
c.id === activeId
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: isDark ? 'hover:bg-neutral-700' : 'hover:bg-neutral-200',
|
||||
]"
|
||||
@click.prevent="selectChapter(c)"
|
||||
>{{ c.title }}</a>
|
||||
</aside>
|
||||
|
||||
<!-- 正文 -->
|
||||
<main class="flex-1 min-w-0">
|
||||
<div v-if="loading" class="flex items-center justify-center h-64 text-neutral-400">
|
||||
<i class="fa fa-spinner fa-spin mr-2"></i>加载中...
|
||||
</div>
|
||||
<div v-else-if="error" class="flex flex-col items-center justify-center h-64 text-neutral-400">
|
||||
<i class="fa fa-exclamation-triangle text-4xl mb-3"></i>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
<article v-else class="max-w-3xl mx-auto p-6 md:p-10">
|
||||
<div class="doc-article-body" v-html="renderedHtml"></div>
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Markdown / 富文本正文排版(作用于 v-html 注入内容,非 scoped) */
|
||||
.doc-article-body {
|
||||
line-height: 1.8;
|
||||
font-size: 15px;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.doc-article-body h1,
|
||||
.doc-article-body h2,
|
||||
.doc-article-body h3,
|
||||
.doc-article-body h4,
|
||||
.doc-article-body h5,
|
||||
.doc-article-body h6 {
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin: 1.4em 0 0.6em;
|
||||
}
|
||||
.doc-article-body h1 { font-size: 1.6em; }
|
||||
.doc-article-body h2 { font-size: 1.4em; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.3em; }
|
||||
.doc-article-body h3 { font-size: 1.2em; }
|
||||
.doc-article-body p { margin: 0.8em 0; }
|
||||
.doc-article-body a { color: #3b82f6; text-decoration: underline; }
|
||||
.doc-article-body ul,
|
||||
.doc-article-body ol { margin: 0.8em 0; padding-left: 1.6em; }
|
||||
.doc-article-body ul { list-style: disc; }
|
||||
.doc-article-body ol { list-style: decimal; }
|
||||
.doc-article-body blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
margin: 1em 0;
|
||||
padding: 0.2em 1em;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
.doc-article-body pre {
|
||||
background: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
overflow-x: auto;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.doc-article-body code {
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.doc-article-body pre code { background: transparent; padding: 0; }
|
||||
.doc-article-body table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.doc-article-body table td,
|
||||
.doc-article-body table th {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.doc-article-body img { max-width: 100%; height: auto; border-radius: 8px; }
|
||||
.doc-article-body hr { border: none; border-top: 1px solid #e5e7eb; margin: 1.5em 0; }
|
||||
|
||||
html.dark .doc-article-body h2 { border-bottom-color: #374151; }
|
||||
html.dark .doc-article-body pre { background: #111827; color: #e5e7eb; }
|
||||
html.dark .doc-article-body blockquote { color: #9ca3af; }
|
||||
html.dark .doc-article-body table td,
|
||||
html.dark .doc-article-body table th { border-color: #374151; }
|
||||
html.dark .doc-article-body hr { border-top-color: #374151; }
|
||||
</style>
|
||||
Reference in new issue
Block a user