页脚改版:删掉重复的导航栏,桌面补站点信息;我的介绍可点去关于页
页脚「导航」栏 - 它是顶部导航的逐项复制(9 条名字和目标完全一样),手机上白占 312px - 已整栏删除;桌面端改用「最新文章」(最近 4 篇,按发布时间倒序, 不把置顶提前),把重复变成发现 - 手机端「最新文章」隐藏(上面就是文章列表,再列一遍没意义) - 不影响 SEO:顶部导航 hidden md:flex 和移动端菜单里的同一批链接 在每页 HTML 里都还在,爬虫照样抓得到 站点信息(新增) - 新增 src/lib/site-stats.ts:文章数/偶得数/分类数/标签数/总字数 在构建期算;「已运行 N 天」「最近更新」由页脚内联脚本按真实时间刷新, 避免站点久不重建时数字停在构建那天;服务端先渲染构建时兜底值,关 JS 不空 - 页脚加一条站点信息带:建站时间 / 已运行 / 文章 / 偶得 / 总字数 / 最近更新 桌面 6 列一行,手机 3 列两行 - 实测数值:2026-07-17 建站、已运行 60 天、30 篇文章、8 条偶得、 2.9 万字、最近更新 59 天前 「我的介绍」跳关于页 - 页脚 BRAND 栏整块包 <a href="/about/">,加 hover 反馈(头像描边转主色、 站名转主色、右上箭头位移)和常显的「关于我 →」提示(手机没有 hover, 不能只在 hover 才出现) - 侧栏作者卡片同样处理。注意这里**只包到签名那一段** —— 下面的社交图标 本身是 <a>,<a> 不能嵌套 <a>;页脚 BRAND 栏没有嵌套链接,所以可以整块包 - 两处都给 aria-label,避免读屏把「头像 alt + 站名 + 签名」念成链接名 工程改动 - footer.tsx -> footer.astro:React 组件不能在构建期 await 统计数据, 换成 Astro 组件后可以直接取;12 处引用同步改成默认导入 - README 补「页脚」一节,并修正组件路径 验证(headless Chrome 131,禁用缓存) - 桌面 1280:页脚 529px,三栏(我的介绍/最新文章/联系)+ 站点信息 + 底栏, 页脚内 nav 链接数 0,无横向溢出 - 手机 390:页脚 822px(原 957),最新文章 display:none,三栏只剩两栏 - 点页脚「我的介绍」-> /about/;点侧栏作者卡片 -> /about/,均正常 - 页面内 `a a` 嵌套数量:页脚 0、侧栏 0
This commit is contained in:
@@ -168,7 +168,7 @@ epigraph: 题记 # 可选
|
||||
| 评论(twikoo) | `src/data/comments.ts` |
|
||||
| 友链列表 | `src/data/friends.json` |
|
||||
| 首页打字机文案 | `src/components/hero.tsx` 顶部的 `phrases` |
|
||||
| 导航栏、页脚 | `src/components/site-header.astro`、`src/components/footer.tsx` |
|
||||
| 导航栏、页脚 | `src/components/site-header.astro`、`src/components/footer.astro` |
|
||||
| 配色与全局样式 | `src/styles/global.css` |
|
||||
| 代码块主题 | `astro.config.mjs` 里 `expressiveCode` 的 `themes` |
|
||||
| 免责声明模板 | `src/data/disclaimers.ts` |
|
||||
@@ -233,6 +233,29 @@ prose-p:leading-[1.78] md:prose-p:leading-[1.85]
|
||||
|
||||
`--header-h: 69px` 是固定页眉的实测高度,移动端菜单和这条胶囊条都靠它定位。**改页眉内边距或头像尺寸时记得同步改它**,否则吸附位置会错。
|
||||
|
||||
### 页脚
|
||||
|
||||
`src/components/footer.astro`(原来是个 React 组件 `footer.tsx`,为了能在构建期 `await` 统计数据改成了 Astro 组件;引用方式随之从 `import { Footer } from "@/components/footer"` 变成 `import Footer from "@/components/footer.astro"`)。
|
||||
|
||||
结构:
|
||||
|
||||
| 区块 | 内容 | 显示范围 |
|
||||
|---|---|---|
|
||||
| 我的介绍 | 头像 + 站名 + 签名,**整块可点去 `/about/`** | 全部 |
|
||||
| 最新文章 | 最近 4 篇(按发布时间倒序,不把置顶提前) | 桌面 |
|
||||
| 联系 | 社交图标 + 订阅 RSS | 全部 |
|
||||
| 站点信息 | 建站时间 / 已运行 / 文章 / 偶得 / 总字数 / 最近更新 | 全部(想只在桌面显示就给它加 `hidden md:grid`) |
|
||||
| 底栏 | 版权 / 备案 / 主题署名 | 全部 |
|
||||
|
||||
**原来的「导航」栏已删除** —— 它是顶部导航的逐项复制(9 条名字和目标完全一样),手机上白占 312px。桌面端改用「最新文章」,把重复变成发现。不影响 SEO:顶部导航和移动端菜单里的链接在每页 HTML 里都还在。
|
||||
|
||||
两点需要注意:
|
||||
|
||||
- **「我的介绍」整块包 `<a>` 是合法的**,因为这一块里没有别的链接。但**侧栏的作者卡片不行** —— 它下面有社交图标(本身是 `<a>`),`<a>` 不能嵌套。所以那里只包到签名那一段。
|
||||
- 整块链接要给 `aria-label`,否则读屏会把「头像 alt + 站名 + 签名」整段念成链接名。
|
||||
|
||||
统计数据在 `src/lib/site-stats.ts`:文章数/偶得数/分类数/标签数/总字数在构建时算,「已运行 N 天」和「最近更新」由页脚里的内联脚本按**真实时间**刷新(站点久不重建也不会停在构建那天),服务端会先渲染一份构建时兜底值,关 JS 也不空。加减统计项改 `footer.astro` 里的 `statCells` 数组即可。
|
||||
|
||||
### 磨砂玻璃
|
||||
|
||||
`global.css` 里有四个 token:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import SiteHeader from "@/components/site-header.astro"
|
||||
import { Footer } from "@/components/footer"
|
||||
import Footer from "@/components/footer.astro"
|
||||
import { cn } from "@/lib/utils"
|
||||
import booksData from "../../public/data/books.json"
|
||||
import comicsData from "../../public/data/comics.json"
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
import type { ComponentType, SVGProps } from "react"
|
||||
import { ArrowRight, Github, Mail, Music, Rss } from "lucide-react"
|
||||
import { QqIcon } from "@/components/icons/qq-icon"
|
||||
import { profile, type ExternalProfileLink, type ExternalProfileLinkType } from "@/data/profile"
|
||||
import { site } from "@/data/site"
|
||||
import { formatWords, getSiteStats } from "@/lib/site-stats"
|
||||
|
||||
const iconMap: Record<ExternalProfileLinkType, ComponentType<SVGProps<SVGSVGElement>>> = {
|
||||
qq: QqIcon,
|
||||
music: Music,
|
||||
github: Github,
|
||||
email: Mail,
|
||||
}
|
||||
|
||||
const stats = await getSiteStats()
|
||||
const contactLinks = profile.links.filter(
|
||||
(link): link is ExternalProfileLink => link.type !== "wechat",
|
||||
)
|
||||
|
||||
const statCells = [
|
||||
{ label: "建站时间", value: stats.startDate, live: undefined },
|
||||
{ label: "已运行", value: String(stats.runningDays), unit: "天", live: "running-days" },
|
||||
{ label: "文章", value: String(stats.postCount), unit: "篇", live: undefined },
|
||||
{ label: "偶得", value: String(stats.thoughtCount), unit: "条", live: undefined },
|
||||
{ label: "总字数", value: formatWords(stats.totalWords), unit: "字", live: undefined },
|
||||
{ label: "最近更新", value: stats.lastUpdatedText, live: "last-update" },
|
||||
]
|
||||
---
|
||||
|
||||
<footer class="border-t border-border/50 bg-background">
|
||||
<div class="max-w-6xl mx-auto px-6 py-16">
|
||||
<div class="grid gap-12 md:grid-cols-3">
|
||||
{/*
|
||||
我的介绍:整块可点去关于页(这里没有嵌套链接,所以整块包 <a> 是合法的)。
|
||||
aria-label 是为了不让读屏把头像 alt + 站名 + 签名全念成链接名。
|
||||
*/}
|
||||
<a href="/about/" class="group block" aria-label={`关于 ${profile.name}`}>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-8 h-8 rounded-full overflow-hidden border border-primary/20 bg-secondary transition-colors duration-200 group-hover:border-primary/60">
|
||||
<img
|
||||
src={profile.avatar}
|
||||
alt={profile.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-foreground font-medium transition-colors duration-200 group-hover:text-primary">
|
||||
{site.name}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed">{profile.bio}</p>
|
||||
<span class="mt-3 inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors duration-200 group-hover:text-primary">
|
||||
关于我
|
||||
<ArrowRight class="w-3 h-3 transition-transform duration-200 group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/*
|
||||
最新文章:手机端上面就是文章列表,再列一遍没意义,所以只在 md 以上显示。
|
||||
页脚原来那栏「导航」是顶部导航的逐项复制(9 条完全一样),已删掉。
|
||||
*/}
|
||||
<div class="hidden md:block md:justify-self-center">
|
||||
<h4 class="text-sm font-medium text-foreground mb-4 uppercase tracking-wide">最新文章</h4>
|
||||
<ul class="flex flex-col gap-3">
|
||||
{stats.latestPosts.map((post) => (
|
||||
<li>
|
||||
<a
|
||||
href={post.href}
|
||||
class="group flex items-baseline gap-3 text-sm text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||
>
|
||||
<span class="tabular-nums text-xs text-muted-foreground/70 shrink-0">{post.date.slice(5)}</span>
|
||||
<span class="line-clamp-1 transition-colors duration-200 group-hover:text-primary">{post.title}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="md:justify-self-end">
|
||||
<h4 class="text-sm font-medium text-foreground mb-4 uppercase tracking-wide">联系</h4>
|
||||
<div class="flex items-center gap-4">
|
||||
{contactLinks.map((link) => {
|
||||
const Icon = iconMap[link.type]
|
||||
return (
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="w-10 h-10 rounded-full border border-border flex items-center justify-center text-muted-foreground hover:text-foreground hover:border-primary/50 transition-all duration-200"
|
||||
aria-label={link.name}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm mt-6">通过 RSS 订阅,获取最新文章更新。</p>
|
||||
<a
|
||||
href="/rss.xml"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:opacity-90 transition-opacity duration-200"
|
||||
>
|
||||
<Rss className="w-4 h-4" />
|
||||
订阅 RSS
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 站点信息:桌面 6 列一行,手机 3 列两行 */}
|
||||
<div class="mt-14 pt-8 border-t border-border/50 grid grid-cols-3 gap-x-4 gap-y-6 md:grid-cols-6">
|
||||
{statCells.map((cell) => (
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground mb-1.5">{cell.label}</div>
|
||||
<div class="text-foreground font-medium tabular-nums">
|
||||
<span data-stat={cell.live}>{cell.value}</span>
|
||||
{cell.unit && <span class="text-xs text-muted-foreground ml-0.5">{cell.unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom */}
|
||||
<div class="mt-10 pt-8 border-t border-border/50 grid gap-4 md:grid-cols-3">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
© {site.startDate.slice(0, 4)} {profile.name}. All rights reserved.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 md:justify-center">
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-muted-foreground text-xs hover:text-foreground transition-colors duration-200"
|
||||
>
|
||||
陇ICP备2026007748号
|
||||
</a>
|
||||
<a
|
||||
href="https://beian.mps.gov.cn/#/query/webSearch?code=62070202000904"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="inline-flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground transition-colors duration-200"
|
||||
>
|
||||
<img src="/images/beian.png" alt="公安备案" class="h-3.5 w-auto shrink-0" />
|
||||
甘公网安备62070202000904号
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/Lapis0x0/VermilionVoid"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="flex items-center gap-2 text-muted-foreground text-xs hover:text-foreground transition-colors duration-200 group md:justify-end md:pr-4"
|
||||
>
|
||||
<Github class="w-3.5 h-3.5" />
|
||||
<span>博客主题:朱墨留白 | VermilionVoid</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script is:inline define:vars={{ siteStartDate: stats.startDate, lastUpdatedIso: stats.lastUpdatedIso }}>
|
||||
// 运行天数和「最近更新」按真实时间算,避免站点久不重建时数字停在构建那天。
|
||||
// 服务端已经渲染了一份兜底值,没 JS 也不会是空的。
|
||||
;(() => {
|
||||
const updateStats = () => {
|
||||
const now = new Date()
|
||||
|
||||
const start = new Date(siteStartDate + "T00:00:00+08:00")
|
||||
const days = Math.max(0, Math.floor((now.getTime() - start.getTime()) / 86400000))
|
||||
document.querySelectorAll('[data-stat="running-days"]').forEach((el) => {
|
||||
el.textContent = String(days)
|
||||
})
|
||||
|
||||
if (!lastUpdatedIso) return
|
||||
const last = new Date(lastUpdatedIso)
|
||||
const diff = Math.max(0, Math.floor((now.getTime() - last.getTime()) / 86400000))
|
||||
const text = diff === 0 ? "今天" : diff === 1 ? "昨天" : `${diff} 天前`
|
||||
document.querySelectorAll('[data-stat="last-update"]').forEach((el) => {
|
||||
el.textContent = text
|
||||
})
|
||||
}
|
||||
|
||||
updateStats()
|
||||
// ClientRouter 切页后页脚是新节点,要再刷一次
|
||||
document.addEventListener("astro:page-load", updateStats)
|
||||
})()
|
||||
</script>
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { ComponentType, SVGProps } from "react"
|
||||
import { Github, Mail, Music, Rss } from "lucide-react"
|
||||
import { profile, type ExternalProfileLink, type ExternalProfileLinkType } from "@/data/profile"
|
||||
import { site } from "@/data/site"
|
||||
import { QqIcon } from "@/components/icons/qq-icon"
|
||||
|
||||
const iconMap: Record<ExternalProfileLinkType, ComponentType<SVGProps<SVGSVGElement>>> = {
|
||||
qq: QqIcon,
|
||||
music: Music,
|
||||
github: Github,
|
||||
email: Mail,
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-border/50 bg-background">
|
||||
<div className="max-w-6xl mx-auto px-6 py-16">
|
||||
<div className="grid gap-12 md:grid-cols-3">
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-full overflow-hidden border border-primary/20 bg-secondary">
|
||||
<img
|
||||
src={profile.avatar}
|
||||
alt={profile.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-foreground font-medium">{site.name}</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{profile.bio}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="md:flex md:justify-center">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground mb-4 uppercase tracking-wide">导航</h4>
|
||||
<nav className="flex flex-col gap-3">
|
||||
{[
|
||||
{ name: "首页", href: "/" },
|
||||
{ name: "时间线", href: "/timeline/" },
|
||||
{ name: "偶得", href: "/thoughts/" },
|
||||
{ name: "相册", href: "/gallery/" },
|
||||
{ name: "书架", href: "/bookshelf/" },
|
||||
{ name: "专题", href: "/studies/" },
|
||||
{ name: "友链", href: "/friends/" },
|
||||
{ name: "关于", href: "/about/" },
|
||||
{ name: "开往", href: "https://www.travellings.cn/go.html", external: true },
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noreferrer" : undefined}
|
||||
className="text-muted-foreground text-sm hover:text-foreground transition-colors duration-200"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connect */}
|
||||
<div className="md:flex md:justify-end">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground mb-4 uppercase tracking-wide">联系</h4>
|
||||
<div className="flex items-center gap-4">
|
||||
{profile.links.filter((link): link is ExternalProfileLink => link.type !== "wechat").map((link) => {
|
||||
const Icon = iconMap[link.type]
|
||||
return (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-10 h-10 rounded-full border border-border flex items-center justify-center text-muted-foreground hover:text-foreground hover:border-primary/50 transition-all duration-200"
|
||||
aria-label={link.name}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mt-6">通过 RSS 订阅,获取最新文章更新。</p>
|
||||
<a
|
||||
href="/rss.xml"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:opacity-90 transition-opacity duration-200"
|
||||
>
|
||||
<Rss className="w-4 h-4" />
|
||||
订阅 RSS
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom */}
|
||||
<div className="mt-16 pt-8 border-t border-border/50 grid gap-4 md:grid-cols-3">
|
||||
<p className="text-muted-foreground text-sm">© {site.startDate.slice(0, 4)} {profile.name}. All rights reserved.</p>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 md:justify-center">
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-muted-foreground text-xs hover:text-foreground transition-colors duration-200"
|
||||
>
|
||||
陇ICP备2026007748号
|
||||
</a>
|
||||
<a
|
||||
href="https://beian.mps.gov.cn/#/query/webSearch?code=62070202000904"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground transition-colors duration-200"
|
||||
>
|
||||
<img src="/images/beian.png" alt="公安备案" className="h-3.5 w-auto shrink-0" />
|
||||
甘公网安备62070202000904号
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/Lapis0x0/VermilionVoid"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 text-muted-foreground text-xs hover:text-foreground transition-colors duration-200 group md:justify-end md:pr-4"
|
||||
>
|
||||
<Github className="w-3.5 h-3.5" />
|
||||
<span>博客主题:朱墨留白 | VermilionVoid</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, type ReactNode } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Calendar } from "lucide-react"
|
||||
import { ArrowRight, Calendar } from "lucide-react"
|
||||
import { profile } from "@/data/profile"
|
||||
import { ProfileContactLink } from "@/components/profile-contact-link"
|
||||
|
||||
@@ -39,23 +39,33 @@ export function Sidebar({
|
||||
|
||||
return (
|
||||
<aside className="w-full lg:w-72 shrink-0 space-y-8">
|
||||
{/* Author Card */}
|
||||
{/* Author Card —— 头像/站名/签名整块可点,跳关于页。
|
||||
注意只能包到这里:下面的社交图标本身是 <a>,<a> 不能嵌套 <a>。
|
||||
aria-label 是为了不让读屏把头像 alt + 站名 + 签名整段念成链接名。 */}
|
||||
<div className="bg-card border border-border/50 rounded-xl p-6 text-center">
|
||||
<div className="w-24 h-24 mx-auto mb-4 rounded-full overflow-hidden border-2 border-primary/20">
|
||||
<a href="/about/" className="group block" aria-label={`关于 ${profile.name}`}>
|
||||
<div className="w-24 h-24 mx-auto mb-4 rounded-full overflow-hidden border-2 border-primary/20 transition-colors duration-200 group-hover:border-primary/60">
|
||||
<img
|
||||
src={profile.avatar}
|
||||
alt={profile.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover"
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="font-serif text-xl font-bold text-foreground mb-1">{profile.name}</h3>
|
||||
<h3 className="font-serif text-xl font-bold text-foreground mb-1 transition-colors duration-200 group-hover:text-primary">
|
||||
{profile.name}
|
||||
</h3>
|
||||
<div className="w-8 h-0.5 bg-primary mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">{profile.bio}</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{profile.bio}</p>
|
||||
<span className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors duration-200 group-hover:text-primary">
|
||||
关于我
|
||||
<ArrowRight className="w-3 h-3 transition-transform duration-200 group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* Social Links */}
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<div className="flex items-center justify-center gap-3 mt-4 mb-4">
|
||||
{profile.links.map((link) => {
|
||||
return (
|
||||
<ProfileContactLink
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { getCollection } from "astro:content"
|
||||
import { site } from "@/data/site"
|
||||
import { buildSidebarMeta, getSortedPosts, toPostMeta } from "./posts"
|
||||
|
||||
export type LatestPostLink = {
|
||||
title: string
|
||||
href: string
|
||||
date: string
|
||||
}
|
||||
|
||||
export type SiteStats = {
|
||||
/** 建站日期,YYYY-MM-DD */
|
||||
startDate: string
|
||||
/** 已运行天数(构建时算的兜底值,页面加载后由内联脚本按真实时间刷新) */
|
||||
runningDays: number
|
||||
postCount: number
|
||||
thoughtCount: number
|
||||
categoryCount: number
|
||||
tagCount: number
|
||||
totalWords: number
|
||||
/** 最后一篇的发布时间(ISO),用于前端算「最近更新」 */
|
||||
lastUpdatedIso: string | null
|
||||
/** 「最近更新」的构建时文案,作为无 JS 时的兜底 */
|
||||
lastUpdatedText: string
|
||||
/** 最近文章,按发布时间倒序(不把置顶提前) */
|
||||
latestPosts: LatestPostLink[]
|
||||
}
|
||||
|
||||
/** 把字数说成人话:12345 -> 1.2 万 */
|
||||
export const formatWords = (n: number) => {
|
||||
if (n >= 10000) return `${(n / 10000).toFixed(1)} 万`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
|
||||
export async function getSiteStats(latestCount = 4): Promise<SiteStats> {
|
||||
const posts = await getSortedPosts()
|
||||
const metas = posts.map(toPostMeta)
|
||||
const { categories, tags } = buildSidebarMeta(metas)
|
||||
const thoughts = await getCollection("thoughts")
|
||||
|
||||
const totalWords = metas.reduce((sum, meta) => sum + (meta.wordCount || 0), 0)
|
||||
|
||||
// 分类里有一条 id 为 "all" 的合集项,统计时要去掉
|
||||
const categoryCount = categories.filter((category) => category.id !== "all").length
|
||||
|
||||
const startDate = site.startDate
|
||||
const runningDays = Math.max(
|
||||
0,
|
||||
Math.floor((Date.now() - startOfDay(new Date(`${startDate}T00:00:00+08:00`)).getTime()) / 86_400_000),
|
||||
)
|
||||
|
||||
// 「最近更新」看的是最后动过的时间,有 updated 就用它
|
||||
const latestDate = posts.reduce<Date | null>((acc, post) => {
|
||||
const d = new Date(post.data.updated ?? post.data.published)
|
||||
return !acc || d > acc ? d : acc
|
||||
}, null)
|
||||
|
||||
const latestPosts = [...posts]
|
||||
.sort((a, b) => new Date(b.data.published).getTime() - new Date(a.data.published).getTime())
|
||||
.slice(0, latestCount)
|
||||
.map((post) => ({
|
||||
title: post.data.title,
|
||||
href: `/posts/${post.slug}/`,
|
||||
date: new Date(post.data.published).toISOString().slice(0, 10),
|
||||
}))
|
||||
|
||||
const sinceUpdate = latestDate
|
||||
? Math.max(0, Math.floor((Date.now() - latestDate.getTime()) / 86_400_000))
|
||||
: null
|
||||
const lastUpdatedText =
|
||||
sinceUpdate === null ? "—" : sinceUpdate === 0 ? "今天" : sinceUpdate === 1 ? "昨天" : `${sinceUpdate} 天前`
|
||||
|
||||
return {
|
||||
startDate,
|
||||
runningDays,
|
||||
postCount: posts.length,
|
||||
thoughtCount: thoughts.length,
|
||||
categoryCount,
|
||||
tagCount: tags.length,
|
||||
totalWords,
|
||||
lastUpdatedIso: latestDate ? latestDate.toISOString() : null,
|
||||
lastUpdatedText,
|
||||
latestPosts,
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { GetStaticPaths } from "astro"
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||
import { ArticleList } from "@/components/article-list"
|
||||
import { Footer } from "@/components/footer"
|
||||
import Footer from "@/components/footer.astro"
|
||||
import SiteHeader from "@/components/site-header.astro"
|
||||
import { PAGE_SIZE } from "@/lib/constants"
|
||||
import { buildSidebarMeta, getSortedPosts, toPostMeta } from "@/lib/posts"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||
import AboutPage from "@/components/pages/AboutPage"
|
||||
import Comments from "@/components/Comments.astro"
|
||||
import { Footer } from "@/components/footer"
|
||||
import Footer from "@/components/footer.astro"
|
||||
import SiteHeader from "@/components/site-header.astro"
|
||||
import { siteTitleSuffix } from "@/data/site"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||
import FriendsPage from "@/components/pages/FriendsPage.astro"
|
||||
import Comments from "@/components/Comments.astro"
|
||||
import { Footer } from "@/components/footer"
|
||||
import Footer from "@/components/footer.astro"
|
||||
import { siteTitleSuffix } from "@/data/site"
|
||||
|
||||
const title = `友链 | ${siteTitleSuffix}`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||
import SiteHeader from "@/components/site-header.astro"
|
||||
import { Footer } from "@/components/footer"
|
||||
import Footer from "@/components/footer.astro"
|
||||
import { siteTitleSuffix } from "@/data/site"
|
||||
import { galleryConfig } from "@/data/gallery"
|
||||
import { getAlbumCover, scanAlbumPhotos } from "@/lib/gallery"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||
import SiteHeader from "@/components/site-header.astro"
|
||||
import { Footer } from "@/components/footer"
|
||||
import Footer from "@/components/footer.astro"
|
||||
import { siteTitleSuffix } from "@/data/site"
|
||||
import { getAllAlbumTags, getAlbums } from "@/lib/gallery"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||
import { ArticleList } from "@/components/article-list"
|
||||
import { Footer } from "@/components/footer"
|
||||
import Footer from "@/components/footer.astro"
|
||||
import SiteHeader from "@/components/site-header.astro"
|
||||
import { Hero } from "@/components/hero"
|
||||
import { PAGE_SIZE } from "@/lib/constants"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||
import ArticleContent from "@/components/ArticleContent.astro"
|
||||
import SiteHeader from "@/components/site-header.astro"
|
||||
import { Footer } from "@/components/footer"
|
||||
import Footer from "@/components/footer.astro"
|
||||
import { getPostsWithNav, type PostEntry } from "@/lib/posts"
|
||||
import Disclaimer from "@/components/Disclaimer.astro"
|
||||
import PasswordProtection from "@/components/PasswordProtection.astro"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||
import SiteHeader from "@/components/site-header.astro"
|
||||
import { Footer } from "@/components/footer"
|
||||
import Footer from "@/components/footer.astro"
|
||||
import { StudyDetailView } from "@/components/studies-detail"
|
||||
import { getAllStudies } from "@/lib/studies"
|
||||
import { siteTitleSuffix } from "@/data/site"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||
import SiteHeader from "@/components/site-header.astro"
|
||||
import { Footer } from "@/components/footer"
|
||||
import Footer from "@/components/footer.astro"
|
||||
import { StudiesList } from "@/components/studies-list"
|
||||
import { getAllStudies } from "@/lib/studies"
|
||||
import { siteTitleSuffix } from "@/data/site"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||
import { ThoughtsView } from "@/components/thoughts-view"
|
||||
import { Footer } from "@/components/footer"
|
||||
import Footer from "@/components/footer.astro"
|
||||
import SiteHeader from "@/components/site-header.astro"
|
||||
import { getSortedThoughts, toThoughtMeta } from "@/lib/thoughts"
|
||||
import { siteTitleSuffix } from "@/data/site"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||
import Timeline from "@/components/Timeline.astro"
|
||||
import SiteHeader from "@/components/site-header.astro"
|
||||
import { Footer } from "@/components/footer"
|
||||
import Footer from "@/components/footer.astro"
|
||||
import { getSortedPosts } from "@/lib/posts"
|
||||
import { siteTitleSuffix } from "@/data/site"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user