00:00:00
友链组件
创建组件
- 在
docs\.vitepress\theme\components\
下创建SLink
文件夹
vue
<template>
<div class="my-links-container">
<!-- 页面主标题区域 -->
<div class="my-links-title">
<h1>{{ title }}</h1>
</div>
<!-- 友链分组列表,每个分组包含标题、描述和友链列表 -->
<div
v-for="(group, index) in linksData"
:key="index"
class="my-links-group"
>
<!-- 分组标题容器 -->
<div class="title-wrapper">
<h3>{{ group.title }}</h3>
</div>
<!-- 分组描述文本 -->
<p class="group-desc">{{ group.descr }}</p>
<!-- 友链列表容器 -->
<div class="links-grid">
<!-- 每个友链项使用LinkItem子组件展示,通过:data传递友链信息 -->
<div
v-for="link in group.list"
:key="link.link"
class="links-grid__item"
>
<LinkItem :data="link" />
</div>
</div>
</div>
<!-- 留言/评论区域,默认显示,可通过frontmatter隐藏 -->
<div v-if="shouldShow" class="my-message-section">
<div class="title-wrapper">
<h3>留链吗</h3>
</div>
<p>留恋的小伙伴,想要和我做友链 💞</p>
<!-- 留言卡片容器 -->
<div class="message-card">
<p>欢迎在评论区留言,格式如下:</p>
<!-- 示例格式 -->
<div class="example-container">
<pre ref="exampleRef">
网站名称: Hyde Blog
网站链接: https://teek.seasir.top/
网站头像: https://teek.seasir.top/avatar/avatar.webp
网站描述: 人心中的成见是一座大山~</pre
>
<button class="copy-button" @click="copyExample">
<span class="copy-icon"></span>
复制示例
</button>
</div>
<!-- 评论区插槽 -->
<!-- 默认为Twikoo评论组件,可通过插槽自定义其他评论系统 -->
<slot name="comments">
<Twikoo />
</slot>
</div>
</div>
<!-- 滚动到评论区按钮 -->
<ScrollToComment
v-if="shouldShow"
:show="showScrollButton"
:scroll-to-comment="scrollToComment"
/>
</div>
</template>
<script setup>
import { useData } from "vitepress";
import LinkItem from "./LinkItem.vue";
import Twikoo from "../Twikoo.vue";
import ScrollToComment from "../ScrollToComment.vue";
import { computed, ref, onMounted, onUnmounted } from "vue";
import { TkMessage } from "vitepress-theme-teek";
/**
* 单个友链的数据结构定义
* @typedef {Object} Link
* @property {string} name - 友链网站名称
* @property {string} link - 友链网站URL地址
* @property {string} avatar - 友链网站头像/Logo的图片URL
* @property {string} descr - 友链网站的简短描述
* @property {boolean} [irregular] - 可选参数,默认值为false,为false时将把头像处理为圆形
*/
/**
* 友链分组的数据结构定义
* @typedef {Object} LinkGroup
* @property {string} title - 分组标题
* @property {string} desc - 分组描述文字
* @property {Link[]} list - 该分组下的友链列表数组
*/
// 从页面frontmatter中获取配置数据
const { frontmatter } = useData();
// 从frontmatter中读取links字段,如果未定义则使用空数组
const linksData = computed(() => frontmatter.value.links || []);
// 从frontmatter中读取title字段,默认值为"我的友链"
const title = computed(() => frontmatter.value.title || "我的友链");
// 当frontmatter中comments为false时隐藏,默认显示
const shouldShow = computed(() => frontmatter.value.comments !== false);
// 示例文本引用
const exampleRef = ref(null);
// 复制示例文本函数
const copyExample = async () => {
if (exampleRef.value) {
const exampleText = exampleRef.value.textContent;
try {
await navigator.clipboard.writeText(exampleText);
TkMessage({
message: "示例格式已复制",
type: "success",
});
} catch (err) {
// 降级方案:使用 document.execCommand
const textArea = document.createElement("textarea");
textArea.value = exampleText;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
TkMessage({
message: "示例格式已复制",
type: "success",
});
} catch (fallbackErr) {
TkMessage({
message: "复制失败,请手动复制示例文本",
type: "error",
});
} finally {
document.body.removeChild(textArea);
}
}
}
};
// 控制按钮显示状态
const showScrollButton = ref(true);
// 滚动到评论区的函数
const scrollToComment = () => {
const commentElement = document.querySelector(
"#twikoo, .my-message-section, .message-card"
);
if (commentElement) {
commentElement.scrollIntoView({
behavior: "smooth",
block: "start",
});
// 显示成功消息提示
TkMessage({
message: "已跳转到留链区,欢迎留下您的友链信息✨",
type: "success",
});
} else {
// 如果没有找到评论区域,显示提示
TkMessage({
message: "未找到留链区",
type: "warning",
});
}
};
// 检查是否滚动到评论区
const checkScrollPosition = () => {
const commentElement = document.querySelector(
".my-message-section, .message-card"
);
if (commentElement) {
const rect = commentElement.getBoundingClientRect();
const windowHeight = window.innerHeight;
// 如果评论区域的顶部进入视窗,则隐藏按钮
showScrollButton.value = rect.top > windowHeight * 0.3;
}
};
// 节流函数,避免频繁触发
let throttleTimer = null;
const throttledCheckScroll = () => {
if (throttleTimer) return;
throttleTimer = setTimeout(() => {
checkScrollPosition();
throttleTimer = null;
}, 100);
};
// 组件挂载时添加滚动监听
onMounted(() => {
window.addEventListener("scroll", throttledCheckScroll);
// 初始检查
setTimeout(checkScrollPosition, 100);
});
// 组件卸载时移除监听
onUnmounted(() => {
window.removeEventListener("scroll", throttledCheckScroll);
if (throttleTimer) {
clearTimeout(throttleTimer);
}
});
</script>
<style scoped>
/* 主容器样式 */
.my-links-container {
max-width: 1500px;
margin: 0 auto;
padding: 40px 10px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
/* 标题区域样式 */
.my-links-title {
margin-bottom: 50px;
padding: 0 10px;
/* 居中 */
text-align: center;
}
/* 主标题样式 */
.my-links-title h1 {
font-size: 2rem;
font-weight: 600;
background: -webkit-linear-gradient(
107deg,
rgb(255, 182, 133) -30.6%,
rgb(255, 111, 29) -1.11%,
rgb(252, 181, 232) 39.14%,
rgb(135, 148, 255) 73.35%,
rgb(60, 112, 255) 97.07%,
rgb(60, 112, 255) 118.97%
);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
line-height: 1.2;
display: inline-block;
font-size: 1.5rem;
}
/* 分组标题装饰线样式 */
.title-wrapper {
position: relative;
margin: 40px 0;
height: 1px;
background: #ddd;
transition: 0.3s;
}
/* 分组标题文字样式 */
.title-wrapper h3 {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: var(--vp-c-bg);
padding: 0 20px;
font-size: 1.3rem;
font-weight: 600;
color: var(--vp-c-text-1);
z-index: 1;
}
/* 分组描述文字样式 */
.group-desc {
text-align: center;
color: var(--vp-c-text-2);
font-size: 0.95rem;
margin-bottom: 30px;
padding: 0 10px;
}
/* 友链网格布局 - 核心响应式实现 */
.links-grid {
display: flex;
flex-wrap: wrap;
justify-content: center; /* 让所有行的内容居中对齐 */
gap: 24px;
margin-bottom: 60px;
padding: 0 8px;
}
/* 每个友链项的样式,设置基础宽度 */
.links-grid__item {
flex: 0 0 calc(100% - 24px); /* 移动设备:每行1个 */
break-inside: avoid;
}
.link-content:hover {
margin-left: calc(-5 * 16px);
}
/* 平板设备:每行2个 */
@media (min-width: 768px) {
.links-grid__item {
flex: 0 0 calc(50% - 24px);
}
}
/* 桌面设备:每行最多4个 */
@media (min-width: 1024px) {
.links-grid__item {
flex: 0 0 calc(25% - 24px);
}
}
/* 留言区样式 */
.my-message-section {
text-align: center;
margin-top: 20px;
}
/* 留言卡片样式 */
.message-card {
width: 100%;
max-width: 1500px;
margin: 30px auto;
padding: 32px;
border-radius: 12px;
background: var(--vp-c-bg);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid var(--vp-c-divider);
text-align: left;
transition: all 0.2s ease;
}
/* 移动端留言卡片适配 */
@media (max-width: 768px) {
.message-card {
padding: 24px;
margin: 24px auto;
}
}
/* 示例容器样式 */
.example-container {
position: relative;
margin: 20px 0;
}
/* 示例格式代码块样式 */
.message-card pre {
background: var(--vp-code-block-bg);
padding: 16px;
border-radius: 8px;
font-size: 0.95rem;
overflow-x: auto;
margin: 0;
border: 1px solid var(--vp-c-divider);
line-height: 1.5;
}
/* 复制按钮样式 */
.copy-button {
position: absolute;
top: 8px;
right: 8px;
background: var(--vp-c-brand);
color: white;
padding: 4px;
border-radius: 6px;
font-size: 0.85rem;
display: flex;
align-items: center;
transition: all 0.2s ease;
}
.copy-button:hover {
background: var(--vp-c-indigo-3);
transform: translateY(-2px);
}
.copy-button:active {
transform: translateY(0);
}
.copy-icon {
font-size: 0.9rem;
}
/* 留言卡片悬停效果 */
.message-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12);
}
</style>
vue
<template>
<div class="link-item-card">
<a :href="data.link" :title="data.name" target="_blank" rel="noopener">
<!-- 头像 -->
<div class="link-avatar">
<img
v-if="!imageFailed && data.avatar"
:src="data.avatar"
:alt="data.name"
@error="handleImageError"
:class="{ irregular: data.irregular }"
/>
<span v-else class="avatar-placeholder">
{{ data.name ? data.name.charAt(0).toUpperCase() : "?" }}
</span>
</div>
<!-- 信息 -->
<div class="link-content">
<div class="link-name">{{ data.name }}</div>
<div class="link-desc" :title="data.descr">
{{ data.descr }}
</div>
</div>
</a>
</div>
</template>
<script setup>
defineProps({
data: {
type: Object,
required: true,
},
});
import { ref } from "vue";
const imageFailed = ref(false);
const handleImageError = () => {
imageFailed.value = true;
};
</script>
<style scoped>
.link-item-card {
height: 100px;
border-radius: 12px;
background: var(--vp-c-bg);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
border: 1px solid var(--vp-c-divider);
transition: all 0.3s ease;
overflow: hidden;
}
.link-item-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.link-item-card a {
display: flex;
align-items: center;
height: 100%;
text-decoration: none;
color: inherit;
gap: 16px;
}
.link-avatar {
flex: 0 0 100px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
transform-origin: center center;
transition: transform 0.5s ease, flex-basis 0.5s ease, width 0.5s ease, opacity 0.5s ease;
}
.link-avatar img {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
transition: transform 0.3s ease;
}
.link-avatar img.irregular {
border-radius: 8px;
object-fit: contain;
}
.link-avatar .avatar-placeholder {
width: 60px;
height: 60px;
background: #f0f0f0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: #555;
font-size: 1.2rem;
transition: transform 0.25s ease-out, border-color 0.25s ease-out, box-shadow 0.25s ease-out;
}
.link-item-card:hover .link-avatar img,
.link-item-card:hover .avatar-placeholder {
transform: scale(0);
}
/* 当鼠标悬停时整体以中心点缩小头像容器并收起布局,使右侧内容左移 */
.link-item-card:hover .link-avatar {
transform: scale(0);
flex: 0 0 0;
width: 0;
min-width: 0;
padding: 0;
opacity: 0;
overflow: hidden;
transition: transform 0.3s ease, flex-basis 0.3s ease, width 0.3s ease, opacity 0.25s ease;
}
.link-content {
flex: 1;
padding: 0 16px 0 0px;
transition: margin-left 0.3s ease, transform 0.25s ease-out, border-color 0.25s ease-out, box-shadow 0.25s ease-out;
}
.link-name {
font-size: 1rem;
font-weight: 600;
color: var(--vp-c-text-1);
margin-bottom: 6px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
word-wrap: break-word;
}
.link-desc {
font-size: 0.875rem;
color: var(--vp-c-text-2);
display: -webkit-box; /* 兼容 WebKit 旧版本 */
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
</style>
vue
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vitepress'
const envId = '填写您自己的twikoo域名'
const twikooJs = ref(null)
const router = useRouter()
function initTwikoo () {
try {
twikoo.init({
envId,
onCommentLoaded: initLightGallery
})
} catch (e) {}
}
function initLightGallery () {
var commentContents = [
...document.getElementsByClassName('vp-doc'),
...document.getElementsByClassName('tk-content')
];
for (var i = 0; i < commentContents.length; i++) {
var commentItem = commentContents[i];
var imgEls = commentItem.getElementsByTagName('img');
if (imgEls.length > 0) {
for (var j = 0; j < imgEls.length; j++) {
var imgEl = imgEls[j];
if (imgEl.parentElement.tagName === 'A') continue;
var aEl = document.createElement('a');
aEl.setAttribute('class', 'tk-lg-link');
aEl.setAttribute('href', imgEl.getAttribute('src'));
aEl.setAttribute('data-src', imgEl.getAttribute('src'));
aEl.appendChild(imgEl.cloneNode(false));
imgEl.parentNode.insertBefore(aEl, imgEl.nextSibling);
imgEl.remove();
}
lightGallery(commentItem, {
selector: '.tk-lg-link',
share: false
})
}
}
}
function initJs () {
if (twikooJs.value) {
twikooJs.value.onload = initTwikoo
router.onAfterRouteChanged = onRoute
}
}
function onRoute (to) {
if (to) setTimeout(initTwikoo, 1000)
}
onMounted(() => {
initTwikoo()
initJs()
})
</script>
<template>
<div class="comment-container vp-raw">
<!-- KaTeX-引入的是 Twikoo 评论系统 + KaTeX 数学公式渲染 -->
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/twikoo@1.6.44/dist/twikoo.css" integrity="sha384-AfEj0r4/OFrOo5t7NnNe46zW/tFgW6x/bCJG8FqQCEo3+Aro6EYUG4+cU+KJWu/X" crossorigin="anonymous">
<component :is="'script'" defer src="https://cdn.jsdelivr.net/npm/katex@0.12.0/dist/katex.min.js" integrity="sha384-g7c+Jr9ZivxKLnZTDUhnkOnsh30B4H0rpLUpJ4jAIKs4fnJI+sEnkvrMWph2EDg4" crossorigin="anonymous"></component>
<component :is="'script'" defer src="https://cdn.jsdelivr.net/npm/katex@0.12.0/dist/contrib/auto-render.min.js" integrity="sha384-mll67QQFJfxn0IYznZYonOWZ644AWYC+Pt2cHqMaRhXVrursRwvLnLaebdGIlYNa" crossorigin="anonymous"></component> -->
<!-- lightGallery-引入的是 Twikoo + lightGallery 图片预览 -->
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/twikoo@1.6.44/dist/twikoo.css" integrity="sha384-U8ohOXEVyF0NGY2LQnH83V4wGxOmFhim4U5xhfE/WDCHdPO2iUKPPYkhpDl9U/Yf" crossorigin="anonymous">
<component :is="'script'" src="https://cdn.jsdelivr.net/npm/lightgallery@2.1.8/lightgallery.min.js" integrity="sha384-l5lFB9srHFAyvfCoHya9X1JwGGTNPvDtikieqZp7qu/bomCw0e0+yoyiL0f7UXLD" crossorigin="anonymous"></component> -->
<!-- Twikoo -->
<div id="twikoo"></div>
<component :is="'script'" src="https://registry.npmmirror.com/twikoo/1.6.44/files/dist/twikoo.min.js" crossorigin="anonymous" ref="twikooJs"></component>
</div>
</template>
注册组件
- 在
docs\.vitepress\theme\index.ts
中注册组件
ts
import SLink from "./components/SLink/index.vue";
export default {
extends: Teek,
async enhanceApp({ app, router }) {
// 使用数组统一注册组件,减少重复代码
const globalComponents = [
{ name: "friend-link", component: SLink }, // 注册友链组件
];
globalComponents.forEach(({ name, component }) => {
app.component(name, component); // 全局注册组件
});
};
}
使用方法
在
frontmatter
中设置layout
为friend-link
,启用友链布局模式建议设置
sidebar: false
,界面更美观设置
comments: false
可关闭底部评论区
md
---
date: 2025-05-01 22:52:11
layout: friend-link
title: 我的友链
sidebar: false
permalink: /friend-link
article: false
comment: false
links:
- title: 鸣谢
descr: "建站中学习和使用了以下博客/网站的技术和分享,特别鸣谢!💖"
list:
- name: vitepress-theme-teek
link: https://vp.teek.top/
avatar: https://vitepress.yiov.top/logo.png
irregular: true
descr: 一个轻量、简洁高效、灵活配置,易于扩展的 VitePress 主题
- name: VitePress
link: https://vitepress.dev/zh/
avatar: https://vitepress.dev/vitepress-logo-mini.svg
irregular: true
descr: 由 Vite 和 Vue 驱动的静态站点生成器
- title: 传送门
descr: "聚集众多优秀独立博客,随机传送 \U0001F680"
list:
- name: 天客 - Teeker
link: https://notes.teek.top/
avatar: https://testingcf.jsdelivr.net/gh/Kele-Bingtang/static/user/avatar2.png
descr: 朝圣的使徒,正在走向编程的至高殿堂!
irregular: false
- name: One
descr: 明心静性,爱自己
link: https://onedayxyy.cn/
avatar: https://onedayxyy.cn/favicon.ico
badge: 运维大佬
badgeType: tip
irregular: false
- name: 威威 Blog
link: https://dl-web.top/
avatar: https://dl-web.top/avatar/avatar.svg
descr: 人心中的成见是一座大山
irregular: false
- name: 时光驿站
link: https://kandu.cxcare.top/
avatar: https://kandu.cxcare.top/logo.svg
descr: 干活满满的技术笔记
irregular: false
- name: 二丫讲梵
descr: 坐而言不如起而行
link: https://wiki.eryajf.net/
avatar: https://wiki.eryajf.net/img/logo.png
badge: Tk道友
badgeType: tip
- name: SnowLin
descr: 一個溫暖的地方
link: https://blog.snowlinlan.com/
avatar: https://blog.snowlinlan.com/logo.png
badge: Tk道友
badgeType: tip
- name: 凿壁偷光不算偷
descr: tk 道友
link: https://sinc.us.kg/
avatar: https://sinc.us.kg/avatar/avatar.webp
badge: Tk道友
badgeType: tip
- name: 极客兔 - 笔记站
descr: 一心创作优质内容
link: https://zhouyu2156.github.io/
avatar: https://zhouyu2156.github.io/favicon.png
badge: Tk道友
badgeType: tip
- name: 兔白白
descr: 牛马
link: https://ydbsq123.top/
avatar: https://pic1.imgdb.cn/item/6804b1be58cb8da5c8b8ffa0.jpg
badge: 妹子
badgeType: tip
- name: 心流笔记
descr: 一个记录生活与学习过程中灵感和感悟的空间
link: http://blog.wilsonzy.cn/c/StreamNotes/
avatar: http://blog.wilsonzy.cn/c/StreamNotes/logo.png
badge: Tk道友
badgeType: tip
- name: OpForge
descr: 运维锻造,知识沉淀
link: https://opforge.srebro.cn/
avatar: https://opforge.srebro.cn/logo.png
badgeType: tip
- name: W3C
descr: W3C COOL
link: https://teek.w3c.cool/
avatar: https://teek.w3c.cool/logo.svg
badge: Tk道友
badgeType: tip
- name: 乔克视界
descr: 云原生爱好者
link: https://jokerbai.com/
avatar: https://static.jokerbai.com/blog/ava.jpg
badge: Tk道友
badgeType: tip
- name: 记得勇敢
descr: 常用组件及代码封装
link: https://vp.xiaoying.org.cn/
avatar: https://vp.xiaoying.org.cn/img/logo.png
badge: Tk道友
badgeType: tip
- name: 星辰の笔记
descr: Code | Think | Share | Repeat
link: https://blog.strarry.top/
avatar: https://blog.strarry.top/avatar.jpg
badge: 酷友
badgeType: tip
- name: 唯知笔记
descr: 探索知识的无限可能
link: https://note.weizwz.com/
avatar: https://p.weizwz.com/logo_a4353391cbf0889b.webp
badge: ""
badgeType: ""
- name: bugcool
descr: 写代码哪有没 bug 的?
link: https://bugcool.cn/
avatar: https://bugcool.cn/wp-content/uploads/2025/08/cropped-s-1.png
badge: ""
badgeType: ""
- name: 王嘉祥
descr: 唱响科普和人生兴事,分享科技与美好生活
link: https://blog.jiaxiang.wang/
avatar: https://blog.jiaxiang.wang/processed_images/logo-512x512.dace0c781ce4c25d.webp
badge: ""
badgeType: ""
categories:
- 关于
coveravatar: https://avatar.onedayxyy.cn/images/TeekCover/1.webp
---