本系列教程共十篇
- valaxy博客全局美化教程(一):分类、标签、归档三个页面的美化教程
- valaxy博客全局美化教程(二):首页公告栏美化,新增随机文章展示板块
- valaxy博客全局美化教程(三):给网页增加加载动画
- valaxy博客全局美化教程(四):增加图片预览功能
- valaxy博客全局美化教程(五):友链页面美化,修改了友链样式以及增加留言板
- valaxy博客全局美化教程(六):留言页面增加信封展开效果
- valaxy博客全局美化教程(七):新增网址导航页面
- valaxy博客全局美化教程(八):页脚倒计时、搜索问题修复
- valaxy博客全局美化教程(九):新增相册页面,支持使用webdav作为相册
- valaxy博客全局美化教程(十):修复构建结束的时候会卡住
最终效果展示
老样子,先看看最终效果
左侧部分是公告栏,会自动识别连接,点击跳转,右侧部分是随机文章,会在这部分显示随机文章,并且会随时间自动切换,当然也可以滚轮手动切换
开始美化
那么久开始咯,大部分操作和上一篇类似,不会的可以回去看,这里不赘述了
修改文件
这部分内容是修改配置文件哦,本部分修改文件1 这里我就是将themeConfig: {},一起给出了,注意区份哦
文件valaxy.config.ts新增代码:
themeConfig: {
//...
notice: {
rotateInterval: 5000,
title: '公告栏',
sections: [
{
label: '--- 主域名 ---',
lines: [
'daily.yybb.us',
'hexo.yybb.us',
// 也支持对象:{ text: '显示文字', url: 'https://example.com' }
],
},
{
label: '--- 备用域名 ---',
lines: [
'vercel.yybb.us',
'aiovtue.onrender.com',
'aiovtue.zeabur.app',
],
},
],
},
//...
}修改文件到此结束啦,下面是新增文件
新增文件
以下是新增文件类型,本部分共新增文件1个,有点长哦
新建文件components\SakuraNoticeBoard.vue
<script setup lang="ts">
import { useConfig, usePostList } from 'valaxy'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const DOT_COUNT = 5
const CENTER_DOT = 2
const DOT_SIZE = 8
const DOT_GAP = 14
const DOT_STEP = DOT_SIZE + DOT_GAP
const DOT_ANIM_MS = 380
const props = defineProps<{
rotateInterval?: number
}>()
const config = useConfig()
const posts = usePostList()
const themeConfig = computed(() => config.value.themeConfig as {
notice?: {
rotateInterval?: number
title?: string
sections?: Array<{
label: string
lines: NoticeLineConfig[]
}>
}
postList?: { defaultImage?: string | string[] }
})
type NoticeLineConfig = string | {
text: string
url?: string
}
interface ResolvedNoticeLine {
text: string
url?: string
external?: boolean
}
function guessNoticeUrl(text: string): string | undefined {
const value = text.trim()
if (!value)
return undefined
if (/^https?:\/\//i.test(value))
return value
if (value.startsWith('/'))
return value
if (/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(value))
return `https://${value}`
return undefined
}
function resolveNoticeLine(line: NoticeLineConfig): ResolvedNoticeLine {
if (typeof line === 'object') {
const url = line.url || guessNoticeUrl(line.text)
return {
text: line.text,
url,
external: url ? /^https?:\/\//i.test(url) : false,
}
}
const url = guessNoticeUrl(line)
return {
text: line,
url,
external: url ? /^https?:\/\//i.test(url) : false,
}
}
const intervalMs = computed(() => props.rotateInterval || themeConfig.value.notice?.rotateInterval || 5000)
const noticeTitle = computed(() => themeConfig.value.notice?.title || '公告栏')
const noticeSections = computed(() => {
const sections = themeConfig.value.notice?.sections
if (!sections?.length)
return []
return sections.map(section => ({
label: section.label,
lines: section.lines.map(line => resolveNoticeLine(line)),
}))
})
const hasPosts = computed(() => posts.value.length > 0)
const currentIndex = ref(0)
const dotTrackRef = ref<HTMLElement | null>(null)
const dotTrackY = ref(0)
const trackTransition = ref(true)
const extraDotEnd = ref<'top' | 'bottom' | null>(null)
const isDotAnimating = ref(false)
const isArticleHovered = ref(false)
let timer: ReturnType<typeof setInterval> | undefined
let scrollQueue = 0
let scrollWorkerRunning = false
let skipTransition: (() => void) | null = null
const currentPost = computed(() => {
const list = posts.value
if (!list.length)
return null
return list[currentIndex.value]
})
const currentCover = computed(() => {
const post = currentPost.value
if (!post)
return ''
const fallback = themeConfig.value.postList?.defaultImage
const defaultImage = Array.isArray(fallback) ? fallback[0] : fallback
return post.cover || defaultImage || ''
})
const visibleDots = computed(() => {
const dots = Array.from({ length: DOT_COUNT }, (_, dotIndex) => ({
key: `dot-${dotIndex}`,
dotIndex,
offset: dotIndex - CENTER_DOT,
}))
if (extraDotEnd.value === 'bottom') {
dots.push({
key: 'dot-extra-bottom',
dotIndex: DOT_COUNT,
offset: CENTER_DOT + 1,
})
}
if (extraDotEnd.value === 'top') {
dots.unshift({
key: 'dot-extra-top',
dotIndex: -1,
offset: -(CENTER_DOT + 1),
})
}
return dots
})
function normalizeIndex(index: number) {
const len = posts.value.length
if (!len)
return 0
return ((index % len) + len) % len
}
function waitTrackTransition(): Promise<void> {
return new Promise((resolve) => {
const track = dotTrackRef.value
if (!track) {
resolve()
return
}
let settled = false
const finish = () => {
if (settled)
return
settled = true
track.removeEventListener('transitionend', onEnd)
skipTransition = null
resolve()
}
skipTransition = finish
const onEnd = (event: TransitionEvent) => {
if (event.propertyName !== 'transform')
return
finish()
}
track.addEventListener('transitionend', onEnd)
window.setTimeout(finish, DOT_ANIM_MS + 60)
})
}
async function animateDotStep(direction: 'next' | 'prev') {
if (direction === 'next') {
extraDotEnd.value = 'bottom'
await nextTick()
trackTransition.value = true
dotTrackY.value = -DOT_STEP
await waitTrackTransition()
trackTransition.value = false
currentIndex.value = normalizeIndex(currentIndex.value + 1)
extraDotEnd.value = null
dotTrackY.value = 0
await nextTick()
trackTransition.value = true
return
}
extraDotEnd.value = 'top'
await nextTick()
trackTransition.value = false
dotTrackY.value = -DOT_STEP
await nextTick()
trackTransition.value = true
dotTrackY.value = 0
await waitTrackTransition()
trackTransition.value = false
currentIndex.value = normalizeIndex(currentIndex.value - 1)
extraDotEnd.value = null
dotTrackY.value = 0
await nextTick()
trackTransition.value = true
}
function skipTrackTransition() {
if (!skipTransition)
return
trackTransition.value = false
skipTransition()
}
async function runScrollWorker() {
if (scrollWorkerRunning)
return
scrollWorkerRunning = true
isDotAnimating.value = true
try {
while (scrollQueue !== 0) {
const direction = scrollQueue > 0 ? 'next' : 'prev'
scrollQueue += direction === 'next' ? -1 : 1
await animateDotStep(direction)
}
}
finally {
scrollWorkerRunning = false
isDotAnimating.value = false
}
}
function enqueueScroll(delta: number) {
if (delta === 0)
return
scrollQueue += delta
if (scrollWorkerRunning)
skipTrackTransition()
else
runScrollWorker()
}
async function goToOffset(offset: number, animateDots = false) {
if (!hasPosts.value || offset === 0)
return
if (!animateDots) {
currentIndex.value = normalizeIndex(currentIndex.value + offset)
return
}
const direction = offset > 0 ? 1 : -1
for (let step = 0; step < Math.abs(offset); step++)
enqueueScroll(direction)
}
function goToDot(offset: number) {
goToOffset(offset, offset !== 0)
}
function initRandomIndex() {
const len = posts.value.length
if (len)
currentIndex.value = Math.floor(Math.random() * len)
}
function onArticleMouseEnter() {
isArticleHovered.value = true
stopAutoRotate()
}
function onArticleMouseLeave() {
isArticleHovered.value = false
startAutoRotate()
}
function onWheel(event: WheelEvent) {
if (!hasPosts.value)
return
event.preventDefault()
if (Math.abs(event.deltaY) < 8)
return
enqueueScroll(event.deltaY > 0 ? 1 : -1)
}
function startAutoRotate() {
stopAutoRotate()
if (!hasPosts.value || posts.value.length <= 1 || isArticleHovered.value)
return
timer = setInterval(() => {
if (!isArticleHovered.value)
enqueueScroll(1)
}, intervalMs.value)
}
function stopAutoRotate() {
if (timer) {
clearInterval(timer)
timer = undefined
}
}
watch(hasPosts, (available) => {
if (available) {
initRandomIndex()
startAutoRotate()
}
else {
stopAutoRotate()
currentIndex.value = 0
}
}, { immediate: true })
onMounted(() => {
if (hasPosts.value)
initRandomIndex()
})
onUnmounted(() => {
stopAutoRotate()
})
</script>
<template>
<div m="b-5 t-10" class="notice-board-wrap">
<div class="notice-board-wrap__notice sakura-card">
<h3 class="notice-board-wrap__title">
{{ noticeTitle }}
</h3>
<template
v-for="(section, sectionIndex) in noticeSections"
:key="`${section.label}-${sectionIndex}`"
>
<div class="notice-board-wrap__section">
{{ section.label }}
</div>
<p
v-for="(line, lineIndex) in section.lines"
:key="`${sectionIndex}-${lineIndex}`"
class="notice-board-wrap__line"
>
<a
v-if="line.url"
:href="line.url"
class="notice-board-wrap__link"
:target="line.external ? '_blank' : undefined"
:rel="line.external ? 'noopener noreferrer' : undefined"
>
{{ line.text }}
</a>
<template v-else>
{{ line.text }}
</template>
</p>
</template>
</div>
<div
v-if="hasPosts && currentPost"
class="notice-board-wrap__article sakura-card"
@mouseenter="onArticleMouseEnter"
@mouseleave="onArticleMouseLeave"
@wheel="onWheel"
>
<RouterLink
class="notice-board-wrap__article-link"
:to="currentPost.path"
:aria-label="`查看文章:${currentPost.title}`"
/>
<div
class="notice-board-wrap__cover-col"
>
<div
class="notice-board-wrap__dots"
role="tablist"
aria-label="文章切换"
>
<div
class="notice-board-wrap__dot-center"
:class="{ 'is-animating': isDotAnimating }"
aria-hidden="true"
/>
<div
ref="dotTrackRef"
class="notice-board-wrap__dots-track"
:class="{ 'is-no-transition': !trackTransition }"
:style="{ transform: `translateY(${dotTrackY}px)` }"
>
<button
v-for="dot in visibleDots"
:key="dot.key"
type="button"
class="notice-board-wrap__dot"
:style="{ '--dot-index': dot.dotIndex }"
:aria-label="dot.offset === 0 ? '当前文章' : `切换${dot.offset > 0 ? '后' : '前'}${Math.abs(dot.offset)}篇`"
:aria-selected="dot.offset === 0"
role="tab"
@click="goToDot(dot.offset)"
/>
</div>
</div>
<div class="notice-board-wrap__cover-frame">
<Transition name="notice-post-fade" mode="out-in">
<div
:key="currentPost.path"
class="notice-board-wrap__cover"
>
<img
v-if="currentCover"
:src="currentCover"
:alt="currentPost.title"
loading="lazy"
>
<span v-else class="notice-board-wrap__cover-placeholder" />
</div>
</Transition>
</div>
</div>
<div
class="notice-board-wrap__meta-col"
>
<Transition name="notice-post-fade" mode="out-in">
<div
:key="currentPost.path"
class="notice-board-wrap__meta sakura-post-card-info"
>
<SakuraPostDate
:date="currentPost.updated || currentPost.date"
class="post-date order-1"
pb-4
text-sm
/>
<h2
pb-4
class="order-2 sakura-post-title notice-board-wrap__post-heading"
>
{{ currentPost.title }}
</h2>
<SakuraPostMeta
pb-2
class="order-3"
:post="currentPost"
/>
<SakuraPostExcerpt
v-if="currentPost.excerpt"
pb-2
class="order-4"
:excerpt="currentPost.excerpt"
/>
</div>
</Transition>
</div>
</div>
<div
v-else
class="notice-board-wrap__article notice-board-wrap__article--empty sakura-card"
>
<p class="notice-board-wrap__empty">
暂无文章
</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.notice-board-wrap {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 7fr);
gap: 12px;
width: 100%;
&__notice,
&__article {
box-sizing: border-box;
min-height: var(--sakura-post-card-height, 250px);
height: var(--sakura-post-card-height, 250px);
color: var(--sakura-color-text);
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.85);
border-radius: var(--sakura-post-card-rd, 12px);
background: var(--sakura-card-bg, var(--sakura-post-card-bg));
}
@at-root html.dark & {
&__notice,
&__article {
border-color: var(--sakura-color-divider, rgb(255 255 255 / 20%));
}
}
&__notice {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
padding: 16px 14px;
text-align: center;
}
&__article {
position: relative;
display: grid;
grid-template-columns: minmax(0, 6fr) minmax(0, 4fr);
transition: transform 0.16s ease, border-color 0.2s ease;
&:hover {
border-color: var(--sakura-color-primary);
}
&:has(.notice-board-wrap__article-link:active) {
transform: scale(0.992);
}
&--empty {
display: flex;
align-items: center;
justify-content: center;
grid-template-columns: none;
}
}
&__article-link {
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
cursor: pointer;
transition: background-color 0.15s ease;
&:active {
background-color: color-mix(in srgb, var(--sakura-color-primary) 12%, transparent);
}
}
&__title {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: var(--sakura-color-text-deep, inherit);
letter-spacing: 0.06em;
text-align: center;
}
&__section {
margin-top: 6px;
font-size: 0.78rem;
font-weight: 700;
color: var(--sakura-color-primary);
letter-spacing: 0.04em;
text-align: center;
&:first-of-type {
margin-top: 0;
}
}
&__line {
margin: 0;
font-size: 0.82rem;
font-weight: 700;
line-height: 1.55;
color: var(--sakura-color-text);
word-break: break-all;
text-align: center;
}
&__link {
color: inherit;
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: var(--sakura-color-primary);
text-decoration: underline;
}
}
&__cover-col {
--cover-height: calc(var(--sakura-post-card-height, 250px) - 24px);
--cover-width: calc(var(--cover-height) * 16 / 9);
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-width: 0;
min-height: 0;
height: 100%;
padding: 12px 10px 12px 34px;
}
&__cover-frame {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-width: 0;
height: 100%;
}
&__meta-col {
display: flex;
align-items: center;
box-sizing: border-box;
min-width: 0;
min-height: 0;
padding: 16px 14px 16px 10px;
overflow: hidden;
}
&__dots {
--dot-size: 8px;
--dot-gap: 14px;
--dot-step: calc(var(--dot-size) + var(--dot-gap));
--dots-viewport-height: calc(var(--dot-size) * 5 + var(--dot-gap) * 4);
position: absolute;
top: 50%;
left: 0;
z-index: 3;
width: 30px;
height: var(--dots-viewport-height);
margin-top: calc(var(--dots-viewport-height) / -2);
overflow: hidden;
pointer-events: none;
}
&__dot-center {
position: absolute;
top: 50%;
left: 50%;
z-index: 2;
width: 10px;
height: 10px;
margin-top: -5px;
margin-left: -5px;
border-radius: 50%;
background: var(--sakura-color-primary);
pointer-events: none;
transition: background-color 0.2s ease;
&.is-animating {
background: color-mix(in srgb, var(--sakura-color-primary) 28%, transparent);
}
}
&__dots-track {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--dot-gap);
pointer-events: auto;
will-change: transform;
transition: transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
&.is-no-transition {
transition: none;
}
}
&__dot {
flex-shrink: 0;
width: var(--dot-size);
height: var(--dot-size);
padding: 0;
border: none;
border-radius: 50%;
background: color-mix(in srgb, var(--sakura-color-primary) 28%, transparent);
cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease, opacity 0.2s ease;
&:hover {
background: color-mix(in srgb, var(--sakura-color-primary) 55%, transparent);
transform: scale(1.15);
}
}
&__cover-col,
&__meta-col {
position: relative;
z-index: 0;
}
&__cover {
display: block;
flex-shrink: 0;
width: min(var(--cover-width), 100%);
aspect-ratio: 16 / 9;
height: auto;
max-height: var(--cover-height);
max-width: 100%;
border-radius: 10px;
overflow: hidden;
background: color-mix(in srgb, var(--sakura-color-primary) 10%, var(--sakura-card-bg));
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__cover-placeholder {
display: block;
width: 100%;
height: 100%;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--sakura-color-primary) 18%, transparent),
color-mix(in srgb, var(--sakura-color-primary) 6%, transparent)
);
}
&__meta {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
&.sakura-post-card-info {
.post-date {
font-size: 12px;
color: var(--sakura-color-text);
}
:deep(.notice-board-wrap__post-heading) {
margin: 0;
display: -webkit-box;
overflow: hidden;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.45;
color: var(--sakura-color-text-deep, inherit);
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
:deep(.sakura-post-meta) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.sakura-post-excerpt) {
margin-bottom: 0;
* {
display: -webkit-box;
overflow: hidden;
font-size: 0.9375rem;
line-height: 1.6;
color: var(--sakura-color-text);
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
}
}
:deep(.pb-4) {
padding-bottom: 0.5rem !important;
}
:deep(.pb-2) {
padding-bottom: 0.25rem !important;
}
}
&__empty {
margin: 0;
color: var(--sakura-color-text-muted, #888);
font-size: 0.9rem;
}
}
.notice-post-fade-enter-active,
.notice-post-fade-leave-active {
transition: opacity 0.3s ease;
}
.notice-post-fade-enter-from,
.notice-post-fade-leave-to {
opacity: 0;
}
@media (max-width: 768px) {
.notice-board-wrap {
grid-template-columns: 1fr;
gap: 10px;
&__notice,
&__article {
height: auto;
min-height: auto;
}
&__notice {
min-height: 160px;
padding: 16px 18px;
}
&__article {
grid-template-columns: 1fr;
min-height: var(--sakura-post-card-height, 250px);
}
&__cover-col {
--cover-height: 160px;
--cover-width: calc(var(--cover-height) * 16 / 9);
min-height: 200px;
padding: 12px 12px 12px 34px;
}
&__meta-col {
min-height: 120px;
padding: 14px 16px;
}
}
}
</style>好啦,刷新一下终端即可看到效果啦,不会的回去看第一篇最后部分
- 本文链接:https://daily.yybb.us/posts/valaxy-2
- 版权声明:本博客所有文章除特别声明外,均默认采用 CC BY-NC-SA 许可协议。
Previous Post valaxy博客全局美化教程(三)

