@mete0r/minimal-homepage 2.0.1
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.
- package/LICENSE +21 -0
- package/README.md +278 -0
- package/index.html +16 -0
- package/package.json +43 -0
- package/public/favicon.svg +1 -0
- package/public/icons.svg +24 -0
- package/src/App.vue +27 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/vite.svg +1 -0
- package/src/assets/vue.svg +1 -0
- package/src/atom.xml +651 -0
- package/src/components/AboutMe.vue +38 -0
- package/src/components/AppFooter.vue +33 -0
- package/src/components/CardSection.vue +147 -0
- package/src/components/HelloWorld.vue +93 -0
- package/src/components/HeroSection.vue +53 -0
- package/src/components/MyProject.vue +160 -0
- package/src/components/NavBar.vue +109 -0
- package/src/components/RSSSection.vue +138 -0
- package/src/components/ThemeToggle.vue +51 -0
- package/src/components/theme-button-element.js +245 -0
- package/src/config.js +1 -0
- package/src/index.js +5 -0
- package/src/main.js +48 -0
- package/src/pages/AboutPage.vue +9 -0
- package/src/pages/HomePage.vue +15 -0
- package/src/router.js +27 -0
- package/src/style.css +96 -0
- package/vite.config.js +32 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="py-16" id="about">
|
|
3
|
+
<div class="rounded-[2.5rem] bg-white/60 dark:bg-gray-900/60 backdrop-blur-xl border border-white/40 dark:border-gray-700/40 p-8 md:p-12 shadow-[0_16px_40px_rgba(15,23,42,0.12)] dark:shadow-[0_18px_40px_rgba(0,0,0,0.5)]">
|
|
4
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
|
|
5
|
+
<div class="order-2 md:order-1">
|
|
6
|
+
<h1 class="text-4xl md:text-5xl font-extrabold mb-4 dark:text-white">关于我</h1>
|
|
7
|
+
<p class="text-lg text-gray-700 dark:text-gray-300 leading-relaxed mb-4">
|
|
8
|
+
{{config.author.aboutMeintro}}
|
|
9
|
+
</p>
|
|
10
|
+
<p class="text-lg text-gray-700 dark:text-gray-300 leading-relaxed mb-6">
|
|
11
|
+
MBTI:<a :href="`https://www.16personalities.com/ch/${config.author.mbti}-人格`" class="font-bold text-blue-600 dark:text-blue-400" target="_blank" rel="noopener noreferrer">{{ config.author.mbti }}</a>
|
|
12
|
+
</p>
|
|
13
|
+
<a
|
|
14
|
+
:href="config.blog.url"
|
|
15
|
+
target="_blank"
|
|
16
|
+
rel="noopener noreferrer"
|
|
17
|
+
class="inline-block px-6 py-3 rounded-2xl bg-blue-600 text-white font-semibold hover:bg-blue-700 transition-colors"
|
|
18
|
+
>
|
|
19
|
+
访问我的博客
|
|
20
|
+
</a>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="order-1 md:order-2 flex justify-center md:justify-end">
|
|
24
|
+
<img
|
|
25
|
+
:src="config.author.aiPortrait"
|
|
26
|
+
alt="AI Portrait"
|
|
27
|
+
class="w-full max-w-md object-contain drop-shadow-2xl"
|
|
28
|
+
style="-webkit-mask-image: linear-gradient(to bottom, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, black 75%, transparent 100%);"
|
|
29
|
+
/>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</section>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<script setup>
|
|
37
|
+
import config from '../../main.config.js'
|
|
38
|
+
</script>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<footer class="relative z-10 py-6 md:py-8 px-4 md:px-10 mt-14 md:mt-20 border-t border-gray-200 dark:border-gray-800 flex flex-col md:flex-row gap-4 md:gap-0 justify-between md:items-center text-sm text-gray-500 dark:text-gray-400">
|
|
3
|
+
<div class="flex flex-col gap-1">
|
|
4
|
+
<span>{{ config.footer.copyright }}</span>
|
|
5
|
+
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" class="hover:text-gray-900 dark:hover:text-white">
|
|
6
|
+
{{ config.footer.icp }}
|
|
7
|
+
</a>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div class="flex gap-4 items-center text-xl">
|
|
11
|
+
<a
|
|
12
|
+
v-for="link in config.footer.links"
|
|
13
|
+
:key="link.url"
|
|
14
|
+
:href="link.url"
|
|
15
|
+
target="_blank"
|
|
16
|
+
rel="noopener noreferrer"
|
|
17
|
+
class="hover:text-gray-900 dark:hover:text-white transition-colors inline-flex items-center justify-center w-5 h-5"
|
|
18
|
+
:title="link.name"
|
|
19
|
+
>
|
|
20
|
+
<template v-if="link.icon">
|
|
21
|
+
<img :src="link.icon" :alt="link.name" class="w-5 h-5" />
|
|
22
|
+
</template>
|
|
23
|
+
<template v-else>
|
|
24
|
+
<span class="text-xs leading-none">{{ link.name.substring(0, 2) }}</span>
|
|
25
|
+
</template>
|
|
26
|
+
</a>
|
|
27
|
+
</div>
|
|
28
|
+
</footer>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<script setup>
|
|
32
|
+
import config from '../../main.config.js'
|
|
33
|
+
</script>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="py-14 md:py-20" id="socials">
|
|
3
|
+
<div class="flex justify-between items-center mb-6 md:mb-10">
|
|
4
|
+
<h2 class="text-2xl md:text-3xl font-bold dark:text-white flex items-center gap-2">
|
|
5
|
+
<span class="w-2 h-7 md:h-8 bg-blue-500 rounded-full"></span>
|
|
6
|
+
社交与链接
|
|
7
|
+
</h2>
|
|
8
|
+
|
|
9
|
+
<div v-if="!isMobile" class="flex gap-2">
|
|
10
|
+
<button
|
|
11
|
+
@click="scroll('left')"
|
|
12
|
+
class="p-2.5 md:p-3 rounded-xl bg-white/50 dark:bg-gray-800/50 backdrop-blur hover:bg-white dark:text-white transition shadow-sm"
|
|
13
|
+
aria-label="Scroll left"
|
|
14
|
+
>
|
|
15
|
+
←
|
|
16
|
+
</button>
|
|
17
|
+
<button
|
|
18
|
+
@click="scroll('right')"
|
|
19
|
+
class="p-2.5 md:p-3 rounded-xl bg-white/50 dark:bg-gray-800/50 backdrop-blur hover:bg-white dark:text-white transition shadow-sm"
|
|
20
|
+
aria-label="Scroll right"
|
|
21
|
+
>
|
|
22
|
+
→
|
|
23
|
+
</button>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div v-if="isMobile" class="space-y-4">
|
|
28
|
+
<a
|
|
29
|
+
v-for="item in pagedSocials"
|
|
30
|
+
:key="item.name"
|
|
31
|
+
:href="item.url"
|
|
32
|
+
target="_blank"
|
|
33
|
+
rel="noopener noreferrer"
|
|
34
|
+
class="block"
|
|
35
|
+
>
|
|
36
|
+
<div class="bg-white/60 dark:bg-gray-800/60 backdrop-blur-xl p-5 rounded-2xl border border-white/40 dark:border-gray-700/40 shadow-[0_10px_28px_rgba(0,0,0,0.05)] dark:shadow-[0_14px_30px_rgba(0,0,0,0.45)] transition-all duration-300 active:scale-[0.99]">
|
|
37
|
+
<div class="text-3xl mb-3 h-9 flex items-center">
|
|
38
|
+
<img
|
|
39
|
+
v-if="isIconUrl(item.icon)"
|
|
40
|
+
:src="item.icon"
|
|
41
|
+
:alt="item.name + ' icon'"
|
|
42
|
+
class="h-9 w-9 object-contain bg-transparent"
|
|
43
|
+
/>
|
|
44
|
+
<span v-else>{{ item.icon }}</span>
|
|
45
|
+
</div>
|
|
46
|
+
<h3 class="font-bold dark:text-white">{{ item.name }}</h3>
|
|
47
|
+
<p class="text-sm text-gray-500">{{ item.label }}</p>
|
|
48
|
+
</div>
|
|
49
|
+
</a>
|
|
50
|
+
|
|
51
|
+
<div v-if="totalPages > 1" class="flex items-center justify-between pt-2">
|
|
52
|
+
<button
|
|
53
|
+
@click="prevPage"
|
|
54
|
+
:disabled="currentPage === 1"
|
|
55
|
+
class="px-3 py-1.5 rounded-lg text-sm bg-white/70 dark:bg-gray-800/70 disabled:opacity-40"
|
|
56
|
+
>
|
|
57
|
+
上一页
|
|
58
|
+
</button>
|
|
59
|
+
<span class="text-sm text-gray-500 dark:text-gray-400">{{ currentPage }} / {{ totalPages }}</span>
|
|
60
|
+
<button
|
|
61
|
+
@click="nextPage"
|
|
62
|
+
:disabled="currentPage === totalPages"
|
|
63
|
+
class="px-3 py-1.5 rounded-lg text-sm bg-white/70 dark:bg-gray-800/70 disabled:opacity-40"
|
|
64
|
+
>
|
|
65
|
+
下一页
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div v-else ref="container" class="flex gap-6 overflow-x-hidden scroll-smooth snap-x">
|
|
71
|
+
<a
|
|
72
|
+
v-for="item in config.socials"
|
|
73
|
+
:key="item.name"
|
|
74
|
+
:href="item.url"
|
|
75
|
+
target="_blank"
|
|
76
|
+
rel="noopener noreferrer"
|
|
77
|
+
class="min-w-[calc(20%-1.25rem)] snap-start group block cursor-pointer"
|
|
78
|
+
>
|
|
79
|
+
<div class="bg-white/60 dark:bg-gray-800/60 backdrop-blur-xl p-6 rounded-[2rem] border border-white/40 dark:border-gray-700/40 shadow-[0_10px_28px_rgba(0,0,0,0.05)] dark:shadow-[0_14px_30px_rgba(0,0,0,0.45)] transition-all duration-500 group-hover:-translate-y-2 group-hover:bg-white dark:group-hover:bg-gray-700">
|
|
80
|
+
<div class="text-4xl mb-4 h-10 flex items-center">
|
|
81
|
+
<img
|
|
82
|
+
v-if="isIconUrl(item.icon)"
|
|
83
|
+
:src="item.icon"
|
|
84
|
+
:alt="item.name + ' icon'"
|
|
85
|
+
class="h-10 w-10 object-contain bg-transparent"
|
|
86
|
+
/>
|
|
87
|
+
<span v-else>{{ item.icon }}</span>
|
|
88
|
+
</div>
|
|
89
|
+
<h3 class="font-bold dark:text-white">{{ item.name }}</h3>
|
|
90
|
+
<p class="text-sm text-gray-500">{{ item.label }}</p>
|
|
91
|
+
</div>
|
|
92
|
+
</a>
|
|
93
|
+
</div>
|
|
94
|
+
</section>
|
|
95
|
+
</template>
|
|
96
|
+
|
|
97
|
+
<script setup>
|
|
98
|
+
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
99
|
+
import config from '../../main.config.js'
|
|
100
|
+
|
|
101
|
+
const container = ref(null)
|
|
102
|
+
const isMobile = ref(false)
|
|
103
|
+
const currentPage = ref(1)
|
|
104
|
+
const mobilePageSize = 3
|
|
105
|
+
|
|
106
|
+
const totalPages = computed(() => Math.max(1, Math.ceil((config.socials?.length || 0) / mobilePageSize)))
|
|
107
|
+
const pagedSocials = computed(() => {
|
|
108
|
+
const start = (currentPage.value - 1) * mobilePageSize
|
|
109
|
+
return (config.socials || []).slice(start, start + mobilePageSize)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const handleResize = () => {
|
|
113
|
+
const mobile = window.innerWidth < 768
|
|
114
|
+
if (mobile !== isMobile.value) {
|
|
115
|
+
isMobile.value = mobile
|
|
116
|
+
currentPage.value = 1
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const prevPage = () => {
|
|
121
|
+
if (currentPage.value > 1) currentPage.value -= 1
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const nextPage = () => {
|
|
125
|
+
if (currentPage.value < totalPages.value) currentPage.value += 1
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const scroll = (dir) => {
|
|
129
|
+
if (!container.value) return
|
|
130
|
+
const step = container.value.offsetWidth * 0.82
|
|
131
|
+
container.value.scrollLeft += dir === 'right' ? step : -step
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const isIconUrl = (icon) => {
|
|
135
|
+
if (typeof icon !== 'string') return false
|
|
136
|
+
return /^(https?:)?\/\//.test(icon) || icon.endsWith('.svg')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
onMounted(() => {
|
|
140
|
+
handleResize()
|
|
141
|
+
window.addEventListener('resize', handleResize)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
onUnmounted(() => {
|
|
145
|
+
window.removeEventListener('resize', handleResize)
|
|
146
|
+
})
|
|
147
|
+
</script>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import viteLogo from '../assets/vite.svg'
|
|
4
|
+
import heroImg from '../assets/hero.png'
|
|
5
|
+
import vueLogo from '../assets/vue.svg'
|
|
6
|
+
|
|
7
|
+
const count = ref(0)
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<section id="center">
|
|
12
|
+
<div class="hero">
|
|
13
|
+
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
|
14
|
+
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
|
15
|
+
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
|
16
|
+
</div>
|
|
17
|
+
<div>
|
|
18
|
+
<h1>Get started</h1>
|
|
19
|
+
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
|
20
|
+
</div>
|
|
21
|
+
<button class="counter" @click="count++">Count is {{ count }}</button>
|
|
22
|
+
</section>
|
|
23
|
+
|
|
24
|
+
<div class="ticks"></div>
|
|
25
|
+
|
|
26
|
+
<section id="next-steps">
|
|
27
|
+
<div id="docs">
|
|
28
|
+
<svg class="icon" role="presentation" aria-hidden="true">
|
|
29
|
+
<use href="/icons.svg#documentation-icon"></use>
|
|
30
|
+
</svg>
|
|
31
|
+
<h2>Documentation</h2>
|
|
32
|
+
<p>Your questions, answered</p>
|
|
33
|
+
<ul>
|
|
34
|
+
<li>
|
|
35
|
+
<a href="https://vite.dev/" target="_blank">
|
|
36
|
+
<img class="logo" :src="viteLogo" alt="" />
|
|
37
|
+
Explore Vite
|
|
38
|
+
</a>
|
|
39
|
+
</li>
|
|
40
|
+
<li>
|
|
41
|
+
<a href="https://vuejs.org/" target="_blank">
|
|
42
|
+
<img class="button-icon" :src="vueLogo" alt="" />
|
|
43
|
+
Learn more
|
|
44
|
+
</a>
|
|
45
|
+
</li>
|
|
46
|
+
</ul>
|
|
47
|
+
</div>
|
|
48
|
+
<div id="social">
|
|
49
|
+
<svg class="icon" role="presentation" aria-hidden="true">
|
|
50
|
+
<use href="/icons.svg#social-icon"></use>
|
|
51
|
+
</svg>
|
|
52
|
+
<h2>Connect with us</h2>
|
|
53
|
+
<p>Join the Vite community</p>
|
|
54
|
+
<ul>
|
|
55
|
+
<li>
|
|
56
|
+
<a href="https://github.com/vitejs/vite" target="_blank">
|
|
57
|
+
<svg class="button-icon" role="presentation" aria-hidden="true">
|
|
58
|
+
<use href="/icons.svg#github-icon"></use>
|
|
59
|
+
</svg>
|
|
60
|
+
GitHub
|
|
61
|
+
</a>
|
|
62
|
+
</li>
|
|
63
|
+
<li>
|
|
64
|
+
<a href="https://chat.vite.dev/" target="_blank">
|
|
65
|
+
<svg class="button-icon" role="presentation" aria-hidden="true">
|
|
66
|
+
<use href="/icons.svg#discord-icon"></use>
|
|
67
|
+
</svg>
|
|
68
|
+
Discord
|
|
69
|
+
</a>
|
|
70
|
+
</li>
|
|
71
|
+
<li>
|
|
72
|
+
<a href="https://x.com/vite_js" target="_blank">
|
|
73
|
+
<svg class="button-icon" role="presentation" aria-hidden="true">
|
|
74
|
+
<use href="/icons.svg#x-icon"></use>
|
|
75
|
+
</svg>
|
|
76
|
+
X.com
|
|
77
|
+
</a>
|
|
78
|
+
</li>
|
|
79
|
+
<li>
|
|
80
|
+
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
|
81
|
+
<svg class="button-icon" role="presentation" aria-hidden="true">
|
|
82
|
+
<use href="/icons.svg#bluesky-icon"></use>
|
|
83
|
+
</svg>
|
|
84
|
+
Bluesky
|
|
85
|
+
</a>
|
|
86
|
+
</li>
|
|
87
|
+
</ul>
|
|
88
|
+
</div>
|
|
89
|
+
</section>
|
|
90
|
+
|
|
91
|
+
<div class="ticks"></div>
|
|
92
|
+
<section id="spacer"></section>
|
|
93
|
+
</template>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="relative min-h-[calc(100vh-120px)] md:min-h-screen flex items-center px-4 md:px-10 pt-8 md:pt-20 overflow-hidden">
|
|
3
|
+
<div
|
|
4
|
+
class="hidden md:flex w-1/3 h-full justify-end items-end relative"
|
|
5
|
+
:style="{ transform: `translateY(${scrollY * 0.5}px)`, opacity: 1 - scrollY / 600 }"
|
|
6
|
+
>
|
|
7
|
+
<img
|
|
8
|
+
:src="config.author.aiPortrait"
|
|
9
|
+
class="max-h-[80vh] object-contain drop-shadow-2xl"
|
|
10
|
+
style="-webkit-mask-image: linear-gradient(to bottom, black 70%, transparent 100%); mask-image: linear-gradient(to bottom, black 70%, transparent 100%);"
|
|
11
|
+
alt="AI Portrait"
|
|
12
|
+
/>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<div class="w-full md:w-2/3 md:pl-16 z-10">
|
|
16
|
+
<div class="md:hidden flex justify-center mb-8">
|
|
17
|
+
<img
|
|
18
|
+
:src="config.author.aiPortrait"
|
|
19
|
+
class="w-full max-w-[260px] object-contain drop-shadow-xl"
|
|
20
|
+
style="-webkit-mask-image: linear-gradient(to bottom, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, black 75%, transparent 100%);"
|
|
21
|
+
alt="AI Portrait"
|
|
22
|
+
/>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<h1 class="text-3xl sm:text-4xl md:text-5xl font-extrabold mb-5 md:mb-6 dark:text-white">Hi, I'm {{ config.author.name }}</h1>
|
|
26
|
+
<p class="text-base sm:text-lg md:text-xl text-gray-700 dark:text-gray-300 mb-9 md:mb-12 max-w-2xl leading-relaxed">
|
|
27
|
+
{{ config.author.intro }}
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
<a
|
|
31
|
+
:href="config.blog.url"
|
|
32
|
+
target="_blank"
|
|
33
|
+
rel="noopener noreferrer"
|
|
34
|
+
class="inline-block px-6 md:px-8 py-3 bg-blue-600 text-white rounded-2xl hover:bg-blue-700 hover:shadow-lg hover:-translate-y-1 transition-all duration-300"
|
|
35
|
+
>
|
|
36
|
+
访问我的博客
|
|
37
|
+
</a>
|
|
38
|
+
</div>
|
|
39
|
+
</section>
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<script setup>
|
|
43
|
+
import { ref, onMounted, onUnmounted } from 'vue'
|
|
44
|
+
import config from '../../main.config.js'
|
|
45
|
+
|
|
46
|
+
const scrollY = ref(0)
|
|
47
|
+
const handleScroll = () => {
|
|
48
|
+
scrollY.value = window.scrollY
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
onMounted(() => window.addEventListener('scroll', handleScroll))
|
|
52
|
+
onUnmounted(() => window.removeEventListener('scroll', handleScroll))
|
|
53
|
+
</script>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="py-14 md:py-20" id="projects">
|
|
3
|
+
<div class="flex items-end justify-between mb-6 md:mb-10 gap-4">
|
|
4
|
+
<h2 class="text-2xl md:text-3xl font-bold dark:text-white">我的项目</h2>
|
|
5
|
+
<a
|
|
6
|
+
v-if="isApiMode && config.github.username"
|
|
7
|
+
:href="`https://github.com/${config.github.username}?tab=repositories`"
|
|
8
|
+
target="_blank"
|
|
9
|
+
rel="noopener noreferrer"
|
|
10
|
+
class="text-xs md:text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline shrink-0"
|
|
11
|
+
>
|
|
12
|
+
查看全部仓库
|
|
13
|
+
</a>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<div v-if="loading" class="text-sm text-gray-500 dark:text-gray-400">
|
|
17
|
+
{{ isApiMode ? '正在读取 GitHub 仓库...' : '正在读取手动项目配置...' }}
|
|
18
|
+
</div>
|
|
19
|
+
<div v-else-if="error" class="text-sm text-red-500">加载失败:{{ error }}</div>
|
|
20
|
+
|
|
21
|
+
<div v-else class="columns-1 md:columns-2 gap-6 space-y-6">
|
|
22
|
+
<a
|
|
23
|
+
v-for="repo in repos"
|
|
24
|
+
:key="repo.id || repo.name"
|
|
25
|
+
:href="repo.url"
|
|
26
|
+
target="_blank"
|
|
27
|
+
rel="noopener noreferrer"
|
|
28
|
+
class="block break-inside-avoid rounded-[1.5rem] md:rounded-[2rem] overflow-hidden bg-white/60 dark:bg-gray-800/60 border border-white/40 dark:border-gray-700/40 backdrop-blur-xl shadow-[0_12px_32px_rgba(15,23,42,0.10)] dark:shadow-[0_16px_36px_rgba(0,0,0,0.45)] transition-transform duration-300 hover:-translate-y-1"
|
|
29
|
+
>
|
|
30
|
+
<img
|
|
31
|
+
v-if="repo.cover"
|
|
32
|
+
:src="repo.cover"
|
|
33
|
+
:alt="repo.name + ' cover'"
|
|
34
|
+
class="w-full h-40 md:h-44 object-cover"
|
|
35
|
+
/>
|
|
36
|
+
|
|
37
|
+
<div class="p-5 md:p-6">
|
|
38
|
+
<div class="flex items-start justify-between gap-4 mb-3">
|
|
39
|
+
<h3 class="text-lg md:text-xl font-bold dark:text-white line-clamp-2">{{ repo.name }}</h3>
|
|
40
|
+
<span class="text-xs px-2 py-1 rounded-full bg-black/5 dark:bg-white/10 text-gray-600 dark:text-gray-300 whitespace-nowrap">
|
|
41
|
+
{{ repo.visibility || 'public' }}
|
|
42
|
+
</span>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4 line-clamp-3">{{ repo.description || '暂无描述' }}</p>
|
|
46
|
+
|
|
47
|
+
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
|
48
|
+
<span>{{ repo.language || 'Unknown' }}</span>
|
|
49
|
+
<span v-if="isApiMode">★ {{ repo.stars || 0 }}</span>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</a>
|
|
53
|
+
</div>
|
|
54
|
+
</section>
|
|
55
|
+
</template>
|
|
56
|
+
|
|
57
|
+
<script setup>
|
|
58
|
+
import { computed, onMounted, ref } from 'vue'
|
|
59
|
+
import config from '../../main.config.js'
|
|
60
|
+
|
|
61
|
+
const repos = ref([])
|
|
62
|
+
const loading = ref(true)
|
|
63
|
+
const error = ref('')
|
|
64
|
+
|
|
65
|
+
const isApiMode = computed(() => {
|
|
66
|
+
const value = config.github?.useGithubApi
|
|
67
|
+
return value === true || value === 'true'
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const normalizeApiRepo = (repo) => ({
|
|
71
|
+
id: repo.id,
|
|
72
|
+
name: repo.name,
|
|
73
|
+
url: repo.html_url,
|
|
74
|
+
description: repo.description,
|
|
75
|
+
language: repo.language,
|
|
76
|
+
visibility: repo.visibility,
|
|
77
|
+
stars: repo.stargazers_count,
|
|
78
|
+
cover: config.github?.covers?.[repo.name] || ''
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const normalizeManualRepo = (repo, idx) => ({
|
|
82
|
+
id: repo.id || `manual-${idx}`,
|
|
83
|
+
name: repo.name || `Project ${idx + 1}`,
|
|
84
|
+
url: repo.url || '#',
|
|
85
|
+
description: repo.description || '',
|
|
86
|
+
language: repo.language || 'Unknown',
|
|
87
|
+
visibility: repo.visibility || 'public',
|
|
88
|
+
stars: 0,
|
|
89
|
+
cover: repo.cover || ''
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const loadManualRepos = () => {
|
|
93
|
+
const manualRepos = Array.isArray(config.github?.manualRepos) ? config.github.manualRepos : []
|
|
94
|
+
repos.value = manualRepos.map(normalizeManualRepo)
|
|
95
|
+
loading.value = false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const loadGithubRepos = async () => {
|
|
99
|
+
const username = config.github?.username
|
|
100
|
+
const maxRepos = config.github?.maxRepos || 10
|
|
101
|
+
const excludeForks = config.github?.excludeForks !== false
|
|
102
|
+
|
|
103
|
+
if (!username) {
|
|
104
|
+
throw new Error('github.username 未配置')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const cacheKey = `github_repos_${username}`
|
|
108
|
+
const cacheExpiryKey = `github_repos_expiry_${username}`
|
|
109
|
+
const now = Date.now()
|
|
110
|
+
|
|
111
|
+
const cachedExpiry = localStorage.getItem(cacheExpiryKey)
|
|
112
|
+
if (cachedExpiry && Number.parseInt(cachedExpiry, 10) > now) {
|
|
113
|
+
try {
|
|
114
|
+
const cachedRepos = JSON.parse(localStorage.getItem(cacheKey) || 'null')
|
|
115
|
+
if (Array.isArray(cachedRepos)) {
|
|
116
|
+
repos.value = cachedRepos.map(normalizeApiRepo)
|
|
117
|
+
loading.value = false
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
} catch (e) {
|
|
121
|
+
console.error('读取缓存失败:', e)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const token = import.meta.env.VITE_GITHUB_TOKEN
|
|
126
|
+
const url = `https://api.github.com/users/${username}/repos?sort=updated&per_page=${maxRepos}`
|
|
127
|
+
const headers = {
|
|
128
|
+
Accept: 'application/vnd.github+json',
|
|
129
|
+
...(token && { Authorization: `Bearer ${token}` })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const res = await fetch(url, { headers })
|
|
133
|
+
if (!res.ok) {
|
|
134
|
+
throw new Error(`GitHub API ${res.status}`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const data = await res.json()
|
|
138
|
+
const filteredRepos = excludeForks ? data.filter((repo) => !repo.fork) : data
|
|
139
|
+
|
|
140
|
+
localStorage.setItem(cacheKey, JSON.stringify(filteredRepos))
|
|
141
|
+
localStorage.setItem(cacheExpiryKey, (now + 24 * 60 * 60 * 1000).toString())
|
|
142
|
+
|
|
143
|
+
repos.value = filteredRepos.map(normalizeApiRepo)
|
|
144
|
+
loading.value = false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
onMounted(async () => {
|
|
148
|
+
try {
|
|
149
|
+
if (!isApiMode.value) {
|
|
150
|
+
loadManualRepos()
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await loadGithubRepos()
|
|
155
|
+
} catch (e) {
|
|
156
|
+
error.value = e instanceof Error ? e.message : '未知错误'
|
|
157
|
+
loading.value = false
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
</script>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<nav
|
|
3
|
+
ref="navRef"
|
|
4
|
+
class="fixed top-0 left-0 right-0 z-50 h-[116px] md:h-[76px] px-4 md:px-8 py-3 md:py-4 backdrop-blur-md bg-white/70 dark:bg-[#424242]/80 transition-colors duration-500"
|
|
5
|
+
>
|
|
6
|
+
<div class="relative h-full">
|
|
7
|
+
<div class="flex items-center justify-between md:h-full">
|
|
8
|
+
<button
|
|
9
|
+
@click="goHome"
|
|
10
|
+
class="flex items-center gap-2.5 md:gap-3 h-full cursor-pointer"
|
|
11
|
+
aria-label="Go Home"
|
|
12
|
+
>
|
|
13
|
+
<img :src="config.author.avatar" class="w-9 h-9 md:w-10 md:h-10 rounded-full shadow-md" alt="avatar" />
|
|
14
|
+
<span class="name-chip font-bold text-base md:text-lg dark:text-white">{{ config.author.name }}</span>
|
|
15
|
+
</button>
|
|
16
|
+
|
|
17
|
+
<div class="hidden md:flex gap-2 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
|
18
|
+
<a
|
|
19
|
+
v-for="link in config.navLinks"
|
|
20
|
+
:key="link.name"
|
|
21
|
+
:href="link.url"
|
|
22
|
+
@click.prevent="handleNavClick(link.url)"
|
|
23
|
+
class="px-4 py-2 rounded-xl transition-all duration-300 hover:bg-black/10 dark:hover:bg-white/20 dark:text-gray-200"
|
|
24
|
+
>
|
|
25
|
+
{{ link.name }}
|
|
26
|
+
</a>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div class="z-[60]">
|
|
30
|
+
<ThemeToggle />
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="md:hidden mt-2 overflow-x-auto no-scrollbar">
|
|
35
|
+
<div class="flex gap-2 pr-2">
|
|
36
|
+
<a
|
|
37
|
+
v-for="link in config.navLinks"
|
|
38
|
+
:key="`mobile-${link.name}`"
|
|
39
|
+
:href="link.url"
|
|
40
|
+
@click.prevent="handleNavClick(link.url)"
|
|
41
|
+
class="shrink-0 px-3 py-1.5 text-sm rounded-lg bg-white/60 dark:bg-white/10 dark:text-gray-200"
|
|
42
|
+
>
|
|
43
|
+
{{ link.name }}
|
|
44
|
+
</a>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</nav>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<script setup>
|
|
52
|
+
import { ref } from 'vue'
|
|
53
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
54
|
+
import config from '../../main.config.js'
|
|
55
|
+
import ThemeToggle from './ThemeToggle.vue'
|
|
56
|
+
|
|
57
|
+
const navRef = ref(null)
|
|
58
|
+
const router = useRouter()
|
|
59
|
+
const route = useRoute()
|
|
60
|
+
|
|
61
|
+
const goHome = () => {
|
|
62
|
+
router.push('/')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const scrollToHashWithRetry = (hash, retries = 20) => {
|
|
66
|
+
const target = document.querySelector(hash)
|
|
67
|
+
if (!target) {
|
|
68
|
+
if (retries <= 0) return
|
|
69
|
+
window.requestAnimationFrame(() => scrollToHashWithRetry(hash, retries - 1))
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const navHeight = navRef.value?.offsetHeight || 0
|
|
74
|
+
const targetTop = target.getBoundingClientRect().top + window.scrollY - navHeight - 8
|
|
75
|
+
window.scrollTo({ top: targetTop, behavior: 'smooth' })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const smoothScrollToHash = (hash) => {
|
|
79
|
+
const target = document.querySelector(hash)
|
|
80
|
+
if (!target) return
|
|
81
|
+
const navHeight = navRef.value?.offsetHeight || 0
|
|
82
|
+
const targetTop = target.getBoundingClientRect().top + window.scrollY - navHeight - 8
|
|
83
|
+
window.scrollTo({ top: targetTop, behavior: 'smooth' })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const handleNavClick = (url) => {
|
|
87
|
+
if (!url) return
|
|
88
|
+
|
|
89
|
+
if (url.startsWith('#')) {
|
|
90
|
+
if (route.path !== '/') {
|
|
91
|
+
router.push({ path: '/', hash: url }).then(() => {
|
|
92
|
+
scrollToHashWithRetry(url)
|
|
93
|
+
})
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
smoothScrollToHash(url)
|
|
98
|
+
router.replace({ hash: url })
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (url.startsWith('/')) {
|
|
103
|
+
router.push(url)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
window.location.href = url
|
|
108
|
+
}
|
|
109
|
+
</script>
|