feat(DocReader): 新增 doc.gvsds.com 动态阅读器(markdown/HTML 渲染、系列侧边栏、URL 历史切换)

This commit is contained in:
TermiNexus committed 2026-08-09 01:50:11 +08:00
1 parent 6b7d14c614
commit 2c50a8070b
4 files changed
+289 -2

No files matched your search

+13
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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
})
+260
View File
@@ -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>