@velonor/theme 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ <template>
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ fill="none"
5
+ viewBox="0 0 24 24"
6
+ stroke-width="1.5"
7
+ stroke="currentColor"
8
+ class="w-6 h-6"
9
+ >
10
+ <path
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
14
+ />
15
+ </svg>
16
+ </template>
@@ -0,0 +1,6 @@
1
+ import { createCategoriesStore } from '@velonor/engine/client';
2
+ // typed by src/types/posts-data.d.ts
3
+ import { data as posts } from '../posts.data.js';
4
+
5
+ export const useCategories = createCategoriesStore(posts);
6
+
@@ -0,0 +1,20 @@
1
+ import { ref, watch, nextTick } from 'vue';
2
+ import { useRoute } from 'vitepress/client';
3
+
4
+ export function useRefreshOnRouteChange() {
5
+ const route = useRoute();
6
+ const refreshFlag = ref<boolean>(false);
7
+ watch(
8
+ route,
9
+ () => {
10
+ refreshFlag.value = false;
11
+ nextTick(() => {
12
+ refreshFlag.value = true;
13
+ });
14
+ },
15
+ {
16
+ immediate: true,
17
+ }
18
+ );
19
+ return { refreshFlag };
20
+ }
@@ -0,0 +1,120 @@
1
+ import { computed, ref, watch } from 'vue';
2
+ import { useRoute } from 'vitepress/client';
3
+ import { useTags } from './useTags';
4
+
5
+ type TagFilterState = ReturnType<typeof createTagFilterState>;
6
+
7
+ const parseTagsParam = (value: string | null): string[] => {
8
+ if (!value) return [];
9
+ return value
10
+ .split(',')
11
+ .map((item) => item.trim())
12
+ .filter(Boolean);
13
+ };
14
+
15
+ const createTagFilterState = () => {
16
+ const route = useRoute();
17
+ const { activeTag, getTagArray, uniqueTagCount, tagsMap, filterPostsByActiveTag } = useTags();
18
+ const selectedTags = ref<string[]>([]);
19
+
20
+ const readFromUrl = () => {
21
+ if (typeof window === 'undefined') return;
22
+ const url = new URL(window.location.href);
23
+ const tagsParam = url.searchParams.get('tags');
24
+ const tagParam = url.searchParams.get('tag');
25
+ const next = tagsParam ? parseTagsParam(tagsParam) : (tagParam ? [tagParam] : []);
26
+ const uniqueNext = Array.from(new Set(next));
27
+ if (JSON.stringify(uniqueNext) !== JSON.stringify(selectedTags.value)) {
28
+ selectedTags.value = uniqueNext;
29
+ }
30
+ activeTag.value = uniqueNext.length === 1 ? uniqueNext[0] : '';
31
+ };
32
+
33
+ const writeToUrl = (tags: string[]) => {
34
+ if (typeof window === 'undefined') return;
35
+ const url = new URL(window.location.href);
36
+ const uniqueTags = Array.from(new Set(tags));
37
+ if (uniqueTags.length === 0) {
38
+ url.searchParams.delete('tags');
39
+ url.searchParams.delete('tag');
40
+ } else {
41
+ url.searchParams.set('tags', uniqueTags.join(','));
42
+ if (uniqueTags.length === 1) {
43
+ url.searchParams.set('tag', uniqueTags[0]);
44
+ } else {
45
+ url.searchParams.delete('tag');
46
+ }
47
+ }
48
+ window.history.replaceState(null, '', url.toString());
49
+ };
50
+
51
+ const setSelectedTags = (tags: string[]) => {
52
+ const uniqueTags = Array.from(new Set(tags));
53
+ selectedTags.value = uniqueTags;
54
+ activeTag.value = uniqueTags.length === 1 ? uniqueTags[0] : '';
55
+ writeToUrl(uniqueTags);
56
+ };
57
+
58
+ const addTag = (tag: string) => {
59
+ if (!tag) return;
60
+ if (selectedTags.value.includes(tag)) return;
61
+ setSelectedTags([...selectedTags.value, tag]);
62
+ };
63
+
64
+ const removeTag = (tag: string) => {
65
+ if (!selectedTags.value.includes(tag)) return;
66
+ setSelectedTags(selectedTags.value.filter((t) => t !== tag));
67
+ };
68
+
69
+ const toggleTag = (tag: string) => {
70
+ if (!tag) {
71
+ setSelectedTags([]);
72
+ return;
73
+ }
74
+ if (selectedTags.value.includes(tag)) {
75
+ removeTag(tag);
76
+ } else {
77
+ addTag(tag);
78
+ }
79
+ };
80
+
81
+ const clearTags = () => {
82
+ setSelectedTags([]);
83
+ };
84
+
85
+ const isTagSelected = (tag: string) => {
86
+ if (!tag) return selectedTags.value.length === 0;
87
+ return selectedTags.value.includes(tag);
88
+ };
89
+
90
+ watch(
91
+ () => route.path,
92
+ () => readFromUrl()
93
+ );
94
+
95
+ if (typeof window !== 'undefined') {
96
+ readFromUrl();
97
+ window.addEventListener('popstate', readFromUrl, { passive: true } as any);
98
+ }
99
+
100
+ return {
101
+ selectedTags,
102
+ getTagArray,
103
+ uniqueTagCount,
104
+ tagsMap,
105
+ filterPostsByActiveTag,
106
+ setSelectedTags,
107
+ addTag,
108
+ removeTag,
109
+ toggleTag,
110
+ clearTags,
111
+ isTagSelected,
112
+ } as const;
113
+ };
114
+
115
+ let sharedState: TagFilterState | null = null;
116
+
117
+ export const useTagFilter = () => {
118
+ if (!sharedState) sharedState = createTagFilterState();
119
+ return sharedState;
120
+ };
@@ -0,0 +1,6 @@
1
+ import { createTagsStore } from '@velonor/engine/client';
2
+ // typed by src/types/posts-data.d.ts
3
+ import { data as posts } from '../posts.data.js';
4
+
5
+ export const useTags = createTagsStore(posts);
6
+
@@ -0,0 +1,22 @@
1
+ import { defineConfig } from 'vitepress';
2
+
3
+ export default defineConfig({
4
+ title: 'Vitepress Open17',
5
+ description: 'A VitePress Site',
6
+ themeConfig: {
7
+ footer: {
8
+ message:
9
+ 'Released under the <a href="https://github.com/open17/@velonor/theme/blob/template/LICENSE">Apache 2.0 License</a>.',
10
+ copyright:
11
+ 'Copyright © 2023-present <a href="https://github.com/open17">open17</a>',
12
+ },
13
+ search: {
14
+ provider: 'local',
15
+ },
16
+ blog: {
17
+ title: 'My Awesome Blog',
18
+ desc: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
19
+ direct: 'rgt',
20
+ },
21
+ },
22
+ });
package/src/env.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ declare module '*.vue' {
2
+ import { DefineComponent } from 'vue';
3
+
4
+ const component: DefineComponent<{}, {}, any>;
5
+
6
+ export default component;
7
+ }
@@ -0,0 +1,44 @@
1
+ import path from 'path';
2
+ import { writeFileSync } from 'fs';
3
+ import { Feed } from 'feed';
4
+ import { createContentLoader } from 'vitepress';
5
+
6
+ export async function genFeed(config) {
7
+ const feed = new Feed({
8
+ title: `${config.site.title}`,
9
+ description: `${config.site.description}`,
10
+ id: `${config.site.themeConfig.feed.baseUrl}`,
11
+ link: `${config.site.themeConfig.feed.baseUrl}`,
12
+ language: `${config.site.lang}`,
13
+ image: `${config.site.themeConfig.feed.image}`,
14
+ favicon: `${config.site.themeConfig.feed.favicon}`,
15
+ copyright: `${config.site.themeConfig.feed.copyright}`,
16
+ });
17
+
18
+ const posts = await createContentLoader('posts/**/*.md', {
19
+ excerpt: true,
20
+ render: true,
21
+ }).load();
22
+
23
+ posts.sort(
24
+ (a, b) => +new Date(b.frontmatter.date) - +new Date(a.frontmatter.date)
25
+ );
26
+ for (const { url, excerpt, frontmatter, html } of posts) {
27
+ feed.addItem({
28
+ title: frontmatter.title,
29
+ id: `${config.site.themeConfig.feed.baseUrl}${url}`,
30
+ link: `${config.site.themeConfig.feed.baseUrl}${url}`,
31
+ description: excerpt,
32
+ content: html?.replaceAll('&ZeroWidthSpace;', ''),
33
+ author: [
34
+ {
35
+ name: frontmatter.author,
36
+ },
37
+ ],
38
+ date: frontmatter.date,
39
+ });
40
+ }
41
+ writeFileSync(path.join(config.outDir, 'feed.rss'), feed.rss2(), {
42
+ encoding: 'utf8',
43
+ });
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ import DefaultTheme from 'vitepress/theme';
2
+ import { type EnhanceAppContext } from 'vitepress';
3
+ import MainLayout from './layouts/Layout.vue';
4
+
5
+ import Blog from './components/Blog.vue';
6
+ import Archive from './components/Archive.vue';
7
+ import Tags from './components/Tags.vue';
8
+ import Categories from './components/Categories.vue';
9
+
10
+ import './style.css';
11
+
12
+ /** @type {import('vitepress').Theme} */
13
+ export default {
14
+ extends: DefaultTheme,
15
+ Layout: MainLayout,
16
+ enhanceApp({ app, router, siteData }: EnhanceAppContext) {
17
+ app.component('blog', Blog);
18
+ app.component('archive', Archive);
19
+ app.component('tags', Tags);
20
+ app.component('categories', Categories);
21
+ },
22
+ };
@@ -0,0 +1,407 @@
1
+ <template>
2
+ <Transition>
3
+ <div id="Loading" v-if="isLoading"></div>
4
+ </Transition>
5
+
6
+ <div class="fixed bottom-6 right-6 z-50" v-if="isPostPage">
7
+ <button @click="togglePostPage" class="layout-toggle-btn"
8
+ :title="switchPageStyle ? getLocalizedString('switchToNormalPage', lang) : getLocalizedString('switchToPostPage', lang)">
9
+ <svg v-if="switchPageStyle" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
11
+ </svg>
12
+ <svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
13
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
14
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">
15
+ </path>
16
+ </svg>
17
+ </button>
18
+ </div>
19
+
20
+ <div v-if="isPostPage && switchPageStyle" class="post-page bg-no-repeat bg-center bg-fixed bg-cover"
21
+ :class="{ loadingStyle: isLoading }" :style="bgImg ? { 'background-image': `url(${bgImg})` } : {}">
22
+ <div class="w-full flex justify-center">
23
+ <div
24
+ class="flex w-full max-w-screen-2xl justify-center items-start pt-0 my-0 gap-5 md:px-20 flex-col-reverse md:flex-row">
25
+ <div class="bg-transparent w-full md:w-1/4 justify-start items-start py-14 mt-5 md:mt-5 flex-col gap-6">
26
+ <div class="flex flex-col gap-6 w-full">
27
+ <UserCard :isMobile="false" />
28
+ <div v-if="showMetaCard"
29
+ class="flex w-full md:rounded-2xl px-5 py-5 flex-col justify-center gap-3 dark:shadow-none shadow-lg border-2 border-[var(--blog-border-c)] bg-[var(--vp-c-blog-bg)]/95 backdrop-blur-md">
30
+ <div class="flex items-center justify-between text-xs uppercase tracking-wider opacity-70">
31
+ <span>Metadata</span>
32
+ <span v-if="metaDateLabel" class="text-[11px] normal-case opacity-70">{{ metaDateLabel }}</span>
33
+ </div>
34
+ <div v-if="metaCategory" class="flex items-center gap-2 flex-wrap">
35
+ <span class="text-[11px] uppercase opacity-70">{{ getLocalizedString('category', lang) || 'Category' }}</span>
36
+ <span class="text-xs px-2.5 py-0.5 rounded-full border border-dashed border-[var(--blog-tag-text-2)] text-[var(--blog-tag-text-2)] bg-[var(--blog-tag-bg-2)]/80">
37
+ {{ metaCategory }}
38
+ </span>
39
+ </div>
40
+ <div v-if="metaTags.length" class="flex items-center gap-2 flex-wrap">
41
+ <span class="text-[11px] uppercase opacity-70">{{ getLocalizedString('tags', lang) || 'Tags' }}</span>
42
+ <div class="flex items-center gap-2 flex-wrap">
43
+ <span
44
+ v-for="tag in metaTags"
45
+ :key="tag"
46
+ class="text-xs px-2.5 py-0.5 rounded-full border border-[var(--blog-tag-text-1)] text-[var(--blog-tag-text-1)] bg-[var(--blog-tag-bg-1)]/80">
47
+ {{ tag }}
48
+ </span>
49
+ </div>
50
+ </div>
51
+ </div>
52
+ <div ref="asideSentinelEl" style="height:1px;"></div>
53
+ <div ref="asideOutlineEl" v-show="!showFloatingOutline"
54
+ class="min-h-28 max-h-[calc(100vh-20rem)] overflow-auto opacity-90 backdrop-blur-md flex w-full md:rounded-2xl px-5 py-5 flex-col justify-start dark:shadow-none shadow-lg border-2 border-[var(--blog-border-c)] bg-[var(--vp-c-blog-bg)]">
55
+ <VPDocAsideOutline />
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ <div class="flex md:w-3/4 md:py-20 py-0 justify-center items-center gap-6 flex-col w-full md:px-3 px-0">
61
+ <Layout class="w-full">
62
+ <template #doc-before>
63
+ <div class="text-3xl md:text-4xl font-bold tracking-tight">{{ title }}</div>
64
+ </template>
65
+ <template #doc-after>
66
+ <Comment />
67
+ </template>
68
+ </Layout>
69
+ </div>
70
+ </div>
71
+ </div>
72
+
73
+ <!-- 浮动目录 -->
74
+ <div v-show="showFloatingOutline" class="floating-outline" :style="{ top: (navHeight + 8) + 'px' }">
75
+ <div class="floating-outline-inner">
76
+ <VPDocAsideOutline />
77
+ </div>
78
+ </div>
79
+ </div>
80
+
81
+ <Layout v-else class="bg-no-repeat bg-center bg-fixed bg-cover"
82
+ :style="bgImg ? { 'background-image': `url(${bgImg})` } : {}" :class="{ loadingStyle: isLoading }">
83
+ <template #doc-before>
84
+ <div class="text-3xl md:text-4xl font-bold tracking-tight">{{ title }}</div>
85
+ </template>
86
+ <template #doc-after>
87
+ <Comment />
88
+ </template>
89
+ </Layout>
90
+ </template>
91
+
92
+ <script lang="ts" setup>
93
+ import DefaultTheme from 'vitepress/theme';
94
+ import { useData } from 'vitepress';
95
+ import { useRoute } from 'vitepress/client';
96
+ import { onMounted, onUnmounted, ref, watch, computed } from 'vue';
97
+ import { getLocalizedString } from '../utils/constants';
98
+ import Comment from '../components/Comment.vue';
99
+ import UserCard from '../components/UserCard.vue';
100
+ import VPDocAsideOutline from 'vitepress/dist/client/theme-default/components/VPDocAsideOutline.vue';
101
+
102
+ const { Layout } = DefaultTheme;
103
+
104
+ // TODO 优化
105
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
106
+ // @ts-ignore
107
+ const isLoading = ref(false);
108
+
109
+ const { frontmatter, isDark, theme, lang } = useData<Open17Config>();
110
+ const blogConfig = theme.value.blog;
111
+ const title = computed(() => frontmatter.value?.title ?? null);
112
+ const isBlogTop = ref<boolean>(frontmatter.value.layout === 'blog');
113
+ const bgImg = ref<string | null>(null);
114
+ const route = useRoute();
115
+
116
+ const isPostPage = computed(() => route.path.startsWith('/posts/'));
117
+ const switchPageStyle = ref<boolean>(true);
118
+
119
+ const metaCategory = computed(() => frontmatter.value?.category || '');
120
+ const metaTags = computed(() => (frontmatter.value?.tags || []) as string[]);
121
+ const metaDate = computed(() => frontmatter.value?.date || '');
122
+ const metaDateLabel = computed(() => {
123
+ if (!metaDate.value) return '';
124
+ const date = new Date(metaDate.value as any);
125
+ if (Number.isNaN(date.getTime())) return String(metaDate.value);
126
+ return date.toLocaleDateString(lang.value || 'en-US', {
127
+ year: 'numeric',
128
+ month: 'short',
129
+ day: 'numeric',
130
+ });
131
+ });
132
+ const showMetaCard = computed(() => metaCategory.value || metaTags.value.length || metaDate.value);
133
+
134
+ const togglePostPage = () => {
135
+ switchPageStyle.value = !switchPageStyle.value;
136
+ };
137
+
138
+
139
+ const handleTopCheck = () => {
140
+ isBlogTop.value = window.scrollY <= 50;
141
+ };
142
+
143
+ onMounted(() => {
144
+ window.addEventListener('scroll', handleTopCheck, { passive: true });
145
+ updateBgImg();
146
+ });
147
+
148
+ const trigger = computed(() => ({
149
+ isDark: isDark.value,
150
+ route: route.path,
151
+ }));
152
+
153
+ watch(trigger, () => {
154
+ updateBgImg();
155
+ });
156
+
157
+ const updateBgImg = () => {
158
+ bgImg.value = getBgImg();
159
+ };
160
+
161
+ const getBgImg = (): string | null => {
162
+ const getBgImageByType = (imageType: 'dark' | 'light'): string | null => {
163
+ const globalImage = typeof blogConfig?.bgImage === 'object'
164
+ ? blogConfig?.bgImage[imageType]
165
+ : blogConfig?.bgImage;
166
+ const localImage = typeof frontmatter.value.bgImage === 'object'
167
+ ? frontmatter.value.bgImage[imageType]
168
+ : frontmatter.value.bgImage;
169
+ return localImage || globalImage || null;
170
+ };
171
+ return isDark.value ? getBgImageByType('dark') : getBgImageByType('light');
172
+ };
173
+
174
+ // 浮动目录
175
+ const showFloatingOutline = ref(false);
176
+ const navHeight = ref(56);
177
+
178
+ const asideSentinelEl = ref<HTMLElement | null>(null);
179
+ // const asideOutlineEl = ref<HTMLElement | null>(null);
180
+
181
+ // 仅文章页且宽屏启用
182
+ const floatingEnabled = computed(() => {
183
+ if (!isPostPage.value || !switchPageStyle.value) return false;
184
+ if (typeof window === 'undefined') return false;
185
+ return window.innerWidth >= 960; // 与默认断点一致
186
+ });
187
+
188
+ const updateNavHeight = () => {
189
+ if (typeof document === 'undefined') return;
190
+ const nav = document.querySelector<HTMLElement>('.VPNavBar');
191
+ navHeight.value = (nav?.offsetHeight || 56);
192
+ };
193
+
194
+ const updateFloating = () => {
195
+ if (!floatingEnabled.value) {
196
+ showFloatingOutline.value = false;
197
+ return;
198
+ }
199
+ const s = asideSentinelEl.value;
200
+ if (!s) return;
201
+
202
+ const rect = s.getBoundingClientRect();
203
+ const threshold = navHeight.value + 8;
204
+
205
+ // 滞后 6px,避免抖动
206
+ const hysteresis = 6;
207
+
208
+ if (!showFloatingOutline.value) {
209
+ if (rect.top <= threshold - hysteresis) {
210
+ showFloatingOutline.value = true;
211
+ }
212
+ } else {
213
+ if (rect.top > threshold + hysteresis) {
214
+ showFloatingOutline.value = false;
215
+ }
216
+ }
217
+ };
218
+
219
+ const handleScrollResize = () => {
220
+ updateNavHeight();
221
+ updateFloating();
222
+ };
223
+
224
+ onMounted(() => {
225
+ updateNavHeight();
226
+ updateFloating();
227
+ window.addEventListener('scroll', updateFloating, { passive: true });
228
+ window.addEventListener('resize', handleScrollResize, { passive: true });
229
+ });
230
+
231
+ // 路由/主题切换时重算
232
+ watch(
233
+ () => [isDark.value, route.path],
234
+ () => {
235
+ updateNavHeight();
236
+ updateFloating();
237
+ }
238
+ );
239
+
240
+ onUnmounted(() => {
241
+ window.removeEventListener('scroll', handleTopCheck as any);
242
+ window.removeEventListener('scroll', updateFloating as any);
243
+ window.removeEventListener('resize', handleScrollResize as any);
244
+ });
245
+ </script>
246
+
247
+ <style>
248
+ #VPContent {
249
+ background: #ffffff74;
250
+ }
251
+
252
+ .dark #VPContent {
253
+ background: #1b1b1fc3;
254
+ }
255
+
256
+ #VPContent .aside-curtain {
257
+ display: none;
258
+ }
259
+
260
+ .MathJax {
261
+ overflow-y: hidden;
262
+ overflow-x: auto;
263
+ }
264
+
265
+ /* 桌面修复 */
266
+ @media (min-width: 960px) {
267
+ .VPNavBar[data-v-cf6e7c5e]:not(.home) {
268
+ background-color: var(--vp-nav-bg-color) !important;
269
+ }
270
+ }
271
+
272
+ /* 文章页占满宽度 */
273
+ @media (min-width: 960px) {
274
+ .post-page #VPContent .VPDoc .container {
275
+ max-width: 100% !important;
276
+ display: block !important;
277
+ }
278
+
279
+ .post-page #VPContent .VPDoc .content {
280
+ max-width: 100% !important;
281
+ padding-left: 0 !important;
282
+ padding-right: 0 !important;
283
+ }
284
+ }
285
+
286
+ #Loading {
287
+ position: absolute;
288
+ top: 0;
289
+ left: 0;
290
+ width: 100%;
291
+ height: 100%;
292
+ background: #000000;
293
+ margin: 0;
294
+ padding: 0%;
295
+ z-index: 10000;
296
+ }
297
+
298
+ .loadingStyle {
299
+ height: 100vh;
300
+ width: 100vw;
301
+ overflow: hidden;
302
+ }
303
+
304
+ .v-enter-active,
305
+ .v-leave-active {
306
+ transition: opacity 0.5s ease;
307
+ }
308
+
309
+ .v-enter-from,
310
+ .v-leave-to {
311
+ opacity: 0;
312
+ }
313
+ </style>
314
+
315
+ <style>
316
+
317
+ /* 文章页容器样式(桌面) */
318
+ @media (min-width: 960px) {
319
+ .post-page #VPContent {
320
+ border: none;
321
+ border-radius: 0.75rem;
322
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
323
+ background: var(--vp-c-bg);
324
+ }
325
+
326
+ .post-page .VPLocalNav { display: none !important; }
327
+ .post-page .VPDoc .aside { display: none !important; }
328
+
329
+ .post-page .VPDoc{
330
+ padding-left: 0;
331
+ padding-right: 0;
332
+ }
333
+
334
+ .post-page .VPContent.has-sidebar {
335
+ padding-left: 0 !important;
336
+ }
337
+
338
+ .post-page .VPFooter { display: none !important; }
339
+ .post-page .VPSidebar { display: none !important; }
340
+ }
341
+
342
+ /* 移动端去除边距 */
343
+ @media (max-width: 767px) {
344
+ .post-page #VPContent,
345
+ .post-page #VPContent .VPDoc,
346
+ .post-page #VPContent .container {
347
+ margin: 0 !important;
348
+ }
349
+ }
350
+ </style>
351
+
352
+ <style scoped>
353
+ .post-page {
354
+ background-color: var(--vp-c-bg-soft);
355
+ }
356
+
357
+ .layout-toggle-btn {
358
+ width: 2.5rem;
359
+ height: 2.5rem;
360
+ background-color: var(--vp-c-brand-1);
361
+ color: var(--vp-c-white);
362
+ border-radius: 50%;
363
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
364
+ transition: all 0.2s ease-in-out;
365
+ display: flex;
366
+ align-items: center;
367
+ justify-content: center;
368
+ border: none;
369
+ cursor: pointer;
370
+ }
371
+
372
+ .layout-toggle-btn:hover {
373
+ transform: scale(1.05);
374
+ box-shadow: 0 14px 28px rgba(0, 0, 0, 0.22);
375
+ }
376
+
377
+ .layout-toggle-btn:active {
378
+ transform: scale(0.95);
379
+ }
380
+
381
+ /* 浮动目录样式 */
382
+ .floating-outline {
383
+ position: fixed;
384
+ left: 16px;
385
+ z-index: 60;
386
+ width: 280px;
387
+ pointer-events: auto;
388
+ }
389
+
390
+ .floating-outline-inner {
391
+ opacity: 0.92;
392
+ backdrop-filter: blur(6px);
393
+ border-radius: 0.75rem;
394
+ background: var(--vp-c-blog-bg);
395
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
396
+ padding: 1.25rem;
397
+ max-height: calc(100vh - 10rem);
398
+ overflow: auto;
399
+ }
400
+
401
+ /* 小屏禁用 */
402
+ @media (max-width: 959px) {
403
+ .floating-outline { display: none; }
404
+ }
405
+
406
+
407
+ </style>
@@ -0,0 +1,4 @@
1
+ import { createPageLinksLoader } from '@velonor/engine/loader';
2
+
3
+ export default createPageLinksLoader(['**/*.md']);
4
+
@@ -0,0 +1,5 @@
1
+ // posts.data.js
2
+ import { createPostsLoader } from '@velonor/engine/loader';
3
+
4
+ export default createPostsLoader('posts/**/*.md');
5
+