本系列教程共十篇
- valaxy博客全局美化教程(一):分类、标签、归档三个页面的美化教程
- valaxy博客全局美化教程(二):首页公告栏美化,新增随机文章展示板块
- valaxy博客全局美化教程(三):给网页增加加载动画
- valaxy博客全局美化教程(四):增加图片预览功能
- valaxy博客全局美化教程(五):友链页面美化,修改了友链样式以及增加留言板
- valaxy博客全局美化教程(六):留言页面增加信封展开效果
- valaxy博客全局美化教程(七):新增网址导航页面
- valaxy博客全局美化教程(八):页脚倒计时、搜索问题修复
- valaxy博客全局美化教程(九):新增相册页面,支持使用webdav作为相册
- valaxy博客全局美化教程(十):修复构建结束的时候会卡住
效果演示
这个页面效果有点多,其中有一个随机网站抽卡界面,大家最好还是亲自体验一下比较好 前往体验
站长的小巧思,点击到处转转按钮有惊喜哦~
文件修改
以下是文件修改部分,共1个修改文件 注意这里给的完整代码哦 文件valaxy.config.ts增加
themeConfig: {
//...
// 导航页「随机网站跳转」抽卡视频:weight 越大越容易抽到
navigation: {
randomDrawVideos: [
{
url: 'https://img.naixiai.cn/2026/06/09/_compressed.mp4',
weight: 1,
},
{
url: 'https://img.naixiai.cn/2026/06/09/_compresseddbc6ff3507fddbf4.mp4',
weight: 2,
},
{
url: 'https://img.naixiai.cn/2026/06/09/_compressedf1fc8ccd127613ac.fr.jpeg',
weight: 3,
},
],
},
//...
}上面这个解释一下,三个url链接是点击随机跳转按钮后的抽卡视频,最好自己准备哦,下面的是概率,挺好玩的 上下两部分都需要添加,下面这部分就是给你的网页增加导航页面按钮,具体放置位置可以看看官方文档的顶栏按钮怎么写的,点我去看看
{
text: '导航',
link: '/navigation',
},新建文件
以下部分是新建文件哦,共7个文件,别漏了哦 这是第一个文件,也是导航页面的索引文件,导航页面所有导航都写在里面,这里写了几个示例,可以自己按格式新增
新增pages\navigation\index.md
layout: navigation
title: 导航
icon: i-ri-compass-3-line
comment: false
cover: https://r2tc.20030327.xyz/file/博客/主题/1780643226230_wallhaven-9d1yjk.png
# 抽卡视频与概率见 valaxy.config.ts → themeConfig.navigation.randomDrawVideos
navGroups:
- name: 软件资源
desc: 绿色软件、应用下载与资源分享
sites:
- name: 果核剥壳
url: https://www.ghxi.com
desc: 精品软件分享与绿色资源下载站
avatar: https://api.dicebear.com/7.x/shapes/svg?seed=ghxi
color: "#e67e22"
- name: ZAPRO 杂铺
url: https://tmioe.com
desc: 软件资源整理与工具分享博客
avatar: https://api.dicebear.com/7.x/shapes/svg?seed=tmioe
color: "#3498db"第二个文件
新增components\NavigationSiteList.vue
<script lang="ts" setup>
import { computed } from 'vue'
import type { NavSiteGroup, NavSiteItem } from '~/types/navigation'
const props = defineProps<{
navGroups?: NavSiteGroup[]
}>()
const previewFallback = 'https://r2tc.20030327.xyz/file/博客/主题/1780643226230_wallhaven-9d1yjk.png'
function normalizeSite(site: NavSiteItem) {
return {
...site,
desc: site.desc || '',
color: site.color || '#0078e7',
avatar: site.avatar || previewFallback,
}
}
const groupList = computed(() => {
if (!Array.isArray(props.navGroups) || !props.navGroups.length)
return []
return props.navGroups.map(group => ({
name: group.name || '',
desc: group.desc || '',
sites: (group.sites || []).map(normalizeSite),
}))
})
</script>
<template>
<div class="nav-site-list">
<section
v-for="(group, groupIndex) in groupList"
:key="groupIndex"
class="nav-site-list__group"
>
<header
v-if="group.name"
class="nav-site-list__header"
>
<h2 class="nav-site-list__title">
<span class="nav-site-list__pin" aria-hidden="true">📌</span>
{{ group.name }}
</h2>
<p
v-if="group.desc"
class="nav-site-list__desc"
>
{{ group.desc }}
</p>
</header>
<ul class="nav-site-list__grid">
<li
v-for="(site, siteIndex) in group.sites"
:key="`${groupIndex}-${siteIndex}`"
class="nav-site-list__item"
:style="{ '--site-color': site.color }"
>
<a
class="nav-site-list__card"
:href="site.url"
target="_blank"
rel="noopener noreferrer"
>
<div class="nav-site-list__info">
<img
class="nav-site-list__avatar"
:src="site.avatar"
:alt="site.name"
loading="lazy"
>
<div class="nav-site-list__meta">
<h3 class="nav-site-list__name">
{{ site.name }}
</h3>
<p class="nav-site-list__text">
{{ site.desc }}
</p>
</div>
</div>
</a>
</li>
</ul>
</section>
</div>
</template>
<style lang="scss" scoped>
.nav-site-list {
&__group + &__group {
margin-top: 36px;
}
&__header {
margin-bottom: 18px;
text-align: center;
}
&__title {
margin: 0;
font-size: 1.35rem;
font-weight: 700;
color: var(--sakura-color-text-deep, inherit);
}
&__pin {
margin-right: 6px;
}
&__desc {
margin: 8px 0 0;
font-size: 0.92rem;
color: var(--sakura-color-text-muted, #888);
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
margin: 0;
padding: 0;
list-style: none;
}
&__card {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.85);
background: var(--sakura-card-bg, var(--sakura-post-card-bg));
text-decoration: none;
color: inherit;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-3px);
border-color: var(--site-color, var(--sakura-color-primary));
box-shadow: 0 10px 24px color-mix(in srgb, var(--site-color, var(--sakura-color-primary)) 18%, transparent);
}
}
&__info {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 16px;
}
&__avatar {
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
border: 2px solid color-mix(in srgb, var(--site-color, var(--sakura-color-primary)) 35%, transparent);
}
&__meta {
min-width: 0;
}
&__name {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: var(--sakura-color-text-deep, inherit);
}
&__text {
margin: 4px 0 0;
font-size: 0.82rem;
line-height: 1.45;
color: var(--sakura-color-text-muted, #888);
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
html.dark .nav-site-list__card {
border-color: var(--sakura-color-divider, rgb(255 255 255 / 20%));
}
</style>第三
新增components\layouts\SakuraNavigationLayout.vue
<script setup lang="ts">
import type { NavDrawVideo, NavSiteGroup, NavSiteItem } from '~/types/navigation'
import { parseNavDrawVideos } from '../../utils/parseNavDrawVideos'
import { useConfig, useFrontmatter } from 'valaxy'
import { computed } from 'vue'
const config = useConfig()
const frontmatter = useFrontmatter()
const coverSrc = computed(() => {
const cover = frontmatter.value?.cover
return typeof cover === 'string' ? cover : ''
})
const navGroups = computed(() => {
const groups = frontmatter.value?.navGroups as NavSiteGroup[] | undefined
return Array.isArray(groups) ? groups : []
})
const allSites = computed(() => {
const list: NavSiteItem[] = []
for (const group of navGroups.value) {
if (Array.isArray(group.sites))
list.push(...group.sites)
}
return list
})
const themeNavigation = computed(() => {
return (config.value?.themeConfig as { navigation?: { randomDrawVideos?: unknown } } | undefined)?.navigation
})
const randomDrawVideos = computed<NavDrawVideo[]>(() => {
const fromPage = parseNavDrawVideos(frontmatter.value?.randomDrawVideos)
if (fromPage.length)
return fromPage
const single = frontmatter.value?.randomDrawVideo
if (typeof single === 'string' && single.length > 0)
return parseNavDrawVideos([single])
return parseNavDrawVideos(themeNavigation.value?.randomDrawVideos)
})
</script>
<template>
<article class="sakura-page sakura-navigation-page">
<header
class="navigation-page-header sakura-page-header"
:class="{ 'has-cover': coverSrc }"
>
<img
v-if="coverSrc"
class="navigation-page-header__cover"
:src="coverSrc"
:alt="frontmatter.title || '导航页头图'"
loading="eager"
decoding="async"
>
<div class="navigation-page-header__inner sakura-safe-padding">
<div class="sakura-header-title" flex="~">
<SakuraTitle :fm="frontmatter" />
</div>
</div>
</header>
<div class="sakura-navigation-main">
<div class="content sakura-page-content">
<RouterView v-slot="{ Component }">
<component :is="Component">
<template #main-content>
<NavigationRandomDraw
:sites="allSites"
:draw-videos="randomDrawVideos"
/>
<NavigationSiteList :nav-groups="navGroups" />
</template>
</component>
</RouterView>
</div>
</div>
</article>
</template>
<style lang="scss">
.sakura-navigation-page {
--navigation-page-outer: max(40px, 3vw, env(safe-area-inset-left, 0px));
--navigation-page-inner: 24px;
--navigation-page-header-gap: 28px;
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
.navigation-page-header {
margin-top: var(--sakura-navbar-height);
width: 100%;
position: relative;
.sakura-header-title {
justify-content: center;
}
&:not(.has-cover) {
margin-top: var(--sakura-navbar-spacing);
}
&.has-cover {
height: 320px;
margin-bottom: var(--navigation-page-header-gap);
overflow: hidden;
display: flex;
align-items: flex-end;
justify-content: center;
}
&__cover {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
z-index: 0;
pointer-events: none;
}
&__inner {
position: relative;
z-index: 1;
width: 100%;
padding-bottom: 25px;
color: #fff;
text-align: center;
}
}
.sakura-navigation-main {
width: 100%;
padding-inline: var(--navigation-page-outer);
box-sizing: border-box;
}
.sakura-page-content {
width: 100%;
max-width: none;
box-sizing: border-box;
padding-inline: var(--navigation-page-inner) !important;
padding-top: var(--navigation-page-header-gap);
padding-bottom: 32px;
background: var(--sakura-color-background);
border-radius: var(--sakura-radius);
}
@media (max-width: 768px) {
--navigation-page-outer: 0px;
--navigation-page-inner: 8px;
--navigation-page-header-gap: 20px;
.sakura-page-content {
padding-inline: max(8px, env(safe-area-inset-left, 0px)) max(8px, env(safe-area-inset-right, 0px)) !important;
border-radius: 0;
}
}
}
</style>
<style lang="scss">
.sakura-navigation-page .navigation-page-header.has-cover .sakura-title {
color: inherit;
text-shadow: 2px 2px 10px black;
}
</style>第四
新增layouts\navigation.vue
<template>
<SakuraNavigationLayout />
</template>第五
新增types\navigation.ts
export interface NavDrawVideo {
url: string
/** 抽取权重,数值越大出现概率越高 */
weight: number
}
export interface NavSiteItem {
name: string
url: string
desc?: string
avatar?: string
color?: string
siteshot?: string
}
export interface NavSiteGroup {
name?: string
desc?: string
sites: NavSiteItem[]
}第六
新增utils\parseNavDrawVideos.ts
import type { NavDrawVideo } from '../types/navigation'
export function parseNavDrawVideos(input: unknown): NavDrawVideo[] {
if (!Array.isArray(input))
return []
const result: NavDrawVideo[] = []
for (const item of input) {
if (typeof item === 'string' && item.length > 0) {
result.push({ url: item, weight: 1 })
continue
}
if (!item || typeof item !== 'object')
continue
const record = item as { url?: unknown, weight?: unknown }
if (typeof record.url !== 'string' || !record.url.length)
continue
const weight = typeof record.weight === 'number' && record.weight > 0
? record.weight
: 1
result.push({ url: record.url, weight })
}
return result
}第七
新增components\NavigationRandomDraw.vue
<script lang="ts" setup>
import type { NavDrawVideo, NavSiteItem } from '~/types/navigation'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useSiteConfig } from 'valaxy'
type DrawPhase = 'idle' | 'video' | 'silhouette' | 'reveal'
const props = defineProps<{
sites: NavSiteItem[]
drawVideos: NavDrawVideo[]
}>()
const phase = ref<DrawPhase>('idle')
const selectedSite = ref<NavSiteItem | null>(null)
const currentVideoUrl = ref('')
const videoRef = ref<HTMLVideoElement | null>(null)
const videoStageRef = ref<HTMLDivElement | null>(null)
const videoCacheRef = ref<HTMLDivElement | null>(null)
const isBusy = computed(() => phase.value !== 'idle' && phase.value !== 'reveal')
const SILHOUETTE_DROP_MS = 880
const SILHOUETTE_REVEAL_DELAY_MS = 100
let autoRevealTimer: ReturnType<typeof setTimeout> | null = null
const previewFallback = 'https://r2tc.20030327.xyz/file/博客/主题/1780643226230_wallhaven-9d1yjk.png'
const triggerLabel = '点我到处转转吧'
const siteConfig = useSiteConfig()
const triggerAvatar = computed(() => siteConfig.value.author?.avatar || previewFallback)
const MARQUEE_ROW_COUNT = 3
const MARQUEE_DURATIONS = [52, 64, 46]
function getAvatar(site: NavSiteItem) {
return site.avatar || previewFallback
}
interface MarqueeSite extends NavSiteItem {
avatar: string
color: string
}
const marqueeRows = computed(() => {
if (!props.sites.length)
return []
const normalized: MarqueeSite[] = props.sites.map(site => ({
...site,
avatar: getAvatar(site),
color: site.color || '#df9193',
}))
const rows: MarqueeSite[][] = Array.from({ length: MARQUEE_ROW_COUNT }, () => [])
normalized.forEach((site, index) => {
rows[index % MARQUEE_ROW_COUNT].push(site)
})
return rows
.filter(row => row.length > 0)
.map((sites, index) => ({
id: index,
sites,
duration: MARQUEE_DURATIONS[index % MARQUEE_DURATIONS.length],
}))
})
const centerRowId = computed(() => {
if (!marqueeRows.value.length)
return -1
return Math.floor(marqueeRows.value.length / 2)
})
function pickRandomSite() {
if (!props.sites.length)
return null
const index = Math.floor(Math.random() * props.sites.length)
return props.sites[index]
}
function pickRandomVideo() {
const videos = props.drawVideos.filter(video => video.url && video.weight > 0)
if (!videos.length)
return ''
const total = videos.reduce((sum, video) => sum + video.weight, 0)
let roll = Math.random() * total
for (const video of videos) {
roll -= video.weight
if (roll < 0)
return video.url
}
return videos[videos.length - 1].url
}
function clearSilhouetteTimers() {
if (autoRevealTimer) {
clearTimeout(autoRevealTimer)
autoRevealTimer = null
}
}
function scheduleAutoReveal() {
clearSilhouetteTimers()
autoRevealTimer = setTimeout(() => {
if (phase.value === 'silhouette')
openReveal()
autoRevealTimer = null
}, SILHOUETTE_REVEAL_DELAY_MS)
}
function onSilhouetteAnimationEnd(e: AnimationEvent) {
if (e.target !== e.currentTarget)
return
if (e.animationName !== 'nav-silhouette-drop' || phase.value !== 'silhouette')
return
scheduleAutoReveal()
}
function resetDraw() {
clearSilhouetteTimers()
phase.value = 'idle'
selectedSite.value = null
document.documentElement.classList.remove('nav-random-draw-open')
if (videoRef.value) {
videoRef.value.pause()
videoRef.value.currentTime = 0
}
}
function preloadVideo() {
const video = videoRef.value
if (!video)
return
video.preload = 'auto'
video.load()
}
async function skipVideo() {
if (phase.value !== 'video')
return
videoRef.value?.pause()
clearSilhouetteTimers()
phase.value = 'silhouette'
}
async function startDraw() {
if (!props.sites.length || isBusy.value)
return
const site = pickRandomSite()
if (!site)
return
selectedSite.value = site
currentVideoUrl.value = pickRandomVideo()
phase.value = 'video'
document.documentElement.classList.add('nav-random-draw-open')
await nextTick()
const video = videoRef.value
if (!video || !currentVideoUrl.value)
return
video.load()
video.currentTime = 0
try {
await video.play()
}
catch {
await skipVideo()
}
}
async function onVideoEnded() {
await skipVideo()
}
async function onVideoError() {
await skipVideo()
}
function openReveal() {
if (phase.value === 'silhouette')
phase.value = 'reveal'
}
function confirmJump() {
if (!selectedSite.value?.url)
return
window.open(selectedSite.value.url, '_blank', 'noopener,noreferrer')
resetDraw()
}
function getMarqueeSiteKey(rowId: number, setIndex: number, siteIndex: number, url: string) {
return `${rowId}-${setIndex}-${siteIndex}-${url}`
}
function onVideoKeydown(e: KeyboardEvent) {
if (phase.value !== 'video')
return
if (e.key === 'Escape') {
skipVideo()
return
}
e.preventDefault()
}
function preloadAllVideos() {
document.querySelectorAll('link.nav-draw-video-preload').forEach(el => el.remove())
for (const video of props.drawVideos) {
const preloadLink = document.createElement('link')
preloadLink.rel = 'preload'
preloadLink.as = 'video'
preloadLink.href = video.url
preloadLink.className = 'nav-draw-video-preload'
document.head.appendChild(preloadLink)
}
if (props.drawVideos.length)
currentVideoUrl.value = props.drawVideos[0].url
nextTick(() => {
preloadVideo()
videoCacheRef.value?.querySelectorAll('video').forEach((el) => {
const video = el as HTMLVideoElement
video.preload = 'auto'
video.load()
})
})
}
onMounted(() => {
preloadAllVideos()
document.addEventListener('keydown', onVideoKeydown)
})
onUnmounted(() => {
document.querySelectorAll('link.nav-draw-video-preload').forEach(el => el.remove())
document.removeEventListener('keydown', onVideoKeydown)
resetDraw()
})
watch(phase, (value) => {
if (value !== 'silhouette')
return
clearSilhouetteTimers()
autoRevealTimer = setTimeout(() => {
if (phase.value === 'silhouette')
openReveal()
autoRevealTimer = null
}, SILHOUETTE_DROP_MS + SILHOUETTE_REVEAL_DELAY_MS + 120)
})
watch(() => props.drawVideos, () => {
preloadAllVideos()
}, { deep: true })
</script>
<template>
<section class="nav-random-draw">
<div
v-if="marqueeRows.length"
class="nav-random-draw__marquee"
>
<div
v-for="row in marqueeRows"
:key="row.id"
class="nav-random-draw__marquee-row"
:class="{ 'is-center-row': row.id === centerRowId }"
>
<div class="nav-random-draw__marquee-viewport">
<div
class="nav-random-draw__marquee-track"
:class="{ 'is-paused': isBusy }"
:style="{ '--marquee-duration': `${row.duration}s` }"
>
<div
v-for="setIndex in 2"
:key="setIndex"
class="nav-random-draw__marquee-set"
>
<a
v-for="(site, siteIndex) in row.sites"
:key="getMarqueeSiteKey(row.id, setIndex, siteIndex, site.url)"
class="nav-random-draw__marquee-card"
:href="site.url"
target="_blank"
rel="noopener noreferrer"
:title="site.name"
:style="{ '--site-color': site.color }"
>
<img
class="nav-random-draw__marquee-avatar"
:src="site.avatar"
:alt="site.name"
loading="lazy"
>
<span class="nav-random-draw__marquee-name">{{ site.name }}</span>
</a>
</div>
</div>
</div>
<button
v-if="row.id === centerRowId"
type="button"
class="nav-random-draw__trigger"
:disabled="!sites.length || !drawVideos.length || isBusy"
:aria-label="triggerLabel"
@click="startDraw"
>
<img
class="nav-random-draw__marquee-avatar"
:src="triggerAvatar"
alt=""
loading="lazy"
aria-hidden="true"
>
<span class="nav-random-draw__marquee-name">{{ triggerLabel }}</span>
</button>
</div>
</div>
<button
v-else
type="button"
class="nav-random-draw__trigger nav-random-draw__trigger--standalone"
:disabled="!sites.length || !drawVideos.length || isBusy"
:aria-label="triggerLabel"
@click="startDraw"
>
<img
class="nav-random-draw__marquee-avatar"
:src="triggerAvatar"
alt=""
loading="lazy"
aria-hidden="true"
>
<span class="nav-random-draw__marquee-name">{{ triggerLabel }}</span>
<span class="nav-random-draw__hint">从本页全部导航中随机抽取</span>
</button>
<Teleport to="body">
<div
v-if="drawVideos.length"
ref="videoCacheRef"
class="nav-random-draw__video-cache"
aria-hidden="true"
>
<video
v-for="item in drawVideos"
:key="`cache-${item.url}`"
:src="item.url"
preload="auto"
muted
playsinline
/>
</div>
<div
ref="videoStageRef"
class="nav-random-draw__video-stage"
:class="{ 'is-active': phase === 'video' }"
>
<video
ref="videoRef"
class="nav-random-draw__video"
:src="currentVideoUrl"
preload="auto"
playsinline
disablepictureinpicture
disableremoteplayback
controlslist="nodownload nofullscreen noplaybackrate"
@ended="onVideoEnded"
@error="onVideoError"
@contextmenu.prevent
/>
<button
v-if="phase === 'video'"
type="button"
class="nav-random-draw__video-skip"
aria-label="跳过视频"
@click="skipVideo"
/>
</div>
<div
v-if="phase === 'silhouette' || phase === 'reveal'"
class="nav-random-draw__overlay"
:class="{ 'is-silhouette': phase === 'silhouette' }"
role="dialog"
aria-modal="true"
:aria-label="phase === 'reveal' ? '网站信息确认' : '随机抽取动画'"
>
<div
v-if="selectedSite"
class="nav-random-draw__silhouette-stage"
>
<div
v-if="phase === 'silhouette'"
class="nav-random-draw__silhouette-burst"
aria-hidden="true"
/>
<div
v-if="phase === 'silhouette'"
class="nav-random-draw__silhouette-particles"
aria-hidden="true"
>
<span
v-for="i in 14"
:key="i"
:style="{ '--i': i }"
/>
</div>
<div
class="nav-random-draw__draw-card"
:class="{
'is-entering': phase === 'silhouette' || phase === 'reveal',
'is-revealed': phase === 'reveal',
}"
@animationend="onSilhouetteAnimationEnd"
>
<div class="nav-random-draw__draw-card-inner">
<div class="nav-random-draw__draw-media">
<div
v-if="phase === 'silhouette'"
class="nav-random-draw__silhouette-ring nav-random-draw__silhouette-ring--outer"
/>
<div
v-if="phase === 'silhouette'"
class="nav-random-draw__silhouette-ring nav-random-draw__silhouette-ring--inner"
/>
<img
class="nav-random-draw__draw-cover"
:src="getAvatar(selectedSite)"
:alt="selectedSite.name"
>
<div
v-if="phase === 'silhouette'"
class="nav-random-draw__silhouette-shine"
/>
</div>
<div class="nav-random-draw__draw-body">
<img
class="nav-random-draw__draw-avatar"
:src="getAvatar(selectedSite)"
:alt="selectedSite.name"
>
<h3 class="nav-random-draw__draw-name">
{{ selectedSite.name }}
</h3>
<p class="nav-random-draw__draw-desc">
{{ selectedSite.desc || '暂无描述' }}
</p>
<p class="nav-random-draw__draw-url">
{{ selectedSite.url }}
</p>
<div class="nav-random-draw__actions">
<button
type="button"
class="nav-random-draw__btn nav-random-draw__btn--primary"
@click="confirmJump"
>
前往访问
</button>
<button
type="button"
class="nav-random-draw__btn nav-random-draw__btn--ghost"
@click="resetDraw"
>
暂不跳转
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</section>
</template>
<style lang="scss" scoped>
.nav-random-draw {
--nav-card-size: 84px;
--nav-card-gap: 12px;
--nav-trigger-width: calc(var(--nav-card-size) * 2 + var(--nav-card-gap));
--nav-trigger-half: calc(var(--nav-trigger-width) / 2);
--nav-fade-range: 152px;
--nav-row-gap: 12px;
--nav-card-hover-lift: 2px;
--nav-card-hover-pad: calc(var(--nav-card-hover-lift) * 2 + 4px);
width: 100%;
margin: 8px 0 32px;
&__marquee {
display: flex;
flex-direction: column;
gap: var(--nav-row-gap);
width: 100%;
}
&__marquee-row {
position: relative;
width: 100%;
min-height: calc(var(--nav-card-size) + var(--nav-card-hover-pad));
}
&__marquee-viewport {
width: 100%;
padding-block: calc(var(--nav-card-hover-pad) / 2);
overflow: hidden;
box-sizing: border-box;
-webkit-mask-image: linear-gradient(
90deg,
transparent 0%,
#000 7%,
#000 93%,
transparent 100%
);
mask-image: linear-gradient(
90deg,
transparent 0%,
#000 7%,
#000 93%,
transparent 100%
);
}
&__marquee-row.is-center-row &__marquee-viewport {
$edge-fade: linear-gradient(
90deg,
transparent 0%,
#000 7%,
#000 93%,
transparent 100%
);
$horizontal-button-fade: linear-gradient(
90deg,
transparent 0%,
#000 4%,
#000 calc(50% - var(--nav-trigger-half) - var(--nav-fade-range)),
rgb(0 0 0 / 82%) calc(50% - var(--nav-trigger-half) - 108px),
rgb(0 0 0 / 58%) calc(50% - var(--nav-trigger-half) - 72px),
rgb(0 0 0 / 32%) calc(50% - var(--nav-trigger-half) - 36px),
transparent calc(50% - var(--nav-trigger-half)),
transparent calc(50% + var(--nav-trigger-half)),
rgb(0 0 0 / 32%) calc(50% + var(--nav-trigger-half) + 36px),
rgb(0 0 0 / 58%) calc(50% + var(--nav-trigger-half) + 72px),
rgb(0 0 0 / 82%) calc(50% + var(--nav-trigger-half) + 108px),
#000 calc(50% + var(--nav-trigger-half) + var(--nav-fade-range)),
#000 96%,
transparent 100%
);
-webkit-mask-image: $edge-fade, $horizontal-button-fade;
-webkit-mask-composite: source-in;
mask-image: $edge-fade, $horizontal-button-fade;
mask-composite: intersect;
}
&__marquee-track {
display: flex;
width: max-content;
animation: nav-marquee-ltr var(--marquee-duration, 48s) linear infinite;
&.is-paused {
animation-play-state: paused;
}
}
&__marquee-set {
display: flex;
flex-shrink: 0;
gap: var(--nav-card-gap);
padding-inline: 6px;
}
&__marquee-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: var(--nav-card-size);
height: var(--nav-card-size);
padding: 8px 6px 6px;
border-radius: 12px;
border: 1.5px solid color-mix(in srgb, var(--site-color, var(--sakura-color-primary)) 42%, var(--sakura-color-divider, rgb(0 0 0 / 12%)));
background: color-mix(in srgb, var(--site-color, var(--sakura-color-primary)) 7%, var(--sakura-card-bg));
box-shadow: 0 4px 14px color-mix(in srgb, var(--site-color, var(--sakura-color-primary)) 10%, transparent);
box-sizing: border-box;
text-decoration: none;
color: inherit;
cursor: pointer;
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
&:hover {
transform: translateY(calc(var(--nav-card-hover-lift) * -1));
border-color: color-mix(in srgb, var(--site-color, var(--sakura-color-primary)) 65%, transparent);
box-shadow: 0 8px 18px color-mix(in srgb, var(--site-color, var(--sakura-color-primary)) 18%, transparent);
}
}
&__marquee-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
object-fit: cover;
border: 2px solid color-mix(in srgb, var(--site-color, var(--sakura-color-primary)) 45%, transparent);
}
&__marquee-name {
max-width: 100%;
font-size: 0.68rem;
line-height: 1.2;
font-weight: 600;
color: var(--sakura-color-text-deep, inherit);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__trigger {
position: absolute;
left: 50%;
top: 50%;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: var(--nav-trigger-width);
height: var(--nav-card-size);
padding: 8px 10px 6px;
border: 1.5px solid color-mix(in srgb, var(--site-color, var(--sakura-color-primary)) 42%, var(--sakura-color-divider, rgb(0 0 0 / 12%)));
border-radius: 12px;
background: color-mix(in srgb, var(--site-color, var(--sakura-color-primary)) 7%, var(--sakura-card-bg, #fff));
color: var(--sakura-color-text-deep, inherit);
cursor: pointer;
box-shadow: 0 4px 14px color-mix(in srgb, var(--site-color, var(--sakura-color-primary)) 10%, transparent);
box-sizing: border-box;
transition: box-shadow 0.16s ease, border-color 0.16s ease, opacity 0.16s ease, filter 0.16s ease;
transform: translate(-50%, -50%);
&:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--site-color, var(--sakura-color-primary)) 65%, transparent);
box-shadow: 0 8px 18px color-mix(in srgb, var(--site-color, var(--sakura-color-primary)) 18%, transparent);
}
&:active:not(:disabled) {
opacity: 0.88;
filter: brightness(0.96);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&--standalone {
position: relative;
left: auto;
top: auto;
transform: none;
width: auto;
min-width: 220px;
height: auto;
margin: 0 auto;
padding: 20px 28px 16px;
gap: 10px;
&:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--site-color, var(--sakura-color-primary)) 65%, transparent);
box-shadow: 0 8px 18px color-mix(in srgb, var(--site-color, var(--sakura-color-primary)) 18%, transparent);
}
&:active:not(:disabled) {
opacity: 0.88;
filter: brightness(0.96);
}
.nav-random-draw__marquee-name {
font-size: 0.92rem;
}
}
}
&__hint {
font-size: 0.82rem;
color: var(--sakura-color-text-muted, #888);
}
&__overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 72%);
backdrop-filter: blur(4px);
animation: nav-overlay-in 0.35s ease;
&.is-silhouette {
background: rgb(0 0 0 / 88%);
backdrop-filter: blur(8px);
}
}
&__silhouette-stage {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
min-height: 280px;
}
&__silhouette-burst {
position: absolute;
width: min(520px, 92vw);
aspect-ratio: 1;
border-radius: 50%;
background: radial-gradient(
circle,
color-mix(in srgb, var(--sakura-color-primary) 55%, transparent) 0%,
color-mix(in srgb, var(--sakura-color-primary) 18%, transparent) 38%,
transparent 72%
);
opacity: 0;
transform: scale(0.35);
filter: blur(8px);
pointer-events: none;
animation: nav-silhouette-burst 0.85s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
&__silhouette-particles {
position: absolute;
inset: 0;
pointer-events: none;
span {
position: absolute;
top: 50%;
left: 50%;
width: 6px;
height: 6px;
border-radius: 50%;
background: color-mix(in srgb, var(--sakura-color-primary) 85%, #fff);
box-shadow: 0 0 10px color-mix(in srgb, var(--sakura-color-primary) 70%, transparent);
opacity: 0;
transform: translate(-50%, -50%) rotate(calc(var(--i) * 25.7deg)) translateY(0);
animation: nav-silhouette-spark 1.1s ease-out calc(var(--i) * 0.03s) forwards;
}
}
&__video-stage {
position: fixed;
inset: auto;
z-index: 10000;
width: 0;
height: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
background: #000;
&.is-active {
inset: 0;
width: 100%;
height: 100%;
min-height: 100dvh;
opacity: 1;
pointer-events: auto;
display: flex;
align-items: center;
justify-content: center;
}
}
&__video {
width: 100%;
height: 100%;
border: none;
outline: none;
object-fit: cover;
pointer-events: none;
background: #000;
&::-webkit-media-controls,
&::-webkit-media-controls-enclosure,
&::-webkit-media-controls-panel,
&::-webkit-media-controls-play-button,
&::-webkit-media-controls-start-playback-button,
&::-webkit-media-controls-timeline,
&::-webkit-media-controls-current-time-display,
&::-webkit-media-controls-time-remaining-display,
&::-webkit-media-controls-mute-button,
&::-webkit-media-controls-volume-slider,
&::-webkit-media-controls-fullscreen-button {
display: none !important;
appearance: none;
}
}
&__video-skip {
position: absolute;
inset: 0;
z-index: 1;
margin: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
}
&__silhouette {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
&__draw-card {
--draw-card-width: min(360px, 82vw);
position: relative;
z-index: 2;
width: var(--draw-card-width);
aspect-ratio: 16 / 10;
border-radius: 14px;
overflow: visible;
&.is-entering {
animation: nav-silhouette-drop 0.88s cubic-bezier(0.22, 0.95, 0.28, 1) forwards;
}
}
&__draw-card-inner {
position: absolute;
inset: 0;
border-radius: inherit;
overflow: hidden;
background: linear-gradient(
145deg,
rgb(12 12 12 / 98%),
rgb(38 38 38 / 92%)
);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--sakura-color-primary) 55%, rgb(255 255 255 / 20%)),
0 0 28px color-mix(in srgb, var(--sakura-color-primary) 42%, transparent),
0 0 56px color-mix(in srgb, var(--sakura-color-primary) 22%, transparent),
0 22px 48px rgb(0 0 0 / 55%);
transition: background-color 0.45s ease, box-shadow 0.45s ease;
.is-revealed & {
background: var(--sakura-card-bg, #fff);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--sakura-color-primary) 35%, transparent),
0 0 32px color-mix(in srgb, var(--sakura-color-primary) 28%, transparent),
0 20px 50px rgb(0 0 0 / 35%);
animation: nav-draw-inner-unveil 0.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
}
&__draw-card.is-entering:not(.is-revealed) &__draw-card-inner {
animation: nav-silhouette-card-glow 2.2s ease-in-out infinite;
}
&__draw-media {
position: absolute;
inset: 0;
overflow: hidden;
transition: opacity 0.42s ease;
.is-revealed & {
opacity: 0;
pointer-events: none;
}
}
&__draw-cover {
width: 100%;
height: 100%;
object-fit: cover;
filter: brightness(0) contrast(1.15) saturate(0);
transform: scale(1.08);
opacity: 0.92;
transition: filter 0.5s ease, opacity 0.35s ease, transform 0.5s ease;
.is-revealed & {
filter: none;
opacity: 0;
transform: scale(1);
}
}
&__draw-body {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
opacity: 0;
overflow: hidden;
padding: 14px 18px 12px;
text-align: center;
pointer-events: none;
transition: opacity 0.42s ease;
.is-revealed & {
opacity: 1;
pointer-events: auto;
}
}
&__draw-avatar {
width: 52px;
height: 52px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--sakura-color-primary);
opacity: 0;
transform: scale(0.82);
transition: opacity 0.4s ease 0.08s, transform 0.4s ease 0.08s;
.is-revealed & {
opacity: 1;
transform: scale(1);
}
}
&__draw-name {
margin: 4px 0 0;
font-size: 1.05rem;
font-weight: 700;
color: var(--sakura-color-text-deep, inherit);
opacity: 0;
transform: translateY(8px);
transition: opacity 0.4s ease 0.12s, transform 0.4s ease 0.12s;
.is-revealed & {
opacity: 1;
transform: translateY(0);
}
}
&__draw-desc {
margin: 0;
font-size: 0.78rem;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
color: var(--sakura-color-text);
opacity: 0;
transform: translateY(8px);
transition: opacity 0.4s ease 0.16s, transform 0.4s ease 0.16s;
.is-revealed & {
opacity: 1;
transform: translateY(0);
}
}
&__draw-url {
margin: 0;
font-size: 0.68rem;
color: var(--sakura-color-text-muted, #888);
word-break: break-all;
opacity: 0;
transition: opacity 0.4s ease 0.2s;
.is-revealed & {
opacity: 1;
}
}
&__draw-card.is-revealed &__actions {
opacity: 1;
transform: translateY(0);
}
&__draw-card &__actions {
display: flex;
gap: 8px;
justify-content: center;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.4s ease 0.18s, transform 0.4s ease 0.18s;
margin-top: 4px;
}
&__silhouette-ring {
position: absolute;
inset: -10px;
border-radius: 18px;
border: 2px solid transparent;
pointer-events: none;
&--outer {
border-color: color-mix(in srgb, var(--sakura-color-primary) 35%, transparent);
animation: nav-silhouette-ring-spin 4.5s linear infinite;
}
&--inner {
inset: -4px;
border-color: color-mix(in srgb, var(--sakura-color-primary) 55%, transparent);
animation: nav-silhouette-ring-spin 3s linear infinite reverse;
}
}
&__silhouette-img {
width: 100%;
height: 100%;
object-fit: cover;
filter: brightness(0) contrast(1.15) saturate(0);
transform: scale(1.08);
opacity: 0.92;
}
&__silhouette-shine {
position: absolute;
inset: 0;
background: linear-gradient(
115deg,
transparent 30%,
rgb(255 255 255 / 18%) 48%,
transparent 66%
);
transform: translateX(-120%);
animation: nav-silhouette-shine 2.8s ease-in-out infinite;
}
&__video-cache {
position: fixed;
width: 0;
height: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
video {
width: 0;
height: 0;
}
}
&__actions {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 22px;
}
&__btn {
min-width: 112px;
padding: 10px 18px;
border-radius: 999px;
font-size: 0.92rem;
font-weight: 700;
cursor: pointer;
transition: transform 0.15s ease, opacity 0.15s ease;
&:active {
transform: scale(0.97);
}
&--primary {
border: none;
background: var(--sakura-color-primary);
color: #fff;
}
&--ghost {
border: 1px solid var(--sakura-color-divider, rgb(0 0 0 / 15%));
background: transparent;
color: var(--sakura-color-text);
}
}
&__draw-card &__btn {
min-width: 88px;
padding: 7px 12px;
font-size: 0.78rem;
}
}
@keyframes nav-marquee-ltr {
from {
transform: translateX(-50%);
}
to {
transform: translateX(0);
}
}
@keyframes nav-overlay-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes nav-silhouette-drop {
0% {
opacity: 0.28;
transform: scale(4.2) translateY(-56vh);
filter: blur(4px);
}
55% {
opacity: 1;
transform: scale(1.06) translateY(0);
filter: blur(0);
}
72% {
transform: scale(0.95) translateY(0);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
filter: blur(0);
}
}
@keyframes nav-silhouette-burst {
0% {
opacity: 0;
transform: scale(0.35);
}
35% {
opacity: 1;
transform: scale(1.15);
}
100% {
opacity: 0.55;
transform: scale(1.45);
}
}
@keyframes nav-silhouette-card-glow {
0%,
100% {
box-shadow:
0 0 0 2px color-mix(in srgb, var(--sakura-color-primary) 45%, rgb(255 255 255 / 18%)),
0 0 24px color-mix(in srgb, var(--sakura-color-primary) 38%, transparent),
0 0 48px color-mix(in srgb, var(--sakura-color-primary) 18%, transparent),
0 22px 48px rgb(0 0 0 / 55%);
}
50% {
box-shadow:
0 0 0 2px color-mix(in srgb, var(--sakura-color-primary) 72%, rgb(255 255 255 / 28%)),
0 0 36px color-mix(in srgb, var(--sakura-color-primary) 58%, transparent),
0 0 72px color-mix(in srgb, var(--sakura-color-primary) 32%, transparent),
0 26px 56px rgb(0 0 0 / 62%);
}
}
@keyframes nav-silhouette-ring-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes nav-silhouette-shine {
0%,
72% {
transform: translateX(-120%);
}
100% {
transform: translateX(120%);
}
}
@keyframes nav-silhouette-spark {
0% {
opacity: 0;
transform: translate(-50%, -50%) rotate(calc(var(--i) * 25.7deg)) translateY(0) scale(0.4);
}
18% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(-50%, -50%) rotate(calc(var(--i) * 25.7deg)) translateY(calc(-80px - var(--i) * 3px)) scale(0.2);
}
}
@keyframes nav-draw-inner-unveil {
0% {
filter: brightness(0.72);
}
40% {
filter: brightness(1.18);
}
100% {
filter: brightness(1);
}
}
</style>
<style lang="scss">
html.nav-random-draw-open {
overflow: hidden;
}
</style>真是一场酣畅淋漓的美化,不是吗~
- 本文链接:https://daily.yybb.us/posts/valaxy-7
- 版权声明:本博客所有文章除特别声明外,均默认采用 CC BY-NC-SA 许可协议。
Previous Post valaxy博客全局美化教程(八)
Next Post valaxy博客全局美化教程(六)

