Skip to content

Commit 9edcb41

Browse files
committed
更新博客展示UI
1 parent da460c7 commit 9edcb41

2 files changed

Lines changed: 206 additions & 9 deletions

File tree

frontend/src/assets/blog.css

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,6 +1328,124 @@ body {
13281328
margin-top: 56px;
13291329
}
13301330

1331+
.toc-fab {
1332+
position: fixed;
1333+
left: max(14px, env(safe-area-inset-left, 0px));
1334+
top: 50%;
1335+
transform: translateY(-50%);
1336+
z-index: 9997;
1337+
border: 1px solid var(--border);
1338+
background: var(--card);
1339+
color: var(--text);
1340+
border-radius: 999px;
1341+
padding: 10px 14px;
1342+
font-size: 13px;
1343+
font-weight: 750;
1344+
cursor: pointer;
1345+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
1346+
}
1347+
1348+
.toc-fab:hover {
1349+
border-color: rgba(170, 59, 255, 0.45);
1350+
color: var(--accent);
1351+
}
1352+
1353+
.toc-overlay {
1354+
position: fixed;
1355+
inset: 0;
1356+
background: rgba(0, 0, 0, 0.16);
1357+
z-index: 10000;
1358+
}
1359+
1360+
[data-theme='dark'] .toc-overlay {
1361+
background: rgba(0, 0, 0, 0.45);
1362+
}
1363+
1364+
.toc-drawer {
1365+
position: fixed;
1366+
top: 0;
1367+
left: 0;
1368+
height: 100svh;
1369+
width: min(360px, calc(100vw - 56px));
1370+
background: rgba(255, 255, 255, 0.72);
1371+
border-right: 1px solid var(--border);
1372+
backdrop-filter: saturate(1.15) blur(18px);
1373+
-webkit-backdrop-filter: saturate(1.15) blur(18px);
1374+
z-index: 10001;
1375+
transform: translateX(-102%);
1376+
transition: transform 0.22s ease;
1377+
display: flex;
1378+
flex-direction: column;
1379+
}
1380+
1381+
[data-theme='dark'] .toc-drawer {
1382+
background: rgba(18, 18, 20, 0.82);
1383+
}
1384+
1385+
.toc-drawer.open {
1386+
transform: translateX(0);
1387+
}
1388+
1389+
.toc-drawer-head {
1390+
display: flex;
1391+
align-items: center;
1392+
justify-content: space-between;
1393+
gap: 12px;
1394+
padding: 14px 14px 10px;
1395+
border-bottom: 1px solid var(--border);
1396+
}
1397+
1398+
.toc-drawer-title {
1399+
font-size: 13px;
1400+
font-weight: 650;
1401+
color: var(--text);
1402+
letter-spacing: 0.06em;
1403+
}
1404+
1405+
.toc-drawer-close {
1406+
border: none;
1407+
background: transparent;
1408+
color: var(--muted);
1409+
cursor: pointer;
1410+
font-size: 22px;
1411+
line-height: 1;
1412+
padding: 2px 6px;
1413+
}
1414+
1415+
.toc-drawer-close:hover {
1416+
color: var(--text);
1417+
}
1418+
1419+
.toc-drawer-body {
1420+
padding: 10px 10px 14px;
1421+
overflow: auto;
1422+
display: flex;
1423+
flex-direction: column;
1424+
gap: 6px;
1425+
}
1426+
1427+
.toc-item {
1428+
text-align: left;
1429+
border: 1px solid transparent;
1430+
background: transparent;
1431+
color: var(--text);
1432+
cursor: pointer;
1433+
padding: 8px 10px;
1434+
border-radius: 10px;
1435+
font-size: 13px;
1436+
line-height: 1.35;
1437+
}
1438+
1439+
.toc-item:hover {
1440+
background: rgba(170, 59, 255, 0.08);
1441+
border-color: rgba(170, 59, 255, 0.14);
1442+
}
1443+
1444+
.toc-item:focus-visible {
1445+
outline: 2px solid rgba(170, 59, 255, 0.6);
1446+
outline-offset: 2px;
1447+
}
1448+
13311449
.article-content {
13321450
padding: 20px 18px 40px;
13331451
}

