开篇
本教程还没有完善好,正在持续撰写中。
添加PageBanner复用组件
注意:下面教程中复用PageBanner组件的地方比较多,如果您对PageBanner需求不大,可直接修改文件把PageBanner的样式写入pages页面文件中,您不需要PageBanner可以手动移除。
在/app/components/partial/下新建文件PageBanner.vue
<script setup lang="ts">
defineProps<{
image: String
title: String
description?: String
}>()
</script>
<template>
<div class="page-banner" :style="{ backgroundImage: `url(${image})` }">
<div class="banner-content">
<h1>{{ title }}</h1>
<p v-if="description">{{ description }}</p>
</div>
<div class="banner-extra">
<slot></slot>
</div>
</div>
</template>
<style lang="scss" scoped>
.page-banner {
background-position: 50%;
background-size: cover;
border-radius: 8px;
margin: 1rem;
max-height: 320px;
min-height: 256px;
overflow: hidden;
position: relative;
.banner-content {
color: #eee;
display: flex;
flex-direction: column;
top: 0;
bottom: 0;
left: 0;
justify-content: space-between;
padding: 1rem;
position: absolute;
text-shadow: 0 4px 5px rgba(#000, .5);
p {
opacity: .9;
}
}
.banner-extra {
align-items: flex-end;
display: flex;
bottom: 0;
right: 0;
justify-content: flex-end;
margin: 1rem;
position: absolute;
}
}
</style>
页面相关
添加本地essay页面
在/app/pages/新建essay.vue
<script setup lang="ts">
import talks from '~/talks'
import { format } from 'date-fns'
import { dateLocale } from '~~/blog.config'
const layoutStore = useLayoutStore()
layoutStore.setAside(['blog-stats', 'blog-tech', 'blog-log'])
const title = '说说'
const description = '记录生活点滴,一些想法。'
const image = 'https://图片链接'
useSeoMeta({ title, description, ogImage: image })
const { author } = useAppConfig()
const recentTalks = [...talks]
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 30)
function replyTalk(content: string): void {
const input = document.querySelector('#twikoo .tk-input textarea')
if (!(input instanceof HTMLTextAreaElement)) return
if (content.trim()) {
const quotes = content.split('\n').map(str => `> ${str}`)
input.value = `${quotes}\n\n`
} else {
input.value = ''
}
input.dispatchEvent(new InputEvent('input'))
const length = input.value.length
input.setSelectionRange(length, length)
input.focus()
}
function getEssayDate(date?: string | Date) {
if (!date) {
return ''
}
if (typeof date === 'string') {
date = new Date(date)
}
return format(date, 'yyyy-MM-dd HH:mm', { locale: dateLocale })
}
</script>
<template>
<ZPageBanner :title :description :image />
<div class="talk-list">
<div class="talk-item" v-for="talk in recentTalks" :key="talk.date">
<div class="talk-meta">
<NuxtImg class="avatar" :src="author.avatar" :alt="author.name" />
<div class="info">
<div class="nick">
{{ author.name }}
<Icon class="verified" name="i-material-symbols:verified" />
</div>
<div class="date">{{ getEssayDate(talk.date) }}</div>
</div>
</div>
<div class="talk-content">
<div class="text" v-if="talk.text" v-html="talk.text"></div>
<div class="images" v-if="talk.images">
<Pic class="image" v-for="image in talk.images" :src="image" />
</div>
<VideoEmbed class="video" v-if="talk.video" v-bind="talk.video" height="" />
</div>
<div class="talk-bottom">
<div class="tags">
<span class="tag" v-for="tag in talk.tags">
<Icon name="ph:tag-bold" />
<span>{{ tag }}</span>
</span>
<ZRawLink
class="location"
v-if="talk.location"
v-tip="`搜索: ${talk.location}`"
:to="`https://bing.com/maps?q=${encodeURIComponent(talk.location)}`"
>
<Icon name="ph:map-pin-bold" />
<span>{{ talk.location }}</span>
</ZRawLink>
</div>
<button class="comment-btn" v-tip="'评论'" @click="replyTalk(talk.text)">
<Icon name="ph:chats-bold" />
</button>
</div>
</div>
<div class="talk-footer">
<p>仅显示最近 30 条记录</p>
</div>
</div>
<PostComment />
</template>
<style lang="scss" scoped>
.talk-list {
animation: float-in .2s backwards;
margin: 1rem;
.talk-item {
animation: float-in .3s backwards;
animation-delay: var(--delay);
border-radius: 8px;
box-shadow: 0 0 0 1px var(--c-bg-soft);
display: flex;
flex-direction: column;
gap: .5rem;
margin-bottom: 1rem;
padding: 1rem;
.talk-meta {
align-items: center;
display: flex;
gap: 10px;
.avatar {
border-radius: 2em;
box-shadow: 2px 4px 1rem var(--ld-shadow);
width: 3em;
}
.nick {
align-items: center;
display: flex;
gap: 5px;
}
.date {
color: var(--c-text-3);
font-family: var(--font-monospace);
font-size: .8rem;
}
.verified {
color: var(--c-primary);
font-size: 16px;
}
}
.talk-content {
color: var(--c-text-2);
display: flex;
flex-direction: column;
gap: .5rem;
line-height: 1.6;
:deep(a[href]) {
margin: -.1em -.2em;
padding: .1em .2em;
background: linear-gradient(var(--c-primary-soft), var(--c-primary-soft)) no-repeat center bottom / 100% .1em;
color: var(--c-primary);
transition: all .2s;
&:hover {
border-radius: .3em;
background-size: 100% 100%;
}
}
.images {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, 1fr);
}
.image {
border-radius: 8px;
overflow: hidden;
padding-bottom: 100%;
position: relative;
:deep(img) {
height: 100%;
object-fit: cover;
position: absolute;
transition: transform .3s;
width: 100%;
&:hover {
transform: scale(1.05);
}
}
}
.video {
border-radius: 8px;
margin: 0;
}
}
.talk-bottom {
align-items: center;
color: var(--c-text-3);
display: flex;
justify-content: space-between;
.tags {
display: flex;
font-size: .7rem;
gap: 4px;
}
.tag, .location {
display: flex;
padding: 2px 4px;
border-radius: 4px;
background-color: var(--c-bg-2);
align-items: center;
cursor: pointer;
transition: all .2s;
&:hover {
opacity: .8;
}
}
.tag .i-ph\:tag-bold + * {
margin-left: .15em;
}
.location {
color: var(--c-primary);
}
}
}
.talk-footer {
color: var(--c-text-3);
font-size: 1rem;
margin: 2rem 0;
text-align: center;
}
}
</style>
在/app/types/新建talk.ts
export type TalkItem = {
text?: string
date: string
images?: string[]
video?: {
type?: 'raw' | 'bilibili' | 'bilibili-nano' | 'youtube' | 'douyin' | 'douyin-wide' | 'tiktok'
id: string
ratio?: string | number
poster?: string
}
tags?: string[]
location?: string
}
在/app/新建talks.ts
这个文件是更新说说内容的地方
import type { TalkItem } from '~/types/talk'
export default [
{
text: '这是一个包含<b>原始视频</b>的动态内容示例。<br>现在支持使用<br>进行换行和使用<b>标签实现加粗。',
date: '2025-09-24 00:00',
video: {
id: 'https://media.w3.org/2010/05/sintel/trailer.mp4',
poster: 'https://lf-package-cn.feishucdn.com/obj/atsx-throne/hire-fe-prod/portal/i18n/static/image/video-poster.d9fdf4be.jpeg'
},
tags: ['游戏'],
location: '天津'
},
{
text: '这是一个包含B站视频的示例。',
date: '2025-09-23 23:00',
video: {
type: 'bilibili',
id: 'BV1Yr421p7rW'
},
tags: ['网站'],
location: '天津'
},
{
text: '这是一个同时包含<b>视频</b>和<b>图片</b>的示例。<br>支持多种媒体格式的展示。',
date: '1885-07-22 20:00',
images: [
'https://图片链接',
'https://图片链接',
'https://图片链接'
],
video: {
type: 'bilibili',
id: 'BV1xx411c7mD'
},
tags: ['旅行'],
location: '成都'
}
] satisfies TalkItem[]
添加tags页面
修改/app/composables/useArticle.ts的第11行代码,添加'tags',
.select('categories', 'tags', ......)
在/app/pages/新建tags.vue
<script setup lang="ts">
import { sort } from 'radash'
const layoutStore = useLayoutStore()
layoutStore.setAside(['blog-stats', 'blog-tech', 'blog-log'])
const appConfig = useAppConfig()
const title = '标签'
const description = `${appConfig.title}的所有文章标签。`
useSeoMeta({ title, description })
const { data: listRaw } = await useArticleIndex()
const articlesByTag = computed(() => {
const result: Record<string, any[]> = {}
const articles = sort(listRaw.value, a => new Date(a.date || 0).getTime(), true)
for (const article of articles) {
if (article.tags) {
for (const tag of article.tags) {
if (!result[tag]) {
result[tag] = []
}
result[tag].push(article)
}
}
}
return result
})
const sortedTags = computed(() => {
return Object.keys(articlesByTag.value).sort((a, b) => {
const aCount = articlesByTag.value[a]?.length || 0
const bCount = articlesByTag.value[b]?.length || 0
return bCount - aCount
})
})
</script>
<template>
<div class="tags">
<section
v-for="tag in sortedTags"
:key="tag"
class="tag-group"
>
<div class="tag-title text-creative">
<h2 class="tag-name">
{{ tag }}
</h2>
<div class="tag-info">
<span>{{ articlesByTag[tag]?.length }}篇</span>
</div>
</div>
<menu class="archive-list">
<TransitionGroup appear name="float-in">
<ZArchive
v-for="article, index in articlesByTag[tag]"
:key="article.path"
v-bind="article"
:to="article.path"
:style="{ '--delay': `${index * 0.03}s` }"
/>
</TransitionGroup>
</menu>
</section>
</div>
</template>
<style lang="scss" scoped>
.tags {
margin: 1rem;
}
.tag-group {
margin: 1rem 0 3rem;
}
.tag-title {
display: flex;
justify-content: space-between;
gap: 1em;
position: sticky;
opacity: .5;
top: 0;
font-size: min(1.5em, 5vw);
color: transparent;
transition: color .2s;
&::selection, :hover > & {
color: var(--c-text-3);
}
> .tag-name {
margin-bottom: -.3em;
mask-image: linear-gradient(#FFF 50%, transparent);
font-size: 3em;
font-weight: 800;
line-height: 1;
z-index: -1;
-webkit-text-stroke: 1px var(--c-text-3);
}
> .tag-info {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
column-gap: .5em;
}
}
</style>
友链朋友圈
在/scripts/新建generate-friend.ts
#!/usr/bin/env ts-node
import fs from 'fs';
import path from 'path';
// 定义必要的接口
export interface Friend {
title: string;
desc: string;
link: string;
avatar: string;
}
interface FeedEntry {
author: string;
sitenick?: string;
title?: string;
link: string;
avatar: string;
error?: string;
}
interface FeedGroup {
entries: FeedEntry[];
}
// 模拟各类头像获取函数
const mockGetGhAvatar = (name: string, { size = 92, mask } = {}) =>
`https://wsrv.nl/?url=github.com/${name}.png?size=${size}${mask ? '&mask=circle' : ''}`;
const mockGetGhIcon = (name: string) => mockGetGhAvatar(name, { size: 32, mask: 'circle' });
const mockGetQqAvatar = (qq: string, size = 140) =>
`https://q1.qlogo.cn/g?b=qq&nk=${qq}&s=${size}`;
const mockGetFavicon = (domain: string, { provider = 'google', size = 32 } = {}) =>
`https://unavatar.webp.se/${provider}/${domain}?w=${size}`;
// 从feeds.ts生成friend.json
export function generateFcircleJson() {
// 不想订阅的友链
const blacklist = ["名称1", "名称2"];
const __dirname = path.dirname(decodeURIComponent(new URL(import.meta.url).pathname));
const feedsPath = path.resolve(__dirname, '../app/feeds.ts');
const outputPath = path.resolve(__dirname, '../public/friend.json');
try {
// 读取并提取feeds.ts中的数组内容
const content = fs.readFileSync(feedsPath, 'utf-8');
const startIndex = content.indexOf('export default [');
const endIndex = content.lastIndexOf(']');
if (startIndex === -1 || endIndex === -1) {
throw new Error('无法找到feeds.ts中的默认导出数组');
}
// 处理数组内容,替换函数调用为模拟结果
let arrayContent = content.substring(startIndex + 15, endIndex + 1)
.replace(/\s+satisfies\s+[^\s\n;]+/g, '')
.replace(/getGhAvatar\('([^']+)'\)(?:\s*,\s*\{[^\}]+\})?/g, (_, name) => `"${mockGetGhAvatar(name)}"`)
.replace(/getGhIcon\('([^']+)'\)/g, (_, name) => `"${mockGetGhIcon(name)}"`)
.replace(/getQqAvatar\('([^']+)'\)(?:\s*,\s*[^\)]+)?/g, (_, qq) => `"${mockGetQqAvatar(qq)}"`)
.replace(/getFavicon\('([^']+)'\)(?:\s*,\s*\{[^\}]+\})?/g, (_, domain) => `"${mockGetFavicon(domain)}"`)
.replace(/QqAvatarSize\.Size\d+/g, '140');
// 解析feed组数据
const feedGroups: FeedGroup[] = eval(`(${arrayContent})`);
// 提取有效友链数据
const friends = feedGroups.flatMap(group =>
group.entries
.filter(entry => !entry.error) // 跳过有错误的条目
.map(entry => {
const siteName = entry.title || entry.sitenick || entry.author;
// 跳过黑名单站点
if (blacklist.includes(siteName)) {
console.log(`跳过黑名单站点: ${siteName}`);
return null;
}
return [siteName, entry.link, entry.avatar];
})
.filter(Boolean) as [string, string, string][]
);
// 确保public目录存在并写入文件
const publicDir = path.resolve(__dirname, '../public');
if (!fs.existsSync(publicDir)) fs.mkdirSync(publicDir, { recursive: true });
const friendData = { friends };
fs.writeFileSync(outputPath, JSON.stringify(friendData, null, 2), 'utf-8');
console.log(`成功生成friend.json文件,共${friends.length}个友链`);
console.log(`文件路径: ${outputPath}`);
return friendData;
} catch (error) {
console.error('生成friend.json时出错:', error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
// 直接运行时执行
if (import.meta.url === new URL(process.argv[1], import.meta.url).href) {
generateFcircleJson();
}
执行
pnpm tsx scripts/generate-friend.ts
会在public目录生成friend.json
在/app/pages/新建fcircle.vue
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
const layoutStore = useLayoutStore()
layoutStore.setAside(['blog-stats', 'blog-tech', 'blog-log'])
const title = '朋友圈'
const description = '发现更多有趣的博主。'
const image = 'https://图片链接'
useSeoMeta({ title, description, ogImage: image })
// 配置选项
const UserConfig = reactive({
api_url: 'https://友链朋友圈前端链接/',
page_size: 20
})
// 状态管理
const allArticles = ref([])
const displayCount = ref(20)
const isLoading = ref(true)
const randomArticle = ref(null)
const showAvatarPopup = ref(false)
const selectedAuthor = ref('')
const selectedAuthorAvatar = ref('')
const selectedArticleLink = ref('')
const articlesByAuthor = ref({})
const lastUpdatedDate = ref('')
// 计算属性
const displayedArticles = computed(() => allArticles.value.slice(0, displayCount.value))
const hasMoreArticles = computed(() => allArticles.value.length > displayCount.value)
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).replace(/\//g, '-')
}
// 刷新随机文章
const refreshRandomArticle = () => {
if (allArticles.value.length > 0) {
const randomIndex = Math.floor(Math.random() * allArticles.value.length)
randomArticle.value = allArticles.value[randomIndex]
}
}
// 加载更多
const loadMore = () => {
displayCount.value += UserConfig.page_size
}
// 模态框相关
const showAvatarPosts = (author, avatar, articleLink) => {
selectedAuthor.value = author
selectedAuthorAvatar.value = avatar
selectedArticleLink.value = articleLink
showAvatarPopup.value = true
}
const closeAvatarPopup = () => {
showAvatarPopup.value = false
}
// 监听点击外部关闭弹窗
const handleClickOutside = (event) => {
const popup = document.getElementById('avatar-popup')
if (popup && !popup.contains(event.target) && showAvatarPopup.value) {
closeAvatarPopup()
}
}
// 获取数据
const fetchData = async () => {
try {
isLoading.value = true
const response = await fetch(`${UserConfig.api_url}all.json`)
const data = await response.json()
// 处理数据
allArticles.value = data.article_data.map(item => ({
id: item.link + Math.random(), // 确保唯一ID
title: item.title,
link: item.link,
author: item.author,
created: item.created,
avatar: item.avatar
}))
// 按作者分组
articlesByAuthor.value = allArticles.value.reduce((acc, article) => {
if (!acc[article.author]) acc[article.author] = []
acc[article.author].push(article)
return acc
}, {})
// 初始化随机文章
refreshRandomArticle()
// 设置最新更新日期
if (allArticles.value.length > 0) {
const sortedArticles = [...allArticles.value].sort((a, b) =>
new Date(b.created) - new Date(a.created)
)
lastUpdatedDate.value = formatDate(sortedArticles[0].created)
}
} catch (error) {
console.error('加载文章失败:', error)
} finally {
isLoading.value = false
}
}
// 生命周期钩子
onMounted(() => {
fetchData()
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
<ZPageBanner :title :description :image>
<div class="fcircle-stats">
<div class="fcircle-stats__update-time">Updated at {{ lastUpdatedDate || '2025-07-17' }}</div>
<div class="fcircle-stats__powered-by">Powered by FriendCircleLite</div>
</div>
</ZPageBanner>
<div class="page-fcircle">
<div class="fcircle">
<!-- 随机文章区域 -->
<div v-if="randomArticle" class="fcircle__random-article">
<div class="fcircle__random-title">随机文章</div>
<div class="article-item">
<a
:href="randomArticle.link"
target="_blank"
rel="noopener noreferrer"
class="article-item__container gradient-card"
>
<span class="article-item__author">{{ randomArticle.author }}</span>
<span class="article-item__title">{{ randomArticle.title }}</span>
<span class="article-item__date">{{ formatDate(randomArticle.created) }}</span>
</a>
</div>
<ZButton
class="btn-refresh gradient-card"
@click="refreshRandomArticle"
icon="uim:process"
/>
</div>
<!-- 文章列表区域 -->
<div class="fcircle__articles">
<div
v-for="(article, index) in displayedArticles"
:key="article.id"
class="article-item article-item--new"
:style="{ '--delay': `${(index % UserConfig.page_size) * 0.05}s` }"
>
<div class="article-item__image" @click="showAvatarPosts(article.author, article.avatar, article.link)">
<NuxtImg
:src="article.avatar"
:alt="article.author"
loading="lazy"
/>
</div>
<a
:href="article.link"
target="_blank"
rel="noopener noreferrer"
class="article-item__container gradient-card"
>
<span class="article-item__author">{{ article.author }}</span>
<span class="article-item__title">{{ article.title }}</span>
<span class="article-item__date">{{ formatDate(article.created) }}</span>
</a>
</div>
</div>
<!-- 加载更多按钮 -->
<ZButton
v-show="hasMoreArticles"
class="btn-load-more gradient-card"
@click="loadMore"
text="加载更多"
/>
<!-- 空状态 -->
<div v-if="!isLoading && allArticles.length === 0" class="error-container">
<Icon class="error-container__icon" name="ph:file-text-bold" />
<p>暂无文章数据</p>
<p class="empty-hint">请稍后再试</p>
</div>
<!-- 作者模态框 - 时间线样式 -->
<Transition name="modal">
<div
v-if="showAvatarPopup && selectedAuthor && articlesByAuthor[selectedAuthor]"
id="avatar-popup"
class="modal"
@click="closeAvatarPopup"
>
<div class="modal__content" @click.stop>
<div class="modal__header">
<NuxtImg
:src="selectedAuthorAvatar"
:alt="selectedAuthor"
loading="lazy"
class="modal__avatar-img"
/>
<h3>{{ selectedAuthor }}</h3>
<a
:href="selectedArticleLink"
target="_blank"
rel="noopener noreferrer"
class="modal__author-link"
>
<Icon name="lucide:external-link" />
</a>
</div>
<div class="modal__body">
<div class="timeline">
<div
v-for="(article, index) in articlesByAuthor[selectedAuthor].slice(0, 10)"
:key="article.id"
class="timeline__item"
:style="{ '--delay': (0.2 + index * 0.1) + 's' }"
>
<span class="timeline__date">{{ formatDate(article.created) }}</span>
<a
:href="article.link"
target="_blank"
rel="noopener noreferrer"
class="timeline__title"
@click="closeAvatarPopup"
>
{{ article.title }}
</a>
</div>
</div>
</div>
<div class="modal__avatar">
<NuxtImg
:src="selectedAuthorAvatar"
:alt="selectedAuthor"
loading="lazy"
/>
</div>
</div>
</div>
</Transition>
</div>
</div>
</template>
<style lang="scss" scoped>
/* 动画定义 */
@keyframes pulse-fade {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes slide-in-up {
0% { opacity: 0; transform: translateY(20px); }
100% { opacity: 1; transform: translateY(0); }
}
/* 主要样式 */
.page-fcircle {
animation: float-in .2s backwards;
margin: 1rem;
}
.fcircle-stats {
align-items: flex-end;
color: #eee;
display: flex;
flex-direction: column;
font-family: var(--font-monospace);
font-size: .7rem;
gap: .1rem;
opacity: .7;
text-shadow: 0 4px 5px rgba(0,0,0,.5);
.fcircle-stats__update-time { opacity: 1; }
.fcircle-stats__powered-by { opacity: .8; }
}
.fcircle {
.fcircle__random-article {
align-items: center;
display: flex;
flex-direction: row;
gap: 10px;
justify-content: space-between;
margin: 1rem 0;
.fcircle__random-title {
font-size: 1.2rem;
white-space: nowrap;
}
.article-item {
flex: 1;
min-width: 0;
.article-item__container {
min-width: 0;
.article-item__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
.fcircle__articles {
display: flex;
flex-direction: column;
gap: .5rem;
}
}
/* 文章项样式 */
.article-item {
align-items: center;
display: flex;
gap: 10px;
width: 100%;
&.article-item--new { animation: float-in .2s var(--delay) backwards; }
.article-item__image {
border-radius: 50%;
box-shadow: 0 0 0 1px var(--c-bg-soft);
display: flex;
flex-shrink: 0;
height: 2rem;
overflow: hidden;
width: 2rem;
img {
height: 100%;
object-fit: cover;
opacity: .8;
transition: all .2s;
width: 100%;
}
}
.article-item__container {
align-items: center;
border-radius: 8px;
box-shadow: 0 0 0 1px var(--c-bg-soft);
display: flex;
gap: 5px;
height: 2.5rem;
overflow: hidden;
padding: 10px;
width: 100%;
&:hover .article-item__title { color: var(--c-text); }
.article-item__author {
color: var(--c-text-3);
font-size: .85rem;
flex-shrink: 0;
display: flex;
align-items: center;
}
.article-item__title {
color: var(--c-text-2);
flex: 1;
font-size: .9375rem;
overflow: hidden;
text-overflow: ellipsis;
transition: color .2s;
white-space: nowrap;
display: flex;
align-items: center;
}
.article-item__date {
color: var(--c-text-3);
font-family: var(--font-monospace);
font-size: .75rem;
flex-shrink: 0;
display: flex;
align-items: center;
}
}
}
/* 按钮样式 */
.btn-refresh {
align-items: center;
background-color: unset;
border-radius: 8px;
color: var(--c-text-2);
cursor: pointer;
display: flex;
flex-shrink: 0;
height: 2.5rem;
justify-content: center;
transition: all .2s ease;
width: 2.5rem;
box-shadow: none;
&:hover {
background-color: unset;
}
}
.btn-load-more {
background-color: var(--ld-bg-card);
border-radius: 8px;
box-shadow: .1em .2em .5rem var(--ld-shadow);
display: block;
font-size: .875rem;
height: 42px;
margin: 1rem auto;
padding: .75rem;
width: 200px;
&:hover { color: var(--c-text); }
}
/* 模态框样式 */
.modal {
align-items: center;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
display: flex;
top: 0;
right: 0;
bottom: 0;
left: 0;
justify-content: center;
position: fixed;
z-index: 100;
.modal__content {
background-color: var(--c-bg-a50);
border-radius: 12px;
box-shadow: 0 0 0 1px var(--c-bg-soft);
max-height: 80vh;
max-width: 500px;
overflow-y: auto;
padding: 1.25rem;
position: relative;
width: 90%;
.modal__header {
align-items: center;
border-bottom: 1px solid var(--c-bg-soft);
display: flex;
gap: 15px;
margin-bottom: 20px;
padding-bottom: 15px;
img { border-radius: 50%; height: 50px; object-fit: cover; width: 50px; }
h3 { flex: 1; font-size: 1.2rem; margin: 0; }
.modal__author-link {
border-radius: 8px;
color: var(--c-text-2);
padding: 8px;
transition: all .3s;
&:hover { background: var(--c-bg-soft); color: var(--c-text); }
}
}
.modal__body {
.timeline {
position: relative;
&:after {
background-color: var(--c-bg-soft);
bottom: 0;
content: "";
left: .25rem;
position: absolute;
top: .5rem;
transform: translate(-50%);
width: 2px;
}
.timeline__item {
animation: float-in .3s var(--delay) backwards;
color: var(--c-text-2);
padding: 0 0 1rem 1.25rem;
position: relative;
&:before {
background-color: var(--c-text-2);
border-radius: 50%;
content: "";
height: .5rem;
left: .25rem;
position: absolute;
top: .5rem;
transform: translateY(-50%) translate(-50%);
transition: transform .3s ease, box-shadow .3s ease;
width: .5rem;
z-index: 1;
}
&:hover:before {
box-shadow: 0 0 8px var(--c-text-2);
transform: translateY(-50%) translate(-50%) scale(1.5);
}
.timeline__date {
color: var(--c-text-3);
display: block;
font-family: var(--font-monospace);
font-size: .875rem;
margin-bottom: .3rem;
}
.timeline__title {
color: var(--c-text-2);
line-height: 1.4;
transition: color .3s;
&:hover { color: var(--c-text); }
}
}
}
}
.modal__avatar {
border-radius: 50%;
bottom: 1.25rem;
filter: blur(5px);
height: 128px;
opacity: .6;
overflow: hidden;
pointer-events: none;
position: absolute;
right: 1.25rem;
width: 128px;
z-index: 1;
img { height: 100%; object-fit: cover; width: 100%; }
}
}
}
/* 模态框过渡 */
.modal-enter-active,
.modal-enter-active .modal__content,
.modal-leave-active,
.modal-leave-active .modal__content {
transition: all .3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .modal__content,
.modal-leave-to .modal__content {
transform: translateY(-20px);
}
.modal-enter-to,
.modal-leave-from {
opacity: 1;
}
.modal-enter-to .modal__content,
.modal-leave-from .modal__content {
transform: translateY(0);
}
/* 错误容器 */
.error-container {
align-items: center;
color: var(--c-text-2);
display: flex;
flex-direction: column;
gap: 12px;
height: 400px;
justify-content: center;
.error-container__icon { font-size: 4rem; }
}
/* 移动端适配 */
@media (max-width: 768px) {
.fcircle__random-article .fcircle__random-title { display: none; }
.page-fcircle .article-item .article-item__container {
flex-wrap: wrap;
height: auto;
}
.page-fcircle .article-item .article-item__container .article-item__author {
flex-grow: 1;
}
.page-fcircle .article-item .article-item__container .article-item__title {
flex-basis: 100%;
order: 3;
white-space: normal;
}
}
</style>
添加about页面
在/app/pages/新建fcircle.vue
<script setup lang="ts">
const layoutStore = useLayoutStore()
layoutStore.setAside(['blog-stats', 'blog-tech', 'blog-log', 'comm-group'])
const { author } = useAppConfig()
const title = '关于我'
const description = '博主的个人介绍页面。'
useSeoMeta({ title, description, ogImage: author.avatar })
// 初始化统计数据
const statsData = ref({
today_uv: '加载中...',
today_pv: '加载中...',
yesterday_uv: '加载中...',
yesterday_pv: '加载中...',
last_month_pv: '加载中...',
last_year_pv: '加载中...'
})
// 获取Umami统计数据
onMounted(async () => {
try {
const response = await $fetch<any>('https://umami链接/api/stats', {
method: 'GET',
headers: {
// 如果需要认证,请添加相应的headers
// 'Authorization': 'Bearer your-token-here'
}
})
if (response) {
statsData.value = {
today_uv: formatNumber(response.today_uv || 0),
today_pv: formatNumber(response.today_pv || 0),
yesterday_uv: formatNumber(response.yesterday_uv || 0),
yesterday_pv: formatNumber(response.yesterday_pv || 0),
last_month_pv: formatNumber(response.last_month_pv || 0),
last_year_pv: formatNumber(response.last_year_pv || 0)
}
}
} catch (error) {
console.error('获取统计数据失败:', error)
}
})
// 格式化数字
function formatNumber(num: number) {
if (num >= 10000) {
return `${(num / 10000).toFixed(1)}万`
}
return num.toString()
}
</script>
<template>
<div class="about-page">
<!-- 移动端导航 -->
<div class="mobile-only">
<ClarityHeader to="/" />
</div>
<div class="about-content">
<!-- 页面标题 -->
<header class="about-header">
<div class="left-content">
<h1>关于我</h1>
<p>总有些事情比永恒更重要!</p>
</div>
<div class="right-content">
<div class="avatar-frame">
<img :src="author.avatar" alt="作者头像" class="avatar-image">
</div>
</div>
</header>
<!-- 卡片网格布局 -->
<div class="cards-grid">
<!-- 个人介绍卡片 -->
<div class="card intro-card">
<p>您好,很高兴认识您!👋</p>
<h2>我叫 {{ author.name }}</h2>
<p>是一名 学生、独立开发者、志海融新成员、博主。</p>
<Icon name="ph:user-circle-bold" class="card-bg-icon" />
</div>
<!-- 信息卡片 - 出生和年龄 -->
<div class="card info-card age-card">
<div class="info-item special-info-item">
<span class="label">出生</span>
<span class="value">2010</span>
</div>
<div class="info-item special-info-item">
<span class="label">当前</span>
<span class="value">15岁 <Icon name="ph:graduation-cap-bold" /></span>
</div>
<Icon name="ph:calendar-dots-bold" class="card-bg-icon" />
</div>
<!-- 座右铭卡片 -->
<div class="card motto-card">
<span class="label">座右铭</span>
<p>越努力</p>
<p>越幸运</p>
<Icon name="ph:compass-bold" class="card-bg-icon" />
</div>
<!-- 关注偏好卡片 -->
<div class="card tech-card">
<span class="label">关注偏好</span>
<h3>资源分享</h3>
<p>小说、PC游戏</p>
<Icon name="ph:star-bold" class="card-bg-icon" />
</div>
<!-- 音乐偏好卡片 -->
<div class="card music-card">
<span class="label">音乐偏好</span>
<h3>情歌、民谣、轻音乐</h3>
<p>等我喜欢就听</p>
<Icon name="ph:music-notes-simple-bold" class="card-bg-icon" />
</div>
<!-- 性格卡片 -->
<div class="card info-card personality-card">
<span class="label">性格</span>
<div class="content-center">
<span class="value">指挥官</span>
<span class="value-small">ENTJ-T</span>
</div>
<ProseA class="card-link" href="https://www.16personalities.com">在 16 Personalities 了解更多</ProseA>
<Icon name="ph:brain-bold" class="card-bg-icon" />
</div>
<!-- 特长卡片 -->
<div class="card specialty-card">
<span class="label">特长</span>
<p class="specialty-text">
Linux、社区管理专家
</p>
<p class="specialty-text">
学习能力 <span class="highlight">MAX</span>
</p>
<Icon name="ph:magic-wand-bold" class="card-bg-icon" />
</div>
<!-- 联系方式卡片 -->
<div class="card contact-card">
<span class="label">联系我</span>
<div class="contact-links">
<ZButton class="contact-link" v-tip="'Github'" icon="ph:github-logo-bold" to="https://github.com" />
<ZButton class="contact-link" v-tip="'Gitee'" icon="simple-icons:gitee" to="https://gitee.com" />
<ZButton class="contact-link" v-tip="'哔哩哔哩'" icon="ri:bilibili-fill" to="https://space.bilibili.com" />
<ZButton class="contact-link" v-tip="'邮箱'" icon="ph:envelope-simple-bold" :to="`mailto:${author.email}`" />
</div>
<Icon name="ph:address-book-bold" class="card-bg-icon" />
</div>
<!-- 网站统计卡片 -->
<div class="card stats-card">
<span class="label">网站统计</span>
<div class="stats-content">
<div class="stats-range-section">
<div class="stats-grid">
<div class="stat-item">
<span class="stat-value">{{ statsData.today_pv }}</span>
<span class="stat-label">浏览量</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ statsData.today_uv }}</span>
<span class="stat-label">访客数</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ statsData.yesterday_pv }}</span>
<span class="stat-label">访问次数</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ statsData.yesterday_uv }}</span>
<span class="stat-label">分钟停留</span>
</div>
</div>
</div>
</div>
<Icon name="ph:chart-line-bold" class="card-bg-icon" />
</div>
</div>
</div>
</div>
<PostComment />
</template>
<style lang="scss" scoped>
// 全局样式
.about-page {
padding: 2rem 1rem;
min-height: calc(100vh - var(--header-height));
animation: float-in .3s backwards;
}
.about-content {
max-width: 1000px;
margin: 0 auto;
}
// 页面标题
.about-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 3rem;
padding: 1rem 0;
text-align: left;
.left-content {
h1 {
margin-bottom: .5rem;
font-size: 2.5rem;
font-weight: 800;
color: var(--c-text-1);
}
p {
margin: 0;
font-size: 1.2rem;
color: var(--c-text-2);
}
}
.right-content {
display: flex;
align-items: center;
justify-content: center;
}
.avatar-frame {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
overflow: hidden;
width: 120px;
height: 120px;
border: 3px solid var(--c-border);
border-radius: 50%;
background-color: var(--c-bg-soft);
transition: all .3s ease;
}
.avatar-image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
// 卡片网格布局
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
// 通用卡片样式
.card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
min-height: 220px;
padding: 2rem 1.5rem;
border: 1px solid var(--c-border);
border-radius: 1.5rem;
background-color: var(--ld-bg-card);
text-align: center;
transition: all .3s ease;
box-shadow: none;
&:hover {
box-shadow: none;
transform: none;
}
.label {
position: absolute;
opacity: .8;
top: 1rem;
left: 1.5rem;
margin: 0;
font-size: .8rem;
color: var(--c-text-2);
}
.card-bg-icon {
position: absolute;
opacity: .1;
right: 1rem;
bottom: 1rem;
font-size: 5rem;
color: var(--c-text-1);
pointer-events: none;
}
}
// 卡片类型样式
// 个人介绍卡片
.intro-card {
grid-column: 1 / -1;
color: var(--c-text-1);
h2 {
margin: .5rem 0;
font-size: 3rem;
font-weight: bold;
}
}
// 信息卡片基类
.info-card {
align-items: stretch;
justify-content: center;
padding: 2.5rem 1.5rem;
color: var(--c-text-1);
.info-item {
display: flex;
flex-direction: column;
flex-grow: 1;
align-items: flex-start;
justify-content: center;
position: relative;
width: 100%;
.label {
flex-shrink: 0;
position: static;
width: 100%;
margin-bottom: .5rem;
text-align: left;
}
}
.value {
display: block;
width: 100%;
font-size: 2.5rem;
font-weight: bold;
text-align: center;
}
.value-small {
display: block;
width: 100%;
font-size: 2rem;
font-weight: bold;
text-align: center;
}
.card-link {
position: absolute;
right: 1.5rem;
bottom: 1rem;
font-size: .8rem;
color: var(--c-text-2);
&:hover {
color: var(--c-primary);
}
}
}
// 年龄卡片
.age-card {
padding: .4rem 1.5rem .5rem;
}
// 座右铭卡片
.motto-card {
color: var(--c-text-1);
p {
margin: 0;
font-size: 2.5rem;
font-weight: bold;
line-height: 1.2;
}
}
// 关注偏好卡片
.tech-card {
color: var(--c-text-1);
h3 {
margin: .5rem 0;
font-size: 3rem;
font-weight: bold;
}
p {
color: var(--c-text-2);
}
}
// 音乐偏好卡片
.music-card {
color: var(--c-text-1);
h3 {
font-size: 2.5rem;
font-weight: bold;
}
p {
color: var(--c-text-2);
}
}
// 特长卡片
.specialty-card {
font-size: 1.8rem;
font-weight: bold;
text-align: center;
color: var(--c-text-1);
.specialty-text {
margin: .2em 0;
}
.highlight {
display: inline-block;
font-size: 2.5rem;
line-height: 1;
color: var(--c-primary);
}
}
// 联系方式卡片
.contact-card {
grid-column: 1 / -1;
color: var(--c-text-1);
.contact-links {
display: flex;
justify-content: center;
flex-wrap: wrap;
max-width: 600px;
margin: 0 auto;
}
.contact-link {
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
background-color: var(--c-bg-1);
border: 1px solid var(--c-border);
border-radius: 50%;
color: var(--c-text-1);
font-size: 1.4rem;
transition: all .2s ease;
padding: 0;
box-shadow: none;
&:hover {
background-color: var(--c-bg-soft);
color: var(--c-text);
}
}
}
// 网站统计卡片
.stats-card {
grid-column: 1 / -1;
color: var(--c-text-1);
}
.stats-content {
width: 100%;
}
.stats-range-section {
margin-bottom: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
margin-bottom: 0;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: .5rem;
background-color: var(--c-bg-1);
border-radius: .8rem;
transition: transform .2s ease;
}
.stat-value {
margin-bottom: .25rem;
font-size: 2rem;
font-weight: bold;
color: var(--c-text-1);
}
.stat-label {
opacity: .9;
font-size: .9rem;
color: var(--c-text-2);
}
// 动画效果
@keyframes float-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// 响应式布局
@media (max-width: 768px) {
.about-page {
padding: 1rem;
}
.about-header {
flex-direction: column;
text-align: center;
margin-bottom: 2rem;
}
.about-header .left-content {
margin-bottom: 1.5rem;
}
.avatar-frame {
width: 100px;
height: 100px;
}
.card {
padding: 1.5rem 1rem;
}
.cards-grid {
gap: 1rem;
}
.age-card {
padding: .4rem 1.5rem .5rem;
}
.contact-card .contact-link {
width: 45px;
height: 45px;
font-size: 1.2rem;
}
.stats-card {
padding: 3rem 1rem 1.5rem;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
// 暗黑模式支持
:deep(.dark-mode) {
.card {
border-color: var(--c-border);
background-color: var(--c-bg-2);
}
.label, .card-bg-icon, .tech-card p, .stat-label {
color: var(--c-text-2);
}
.info-card .card-link {
color: var(--c-text-2);
&:hover {
color: var(--c-primary);
}
}
.specialty-card .highlight {
color: var(--c-primary);
}
.contact-card .contact-link {
background-color: var(--c-bg-1);
border-color: var(--c-border);
color: var(--c-text-1);
&:hover {
background-color: var(--c-bg-soft);
color: var(--c-text);
transform: translateY(-2px);
border-color: var(--c-border);
}
}
.stat-item {
background-color: var(--c-bg-1);
}
.avatar-frame {
border-color: var(--c-border);
background-color: var(--c-bg-soft);
}
}
</style>
<style lang="scss">
.dark .tippy-box {
background-color: var(--c-bg-2);
.tippy-svg-arrow {
fill: var(--c-bg-2);
}
}
</style>
评论加载中...