frontend/src/views/ArticleDetailView.vue

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,51 @@
3535
<CommentSection v-if="article?.id" :article-id="article.id" />
3636
</template>
3737
</div>
38+
39+
<!-- 外挂式目录:左侧抽屉 -->
40+
<button
41+
v-if="tocItems.length"
42+
class="toc-fab"
43+
type="button"
44+
:aria-expanded="tocOpen ? 'true' : 'false'"
45+
aria-controls="article-toc-drawer"
46+
@click="tocOpen = true"
47+
>
48+
目录
49+
</button>
50+
51+
<div v-if="tocOpen" class="toc-overlay" @click="tocOpen = false" />
52+
<aside
53+
id="article-toc-drawer"
54+
class="toc-drawer"
55+
:class="{ open: tocOpen }"
56+
role="dialog"
57+
aria-label="文章目录"
58+
>
59+
<div class="toc-drawer-head">
60+
<div class="toc-drawer-title">目录</div>
61+
<button class="toc-drawer-close" type="button" aria-label="关闭目录" @click="tocOpen = false">
62+
×
63+
</button>
64+
</div>
65+
<nav class="toc-drawer-body" aria-label="目录导航">
66+
<button
67+
v-for="it in tocItems"
68+
:key="it.id"
69+
class="toc-item"
70+
type="button"
71+
:style="{ paddingLeft: `${8 + Math.max(0, it.level - 1) * 12}px` }"
72+
@click="jumpToHeading(it.id)"
73+
>
74+
{{ it.text }}
75+
</button>
76+
</nav>
77+
</aside>
3878
</div>
3979
</template>
4080

4181
<script setup>
42-
import { computed, onMounted, ref } from 'vue'
82+
import { onBeforeUnmount, onMounted, ref, watchEffect } from 'vue'
4383
import { useRoute } from 'vue-router'
4484
import DOMPurify from 'dompurify'
4585
import { marked } from 'marked'
@@ -50,6 +90,9 @@ import CommentSection from '../components/CommentSection.vue'
5090
const route = useRoute()
5191
const loading = ref(true)
5292
const article = ref({})
93+
const html = ref('')
94+
const tocItems = ref([])
95+
const tocOpen = ref(false)
5396
5497
onMounted(async () => {
5598
loading.value = true
@@ -61,20 +104,40 @@ onMounted(async () => {
61104
}
62105
})
63106
64-
const html = computed(() => {
65-
const md = article.value.contentMarkdown || ''
66-
const raw = marked.parse(md)
67-
return DOMPurify.sanitize(raw)
107+
watchEffect(() => {
108+
const md = article.value?.contentMarkdown || ''
109+
const items = []
110+
111+
const renderer = new marked.Renderer()
112+
const slugger = new marked.Slugger()
113+
114+
renderer.heading = (text, level, raw) => {
115+
const id = slugger.slug(String(raw || text || ''))
116+
const plain = String(text || '')
117+
.replace(/<[^>]+>/g, '')
118+
.replace(/\s+/g, ' ')
119+
.trim()
120+
if (plain) items.push({ id, level, text: plain })
121+
return `<h${level} id="${id}">${text}</h${level}>`
122+
}
123+
124+
const rawHtml = marked.parse(md, { renderer })
125+
html.value = DOMPurify.sanitize(rawHtml)
126+
tocItems.value = items
127+
128+
if (!items.length) tocOpen.value = false
68129
})
69130
70131
/** 后端若将来返回分类名则展示 */
71-
const categoryLabel = computed(() => {
132+
const categoryLabel = ref('')
133+
const readMinutes = ref(1)
134+
135+
watchEffect(() => {
72136
const a = article.value
73-
return a?.categoryName || a?.categoryLabel || ''
137+
categoryLabel.value = a?.categoryName || a?.categoryLabel || ''
138+
readMinutes.value = estimateReadMinutes(a?.contentMarkdown)
74139
})
75140
76-
const readMinutes = computed(() => estimateReadMinutes(article.value?.contentMarkdown))
77-
78141
function estimateReadMinutes(md) {
79142
if (!md || typeof md !== 'string') return 1
80143
const chars = md.replace(/\s+/g, '').length
@@ -89,4 +152,20 @@ function formatDate(v) {
89152
return ''
90153
}
91154
}
155+
156+
function jumpToHeading(id) {
157+
tocOpen.value = false
158+
requestAnimationFrame(() => {
159+
const el = document.getElementById(id)
160+
if (!el) return
161+
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
162+
})
163+
}
164+
165+
function onKeyDown(e) {
166+
if (e.key === 'Escape' && tocOpen.value) tocOpen.value = false
167+
}
168+
169+
onMounted(() => window.addEventListener('keydown', onKeyDown))
170+
onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown))
92171
</script>

0 commit comments

Comments
 (0)