first commit

This commit is contained in:
2026-01-07 16:24:34 +00:00
commit 5c1399bb96
362 changed files with 59794 additions and 0 deletions

134
src/components/README.md Normal file
View File

@@ -0,0 +1,134 @@
# Components 组件目录
本目录包含项目中所有的可复用组件,按功能分类组织。
## 📁 目录结构
### 🏗️ layout/ - 布局组件
页面布局和结构相关的组件,负责整体页面框架。
- `Footer.astro` - 页脚组件
- `Navbar.astro` - 导航栏组件
- `PostPage.astro` - 文章页面布局组件
- `ConfigCarrier.astro` - 配置载体组件
- `GlobalStyles.astro` - 全局样式组件
- `SideBar.astro` - 侧边栏组件(响应式布局)
- `LeftSideBar.astro` - 左侧边栏组件
- `RightSideBar.astro` - 右侧边栏组件
- `DropdownMenu.astro` - 下拉菜单组件
- `NavMenuPanel.astro` - 导航菜单面板
### 🎮 interactive/ - 交互组件
具有用户交互功能的组件,如切换、搜索、面板等。
- `LightDarkSwitch.svelte` - 主题切换组件
- `LayoutSwitchButton.svelte` - 布局切换按钮
- `Search.svelte` - 搜索功能组件
- `ArchivePanel.svelte` - 归档面板组件
- `FontManager.astro` - 字体管理组件
- `DisplaySettings.svelte` - 显示设置组件
- `OverlayWallpaper.astro` - 覆盖层壁纸组件
- `WallpaperSwitch.svelte` - 壁纸模式切换组件
### 📄 content/ - 内容组件
用于展示内容的组件,如文章卡片、元数据等。
- `PostCard.astro` - 文章卡片组件
- `PostMeta.astro` - 文章元数据组件
- `TypewriterText.astro` - 打字机效果文本组件
- `StatCard.astro` - 统计卡片组件
- `Profile.astro` - 个人资料组件
### 🔧 common/ - 公共组件
通用的、可复用的 UI 组件,分为三个子文件夹:
#### base/ - 基础 UI 组件
- `DropdownPanel` (Astro & Svelte) - 下拉面板容器
- `DropdownItem` (Astro & Svelte) - 下拉选项
#### controls/ - 控制交互组件
- `BackToTop.astro` - 返回顶部按钮
- `ButtonLink.astro` - 链接按钮组件
- `ButtonTag.astro` - 标签按钮组件
- `Pagination.astro` - 静态路由分页组件Astro 原生分页)
- `ClientPagination.astro` - 客户端 JavaScript 分页组件DOM 显示/隐藏控制)
- `FloatingTOC.astro` - 浮动目录组件
#### styles/ - 样式组件
- `TOCStyles.astro` - 目录样式组件
### 🧩 widget/ - 小部件组件
各种功能小部件如音乐播放器、Live2D等。
- `Advertisement.astro` - 广告组件
- `Announcement.astro` - 公告组件
- `Calendar.astro` - 日历组件
- `Categories.astro` - 分类组件
- `Live2DWidget.astro` - Live2D 小部件
- `MusicPlayer.astro` - 音乐播放器组件
- `PioMessageBox.astro` - Pio 消息框组件
- `SidebarTOC.astro` - 侧边栏目录组件
- `SiteStats.astro` - 站点统计组件
- `SpineModel.astro` - Spine 模型组件
- `Tags.astro` - 标签组件
- `WidgetLayout.astro` - 小部件布局组件
### 🔧 misc/ - 杂项组件
各种辅助和工具组件。
- `Icon.astro` - 图标组件
- `IconifyLoader.astro` - Iconify 加载器组件
- `ImageWrapper.astro` - 图片包装器组件
- `License.astro` - 许可证组件
- `Markdown.astro` - Markdown 渲染组件
- `RandomCoverImage.astro` - 随机封面图组件
- `SharePoster.svelte` - 分享海报组件
### 💬 comment/ - 评论组件
评论系统相关组件。
- `index.astro` - 评论主组件
- `Artalk.astro` - Artalk 评论组件
- `Disqus.astro` - Disqus 评论组件
- `Giscus.astro` - Giscus 评论组件
- `Twikoo.astro` - Twikoo 评论组件
- `Waline.astro` - Waline 评论组件
### 📃 pages/ - 页面组件
页面的相关组件
#### 🎬 pages/bangumi/ - 番组计划组件
Bangumi 番组追踪页面的相关组件,用于展示和管理用户的动漫追番记录。
- `BangumiSection.astro` - 番组分类展示组件,用于展示单个分类的项目列表和筛选控制
- `Card.astro` - 番组卡片组件,展示单个动漫/游戏/书籍作品的基本信息和状态标签
- `FilterControls.astro` - 筛选控制组件,提供按状态筛选的按钮组
- `TabNav.astro` - 标签导航组件,用于在不同分类(书籍、动画、音乐、游戏等)之间切换
### ✨ effects/ - 特效组件
页面特效和动画相关的组件。
- `FancyboxManager.astro` - Fancybox 图片查看器管理组件
- `KatexManager.astro` - Katex 数学公式渲染管理组件
- `SakuraEffect.astro` - 樱花飘落特效组件
## 📚 详细文档
详细的组件使用说明,请查看各目录下的 `README.md` 文件:
- [common/ 公共组件详细文档](./common/README.md)
## 🗂️ 组件分类原则
1. **layout/** - 页面布局和结构
2. **interactive/** - 用户交互功能
3. **content/** - 内容展示
4. **common/** - 公共可复用组件
5. **widget/** - 功能小部件
6. **misc/** - 辅助工具
7. **comment/** - 评论系统
8. **pages/** - 页面特定组件
9. **effects/** - 页面特效和动画
---

View File

@@ -0,0 +1,19 @@
---
interface Props {
analyticsId: string;
}
const { analyticsId } = Astro.props;
---
<!-- Google tag (gtag.js) -->
<script is:inline data-swup-ignore-script async src={`https://www.googletagmanager.com/gtag/js?id=${analyticsId}`}></script>
<script is:inline data-swup-ignore-script define:vars={{analyticsId}}>window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', analyticsId);
</script>

View File

@@ -0,0 +1,15 @@
---
interface Props {
clarityId: string;
}
const { clarityId } = Astro.props;
---
<script is:inline data-swup-ignore-script define:vars={{ clarityId }}>
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", clarityId);
</script>

View File

@@ -0,0 +1,50 @@
---
import { commentConfig, siteConfig } from "@/config";
interface Props {
path: string;
}
const config = {
...commentConfig.artalk,
el: "#artalk",
site: siteConfig.title,
pageKey: Astro.props.path,
dark: "html.dark",
pageTitle: "",
...(commentConfig.artalk?.visitorCount ? { pageview: true } : {}),
};
---
<!-- Artalk -->
<div class="relative w-full">
<!-- 挂载点 -->
<div id="artalk" style="--at-color-main: var(--primary); --at-color-bg: var(--card-bg); --at-color-border: var(--line-divider);"></div>
<!-- 引入 Artalk 样式 -->
<link rel="stylesheet" href="https://unpkg.com/artalk/dist/Artalk.css"/>
<!-- 脚本逻辑 -->
<script type="module" is:inline define:vars={{config}}>
import Artalk from 'https://unpkg.com/artalk/dist/Artalk.mjs';
// 初始化 Artalk
const artalk = Artalk.init(config);
// 深色模式
function updateTheme() {
const isDark = document.documentElement.classList.contains('dark');
artalk.setDarkMode(isDark);
}
updateTheme();
const observer = new MutationObserver((_mutations) => {
updateTheme();
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
</script>
</div>

View File

@@ -0,0 +1,37 @@
---
import { commentConfig } from "@/config";
interface Props {
identifier: string;
url: string;
title: string;
}
const { identifier, url, title } = Astro.props;
if (!commentConfig || !commentConfig.disqus) {
throw new Error("Disqus comments are not configured");
}
const shortname = commentConfig.disqus.shortname;
---
<div id="disqus_thread"></div>
<script is:inline define:vars={{ shortname, identifier, url, title }}>
// @ts-ignore
window.disqus_config = function () {
this.page.url = url
this.page.identifier = identifier
this.page.title = title
}
;(function () {
var d = document,
s = d.createElement('script')
s.src = 'https://' + shortname + '.disqus.com/embed.js'
s.setAttribute('data-timestamp', new Date().toString())
;(d.head || d.body).appendChild(s)
})()
</script>
<noscript>Please enable JavaScript to view the <a href="https://disqus.com/?ref_noscript">comments powered by Disqus.</a></noscript>
<style is:global></style>

View File

@@ -0,0 +1,63 @@
---
import { commentConfig } from "@/config";
if (!commentConfig || !commentConfig.giscus) {
throw new Error("Giscus comments are not configured");
}
const giscus = commentConfig.giscus;
const lightTheme = "light";
const darkTheme = "dark";
---
<script is:inline type="module" src="https://esm.sh/giscus"></script>
<!--
Giscus 官方Web Component用法兼容Astro静态输出无需import包无需NPM依赖
参考https://giscus.app/ 详情配置说明
-->
<giscus-widget
id="comments"
repo={giscus.repo}
repoId={giscus.repoId}
category={giscus.category}
categoryId={giscus.categoryId}
mapping={giscus.mapping}
strict={giscus.strict}
reactionsEnabled={giscus.reactionsEnabled}
emitMetadata={giscus.emitMetadata}
inputPosition={giscus.inputPosition}
theme={lightTheme}
lang={giscus.lang}
loading={giscus.loading}
/>
<script is:inline define:vars={{ lightTheme, darkTheme }}>
function updateGiscusTheme() {
const isDark = document.documentElement.classList.contains('dark');
const theme = isDark ? darkTheme : lightTheme;
const widget = document.querySelector('giscus-widget');
if (widget) {
widget.setAttribute('theme', theme);
}
}
// Initial update
updateGiscusTheme();
// Clean up previous observer if exists
if (window.giscusThemeObserver) {
window.giscusThemeObserver.disconnect();
}
// Create new observer
window.giscusThemeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
updateGiscusTheme();
}
});
});
window.giscusThemeObserver.observe(document.documentElement, { attributes: true });
</script>

View File

@@ -0,0 +1,88 @@
---
import { commentConfig } from "@/config/commentConfig";
import { url } from "@/utils/url-utils";
interface Props {
path: string;
}
const config = {
...commentConfig.twikoo,
el: "#tcomment",
path: Astro.props.path,
};
---
<div id="tcomment"></div>
<!-- 使用自编译的 Twikoo 文件,避免点赞等按钮导致页面触发滚动回顶部 https://github.com/twikoojs/twikoo/issues/721 -->
<script is:inline src={url("/assets/js/firefly-twikoo-1.6.44.all.min.js")}></script>
<script is:inline define:vars={{ config }}>
// 获取当前页面路径
function getCurrentPath() {
const pathname = window.location.pathname;
return pathname.endsWith("/") && pathname.length > 1
? pathname.slice(0, -1)
: pathname;
}
// 动态创建配置对象
function createTwikooConfig() {
return {
...config,
path: getCurrentPath(),
el: "#tcomment",
};
}
// 初始化 Twikoo
function initTwikoo() {
if (typeof twikoo !== "undefined") {
const commentEl = document.getElementById("tcomment");
if (commentEl) {
commentEl.innerHTML = "";
const dynamicConfig = createTwikooConfig();
console.log("[Twikoo] 初始化配置:", dynamicConfig);
twikoo
.init(dynamicConfig)
.then(() => {
console.log("[Twikoo] 初始化完成");
})
.catch((error) => {
console.error("[Twikoo] 初始化失败:", error);
});
}
} else {
// 如果 Twikoo 未加载,稍后重试
setTimeout(initTwikoo, 500);
}
}
// 页面加载时初始化
document.addEventListener("DOMContentLoaded", initTwikoo);
// Swup 页面切换后重新初始化
if (window.swup && window.swup.hooks) {
window.swup.hooks.on("content:replace", function () {
setTimeout(initTwikoo, 200);
});
} else {
document.addEventListener("swup:enable", function () {
if (window.swup && window.swup.hooks) {
window.swup.hooks.on("content:replace", function () {
setTimeout(initTwikoo, 200);
});
}
});
}
// 自定义事件监听
document.addEventListener("firefly:page:loaded", function () {
const commentEl = document.getElementById("tcomment");
if (commentEl) {
console.log("[Twikoo] 通过自定义事件重新初始化");
initTwikoo();
}
});
</script>

View File

@@ -0,0 +1,25 @@
---
import { commentConfig } from "@/config";
interface Props {
path: string;
}
const config = {
...commentConfig.waline,
el: "#waline",
path: Astro.props.path,
dark: "html.dark",
wordLimit: ["2", "300"],
...(commentConfig.waline?.visitorCount ? { pageview: true } : {}),
};
---
<!-- Waline -->
<div class="relative w-full">
<div id="waline"></div>
<link rel="stylesheet" href="https://unpkg.com/@waline/client@v3/dist/waline.css" />
<script type="module" is:inline define:vars={{ config }}>
import { init } from 'https://unpkg.com/@waline/client@v3/dist/waline.js';
init(config);
</script>
</div>

View File

@@ -0,0 +1,70 @@
---
import type { CollectionEntry } from "astro:content";
import { commentConfig } from "@/config/commentConfig";
import Key from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import { removeFileExtension } from "@/utils/url-utils";
import Artalk from "./Artalk.astro";
import Disqus from "./Disqus.astro";
import Giscus from "./Giscus.astro";
import Twikoo from "./Twikoo.astro";
import Waline from "./Waline.astro";
interface Props {
post: CollectionEntry<"posts"> | CollectionEntry<"spec">;
customPath?: string;
}
const { post, customPath } = Astro.props;
const { id } = post;
// 根据页面类型确定路径
const slug = removeFileExtension(id);
const path =
customPath || (post.collection === "posts" ? `/posts/${slug}` : `/${slug}`);
const url = `${Astro.site?.href}${path}`;
// 强制commentService类型为string避免类型不兼容警告
let commentService: string = commentConfig?.type || "none";
---
{
commentService !== "none" && (
<div class="card-base p-8 mb-6 relative overflow-hidden">
<!-- 评论区装饰性背景 -->
<div class="absolute top-0 right-0 w-32 h-32 opacity-5 pointer-events-none">
<svg viewBox="0 0 100 100" class="w-full h-full">
<circle cx="50" cy="50" r="40" fill="currentColor" class="text-[var(--primary)]" />
<circle cx="50" cy="50" r="25" fill="none" stroke="currentColor" stroke-width="2" class="text-[var(--primary)]" />
<circle cx="50" cy="50" r="10" fill="currentColor" class="text-[var(--primary)]" />
</svg>
</div>
<!-- 评论区标题 -->
<div class="relative z-10 mb-6">
<div class="flex items-center gap-3 mb-2">
<div class="w-1 h-6 bg-gradient-to-b from-[var(--primary)] to-transparent rounded-full"></div>
<h3 class="text-xl font-bold text-[var(--btn-content)]">{i18n(Key.commentSection)}</h3>
</div>
<p class="text-sm text-[var(--content-meta)] ml-4">{i18n(Key.commentSubtitle)}</p>
</div>
<!-- 评论内容区域 -->
<div class="relative z-10">
{commentService === "twikoo" && <Twikoo path={path} />}
{commentService === "waline" && <Waline path={path} />}
{commentService === "giscus" && <Giscus />}
{commentService === "disqus" && <Disqus identifier={slug} url={url} title={'title' in post.data ? post.data.title : slug} />}
{commentService === "artalk" && <Artalk path={path} />}
{commentService === "none" && (
<div class="text-center py-8 text-[var(--content-meta)]">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--btn-regular-bg)] flex items-center justify-center">
<svg class="w-8 h-8 text-[var(--primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
</svg>
</div>
<p>{i18n(Key.commentNotConfigured)}</p>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,196 @@
# Common 公共组件
位于 `src/components/common/`
这个文件夹包含项目中通用的、可复用的 UI 组件。
## 📁 文件夹结构
```
common/
├── base/ # 基础 UI 组件
├── controls/ # 控制交互组件
└── styles/ # 样式组件
```
## 📦 base/ - 基础 UI 组件
基础的 UI 容器和组件,用于构建更复杂的界面。
### DropdownPanel (Astro & Svelte)
下拉面板容器组件,提供统一的卡片样式背景。
**文件:**
- `DropdownPanel.astro` - Astro 版本
- `DropdownPanel.svelte` - Svelte 版本
**Props:**
- `class?: string` - 可选的额外类名
- `children?: Snippet` (Svelte only)
**使用示例:**
```astro
import DropdownPanel from "@/components/common/base/DropdownPanel.astro";
<DropdownPanel class="dropdown-content">
<!-- 下拉菜单项 -->
</DropdownPanel>
```
### DropdownItem (Astro & Svelte)
下拉面板选项组件,提供统一的按钮样式。
**文件:**
- `DropdownItem.astro` - Astro 版本
- `DropdownItem.svelte` - Svelte 版本
**Props (Astro):**
- `href?: string` - 链接地址
- `target?: string` - 链接目标
- `isActive?: boolean` - 是否为激活状态
- `isLast?: boolean` - 是否为最后一项
- `class?: string` - 额外类名
- `onclick?: string` - 点击事件
**Props (Svelte):**
- `isActive?: boolean` - 是否为激活状态
- `isLast?: boolean` - 是否为最后一项
- `class?: string` - 额外类名
- `onclick?: (event: MouseEvent) => void` - 点击事件
- `children?: Snippet`
**使用场景:**
- WallpaperSwitch.svelte - 壁纸模式切换
- LightDarkSwitch.svelte - 亮暗色主题切换
- DropdownMenu.astro - 导航栏下拉菜单
## 🎛️ controls/ - 控制交互组件
用户交互控制组件,如按钮、分页等。
### BackToTop.astro
返回顶部按钮组件。
**使用:**
```astro
import BackToTop from "@/components/common/controls/BackToTop.astro";
<BackToTop />
```
### ButtonLink.astro
链接按钮组件,用于分类等场景。
**使用:**
```astro
import ButtonLink from "@/components/common/controls/ButtonLink.astro";
<ButtonLink href="/category/tech">技术</ButtonLink>
```
### ButtonTag.astro
标签按钮组件,用于标签展示。
**使用:**
```astro
import ButtonTag from "@/components/common/controls/ButtonTag.astro";
<ButtonTag href="/tag/javascript">JavaScript</ButtonTag>
```
### Pagination.astro
静态路由分页组件,用于 Astro 原生分页(文章列表等)。
**Props:**
- `page: Page` - Astro 的 Page 对象(由 `paginate()` 生成)
- `class?: string` - 可选的额外类名
- `style?: string` - 可选的样式
**使用:**
```astro
import Pagination from "@/components/common/controls/Pagination.astro";
<Pagination page={page} />
```
**使用场景:**
- `[...page].astro` - 文章列表分页
### ClientPagination.astro
客户端 JavaScript 分页组件,用于 DOM 级别的显示/隐藏控制。
**Props:**
- `totalItems: number` - 总条目数
- `itemsPerPage: number` - 每页显示数量
- `currentPage: number` - 当前页码
- `sectionId: string` - 分页区域唯一标识
**特点:**
- 支持移动端和桌面端不同布局
- 可以响应筛选器事件(通过 `updatePagination` 自定义事件)
- 支持多个独立的分页区域(通过 `sectionId`
- 通过 JavaScript 控制 `data-item-section` 元素的显示隐藏
**使用:**
```astro
import ClientPagination from "@/components/common/controls/ClientPagination.astro";
<ClientPagination
totalItems={items.length}
itemsPerPage={12}
currentPage={1}
sectionId="my-section"
/>
```
**使用场景:**
- `bangumi.astro` - 番组页面的动态分页
- 任何需要客户端分页的场景
## 🎨 styles/ - 样式组件
提供统一样式的组件。
### TOCStyles.astro
目录Table of Contents的样式组件。
**使用:**
```astro
import TOCStyles from "@/components/common/styles/TOCStyles.astro";
<TOCStyles />
```
**使用场景:**
- SidebarTOC.astro - 侧边栏目录
- FloatingTOC.astro - 浮动目录
## 📝 样式规范
### 下拉面板
- 容器: `card-base float-panel p-2`
- 最小宽度: `min-w-[12rem]`
### 下拉选项
- 基础类: `btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95`
- 间距: 非最后一项 `mb-0.5`
- 激活: `current-theme-btn`
### 图标
- 大小: `text-[1.25rem]`
- 间距: `mr-3`
## 🔧 技术说明
### Astro vs Svelte
- **Astro 组件**: 适用于静态内容,服务端渲染
- **Svelte 组件**: 适用于需要客户端交互的场景
### Svelte 5 新特性
Base 组件使用 Svelte 5 的 **Snippet****`{@render}`** 语法:
- `children?: Snippet` - 接收子内容
- `{@render children()}` - 渲染子内容
- 完全兼容 Svelte 5 标准
## 📚 参考
- [Svelte 5 迁移指南](https://svelte.dev/docs/svelte/v5-migration-guide)

View File

@@ -0,0 +1,41 @@
---
/**
* 公共下拉面板选项组件
* 用于下拉面板中的选项项
*/
interface Props {
href?: string;
target?: string;
isActive?: boolean;
isLast?: boolean;
class?: string;
onclick?: string;
}
const {
href,
target,
isActive = false,
isLast = false,
class: className,
onclick,
} = Astro.props;
const baseClasses =
"flex transition whitespace-nowrap items-center !justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95";
const spacingClass = isLast ? "" : "mb-0.5";
const activeClass = isActive ? "current-theme-btn" : "";
const allClasses =
`${baseClasses} ${spacingClass} ${activeClass} ${className || ""}`.trim();
const Tag = href ? "a" : "button";
---
<Tag
href={href}
target={target}
class={allClasses}
onclick={onclick}
>
<slot />
</Tag>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
/**
* 公共下拉面板选项组件 (Svelte 5 版本)
* 用于下拉面板中的选项项
*/
import type { Snippet } from "svelte";
interface Props {
isActive?: boolean;
isLast?: boolean;
class?: string;
onclick?: (event: MouseEvent) => void;
children?: Snippet;
}
let {
isActive = false,
isLast = false,
class: className = "",
onclick,
children,
...restProps
}: Props = $props();
const baseClasses =
"flex transition whitespace-nowrap items-center !justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95";
// 使用 $derived 使类名响应式
const allClasses = $derived.by(() => {
const spacingClass = isLast ? "" : "mb-0.5";
const activeClass = isActive ? "current-theme-btn" : "";
return `${baseClasses} ${spacingClass} ${activeClass} ${className}`.trim();
});
</script>
<button
class={allClasses}
{onclick}
{...restProps}
>
{#if children}
{@render children()}
{/if}
</button>

View File

@@ -0,0 +1,15 @@
---
/**
* 公共下拉面板组件
* 用于壁纸切换、亮暗色切换、导航栏菜单等下拉面板
*/
interface Props {
class?: string;
}
const { class: className } = Astro.props;
---
<div class:list={["card-base float-panel p-2", className]}>
<slot />
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
/**
* 公共下拉面板组件 (Svelte 5 版本)
* 用于壁纸切换、亮暗色切换等下拉面板
*/
import type { Snippet } from "svelte";
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = "", children, ...restProps }: Props = $props();
</script>
<div class={`card-base float-panel p-2 ${className}`.trim()} {...restProps}>
{#if children}
{@render children()}
{/if}
</div>

View File

@@ -0,0 +1,223 @@
---
import { Icon } from "astro-icon/components";
---
<!-- There can't be a filter on parent element, or it will break `fixed` -->
<div class="back-to-top-wrapper hidden lg:block">
<div
id="back-to-top-btn"
class="back-to-top-btn hide flex items-center rounded-2xl overflow-hidden transition"
onclick="backToTop()"
>
<button
aria-label="Back to Top"
class="h-[3.75rem] w-[3.75rem] rounded-2xl"
>
<Icon name="material-symbols:keyboard-arrow-up-rounded" class="mx-auto" />
</button>
</div>
</div>
<style lang="stylus">
.back-to-top-wrapper
width: 3.75rem
height: 3.75rem
position: absolute
right: 0
top: 0
pointer-events: none
.back-to-top-btn
color: var(--primary)
font-size: 2.25rem
font-weight: bold
position: fixed
bottom: 8rem
right: 2rem
opacity: 1
cursor: pointer
pointer-events: auto
z-index: 1000
transition: all 0.3s ease
border-radius: 1rem
backdrop-filter: blur(12px)
background-color: var(--card-bg)
border: 1px solid rgba(0, 0, 0, 0.1)
i
font-size: 1.75rem
&.hide
transform: scale(0.9)
opacity: 0
pointer-events: none
&:active
transform: scale(0.9)
&:hover
transform: scale(1.05)
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15)
background-color: rgba(255, 255, 255, 0.95)
border-color: rgba(0, 0, 0, 0.2)
/* 暗色主题样式 */
:global(.dark) .back-to-top-btn
background-color: var(--card-bg)
border: 1px solid rgba(255, 255, 255, 0.15)
color: var(--primary, #60a5fa)
&:hover
background-color: var(--card-bg)
border-color: rgba(255, 255, 255, 0.3)
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3)
/* 响应式调整 */
@media (max-width: 1024px)
.back-to-top-btn
right: 1rem
bottom: 6rem
@media (max-width: 768px)
.back-to-top-btn
right: 0.75rem
bottom: 4rem
width: 3rem
height: 3rem
font-size: 1.5rem
border-radius: 0.75rem
i
font-size: 1.25rem
@media (max-width: 480px)
.back-to-top-btn
right: 0.5rem
bottom: 4rem
width: 2.5rem
height: 2.5rem
font-size: 1.25rem
border-radius: 0.5rem
i
font-size: 1rem
/* 高缩放比例适配 */
@media (min-resolution: 2dppx)
.back-to-top-btn
right: max(0.5rem, 2rem - 2vw)
bottom: max(7rem, 8rem - 5vh)
/* 超小屏幕适配 */
@media (max-width: 360px)
.back-to-top-btn
right: 0.25rem
bottom: 3rem
width: 2rem
height: 2rem
font-size: 1rem
border-radius: 0.375rem
i
font-size: 0.875rem
/* 横屏模式适配 */
@media (orientation: landscape) and (max-height: 500px)
.back-to-top-btn
bottom: 8rem
right: 0.5rem
/* 确保按钮始终在可视区域内 */
.back-to-top-btn
/* 防止按钮被裁剪 */
min-width: 2rem
min-height: 2rem
/* 确保按钮有足够的点击区域 */
padding: 0.25rem
/* 防止按钮超出屏幕 */
max-width: 4rem
max-height: 4rem
/* 确保圆角在所有尺寸下都正确 */
border-radius: 1rem !important
/* 高DPI屏幕优化 */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi)
.back-to-top-btn
/* 确保在高DPI屏幕上清晰显示 */
image-rendering: -webkit-optimize-contrast
image-rendering: crisp-edges
/* 极低分辨率适配 */
@media (max-width: 320px)
.back-to-top-btn
right: 0.125rem
bottom: 2.5rem
width: 1.75rem
height: 1.75rem
font-size: 0.875rem
border-radius: 0.25rem
i
font-size: 0.75rem
/* 确保按钮在容器内正确显示 */
.back-to-top-wrapper
/* 确保容器不会裁剪内容 */
overflow: visible
/* 防止容器影响按钮定位 */
z-index: 999
/* 按钮激活状态 */
.back-to-top-btn:active
transform: scale(0.95)
</style>
<script is:raw is:inline>
function backToTop() {
// 直接使用原生滚动避免OverlayScrollbars冲突
window.scroll({ top: 0, behavior: "smooth" });
}
// 响应式返回顶部按钮管理器
if (typeof window.BackToTopManager === "undefined") {
window.BackToTopManager = class BackToTopManager {
constructor() {
this.button = document.getElementById("back-to-top-btn");
this.wrapper = document.querySelector(".back-to-top-wrapper");
this.init();
}
init() {
if (!this.button || !this.wrapper) return;
this.setupScrollListener();
}
setupScrollListener() {
const updateVisibility = () => {
const scrollTop =
window.pageYOffset || document.documentElement.scrollTop;
// 当滚动超过200px时显示按钮
if (scrollTop > 200) {
this.button.classList.remove("hide");
} else {
this.button.classList.add("hide");
}
};
window.addEventListener("scroll", updateVisibility, { passive: true });
}
// 移除resize监听和位置更新逻辑因为CSS媒体查询已经处理了响应式定位
};
}
// 页面加载完成后初始化
document.addEventListener("DOMContentLoaded", () => {
new BackToTopManager();
});
// 如果页面已经加载完成,立即初始化
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
new BackToTopManager();
});
} else {
new BackToTopManager();
}
</script>

View File

@@ -0,0 +1,43 @@
---
interface Props {
badge?: string;
url?: string;
label?: string;
}
const { badge, url, label } = Astro.props;
---
<a href={url} aria-label={label}>
<button
class:list={`
w-full
h-10
rounded-lg
bg-none
hover:bg-[var(--btn-plain-bg-hover)]
active:bg-[var(--btn-plain-bg-active)]
transition-all
pl-2
hover:pl-3
text-neutral-700
hover:text-[var(--primary)]
dark:text-neutral-300
dark:hover:text-[var(--primary)]
`
}
>
<div class="flex items-center justify-between relative mr-2">
<div class="overflow-hidden text-left whitespace-nowrap overflow-ellipsis ">
<slot></slot>
</div>
{ badge !== undefined && badge !== null && badge !== '' &&
<div class="transition px-2 h-7 ml-4 min-w-[2rem] rounded-lg text-sm font-bold
text-[var(--btn-content)] dark:text-[var(--deep-text)]
bg-[oklch(0.95_0.025_var(--hue))] dark:bg-[var(--primary)]
flex items-center justify-center">
{ badge }
</div>
}
</div>
</button>
</a>

View File

@@ -0,0 +1,13 @@
---
interface Props {
size?: string;
dot?: boolean;
href?: string;
label?: string;
}
const { dot, href, label }: Props = Astro.props;
---
<a href={href} aria-label={label} class="btn-regular h-8 text-sm px-3 rounded-lg">
{dot && <div class="h-1 w-1 bg-[var(--btn-content)] dark:bg-[var(--card-bg)] transition rounded-md mr-2"></div>}
<slot></slot>
</a>

View File

@@ -0,0 +1,404 @@
---
import { Icon } from "astro-icon/components";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
interface Props {
totalItems: number;
itemsPerPage: number;
currentPage: number;
sectionId: string;
}
const { totalItems, itemsPerPage, currentPage, sectionId } = Astro.props;
const totalPages = Math.ceil(totalItems / itemsPerPage);
// 生成智能分页页码数组
function generatePageNumbers(current: number, total: number) {
const delta = 2; // 当前页左右显示的页码数量
const range: number[] = [];
const rangeWithDots: (number | string)[] = [];
// 如果总页数小于等于7显示所有页码
if (total <= 7) {
for (let i = 1; i <= total; i++) {
range.push(i);
}
return range;
}
// 计算显示范围
const left = Math.max(2, current - delta);
const right = Math.min(total - 1, current + delta);
// 始终显示第一页
rangeWithDots.push(1);
// 如果左边界大于2添加省略号
if (left > 2) {
rangeWithDots.push("...");
}
// 添加中间页码
for (let i = left; i <= right; i++) {
rangeWithDots.push(i);
}
// 如果右边界小于最后一页-1添加省略号
if (right < total - 1) {
rangeWithDots.push("...");
}
// 始终显示最后一页如果总页数大于1
if (total > 1) {
rangeWithDots.push(total);
}
return rangeWithDots;
}
const pageNumbers = generatePageNumbers(currentPage, totalPages);
---
{totalPages > 1 && (
<div class="responsive-pagination flex justify-center items-center mt-8" data-pagination-section={sectionId}>
<!-- 移动端简化版分页 -->
<div class="mobile-pagination items-center gap-3">
<button
type="button"
class="btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11 disabled:opacity-50 disabled:cursor-not-allowed"
data-page="prev"
data-section={sectionId}
disabled={currentPage === 1}
aria-label={i18n(I18nKey.bangumiPrevPage)}
>
<Icon name="material-symbols:chevron-left-rounded" class="text-[1.75rem]" />
</button>
<!-- 移动端页码信息 -->
<div class="bg-[var(--card-bg)] flex items-center rounded-lg px-4 h-11 gap-1.5">
<span class="mobile-current-page text-base font-bold text-[var(--primary)]">{currentPage}</span>
<span class="text-sm text-neutral-500 dark:text-neutral-500">/</span>
<span class="mobile-total-pages text-base font-bold text-neutral-700 dark:text-neutral-300">{totalPages}</span>
</div>
<button
type="button"
class="btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11 disabled:opacity-50 disabled:cursor-not-allowed"
data-page="next"
data-section={sectionId}
disabled={currentPage === totalPages}
aria-label={i18n(I18nKey.bangumiNextPage)}
>
<Icon name="material-symbols:chevron-right-rounded" class="text-[1.75rem]" />
</button>
</div>
<!-- 桌面端完整版分页 -->
<div class="desktop-pagination items-center gap-3">
<button
type="button"
class="btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11 disabled:opacity-50 disabled:cursor-not-allowed"
data-page="prev"
data-section={sectionId}
disabled={currentPage === 1}
aria-label={i18n(I18nKey.bangumiPrevPage)}
>
<Icon name="material-symbols:chevron-left-rounded" class="text-[1.75rem]" />
</button>
<div class="bg-[var(--card-bg)] flex flex-row rounded-lg items-center text-neutral-700 dark:text-neutral-300 font-bold" data-page-numbers={sectionId}>
{pageNumbers.map((pageItem) => (
pageItem === '...' ? (
<Icon name="material-symbols:more-horiz" class="mx-1" />
) : (
<button
type="button"
class:list={[
"rounded-lg overflow-hidden w-11 h-11 flex items-center justify-center font-bold",
{
"bg-[var(--primary)] text-white dark:text-black/70": pageItem === currentPage,
"btn-card active:scale-[0.85]": pageItem !== currentPage
}
]}
data-page={pageItem}
data-section={sectionId}
aria-label={`${pageItem}`}
aria-current={pageItem === currentPage ? "page" : undefined}
>
{pageItem}
</button>
)
))}
</div>
<button
type="button"
class="btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11 disabled:opacity-50 disabled:cursor-not-allowed"
data-page="next"
data-section={sectionId}
disabled={currentPage === totalPages}
aria-label={i18n(I18nKey.bangumiNextPage)}
>
<Icon name="material-symbols:chevron-right-rounded" class="text-[1.75rem]" />
</button>
</div>
</div>
)}
<script is:inline define:vars={{ itemsPerPage, sectionId }}>
function initPagination() {
let currentPage = 1;
const pagination = document.querySelector(`[data-pagination-section="${sectionId}"]`);
if (!pagination) return;
// 更新移动端页码显示
function updateMobileDisplay() {
const mobileCurrentPage = pagination.querySelector('.mobile-current-page');
if (mobileCurrentPage) {
mobileCurrentPage.textContent = currentPage.toString();
}
}
const pageButtons = pagination.querySelectorAll('[data-page]');
pageButtons.forEach(button => {
button.addEventListener('click', function() {
const page = this.dataset.page;
if (page === 'prev') {
currentPage = Math.max(1, currentPage - 1);
} else if (page === 'next') {
const items = document.querySelectorAll(`[data-item-section="${sectionId}"]:not(.hidden)`);
const totalPages = Math.ceil(items.length / itemsPerPage);
currentPage = Math.min(totalPages, currentPage + 1);
} else {
currentPage = parseInt(page);
}
updatePage();
updateMobileDisplay();
});
});
// Listen for filter updates
if (pagination) {
pagination.addEventListener('updatePagination', function(_event) {
currentPage = 1;
updatePage();
updateMobileDisplay();
});
}
function updatePage() {
const items = document.querySelectorAll(`[data-item-section="${sectionId}"]:not(.hidden)`);
const totalPages = Math.ceil(items.length / itemsPerPage);
// Hide all items first
for (const item of items) {
item.style.display = 'none';
}
// Show items for current page
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
for (let i = startIndex; i < endIndex && i < items.length; i++) {
items[i].style.display = 'block';
}
// Update pagination buttons
updatePaginationButtons(totalPages);
}
// 生成智能分页页码数组的JavaScript版本
function generatePageNumbers(current, total) {
const delta = 2; // 当前页左右显示的页码数量
const rangeWithDots = [];
// 如果总页数小于等于7显示所有页码
if (total <= 7) {
for (let i = 1; i <= total; i++) {
rangeWithDots.push(i);
}
return rangeWithDots;
}
// 计算显示范围
const left = Math.max(2, current - delta);
const right = Math.min(total - 1, current + delta);
// 始终显示第一页
rangeWithDots.push(1);
// 如果左边界大于2添加省略号
if (left > 2) {
rangeWithDots.push('...');
}
// 添加中间页码
for (let i = left; i <= right; i++) {
rangeWithDots.push(i);
}
// 如果右边界小于最后一页-1添加省略号
if (right < total - 1) {
rangeWithDots.push('...');
}
// 始终显示最后一页如果总页数大于1
if (total > 1) {
rangeWithDots.push(total);
}
return rangeWithDots;
}
function updatePaginationButtons(totalPages) {
// 更新所有的上一页和下一页按钮(移动端和桌面端)
const prevButtons = pagination.querySelectorAll('[data-page="prev"]');
const nextButtons = pagination.querySelectorAll('[data-page="next"]');
const pageNumbersContainer = pagination.querySelector(`[data-page-numbers="${sectionId}"]`);
prevButtons.forEach(btn => {
btn.disabled = currentPage === 1;
});
nextButtons.forEach(btn => {
btn.disabled = currentPage === totalPages;
});
if (pageNumbersContainer) {
pageNumbersContainer.innerHTML = '';
const pageNumbers = generatePageNumbers(currentPage, totalPages);
pageNumbers.forEach(pageItem => {
if (pageItem === '...') {
// 创建省略号图标
const iconContainer = document.createElement('span');
iconContainer.innerHTML = '<svg class="mx-1" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>';
pageNumbersContainer.appendChild(iconContainer.firstChild);
} else {
const button = document.createElement('button');
button.className = pageItem === currentPage
? 'rounded-lg overflow-hidden w-11 h-11 flex items-center justify-center font-bold bg-[var(--primary)] text-white dark:text-black/70'
: 'rounded-lg overflow-hidden w-11 h-11 flex items-center justify-center font-bold btn-card active:scale-[0.85]';
button.dataset.page = pageItem.toString();
button.dataset.section = sectionId;
button.textContent = pageItem.toString();
button.addEventListener('click', function() {
currentPage = pageItem;
updatePage();
updateMobileDisplay();
});
pageNumbersContainer.appendChild(button);
}
});
}
}
// Initial page setup
updatePage();
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPagination);
} else {
initPagination();
}
</script>
<style>
.responsive-pagination {
/* 确保在所有设备上都能正确显示 */
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* 移动端样式 (< 768px) */
.mobile-pagination {
display: flex;
padding: 0 1rem;
}
.desktop-pagination {
display: none;
}
/* 桌面端样式 (>= 768px) */
@media (min-width: 768px) {
.mobile-pagination {
display: none;
}
.desktop-pagination {
display: flex;
}
}
/* 小屏手机优化 */
@media (max-width: 640px) {
.mobile-pagination {
padding: 0 0.5rem;
}
.mobile-pagination button {
padding: 0.25rem;
}
}
/* 超小屏优化 */
@media (max-width: 480px) {
.mobile-pagination {
padding: 0 0.25rem;
}
.mobile-pagination .space-x-1 > * + * {
margin-left: 0.125rem;
}
}
/* 平滑过渡效果 */
.responsive-pagination button {
transition: all 0.2s ease-in-out;
}
/* 高对比度模式支持 */
@media (prefers-contrast: high) {
.responsive-pagination button {
border: 1px solid currentColor;
}
}
/* 减少动画偏好支持 */
@media (prefers-reduced-motion: reduce) {
.responsive-pagination button {
transition: none;
}
}
/* 触摸设备优化 */
@media (hover: none) and (pointer: coarse) {
.responsive-pagination button {
min-height: 44px; /* iOS建议的最小触摸目标 */
min-width: 44px;
}
/* 移动端触摸优化 */
.mobile-pagination button {
min-height: 40px;
min-width: 40px;
}
}
/* 横屏手机优化 */
@media (max-width: 768px) and (orientation: landscape) {
.mobile-pagination {
padding: 0 0.5rem;
}
.mobile-pagination button {
padding: 0.25rem;
}
}
</style>

View File

@@ -0,0 +1,480 @@
---
import type { MarkdownHeading } from "astro";
import { Icon } from "astro-icon/components";
import TOCStyles from "@/components/common/styles/TOCStyles.astro";
import { sidebarLayoutConfig } from "@/config/sidebarConfig";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
interface Props {
headings: MarkdownHeading[];
}
let { headings: _ = [] } = Astro.props;
// 检查侧边栏目录组件是否启用
const sidebarTocComponent = sidebarLayoutConfig.rightComponents?.find(
(c) => c.type === "sidebarToc",
);
const isSidebarTocEnabled = sidebarTocComponent?.enable ?? false;
const sidebarPosition = sidebarLayoutConfig.position;
const showRightSidebarOnPostPage =
sidebarLayoutConfig.showRightSidebarOnPostPage;
---
<!-- 悬浮TOC按钮 -->
<div id="floating-toc-wrapper" class="floating-toc-wrapper" data-is-sidebar-toc-enabled={String(isSidebarTocEnabled)} data-sidebar-position={sidebarPosition} data-show-right-sidebar-on-post-page={String(showRightSidebarOnPostPage)}>
<div
id="floating-toc-btn"
class="floating-toc-btn hide flex items-center rounded-2xl overflow-hidden transition"
onclick="window.toggleFloatingTOC()"
>
<button
aria-label="Table of Contents"
class="h-[3.75rem] w-[3.75rem] rounded-2xl"
>
<Icon name="material-symbols:format-list-bulleted" class="mx-auto" />
</button>
</div>
</div>
<!-- 悬浮TOC面板 -->
<div
id="floating-toc-panel"
class="floating-toc-panel hide fixed w-80 max-h-[24rem] overflow-hidden rounded-2xl shadow-2xl backdrop-blur-lg border border-white/20 bg-white/60 dark:bg-black/60 dark:border-white/10 z-50 md:w-80 w-[calc(100vw-2rem)] md:max-h-[24rem] max-h-[calc(100vh-8rem)]"
style="background-color: rgba(var(--card-bg-rgb, 255, 255, 255), 0.6); bottom: calc(13rem + 6rem); right: 2rem;"
>
<div
class="p-4 border-b border-gray-200 dark:border-gray-700 sticky top-0 backdrop-blur-sm z-10"
style="background-color: rgba(var(--card-bg-rgb, 255, 255, 255), 0.6);"
>
<div class="flex items-center justify-between">
<h3
class="text-lg font-bold text-[var(--primary)] flex items-center gap-2"
>
{i18n(I18nKey.tableOfContents)}
</h3>
<button
onclick="window.toggleFloatingTOC()"
aria-label="Close TOC"
class="btn-plain rounded-lg h-8 w-8 active:scale-90"
>
<Icon name="material-symbols:close" class="text-lg" />
</button>
</div>
</div>
<div class="toc-scroll-container p-4">
<div
class="toc-content"
id="floating-toc-content"
style="width: 100%; max-width: 100%;"
>
<!-- TOC内容将由JavaScript动态生成 -->
</div>
</div>
</div>
<style lang="stylus">
.floating-toc-wrapper
width: 3.75rem
height: 3.75rem
position: absolute
right: 0
top: 0
pointer-events: none
.floating-toc-btn
color: var(--primary)
font-size: 2.25rem
font-weight: bold
border: none
position: fixed
bottom: 13rem
right: 2rem
opacity: 1
cursor: pointer
pointer-events: auto
z-index: 1000
transition: all 0.3s ease
border-radius: 1rem
backdrop-filter: blur(12px)
background-color: var(--card-bg)
border: 1px solid rgba(0, 0, 0, 0.1)
&.hide
transform: scale(0.9)
opacity: 0
pointer-events: none
&:active
transform: scale(0.9)
&:hover
transform: scale(1.05)
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15)
background-color: rgba(255, 255, 255, 0.95)
border-color: rgba(0, 0, 0, 0.2)
/* 暗色主题样式 */
:global(.dark) .floating-toc-btn
background-color: var(--card-bg)
border: 1px solid rgba(255, 255, 255, 0.15)
color: var(--primary, #60a5fa)
&:hover
background-color: var(--card-bg)
border-color: rgba(255, 255, 255, 0.3)
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3)
.floating-toc-panel
transition: all 0.3s ease
transform: translateY(20px)
opacity: 0
pointer-events: none
overflow: hidden
box-sizing: border-box
backdrop-filter: blur(20px)
-webkit-backdrop-filter: blur(20px)
&.show
transform: translateY(0)
opacity: 1
pointer-events: auto
&.hide
transform: translateY(20px)
opacity: 0
pointer-events: none
visibility: hidden
width: 0
height: 0
max-width: 0
max-height: 0
margin: 0
padding: 0
/* FloatingTOC 特定样式 */
.toc-scroll-container
max-height: calc(24rem - 5rem)
@media (max-width: 768px)
max-height: calc(24rem - 5rem)
/* 移动端隐藏活动指示器 */
@media (max-width: 768px)
#floating-active-indicator
display: none
/* 响应式调整 */
@media (max-width: 1024px)
.floating-toc-btn
right: 1rem
bottom: 10rem
.floating-toc-panel
right: 1rem !important
bottom: calc(10rem + 4rem) !important
width: calc(100vw - 2rem)
max-width: 20rem
max-height: 20rem
@media (max-width: 768px)
.floating-toc-btn
right: 0.75rem
bottom: 8rem
width: 3rem
height: 3rem
font-size: 1.5rem
border-radius: 0.75rem
.floating-toc-panel
right: 0.75rem !important
bottom: calc(8rem + 4rem) !important
width: calc(100vw - 1.5rem)
max-width: 20rem
max-height: 28rem
/* 高缩放比例适配 */
@media (min-resolution: 2dppx)
.floating-toc-btn
right: max(0.5rem, 2rem - 2vw)
bottom: max(12rem, 16rem - 5vh)
.floating-toc-panel
right: max(0.5rem, 2rem - 2vw) !important
bottom: calc(max(12rem, 16rem - 5vh) + 6rem) !important
/* 横屏模式适配 */
@media (orientation: landscape) and (max-height: 500px)
.floating-toc-btn
bottom: 6rem
right: 0.5rem
.floating-toc-panel
bottom: calc(6rem + 4rem) !important
right: 0.5rem !important
max-height: 12rem
/* 确保按钮始终在可视区域内 */
.floating-toc-btn
min-width: 2rem
min-height: 2rem
padding: 0.25rem
max-width: 4rem
max-height: 4rem
border-radius: 1rem !important
/* 高DPI屏幕优化 */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi)
.floating-toc-btn
image-rendering: -webkit-optimize-contrast
image-rendering: crisp-edges
/* 确保按钮在容器内正确显示 */
.floating-toc-wrapper
overflow: visible
z-index: 999
/* 按钮激活状态 */
.floating-toc-btn:active
transform: scale(0.95)
/* 暗色主题半透明效果 */
:global(.dark) .floating-toc-panel
background-color: rgba(0, 0, 0, 0.6) !important
backdrop-filter: blur(20px)
-webkit-backdrop-filter: blur(20px)
:global(.dark) .floating-toc-panel .p-4
background-color: rgba(0, 0, 0, 0.8) !important
</style>
<TOCStyles />
<script>
import { TOCManager, isPostPage } from "@/utils/tocUtils";
// 从 data 属性读取配置
const wrapper = document.querySelector('.floating-toc-wrapper');
const isSidebarTocEnabled = wrapper?.getAttribute('data-is-sidebar-toc-enabled') === 'true';
if (typeof window.FloatingTOC === "undefined") {
window.FloatingTOC = {
btn: null,
panel: null,
manager: null,
isPostPage: isPostPage // 使用导入的工具函数
};
}
// 切换 TOC 面板
window.toggleFloatingTOC = function () {
const panel = window.FloatingTOC.panel;
if (!panel) return;
const isHidden = panel.classList.contains("hide");
if (isHidden) {
panel.classList.remove("hide");
panel.classList.add("show");
} else {
panel.classList.remove("show");
panel.classList.add("hide");
}
};
// 关闭 TOC 面板
function closeTOC() {
const panel = window.FloatingTOC.panel;
if (panel && !panel.classList.contains("hide")) {
panel.classList.add("hide");
panel.classList.remove("show");
}
}
// 设置自动关闭功能
function setupAutoClose() {
// 监听页面卸载和隐藏
window.addEventListener("beforeunload", closeTOC);
window.addEventListener("pagehide", closeTOC);
window.addEventListener("popstate", closeTOC);
document.addEventListener("visibilitychange", () => {
if (document.hidden) closeTOC();
});
// 监听 Astro 页面切换
document.addEventListener("astro:page-load", closeTOC);
document.addEventListener("astro:before-preparation", closeTOC);
// 监听 hashchange排除TOC内部导航
window.addEventListener("hashchange", () => {
if (!window.tocInternalNavigation) {
closeTOC();
}
window.tocInternalNavigation = false;
});
// 监听外部链接点击
document.addEventListener("click", (e) => {
const target = e.target as Element | null;
const link = target?.closest("a");
if (!link || !link.href) return;
const href = link.getAttribute("href");
const isExternalLink = href && !href.startsWith("#") && !href.startsWith("javascript:");
if (isExternalLink && !window.tocInternalNavigation) {
const tocPanel = document.getElementById("floating-toc-panel");
if (!tocPanel || !tocPanel.contains(link)) {
closeTOC();
}
}
});
// 拦截 history API
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function (...args) {
closeTOC();
return originalPushState.apply(this, args);
};
history.replaceState = function (...args) {
closeTOC();
return originalReplaceState.apply(this, args);
};
}
// 检查是否应该显示悬浮目录
function shouldShowFloatingTOC() {
// 检查是否为文章页面
if (!window.FloatingTOC.isPostPage()) {
return false;
}
// 获取侧边栏位置配置
const wrapper = document.querySelector('.floating-toc-wrapper');
let sidebarPosition = wrapper?.getAttribute('data-sidebar-position') || 'left';
const showRightSidebarOnPostPage = wrapper?.getAttribute('data-show-right-sidebar-on-post-page') === 'true';
// 如果配置了在文章详情页显示右侧边栏,且当前是文章详情页,则视为双侧边栏模式
if (sidebarPosition === 'left' && showRightSidebarOnPostPage) {
sidebarPosition = 'both';
}
// 如果是单侧边栏模式(左侧),则始终显示悬浮目录(因为右侧边栏不存在)
if (sidebarPosition === 'left') {
return true;
}
// 检查屏幕宽度
const isSmallScreen = window.innerWidth < 1200;
// 显示条件:网格模式 OR 小屏幕 OR 侧边栏目录未启用
return isSmallScreen || !isSidebarTocEnabled;
}
// 更新悬浮目录的显示状态
function updateFloatingTOCVisibility() {
const btn = window.FloatingTOC.btn;
if (!btn) return;
if (shouldShowFloatingTOC()) {
btn.classList.remove("hide");
} else {
btn.classList.add("hide");
// 同时关闭面板
closeTOC();
}
}
// 初始化 FloatingTOC
async function initFloatingTOC() {
window.FloatingTOC.btn = document.getElementById("floating-toc-btn");
window.FloatingTOC.panel = document.getElementById("floating-toc-panel");
if (!window.FloatingTOC.btn || !window.FloatingTOC.panel) {
return;
}
// 更新显示状态
updateFloatingTOCVisibility();
try {
// 清理旧实例
if (window.FloatingTOC.manager) {
window.FloatingTOC.manager.cleanup();
}
// 创建新实例
window.FloatingTOC.manager = new TOCManager({
contentId: "floating-toc-content",
indicatorId: "floating-active-indicator",
maxLevel: 3,
scrollOffset: 80,
});
// 初始化
window.FloatingTOC.manager.init();
// 设置点击事件 - 标记为 TOC 内部导航
const tocContent = document.getElementById("floating-toc-content");
if (tocContent) {
tocContent.addEventListener("click", (e) => {
const target = e.target as Element | null;
const anchor = target?.closest('a[href^="#"]');
if (anchor) {
window.tocInternalNavigation = true;
}
}, { capture: true });
}
// 设置自动关闭
setupAutoClose();
} catch (error) {
console.error("Failed to load TOCManager:", error);
}
}
// 初始化
initFloatingTOC();
// 监听页面切换事件
if (typeof window !== "undefined" && !window.floatingTOCListenersInitialized) {
window.floatingTOCListenersInitialized = true;
// Swup 路由切换
document.addEventListener("swup:contentReplaced", () => {
setTimeout(initFloatingTOC, 100);
});
// Astro 页面切换事件
document.addEventListener("astro:page-load", () => {
setTimeout(initFloatingTOC, 100);
});
// 浏览器导航事件
window.addEventListener("popstate", () => {
setTimeout(initFloatingTOC, 200);
});
// 监听布局模式切换(通过自定义事件)
window.addEventListener('layoutChange', () => {
updateFloatingTOCVisibility();
});
// 监听窗口大小变化
let resizeTimeout: ReturnType<typeof setTimeout>;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
updateFloatingTOCVisibility();
}, 300);
});
}
</script>

View File

@@ -0,0 +1,123 @@
---
import type { Page } from "astro";
import { Icon } from "astro-icon/components";
import { url } from "@/utils/url-utils";
interface Props {
page: Page;
class?: string;
style?: string;
}
const { page, style } = Astro.props;
const HIDDEN = -1;
const className = Astro.props.class;
const ADJ_DIST = 2;
const VISIBLE = ADJ_DIST * 2 + 1;
// for test
let count = 1;
let l = page.currentPage;
let r = page.currentPage;
while (0 < l - 1 && r + 1 <= page.lastPage && count + 2 <= VISIBLE) {
count += 2;
l--;
r++;
}
while (0 < l - 1 && count < VISIBLE) {
count++;
l--;
}
while (r + 1 <= page.lastPage && count < VISIBLE) {
count++;
r++;
}
let pages: number[] = [];
if (l > 1) pages.push(1);
if (l === 3) pages.push(2);
if (l > 3) pages.push(HIDDEN);
for (let i = l; i <= r; i++) pages.push(i);
if (r < page.lastPage - 2) pages.push(HIDDEN);
if (r === page.lastPage - 2) pages.push(page.lastPage - 1);
if (r < page.lastPage) pages.push(page.lastPage);
const getPageUrl = (p: number) => {
if (p === 1) return "/";
return `/${p}/`;
};
---
<div class:list={[className, "flex flex-col gap-4 items-center"]} style={style}>
<!-- 分页信息 - 已禁用 -->
<!--
{
page.lastPage > 1 && (
<div class="text-sm text-[var(--text-secondary)] text-center">
第 {page.currentPage} 页,共 {page.lastPage} 页
</div>
)
}
-->
<!-- 分页控件 -->
<div class="flex flex-row gap-3 justify-center">
<a
href={page.url.prev || ""}
aria-label={page.url.prev ? "Previous Page" : null}
class:list={[
"btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11",
{ disabled: page.url.prev == undefined },
]}
>
<Icon
name="material-symbols:chevron-left-rounded"
class="text-[1.75rem]"
/>
</a>
<div
class="bg-[var(--card-bg)] flex flex-row rounded-lg items-center text-neutral-700 dark:text-neutral-300 font-bold"
>
{
pages.map((p) => {
if (p == HIDDEN)
return <Icon name="material-symbols:more-horiz" class="mx-1" />;
if (p == page.currentPage)
return (
<div
class="h-11 w-11 rounded-lg bg-[var(--primary)] flex items-center justify-center
font-bold text-white dark:text-black/70"
>
{p}
</div>
);
return (
<a
href={url(getPageUrl(p))}
aria-label={`Page ${p}`}
class="btn-card w-11 h-11 rounded-lg overflow-hidden active:scale-[0.85]"
>
{p}
</a>
);
})
}
</div>
<a
href={page.url.next || ""}
aria-label={page.url.next ? "Next Page" : null}
class:list={[
"btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11",
{ disabled: page.url.next == undefined },
]}
>
<Icon
name="material-symbols:chevron-right-rounded"
class="text-[1.75rem]"
/>
</a>
</div>
</div>

View File

@@ -0,0 +1,114 @@
---
/**
* TOC 共享样式组件
* 用于 SidebarTOC 和 FloatingTOC
*/
---
<style lang="stylus" is:global>
/* TOC 滚动容器 */
.toc-scroll-container
overflow-y: auto
overflow-x: hidden
-webkit-overflow-scrolling: touch
scroll-behavior: smooth
/* TOC 内容容器 */
.toc-content
display: flex
flex-direction: column
gap: 2px
position: relative
overflow: visible
width: 100%
max-width: 100%
box-sizing: border-box
contain: layout
align-items: flex-start
/* TOC 链接样式 */
.toc-content a
display: flex
align-items: center
text-decoration: none
color: inherit
border-radius: 0.75rem
transition: all 0.2s ease
width: 100%
min-width: 0
flex-shrink: 0
max-width: 100%
overflow: hidden
box-sizing: border-box
position: relative
&:hover
background: var(--toc-btn-hover)
/* 文本内容防溢出 */
.toc-content a div:last-child
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
min-width: 0
flex: 1
max-width: calc(100% - 2rem)
box-sizing: border-box
/* 徽章容器固定宽度 */
.toc-content a div:first-child
flex-shrink: 0
width: 1.25rem
height: 1.25rem
/* 活动指示器基础样式 */
.toc-active-indicator
position: absolute
left: 0
right: 0
background: var(--toc-btn-hover)
border-radius: 0.75rem
transition: all 0.2s ease
z-index: -1
/* 滚动条样式 - Webkit */
.toc-scroll-container::-webkit-scrollbar
width: 6px
.toc-scroll-container::-webkit-scrollbar-track
background: transparent
border-radius: 3px
.toc-scroll-container::-webkit-scrollbar-thumb
background: linear-gradient(180deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.2) 100%)
border-radius: 3px
border: 1px solid rgba(255, 255, 255, 0.1)
transition: all 0.2s ease
.toc-scroll-container::-webkit-scrollbar-thumb:hover
background: linear-gradient(180deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.3) 100%)
border-color: rgba(255, 255, 255, 0.2)
.toc-scroll-container::-webkit-scrollbar-thumb:active
background: linear-gradient(180deg, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.4) 100%)
/* 暗色主题滚动条 - Webkit */
:global(.dark) .toc-scroll-container::-webkit-scrollbar-thumb
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.2) 100%)
border: 1px solid rgba(0, 0, 0, 0.1)
:global(.dark) .toc-scroll-container::-webkit-scrollbar-thumb:hover
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.3) 100%)
border-color: rgba(0, 0, 0, 0.2)
:global(.dark) .toc-scroll-container::-webkit-scrollbar-thumb:active
background: linear-gradient(180deg, rgba(255, 255, 255, 0.3) 0%, rgba(255, 255, 255, 0.4) 100%)
/* 滚动条样式 - Firefox */
.toc-scroll-container
scrollbar-width: thin
scrollbar-color: rgba(0, 0, 0, 0.2) transparent
:global(.dark) .toc-scroll-container
scrollbar-color: rgba(255, 255, 255, 0.2) transparent
</style>

View File

@@ -0,0 +1,259 @@
---
import type { CollectionEntry } from "astro:content";
import { render } from "astro:content";
import { Icon } from "astro-icon/components";
import RandomCoverImage from "@/components/misc/RandomCoverImage.astro";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import { processCoverImageSync } from "@/utils/image-utils";
import { getFileDirFromPath, getTagUrl } from "@/utils/url-utils";
import PostMetadata from "./PostMeta.astro";
interface Props {
class?: string;
entry: CollectionEntry<"posts">;
title: string;
url: string;
published: Date;
updated?: Date;
tags: string[];
category: string | null;
image: string;
description: string;
draft: boolean;
pinned?: boolean;
style: string;
}
const {
entry,
title,
url,
published,
updated,
tags,
category,
image,
description,
pinned,
style,
} = Astro.props;
const className = Astro.props.class;
// 处理随机图如果image为"api"则从配置的API获取随机图
const processedImage = processCoverImageSync(image, entry.id);
const hasCover =
processedImage !== undefined &&
processedImage !== null &&
processedImage !== "";
const coverWidth = "30%";
const { remarkPluginFrontmatter } = await render(entry);
---
<div
class:list={[
"post-card-wrapper",
hasCover ? "has-cover" : "no-cover",
pinned ? "pinned" : "",
"card-base flex flex-col-reverse md:flex-col w-full rounded-[var(--radius-large)] overflow-hidden relative",
className,
]}
style={style}
>
<!-- pinned icon -->
<div
class:list={[
"post-card-content",
"pl-6 md:pl-9 pr-6 md:pr-2 pt-6 md:pt-7 pb-6 md:pb-7 relative flex flex-col h-full",
{
"w-full md:w-[calc(100%_-_52px_-_12px)]": !hasCover,
"w-full md:w-[calc(100%_-_var(--coverWidth)_-_1.5rem)]": hasCover,
},
]}
>
<a
href={url}
class="post-card-title transition group w-full block font-bold mb-3 text-3xl text-90
hover:text-[var(--primary)] dark:hover:text-[var(--primary)]
active:text-[var(--title-active)] dark:active:text-[var(--title-active)]
before:w-1 before:h-5 before:rounded-md before:bg-[var(--primary)]
before:absolute before:top-[35px] before:left-[18px] before:hidden md:before:block"
>
<!-- {
pinned && (
<>
<Icon
name="mdi:pin"
class="inline text-[var(--primary)] text-2xl mr-2 -translate-y-0.5"
/>
</>
)
} -->
{title}
<Icon
class="inline text-[2rem] text-[var(--primary)] md:hidden translate-y-0.5 absolute"
name="material-symbols:chevron-right-rounded"
/>
<Icon
class="text-[var(--primary)] text-[2rem] transition hidden md:inline absolute translate-y-0.5 opacity-0 group-hover:opacity-100 -translate-x-1 group-hover:translate-x-0"
name="material-symbols:chevron-right-rounded"
/>
</a>
<!-- metadata -->
<PostMetadata
published={published}
updated={updated}
tags={tags}
category={category || undefined}
hideTagsForMobile={true}
hideUpdateDate={true}
words={remarkPluginFrontmatter.words}
minutes={remarkPluginFrontmatter.minutes}
showWordCount={true}
pinned={pinned}
className="mb-4 post-meta"
/>
<!-- description -->
<div
class:list={[
"transition text-75 mb-3.5 pr-4 description flex-grow",
{ "line-clamp-2 md:line-clamp-1": !description },
]}
>
{description || remarkPluginFrontmatter.excerpt}
</div>
<!-- tags -->
<div
class="text-sm text-black/30 dark:text-white/30 flex flex-wrap gap-2 transition stats mt-auto"
>
{
tags &&
tags.length > 0 &&
tags.map((tag, _i) => (
<a
href={getTagUrl(tag)}
aria-label={`View all posts with the ${tag.trim()} tag`}
class="btn-regular h-6 text-xs px-2 py-1 rounded-md border border-[var(--line-divider)]
transition-all duration-200 hover:scale-105 active:scale-95"
>
#{tag.trim()}
</a>
))
}
{
!(tags && tags.length > 0) && (
<span class="text-[var(--content-meta)] text-xs">
{i18n(I18nKey.noTags)}
</span>
)
}
</div>
</div>
{
hasCover && (
<a
href={url}
aria-label={title}
class:list={[
"post-card-image",
"group",
"w-full md:w-[var(--coverWidth)]",
"aspect-[2/1] md:aspect-auto",
"relative md:absolute md:top-4 md:bottom-4 md:right-4",
"rounded-t-[var(--radius-large)] md:rounded-xl overflow-hidden active:scale-95",
]}
>
<div class="absolute pointer-events-none z-10 w-full h-full group-hover:bg-black/30 group-active:bg-black/50 transition" />
<div class="absolute pointer-events-none z-20 w-full h-full flex items-center justify-center ">
<Icon
name="material-symbols:chevron-right-rounded"
class="transition opacity-0 group-hover:opacity-100 scale-50 group-hover:scale-100 text-white text-5xl"
/>
</div>
<RandomCoverImage
src={processedImage}
basePath={getFileDirFromPath(entry.filePath || '')}
alt="Cover Image of the Post"
class="w-full h-full"
seed={entry.id}
preview={true}
/>
</a>
)
}
{
!hasCover && (
<a
href={url}
aria-label={title}
class:list={[
"post-card-enter-btn",
"!hidden md:!flex btn-regular w-[3.25rem]",
"absolute right-3 top-3 bottom-3 rounded-xl bg-[var(--enter-btn-bg)]",
"hover:bg-[var(--enter-btn-bg-hover)] active:bg-[var(--enter-btn-bg-active)] active:scale-95",
]}
>
<Icon
name="material-symbols:chevron-right-rounded"
class="transition text-[var(--primary)] text-4xl mx-auto"
/>
</a>
)
}
</div>
<style define:vars={{ coverWidth }}>
/* Grid Mode Styles */
:global(.grid-mode) .post-card-wrapper {
flex-direction: column-reverse !important;
height: 100% !important;
justify-content: flex-end !important;
}
:global(.grid-mode) .post-card-content {
width: 100% !important;
padding-right: 1rem !important;
height: auto !important;
flex-grow: 1 !important;
}
:global(.grid-mode) .no-cover .post-card-content {
padding-right: 1rem !important;
}
:global(.grid-mode) .post-card-image {
position: relative !important;
top: auto !important;
bottom: auto !important;
right: auto !important;
width: 100% !important;
margin: 0 !important;
border-radius: var(--radius-large) var(--radius-large) 0 0 !important;
max-height: none !important;
aspect-ratio: 2/1 !important;
}
:global(.grid-mode) .description {
flex-grow: 0 !important;
}
:global(.grid-mode) .stats {
margin-top: auto !important;
}
:global(.grid-mode) .has-cover .post-card-content {
padding-top: 0.8rem !important;
}
:global(.grid-mode) .has-cover .post-card-title::before {
top: 1.3rem !important;
}
</style>

View File

@@ -0,0 +1,223 @@
---
import { Icon } from "astro-icon/components";
import { commentConfig } from "@/config";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import { formatDateToYYYYMMDD } from "@/utils/date-utils";
import { getCategoryUrl, getTagUrl } from "@/utils/url-utils";
export interface Props {
published: Date;
updated?: Date;
category?: string;
tags?: string[];
hideUpdateDate?: boolean;
hideTagsForMobile?: boolean;
isHome?: boolean;
className?: string;
id?: string;
words?: number;
minutes?: number;
showWordCount?: boolean; // 是否显示字数统计默认false显示标签
customPath?: string;
pinned?: boolean;
}
const {
published,
updated,
category,
tags,
hideUpdateDate,
hideTagsForMobile,
isHome,
className = "",
id,
words,
minutes,
showWordCount = false,
customPath,
pinned,
} = Astro.props;
const path = customPath || (id ? `/posts/${id}` : "");
---
<div
class:list={[
"flex flex-wrap text-neutral-500 dark:text-neutral-400 items-center gap-4 gap-x-4 gap-y-2",
className,
]}
>
<!-- pinned -->
{
pinned && (
<div class="pinned-btn flex items-center gap-1 bg-[var(--btn-regular-bg)] text-[var(--btn-content)] rounded-md px-2 py-1.5 font-bold">
<Icon name="material-symbols:pinboard" class="text-xl" />
<span class="text-sm">{i18n(I18nKey.pinned)}</span>
</div>
)
}
<!-- publish date -->
<div class="flex items-center">
<div class="meta-icon">
<Icon
name="material-symbols:calendar-today-outline-rounded"
class="text-xl"
/>
</div>
<span class="text-50 text-sm font-medium"
>{formatDateToYYYYMMDD(published)}</span
>
</div>
<!-- update date -->
{
!hideUpdateDate && updated && updated.getTime() !== published.getTime() && (
<div class="flex items-center">
<div class="meta-icon">
<Icon
name="material-symbols:edit-calendar-outline-rounded"
class="text-xl"
/>
</div>
<span class="text-50 text-sm font-medium">
{formatDateToYYYYMMDD(updated)}
</span>
</div>
)
}
<!-- categories -->
<div class="flex items-center">
<div class="meta-icon">
<Icon name="material-symbols:book-2-outline-rounded" class="text-xl" />
</div>
<div class="flex flex-row flex-nowrap items-center">
<a
href={getCategoryUrl(category || "")}
aria-label={`View all posts in the ${category} category`}
class="link-lg transition text-50 text-sm font-medium
hover:text-[var(--primary)] dark:hover:text-[var(--primary)] whitespace-nowrap"
>
{category || i18n(I18nKey.uncategorized)}
</a>
</div>
</div>
<!-- word count and read time (only for post list) -->
{
showWordCount && words !== undefined && minutes !== undefined && (
<>
<!-- word count -->
<div class="flex items-center">
<div class="meta-icon">
<Icon
name="material-symbols:article-outline-rounded"
class="text-xl"
/>
</div>
<div class="text-50 text-sm font-medium">
{words}
{" " + i18n(words === 1 ? I18nKey.wordCount : I18nKey.wordsCount)}
</div>
</div>
{/* <!-- read time -->
<div class="flex items-center">
<div class="meta-icon">
<Icon
name="material-symbols:schedule-outline-rounded"
class="text-xl"
/>
</div>
<div class="text-50 text-sm font-medium">
{minutes}
{" " +
i18n(minutes === 1 ? I18nKey.minuteCount : I18nKey.minutesCount)}
</div>
</div> */}
</>
)
}
<!-- tags (only for post detail page) -->
{
!showWordCount && (
<div class:list={["items-center", {"flex": !hideTagsForMobile, "hidden md:flex": hideTagsForMobile}]}>
<div class="meta-icon">
<Icon name="material-symbols:tag-rounded" class="text-xl" />
</div>
<div class="flex flex-row flex-nowrap items-center">
{(tags && tags.length > 0) && tags.map((tag, i) => (
<div class:list={[{"hidden": i == 0}, "mx-1.5 text-[var(--meta-divider)] text-sm"]}>/</div>
<a href={getTagUrl(tag)} aria-label={`View all posts with the ${tag.trim()} tag`}
class="link-lg transition text-50 text-sm font-medium
hover:text-[var(--primary)] dark:hover:text-[var(--primary)] whitespace-nowrap">
{tag.trim()}
</a>
))}
{!(tags && tags.length > 0) && <div class="transition text-50 text-sm font-medium">{i18n(I18nKey.noTags)}</div>}
</div>
</div>
)
}
<!-- Twikoo访问量统计首页不显示且文章访问量统计启用时显示 -->
{
!isHome &&
commentConfig.type === 'twikoo' &&
commentConfig.twikoo?.visitorCount &&
id && (
<div class="flex items-center">
<div class="meta-icon">
<Icon
name="material-symbols:visibility-outline-rounded"
class="text-xl"
/>
</div>
<span class="text-50 text-sm font-medium mr-1">
{i18n(I18nKey.pageViews)}
</span>
<span class="text-50 text-sm font-medium" id="twikoo_visitors">
{i18n(I18nKey.pageViewsLoading)}
</span>
</div>
)
}
<!-- Waline访问量统计首页不显示、仅type为waline时显示 -->
{
!isHome &&
commentConfig.type === 'waline' &&
commentConfig.waline?.visitorCount &&
id && (
<div class="flex items-center">
<div class="meta-icon">
<Icon name="material-symbols:visibility-outline-rounded" class="text-xl" />
</div>
<span class="text-50 text-sm font-medium mr-1">
{i18n(I18nKey.pageViews)}
</span>
<span class="text-50 text-sm font-medium waline-pageview-count" data-path={path}>{i18n(I18nKey.pageViewsLoading)}</span>
</div>
)
}
<!-- artalk访问量统计首页不显示、仅type为artalk时显示 -->
{
!isHome &&
commentConfig.type === 'artalk' &&
commentConfig.waline?.visitorCount &&
id && (
<div class="flex items-center">
<div class="meta-icon">
<Icon name="material-symbols:visibility-outline-rounded" class="text-xl" />
</div>
<span class="text-50 text-sm font-medium mr-1">
{i18n(I18nKey.pageViews)}
</span>
<span class="text-50 text-sm font-medium artalk-pv-count" data-path={path}>{i18n(I18nKey.pageViewsLoading)}</span>
</div>
)
}
</div>

View File

@@ -0,0 +1,106 @@
---
import { Icon } from "astro-icon/components";
import ImageWrapper from "@/components/misc/ImageWrapper.astro";
import { profileConfig } from "@/config/profileConfig";
import { url } from "@/utils/url-utils";
---
<div class="card-base p-3">
<a
aria-label="Go to About Page"
href={url("/about/")}
class="group block relative mx-auto mt-1 lg:mx-0 lg:mt-0 mb-3
max-w-[12rem] lg:max-w-none overflow-hidden rounded-xl active:scale-95"
>
<div
class="absolute transition pointer-events-none group-hover:bg-black/30 group-active:bg-black/50
w-full h-full z-50 flex items-center justify-center"
>
<Icon
name="fa6-regular:address-card"
class="transition opacity-0 scale-90 group-hover:scale-100 group-hover:opacity-100 text-white text-5xl"
/>
</div>
<ImageWrapper
src={profileConfig.avatar || ""}
alt="Profile Image of the Author"
class="mx-auto lg:w-full h-full lg:mt-0"
/>
</a>
<div class="px-2">
<div
class="font-bold text-xl text-center mb-1 dark:text-neutral-50 transition"
>
{profileConfig.name}
</div>
<div
class="h-1 w-5 bg-[var(--primary)] mx-auto rounded-full mb-2 transition"
>
</div>
<div class="text-center text-neutral-400 mb-2.5 transition">
{profileConfig.bio}
</div>
<div class="flex flex-wrap gap-2 justify-center mb-1">
{profileConfig.links.length > 1 && profileConfig.links.map(item =>
{
const showName = item.showName;
const className = showName
? "btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95"
: "btn-regular rounded-lg h-10 w-10 active:scale-90";
if (item.url.startsWith("mailto:")) {
const encodedEmail = Buffer.from(item.url.replace("mailto:", "")).toString("base64");
return <a rel="me" aria-label={item.name} href="#" data-encoded-email={encodedEmail} onclick={`
(function() {
const encodedEmail = this.getAttribute('data-encoded-email');
const decodedEmail = atob(encodedEmail);
this.href = 'mailto:' + decodedEmail;
this.removeAttribute('data-encoded-email');
this.removeAttribute('onclick');
this.click();
return false;
}).call(this);
`.replace(/\s+/g, " ").trim()
},
class={className}>
<Icon name={item.icon} class="text-[1.5rem]"></Icon>
{showName && item.name}
</a>
} else {
return <a rel="me" aria-label={item.name} href={item.url} target="_blank" class={className}>
<Icon name={item.icon} class="text-[1.5rem]"></Icon>
{showName && item.name}
</a>
}
}
)}
{profileConfig.links.length == 1 && (function(item){
if (item.url.startsWith("mailto:")) {
const encodedEmail = Buffer.from(item.url.replace("mailto:", "")).toString("base64");
return <a rel="me" aria-label={item.name} href="#" data-encoded-email={encodedEmail} onclick={`
(function() {
const encodedEmail = this.getAttribute('data-encoded-email');
const decodedEmail = atob(encodedEmail);
this.href = 'mailto:' + decodedEmail;
this.removeAttribute('data-encoded-email');
this.removeAttribute('onclick');
this.click();
return false;
}).call(this);
`.replace(/\s+/g, " ").trim()
},
class="btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95">
<Icon name={item.icon} class="text-[1.5rem]"></Icon>
{item.name}
</a>
} else {
return <a rel="me" aria-label={item.name} href={item.url} target="_blank"
class="btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95">
<Icon name={item.icon} class="text-[1.5rem]"></Icon>
{item.name}
</a>
}
})(profileConfig.links[0])}
</div>
</div>
</div>

View File

@@ -0,0 +1,171 @@
---
export interface Props {
title: string;
value: string | number;
subtitle?: string;
icon?: string;
color?: string;
gradient?: {
from: string;
to: string;
};
size?: "small" | "medium" | "large";
trend?: {
value: number;
isPositive: boolean;
label?: string;
};
link?: {
url: string;
text: string;
};
}
const {
title,
value,
subtitle,
icon,
color = "#3B82F6",
gradient,
size = "medium",
trend,
link,
} = Astro.props;
// 尺寸样式映射
const getSizeClasses = (size: string) => {
switch (size) {
case "small":
return {
container: "p-3",
value: "text-xl",
title: "text-xs",
subtitle: "text-xs",
icon: "text-lg",
iconContainer: "w-8 h-8",
};
case "large":
return {
container: "p-6",
value: "text-4xl",
title: "text-base",
subtitle: "text-sm",
icon: "text-2xl",
iconContainer: "w-12 h-12",
};
default: // medium
return {
container: "p-4",
value: "text-2xl",
title: "text-sm",
subtitle: "text-xs",
icon: "text-xl",
iconContainer: "w-10 h-10",
};
}
};
const sizeClasses = getSizeClasses(size);
// 生成渐变背景样式
const getBackgroundStyle = () => {
if (gradient) {
return `background: linear-gradient(135deg, ${gradient.from}, ${gradient.to})`;
}
return `background: linear-gradient(135deg, ${color}15, ${color}25)`;
};
// 生成图标容器样式
const getIconStyle = () => {
return `background-color: ${color}20; color: ${color}`;
};
---
<div
class="rounded-lg border border-black/10 dark:border-white/10 hover:shadow-lg transition-all duration-300 group cursor-pointer"
style={getBackgroundStyle()}
>
<div class={sizeClasses.container}>
<!-- 顶部:图标和趋势 -->
<div class="flex items-start justify-between mb-3">
<!-- 图标 -->
{icon && (
<div
class={`rounded-lg flex items-center justify-center ${sizeClasses.iconContainer}`}
style={getIconStyle()}
>
<iconify-icon icon={icon} class={sizeClasses.icon}></iconify-icon>
</div>
)}
<!-- 趋势指示器 -->
{trend && (
<div class={`flex items-center gap-1 ${trend.isPositive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
<iconify-icon
icon={trend.isPositive ? 'material-symbols:trending-up' : 'material-symbols:trending-down'}
class="text-sm"
></iconify-icon>
<span class="text-xs font-medium">
{trend.isPositive ? '+' : ''}{trend.value}%
</span>
</div>
)}
</div>
<!-- 主要数值 -->
<div class={`font-bold mb-1 ${sizeClasses.value}`} style={`color: ${color}`}>
{value}
</div>
<!-- 标题 -->
<div class={`font-medium text-black/70 dark:text-white/70 mb-1 ${sizeClasses.title}`}>
{title}
</div>
<!-- 副标题 -->
{subtitle && (
<div class={`text-black/60 dark:text-white/60 ${sizeClasses.subtitle}`}>
{subtitle}
</div>
)}
<!-- 趋势标签 -->
{trend && trend.label && (
<div class={`text-black/50 dark:text-white/50 mt-1 ${sizeClasses.subtitle}`}>
{trend.label}
</div>
)}
<!-- 链接 -->
{link && (
<div class="mt-3">
<a
href={link.url}
class={`text-blue-600 dark:text-blue-400 hover:underline font-medium transition-colors ${sizeClasses.subtitle}`}
>
{link.text} →
</a>
</div>
)}
</div>
<!-- 悬停效果 -->
<div class="absolute inset-0 bg-white/5 dark:bg-black/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-lg pointer-events-none"></div>
</div>
<script>
// 加载 Iconify 图标库
if (typeof window !== 'undefined' && !window.iconifyLoaded) {
const script = document.createElement('script');
script.src = 'https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js';
document.head.appendChild(script);
window.iconifyLoaded = true;
}
</script>
<style>
.group {
position: relative;
}
</style>

View File

@@ -0,0 +1,176 @@
---
export interface Props {
text: string | string[];
speed?: number;
deleteSpeed?: number;
pauseTime?: number;
class?: string;
}
const {
text,
speed = 100,
deleteSpeed = 50,
pauseTime = 2000,
class: className = "",
} = Astro.props;
const textData = Array.isArray(text) ? JSON.stringify(text) : text;
---
<span
class={`typewriter ${className}`}
data-text={textData}
data-speed={speed}
data-delete-speed={deleteSpeed}
data-pause-time={pauseTime}></span>
<script>
class TypewriterEffect {
private element: HTMLElement;
private texts: string[];
private currentTextIndex: number = 0;
private speed: number;
private deleteSpeed: number;
private pauseTime: number;
private currentIndex: number = 0;
private isDeleting: boolean = false;
private timeoutId: number | null = null;
constructor(element: HTMLElement) {
this.element = element;
const textData = element.dataset.text || "";
// 尝试解析为JSON数组如果失败则作为单个字符串处理
try {
const parsed = JSON.parse(textData);
this.texts = Array.isArray(parsed) ? parsed : [textData];
} catch {
this.texts = [textData];
}
this.speed = parseInt(element.dataset.speed || "100");
this.deleteSpeed = parseInt(element.dataset.deleteSpeed || "50");
this.pauseTime = parseInt(element.dataset.pauseTime || "2000");
// 如果有多条文本且未启用打字机效果,随机显示一条
if (this.texts.length > 1 && !this.isTypewriterEnabled()) {
this.showRandomText();
} else {
this.start();
}
}
private isTypewriterEnabled(): boolean {
// 检查是否有打字机相关的数据属性
return (
this.element.dataset.speed !== undefined ||
this.element.dataset.deleteSpeed !== undefined ||
this.element.dataset.pauseTime !== undefined
);
}
private showRandomText() {
const randomIndex = Math.floor(Math.random() * this.texts.length);
this.element.textContent = this.texts[randomIndex];
}
private start() {
if (this.texts.length === 0) return;
this.type();
}
private getCurrentText(): string {
return this.texts[this.currentTextIndex] || "";
}
private type() {
const currentText = this.getCurrentText();
if (this.isDeleting) {
// 删除字符
if (this.currentIndex > 0) {
this.currentIndex--;
this.element.textContent = currentText.substring(
0,
this.currentIndex
);
this.timeoutId = window.setTimeout(
() => this.type(),
this.deleteSpeed
);
} else {
// 删除完成,切换到下一条文本
this.isDeleting = false;
this.currentTextIndex =
(this.currentTextIndex + 1) % this.texts.length;
this.timeoutId = window.setTimeout(() => this.type(), this.speed);
}
} else {
// 添加字符
if (this.currentIndex < currentText.length) {
this.currentIndex++;
this.element.textContent = currentText.substring(
0,
this.currentIndex
);
this.timeoutId = window.setTimeout(() => this.type(), this.speed);
} else {
// 打字完成,暂停后开始删除(如果有多条文本)
if (this.texts.length > 1) {
this.isDeleting = true;
this.timeoutId = window.setTimeout(
() => this.type(),
this.pauseTime
);
}
// 如果只有一条文本,保持显示不删除
}
}
}
public destroy() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
}
}
// 初始化所有打字机效果
document.addEventListener("DOMContentLoaded", () => {
const typewriterElements = document.querySelectorAll(".typewriter");
typewriterElements.forEach((element) => {
new TypewriterEffect(element as HTMLElement);
});
});
// 支持页面切换时重新初始化
document.addEventListener("swup:contentReplaced", () => {
const typewriterElements = document.querySelectorAll(".typewriter");
typewriterElements.forEach((element) => {
new TypewriterEffect(element as HTMLElement);
});
});
</script>
<style>
.typewriter {
position: relative;
}
.typewriter::after {
content: "|";
animation: blink 1s infinite;
margin-left: 2px;
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,105 @@
---
import "@fancyapps/ui/dist/fancybox/fancybox.css";
import "@/styles/fancybox-custom.css";
// Fancybox 管理器组件
---
<script>
let Fancybox: any;
async function setup() {
const selectors = [
".custom-md img, #post-cover img, .moment-images img",
".moment-images a[data-fancybox]",
"[data-fancybox]:not(.moment-images a)"
];
// 检查页面是否存在需要 Fancybox 的元素
const hasElements = selectors.some(selector => document.querySelector(selector));
if (!hasElements) return;
// 动态导入资源
if (!Fancybox) {
const mod = await import("@fancyapps/ui");
Fancybox = mod.Fancybox;
}
// 通用配置
const commonOptions = {
Thumbs: {
autoStart: true,
showOnStart: "yes",
},
Toolbar: {
display: {
left: ["infobar"],
middle: [
"zoomIn",
"zoomOut",
"toggle1to1",
"rotateCCW",
"rotateCW",
"flipX",
"flipY",
],
right: ["slideshow", "thumbs", "close"],
},
},
animated: true,
dragToClose: true,
keyboard: {
Escape: "close",
Delete: "close",
Backspace: "close",
PageUp: "next",
PageDown: "prev",
ArrowUp: "next",
ArrowDown: "prev",
ArrowRight: "next",
ArrowLeft: "prev",
},
fitToView: true,
preload: 3,
infinite: true,
Panzoom: {
maxScale: 3,
minScale: 1,
},
caption: false,
};
// 绑定图片
Fancybox.bind(".custom-md img, #post-cover img, .moment-images img", {
...commonOptions,
groupAll: true,
Carousel: {
transition: "slide",
preload: 2,
},
});
// 绑定链接
Fancybox.bind(".moment-images a[data-fancybox]", {
...commonOptions,
source: (el: any) => el.getAttribute("data-src") || el.getAttribute("href"),
});
// 绑定其他
Fancybox.bind("[data-fancybox]:not(.moment-images a)", commonOptions);
}
function cleanupFancybox() {
if (Fancybox) {
Fancybox.close();
Fancybox.unbind(document.body);
}
}
// 初始化
setup();
// Swup 生命周期整合
document.addEventListener("swup:content:replace", setup);
document.addEventListener("swup:visit:start", cleanupFancybox);
</script>

View File

@@ -0,0 +1,16 @@
---
---
<script>
// KaTeX 样式按需加载
function loadKatex() {
if (document.querySelector('.katex')) {
import('katex/dist/katex.css');
}
}
// 初始化
loadKatex();
// Swup 页面切换支持
document.addEventListener('swup:content:replace', loadKatex);
</script>

View File

@@ -0,0 +1,357 @@
---
import { sakuraConfig } from "@/config";
const config = sakuraConfig;
---
{sakuraConfig?.enable && (
<script is:inline define:vars={{ sakuraConfig: config }}>
let windowWidth = window.innerWidth;
let windowHeight = window.innerHeight;
// 樱花对象类
class Sakura {
constructor(x, y, s, r, a, fn, idx, img, limitArray, config) {
this.x = x;
this.y = y;
this.s = s;
this.r = r;
this.a = a;
this.fn = fn;
this.idx = idx;
this.img = img;
this.limitArray = limitArray;
this.config = config;
}
draw(cxt) {
cxt.save();
cxt.translate(this.x, this.y);
cxt.rotate(this.r);
cxt.globalAlpha = this.a;
cxt.drawImage(this.img, 0, 0, 40 * this.s, 40 * this.s);
cxt.restore();
}
update() {
this.x = this.fn.x(this.x, this.y);
this.y = this.fn.y(this.y, this.y);
this.r = this.fn.r(this.r);
this.a = this.fn.a(this.a);
// 如果樱花越界,重新调整位置
if (
this.x > windowWidth ||
this.x < 0 ||
this.y > windowHeight ||
this.y < 0 ||
this.a <= 0
) {
// 如果樱花不做限制
if (this.limitArray[this.idx] === -1) {
this.resetPosition();
}
// 否则樱花有限制
else {
if (this.limitArray[this.idx] > 0) {
this.resetPosition();
this.limitArray[this.idx]--;
}
}
}
}
resetPosition() {
this.r = getRandom('fnr', this.config);
if (Math.random() > 0.4) {
this.x = getRandom('x', this.config);
this.y = 0;
this.s = getRandom('s', this.config);
this.r = getRandom('r', this.config);
this.a = getRandom('a', this.config);
} else {
this.x = windowWidth;
this.y = getRandom('y', this.config);
this.s = getRandom('s', this.config);
this.r = getRandom('r', this.config);
this.a = getRandom('a', this.config);
}
}
}
// 樱花列表类
class SakuraList {
constructor() {
this.list = [];
}
push(sakura) {
this.list.push(sakura);
}
update() {
for (let i = 0, len = this.list.length; i < len; i++) {
this.list[i].update();
}
}
draw(cxt) {
for (let i = 0, len = this.list.length; i < len; i++) {
this.list[i].draw(cxt);
}
}
get(i) {
return this.list[i];
}
size() {
return this.list.length;
}
}
// 获取随机值的函数
function getRandom(option, config) {
let ret;
let random;
switch (option) {
case 'x':
ret = Math.random() * windowWidth;
break;
case 'y':
ret = Math.random() * windowHeight;
break;
case 's':
ret = config.size.min + Math.random() * (config.size.max - config.size.min);
break;
case 'r':
ret = Math.random() * 6;
break;
case 'a':
ret = config.opacity.min + Math.random() * (config.opacity.max - config.opacity.min);
break;
case 'fnx':
random = config.speed.horizontal.min + Math.random() * (config.speed.horizontal.max - config.speed.horizontal.min);
ret = function (x, _y) {
return x + random;
};
break;
case 'fny':
random = config.speed.vertical.min + Math.random() * (config.speed.vertical.max - config.speed.vertical.min);
ret = function (_x, y) {
return y + random;
};
break;
case 'fnr':
ret = function (r) {
return r + config.speed.rotation;
};
break;
case 'fna':
ret = function (alpha) {
return alpha - config.speed.fadeSpeed * 0.01;
};
break;
}
return ret;
}
// 樱花管理器类
class SakuraManager {
constructor(config) {
this.config = config;
this.canvas = null;
this.ctx = null;
this.sakuraList = null;
this.animationId = null;
this.img = null;
this.isRunning = false;
}
// 初始化樱花特效
async init() {
if (!this.config.enable || this.isRunning) {
return;
}
// 创建图片对象
this.img = new Image();
this.img.src = '/assets/images/sakura.png'; // 使用樱花图片
// 等待图片加载完成
await new Promise((resolve, reject) => {
if (this.img) {
this.img.onload = () => resolve();
this.img.onerror = () => reject(new Error('Failed to load sakura image'));
}
});
this.createCanvas();
this.createSakuraList();
this.startAnimation();
this.isRunning = true;
}
// 创建画布
createCanvas() {
this.canvas = document.createElement('canvas');
this.canvas.height = windowHeight;
this.canvas.width = windowWidth;
this.canvas.setAttribute('style', `position: fixed; left: 0; top: 0; pointer-events: none; z-index: ${this.config.zIndex}; transform: translateZ(0);`);
this.canvas.setAttribute('id', 'canvas_sakura');
document.body.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
// 监听窗口大小变化
window.addEventListener('resize', this.handleResize.bind(this));
}
// 创建樱花列表
createSakuraList() {
if (!this.img || !this.ctx) return;
this.sakuraList = new SakuraList();
const limitArray = new Array(this.config.sakuraNum).fill(this.config.limitTimes);
for (let i = 0; i < this.config.sakuraNum; i++) {
const randomX = getRandom('x', this.config);
const randomY = getRandom('y', this.config);
const randomS = getRandom('s', this.config);
const randomR = getRandom('r', this.config);
const randomA = getRandom('a', this.config);
const randomFnx = getRandom('fnx', this.config);
const randomFny = getRandom('fny', this.config);
const randomFnR = getRandom('fnr', this.config);
const randomFnA = getRandom('fna', this.config);
const sakura = new Sakura(
randomX,
randomY,
randomS,
randomR,
randomA,
{
x: randomFnx,
y: randomFny,
r: randomFnR,
a: randomFnA,
},
i,
this.img,
limitArray,
this.config
);
sakura.draw(this.ctx);
this.sakuraList.push(sakura);
}
}
// 开始动画
startAnimation() {
if (!this.ctx || !this.canvas || !this.sakuraList) return;
const animate = () => {
if (!this.ctx || !this.canvas || !this.sakuraList) return;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.sakuraList.update();
this.sakuraList.draw(this.ctx);
this.animationId = requestAnimationFrame(animate);
};
this.animationId = requestAnimationFrame(animate);
}
// 处理窗口大小变化
handleResize() {
windowWidth = window.innerWidth;
windowHeight = window.innerHeight;
if (this.canvas) {
this.canvas.width = windowWidth;
this.canvas.height = windowHeight;
}
}
// 停止樱花特效
stop() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.canvas) {
document.body.removeChild(this.canvas);
this.canvas = null;
}
window.removeEventListener('resize', this.handleResize.bind(this));
this.isRunning = false;
}
// 切换樱花特效
toggle() {
if (this.isRunning) {
this.stop();
} else {
this.init();
}
}
// 更新配置
updateConfig(newConfig) {
const wasRunning = this.isRunning;
if (wasRunning) {
this.stop();
}
this.config = newConfig;
if (wasRunning && newConfig.enable) {
this.init();
}
}
// 获取运行状态
getIsRunning() {
return this.isRunning;
}
}
// 创建全局樱花管理器实例
let globalSakuraManager = null;
// 初始化樱花特效
function initSakura(config) {
if (globalSakuraManager) {
globalSakuraManager.updateConfig(config);
} else {
globalSakuraManager = new SakuraManager(config);
if (config.enable) {
globalSakuraManager.init();
}
}
}
// 樱花特效初始化
(function() {
// 全局标记,确保樱花特效只初始化一次
if (window.sakuraInitialized) {
return;
}
// 初始化樱花特效的函数
const setupSakura = () => {
if (sakuraConfig.enable && !window.sakuraInitialized) {
initSakura(sakuraConfig);
window.sakuraInitialized = true;
}
};
// 页面加载完成后初始化樱花特效
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupSakura);
} else {
setupSakura();
}
})();
</script>
)}

View File

@@ -0,0 +1,156 @@
<script lang="ts">
import { onMount } from "svelte";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import { getPostUrlBySlug } from "@/utils/url-utils";
export let tags: string[] = [];
export let categories: string[] = [];
export let sortedPosts: Post[] = [];
const params = new URLSearchParams(window.location.search);
tags = params.has("tag") ? params.getAll("tag") : [];
categories = params.has("category") ? params.getAll("category") : [];
const uncategorized = params.get("uncategorized");
interface Post {
id: string;
data: {
title: string;
tags: string[];
category?: string | null;
published: Date;
};
}
interface Group {
year: number;
posts: Post[];
}
let groups: Group[] = [];
function formatDate(date: Date) {
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${month}-${day}`;
}
function formatTag(tagList: string[]) {
return tagList.map((t) => `#${t}`).join(" ");
}
onMount(async () => {
let filteredPosts: Post[] = sortedPosts;
if (tags.length > 0) {
filteredPosts = filteredPosts.filter(
(post) =>
Array.isArray(post.data.tags) &&
post.data.tags.some((tag) => tags.includes(tag)),
);
}
if (categories.length > 0) {
filteredPosts = filteredPosts.filter(
(post) => post.data.category && categories.includes(post.data.category),
);
}
if (uncategorized) {
filteredPosts = filteredPosts.filter((post) => !post.data.category);
}
// 按发布时间倒序排序,确保不受置顶影响
filteredPosts = filteredPosts
.slice()
.sort((a, b) => b.data.published.getTime() - a.data.published.getTime());
const grouped = filteredPosts.reduce(
(acc, post) => {
const year = post.data.published.getFullYear();
if (!acc[year]) {
acc[year] = [];
}
acc[year].push(post);
return acc;
},
{} as Record<number, Post[]>,
);
const groupedPostsArray = Object.keys(grouped).map((yearStr) => ({
year: Number.parseInt(yearStr, 10),
posts: grouped[Number.parseInt(yearStr, 10)],
}));
groupedPostsArray.sort((a, b) => b.year - a.year);
groups = groupedPostsArray;
});
</script>
<div class="card-base px-8 py-6">
{#each groups as group}
<div>
<div class="flex flex-row w-full items-center h-[3.75rem]">
<div class="w-[15%] md:w-[10%] transition text-2xl font-bold text-right text-75">
{group.year}
</div>
<div class="w-[15%] md:w-[10%]">
<div
class="h-3 w-3 bg-none rounded-full outline outline-[var(--primary)] mx-auto
-outline-offset-[2px] z-50 outline-3"
></div>
</div>
<div class="w-[70%] md:w-[80%] transition text-left text-50">
{group.posts.length} {i18n(group.posts.length === 1 ? I18nKey.postCount : I18nKey.postsCount)}
</div>
</div>
{#each group.posts as post}
<a
href={getPostUrlBySlug(post.id)}
aria-label={post.data.title}
class="group btn-plain !block h-10 w-full rounded-lg hover:text-[initial]"
>
<div class="flex flex-row justify-start items-center h-full">
<!-- date -->
<div class="w-[15%] md:w-[10%] transition text-sm text-right text-50">
{formatDate(post.data.published)}
</div>
<!-- dot and line -->
<div class="w-[15%] md:w-[10%] relative dash-line h-full flex items-center">
<div
class="transition-all mx-auto w-1 h-1 rounded group-hover:h-5
bg-[oklch(0.5_0.05_var(--hue))] group-hover:bg-[var(--primary)]
outline outline-4 z-50
outline-[var(--card-bg)]
group-hover:outline-[var(--btn-plain-bg-hover)]
group-active:outline-[var(--btn-plain-bg-active)]"
></div>
</div>
<!-- post title -->
<div
class="w-[70%] md:max-w-[65%] md:w-[65%] text-left font-bold
group-hover:translate-x-1 transition-all group-hover:text-[var(--primary)]
text-75 pr-8 whitespace-nowrap overflow-ellipsis overflow-hidden"
>
{post.data.title}
</div>
<!-- tag list -->
<div
class="hidden md:block md:w-[15%] text-left text-sm transition
whitespace-nowrap overflow-ellipsis overflow-hidden text-30"
>
{formatTag(post.data.tags)}
</div>
</div>
</a>
{/each}
</div>
{/each}
</div>

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import Icon from "@iconify/svelte";
import { getDefaultHue, getHue, setHue } from "@utils/setting-utils";
let hue = getHue();
const defaultHue = getDefaultHue();
function resetHue() {
hue = getDefaultHue();
}
$: if (hue || hue === 0) {
setHue(hue);
}
</script>
<div id="display-setting" class="float-panel float-panel-closed absolute transition-all w-80 right-4 px-4 py-4">
<div class="flex flex-row gap-2 mb-3 items-center justify-between">
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
before:absolute before:-left-3 before:top-[0.33rem]"
>
{i18n(I18nKey.themeColor)}
<button aria-label="Reset to Default" class="btn-regular w-7 h-7 rounded-md active:scale-90"
class:opacity-0={hue === defaultHue} class:pointer-events-none={hue === defaultHue} on:click={resetHue}>
<div class="text-[var(--btn-content)]">
<Icon icon="fa6-solid:arrow-rotate-left" class="text-[0.875rem]"></Icon>
</div>
</button>
</div>
<div class="flex gap-1">
<div id="hueValue" class="transition bg-[var(--btn-regular-bg)] w-10 h-7 rounded-md flex justify-center
font-bold text-sm items-center text-[var(--btn-content)]">
{hue}
</div>
</div>
</div>
<div class="w-full h-6 px-1 bg-[oklch(0.80_0.10_0)] dark:bg-[oklch(0.70_0.10_0)] rounded select-none">
<input aria-label={i18n(I18nKey.themeColor)} type="range" min="0" max="360" bind:value={hue}
class="slider" id="colorSlider" step="5" style="width: 100%">
</div>
</div>
<style lang="stylus">
#display-setting
input[type="range"]
-webkit-appearance none
height 1.5rem
background-image var(--color-selection-bar)
transition background-image 0.15s ease-in-out
/* Input Thumb */
&::-webkit-slider-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
&::-moz-range-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
border-width 0
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
&::-ms-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
</style>

View File

@@ -0,0 +1,173 @@
---
import { siteConfig } from "@/config";
const { font: fontConfig } = siteConfig;
// 获取选中的字体
const getSelectedFonts = () => {
if (!fontConfig.enable || !fontConfig.selected) return [];
const selectedIds = Array.isArray(fontConfig.selected)
? fontConfig.selected
: [fontConfig.selected];
return selectedIds
.map((id) => fontConfig.fonts[id])
.filter((font) => font?.src); // 过滤掉系统字体和不存在的字体
};
const selectedFonts = getSelectedFonts();
// 生成字体CSS类名
const generateFontClasses = () => {
if (!fontConfig.enable) return [];
const selectedIds = Array.isArray(fontConfig.selected)
? fontConfig.selected
: [fontConfig.selected];
return selectedIds.map((id) => `font-${id}-enabled`);
};
const fontClasses = generateFontClasses();
// 生成font-family回退样式
const generateFontFamilyStyle = () => {
if (!fontConfig.enable || !fontConfig.selected) return "";
const selectedIds = Array.isArray(fontConfig.selected)
? fontConfig.selected
: [fontConfig.selected];
const selectedFontFamilies = selectedIds
.map((id) => fontConfig.fonts[id])
.filter((font) => font)
.map((font) => `"${font.family}"`);
if (selectedFontFamilies.length === 0) return "";
const fallbacks = fontConfig.fallback || [];
const allFonts = [...selectedFontFamilies, ...fallbacks];
return `font-family: ${allFonts.join(", ")};`;
};
const fontFamilyStyle = generateFontFamilyStyle();
---
<!-- 字体样式表链接 -->{
selectedFonts.map((font) => {
// 判断是否为外部链接
const isExternalUrl =
font.src.startsWith("http://") ||
font.src.startsWith("https://") ||
font.src.startsWith("//");
if (isExternalUrl) {
// 外部字体链接 (如 Google Fonts, CDN等)
return <link rel="stylesheet" href={font.src} />;
} else {
// 本地字体文件
return (
<style
set:html={`
@font-face {
font-family: "${font.family}";
src: url("${font.src}") ${font.format ? `format("${font.format}")` : ""};
${font.weight ? `font-weight: ${font.weight};` : ""}
${font.style ? `font-style: ${font.style};` : ""}
${font.display ? `font-display: ${font.display};` : ""}
${font.unicodeRange ? `unicode-range: ${font.unicodeRange};` : ""}
}
`}
/>
);
}
})
}
<!-- 字体预加载链接 -->
{
fontConfig.enable &&
fontConfig.preload &&
selectedFonts
.filter((font) => !font.src.startsWith("http"))
.map((font) => (
<link
rel="preload"
href={font.src}
as="font"
type={`font/${font.format || "woff2"}`}
crossorigin
/>
))
}
<!-- 全局字体样式 -->
{
fontConfig.enable && fontFamilyStyle && (
<style
set:html={`
:root {
--font-family-custom: ${selectedFonts.map((font) => `"${font.family}"`).join(", ")};
--font-family-fallback: ${(fontConfig.fallback || []).join(", ")};
}
/* 应用自定义字体到body */
body {
${fontFamilyStyle}
}
/* 为每个选中的字体生成对应的CSS类 */
${selectedFonts
.map((font) => {
return `
.font-${font.id},
.font-${font.id} * {
font-family: "${font.family}", var(--font-family-fallback) !important;
}
`;
})
.join("\n")}
/* 为整体启用字体的body添加类名 */
${fontClasses.map((className) => `.${className}`).join(",\n")} {
/* 字体相关的全局样式可以在这里添加 */
}
`}
/>
)
}
<script>
// 字体加载优化和错误处理
if (document.fonts && typeof document.fonts.ready !== "undefined") {
document.fonts.ready
.then(() => {
console.log("All fonts have been loaded");
// 触发自定义事件,通知字体加载完成
document.dispatchEvent(new CustomEvent("fontsLoaded"));
})
.catch((error: Error) => {
console.warn("Font loading failed:", error);
});
}
// 字体加载性能监控
if (typeof PerformanceObserver !== "undefined") {
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.entryType === "resource") {
// console.log(`Font resource loaded: ${entry.name} (${entry.duration.toFixed(2)}ms)`);
}
});
});
try {
observer.observe({ entryTypes: ["resource"] });
} catch (e) {
// 浏览器不支持时静默失败
}
}
</script>

View File

@@ -0,0 +1,159 @@
<script lang="ts">
import { onMount } from "svelte";
import { siteConfig } from "@/config";
export let currentLayout: "list" | "grid" = "list";
let mounted = false;
let isSmallScreen = false;
let isSwitching = false;
function checkScreenSize() {
isSmallScreen = window.innerWidth < 1200;
if (isSmallScreen) {
currentLayout = "list";
}
}
onMount(() => {
mounted = true;
checkScreenSize();
// 从localStorage读取用户偏好如果没有则使用传入的默认值
const savedLayout = localStorage.getItem("postListLayout");
if (savedLayout && (savedLayout === "list" || savedLayout === "grid")) {
currentLayout = savedLayout;
} else {
// 如果没有保存的偏好使用传入的默认布局从props
// currentLayout已经在声明时设置了默认值
}
// 监听窗口大小变化
window.addEventListener("resize", checkScreenSize);
return () => {
window.removeEventListener("resize", checkScreenSize);
};
});
function switchLayout() {
if (!mounted || isSmallScreen || isSwitching) return;
isSwitching = true;
currentLayout = currentLayout === "list" ? "grid" : "list";
localStorage.setItem("postListLayout", currentLayout);
// 触发自定义事件,通知父组件布局已改变
const event = new CustomEvent("layoutChange", {
detail: { layout: currentLayout },
});
window.dispatchEvent(event);
// 动画完成后重置状态
setTimeout(() => {
isSwitching = false;
}, 500);
}
// 监听布局变化事件
onMount(() => {
const handleCustomEvent = (event: Event) => {
const customEvent = event as CustomEvent<{ layout: "list" | "grid" }>;
currentLayout = customEvent.detail.layout;
};
window.addEventListener("layoutChange", handleCustomEvent);
return () => {
window.removeEventListener("layoutChange", handleCustomEvent);
};
});
// 监听PostPage的布局初始化事件
onMount(() => {
const handleLayoutInit = () => {
// 从PostPage获取当前布局状态
const postListContainer = document.getElementById("post-list-container");
if (postListContainer) {
const isGridMode = postListContainer.classList.contains("grid-mode");
currentLayout = isGridMode ? "grid" : "list";
}
};
// 延迟执行确保PostPage已经初始化
setTimeout(handleLayoutInit, 100);
return () => {
// 清理函数
};
});
</script>
{#if mounted && siteConfig.postListLayout.allowSwitch && !isSmallScreen}
<button
aria-label="切换文章列表布局"
class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90 flex items-center justify-center theme-switch-btn {isSwitching ? 'switching' : ''}"
on:click={switchLayout}
disabled={isSwitching}
title={currentLayout === 'list' ? '切换到网格模式' : '切换到列表模式'}
>
{#if currentLayout === 'list'}
<!-- 列表图标 -->
<svg class="w-5 h-5 icon-transition" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6h16v2H4zm0 5h16v2H4zm0 5h16v2H4z"/>
</svg>
{:else}
<!-- 网格图标 -->
<svg class="w-5 h-5 icon-transition" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 3h7v7H3V3zm0 11h7v7H3v-7zm11-11h7v7h-7V3zm0 11h7v7h-7v-7z"/>
</svg>
{/if}
</button>
{/if}
<style>
/* 确保主题切换按钮的背景色即时更新 */
.theme-switch-btn::before {
transition: transform 75ms ease-out, background-color 0ms !important;
}
/* 图标过渡动画 */
.icon-transition {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
}
/* 切换中的按钮动画 */
.switching {
pointer-events: none;
}
.switching .icon-transition {
animation: iconRotate 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes iconRotate {
0% {
transform: rotate(0deg) scale(1);
opacity: 1;
}
50% {
transform: rotate(180deg) scale(0.8);
opacity: 0.5;
}
100% {
transform: rotate(360deg) scale(1);
opacity: 1;
}
}
/* 悬停效果增强 */
.theme-switch-btn:not(.switching):hover .icon-transition {
transform: scale(1.1);
}
/* 按钮禁用状态 */
.theme-switch-btn:disabled {
cursor: not-allowed;
opacity: 0.7;
}
</style>

View File

@@ -0,0 +1,154 @@
<script lang="ts">
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import DropdownItem from "@/components/common/base/DropdownItem.svelte";
import DropdownPanel from "@/components/common/base/DropdownPanel.svelte";
import { DARK_MODE, LIGHT_MODE, SYSTEM_MODE } from "@/constants/constants";
import type { LIGHT_DARK_MODE } from "@/types/config.ts";
import {
applyThemeToDocument,
getStoredTheme,
setTheme,
} from "@/utils/setting-utils";
// Define Swup type for window object
interface SwupHooks {
on(event: string, callback: () => void): void;
}
interface SwupInstance {
hooks?: SwupHooks;
}
type WindowWithSwup = Window & { swup?: SwupInstance };
let mode: LIGHT_DARK_MODE = $state(LIGHT_MODE);
let displayedMode: LIGHT_DARK_MODE = $state(LIGHT_MODE); // 显示的实际主题在system模式下会随系统变化
function switchScheme(newMode: LIGHT_DARK_MODE) {
mode = newMode;
setTheme(newMode);
}
// 更新显示的主题(用于显示当前实际主题)
function updateDisplayedMode() {
if (mode === SYSTEM_MODE) {
// 如果是system模式显示实际的系统主题
const isSystemDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
displayedMode = isSystemDark ? DARK_MODE : LIGHT_MODE;
} else {
displayedMode = mode;
}
}
// 使用onMount确保在组件挂载后正确初始化
onMount(() => {
// 立即获取并设置正确的主题
const storedTheme = getStoredTheme();
mode = storedTheme;
updateDisplayedMode();
// 确保DOM状态与存储的主题一致只对非system模式检查
if (storedTheme !== SYSTEM_MODE) {
const currentTheme = document.documentElement.classList.contains("dark")
? DARK_MODE
: LIGHT_MODE;
if (storedTheme !== currentTheme) {
applyThemeToDocument(storedTheme);
}
}
// 如果是system模式监听系统主题变化
if (storedTheme === SYSTEM_MODE) {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleSystemChange = () => {
updateDisplayedMode();
};
mediaQuery.addEventListener("change", handleSystemChange);
}
// 添加Swup监听
const handleContentReplace = () => {
const newTheme = getStoredTheme();
mode = newTheme;
updateDisplayedMode();
};
// 检查Swup是否已经加载
const win = window as WindowWithSwup;
if (win.swup?.hooks) {
win.swup.hooks.on("content:replace", handleContentReplace);
} else {
document.addEventListener("swup:enable", () => {
const w = window as WindowWithSwup;
if (w.swup?.hooks) {
w.swup.hooks.on("content:replace", handleContentReplace);
}
});
}
// 监听主题变化事件
const handleThemeChange = () => {
// 只有当mode不是system模式时才更新mode
// system模式下mode应该保持为SYSTEM_MODEdisplayedMode会自动更新
if (mode !== SYSTEM_MODE) {
const newTheme = getStoredTheme();
mode = newTheme;
updateDisplayedMode();
} else {
// system模式下只需要更新displayedMode
updateDisplayedMode();
}
};
window.addEventListener("theme-change", handleThemeChange);
// 清理函数
return () => {
window.removeEventListener("theme-change", handleThemeChange);
};
});
</script>
<div class="relative z-50" role="menu" tabindex="-1">
<button aria-label="Light/Dark Mode" role="menuitem" class="relative btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="scheme-switch">
<div class="absolute" class:opacity-0={displayedMode !== LIGHT_MODE}>
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem]"></Icon>
</div>
<div class="absolute" class:opacity-0={displayedMode !== DARK_MODE}>
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem]"></Icon>
</div>
</button>
<div id="theme-mode-panel" class="absolute transition float-panel-closed top-11 -right-2 pt-5 z-50">
<DropdownPanel>
<DropdownItem
isActive={mode === LIGHT_MODE}
isLast={false}
onclick={() => switchScheme(LIGHT_MODE)}
>
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.lightMode)}
</DropdownItem>
<DropdownItem
isActive={mode === DARK_MODE}
isLast={false}
onclick={() => switchScheme(DARK_MODE)}
>
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.darkMode)}
</DropdownItem>
<DropdownItem
isActive={mode === SYSTEM_MODE}
isLast={true}
onclick={() => switchScheme(SYSTEM_MODE)}
>
<Icon icon="material-symbols:brightness-auto-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.systemMode)}
</DropdownItem>
</DropdownPanel>
</div>
</div>

View File

@@ -0,0 +1,68 @@
---
import { url } from "@/utils/url-utils";
interface Props {
config: {
src: {
desktop?: string;
mobile?: string;
};
position?: string;
zIndex?: number;
opacity?: number;
blur?: number;
};
className?: string;
}
const { config, className } = Astro.props;
// 获取图片源
const desktopSrc = config.src.desktop || config.src.mobile || "";
const mobileSrc = config.src.mobile || config.src.desktop || "";
// 如果没有任何图片源,不渲染
if (!desktopSrc && !mobileSrc) {
return null;
}
// 样式相关
const position = config.position || "center";
const zIndex = config.zIndex || -1;
const opacity = config.opacity || 0.8;
const blur = config.blur || 0;
---
<div
class:list={[
"fixed inset-0 w-full h-full overflow-hidden pointer-events-none",
className
].filter(Boolean)}
style={`z-index: ${zIndex}; opacity: ${opacity};`}
data-overlay-wallpaper
>
<!-- 桌面端壁纸 -->
{desktopSrc && (
<div class="hidden lg:block w-full h-full relative">
<img
src={desktopSrc.startsWith('/') ? url(desktopSrc) : desktopSrc}
alt="Desktop wallpaper"
class="absolute inset-0 w-full h-full object-cover"
style={`object-position: ${position};${blur > 0 ? ` filter: blur(${blur}px);` : ''}`}
/>
</div>
)}
<!-- 移动端壁纸 -->
{mobileSrc && (
<div class="block lg:hidden w-full h-full relative">
<img
src={mobileSrc.startsWith('/') ? url(mobileSrc) : mobileSrc}
alt="Mobile wallpaper"
class="absolute inset-0 w-full h-full object-cover"
style={`object-position: ${position};${blur > 0 ? ` filter: blur(${blur}px);` : ''}`}
/>
</div>
)}
</div>

View File

@@ -0,0 +1,291 @@
<script lang="ts">
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import Icon from "@iconify/svelte";
import { navigateToPage } from "@utils/navigation-utils";
import { MeiliSearch } from "meilisearch";
import { onMount } from "svelte";
import type { SearchResult } from "@/global";
import { type MeiliSearchConfig, NavBarSearchMethod } from "@/types/config";
import { url as formatUrl } from "@/utils/url-utils";
// --- Props from Astro ---
export let searchMethod: NavBarSearchMethod;
export let meiliSearchConfig: MeiliSearchConfig | undefined = undefined;
// --- State ---
let keywordDesktop = "";
let keywordMobile = "";
let result: SearchResult[] = [];
let isSearching = false;
let initialized = false;
let meiliClient: MeiliSearch | null = null;
let debounceTimer: NodeJS.Timeout;
// --- Mocks for Dev Mode ---
const fakeResult: SearchResult[] = [
{
url: formatUrl("/"),
meta: { title: "This Is a Fake Search Result" },
excerpt:
"Because Pagefind cannot work in the <mark>dev</mark> environment.",
},
{
url: formatUrl("/"),
meta: { title: "If You Want to Test the Search" },
excerpt: "Try running <mark>npm build && npm preview</mark> instead.",
},
];
// --- UI Logic ---
const togglePanel = () => {
document
.getElementById("search-panel")
?.classList.toggle("float-panel-closed");
};
const setPanelVisibility = (show: boolean, isDesktop: boolean): void => {
const panel = document.getElementById("search-panel");
if (
!panel ||
(isDesktop && !keywordDesktop) ||
(!isDesktop && !keywordMobile)
)
return;
show
? panel.classList.remove("float-panel-closed")
: panel.classList.add("float-panel-closed");
};
const closeSearchPanel = (): void => {
document.getElementById("search-panel")?.classList.add("float-panel-closed");
keywordDesktop = "";
keywordMobile = "";
result = [];
};
const handleResultClick = (event: Event, url: string): void => {
event.preventDefault();
closeSearchPanel();
navigateToPage(url);
};
// --- Core Search Logic ---
const search = async (keyword: string, isDesktop: boolean): Promise<void> => {
if (!keyword) {
setPanelVisibility(false, isDesktop);
result = [];
return;
}
if (!initialized) return;
isSearching = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
try {
let searchResults: SearchResult[] = [];
if (searchMethod === NavBarSearchMethod.MeiliSearch) {
if (!meiliClient)
throw new Error("MeiliSearch client not initialized.");
const index = meiliClient.index(meiliSearchConfig.INDEX_NAME);
const searchResponse = await index.search(keyword, {
limit: 10,
attributesToHighlight: ["title", "content", "description"],
attributesToCrop: ["content:50"],
highlightPreTag: "<mark>",
highlightPostTag: "</mark>",
});
console.log(searchResponse);
// Map MeiliSearch results to our standard SearchResult format
searchResults = searchResponse.hits
.filter((hit) => hit._formatted)
.map((hit) => {
return {
url: hit._formatted?.slug,
meta: { title: hit._formatted?.title },
excerpt: hit._formatted?.description,
content: hit._formatted?.content,
};
});
} else if (searchMethod === NavBarSearchMethod.PageFind) {
if (import.meta.env.PROD && window.pagefind) {
const response = await window.pagefind.search(keyword);
searchResults = await Promise.all(
response.results.map((item) => item.data()),
);
} else if (import.meta.env.DEV) {
searchResults = fakeResult;
}
}
result = searchResults;
setPanelVisibility(true, isDesktop);
} catch (error) {
console.error("Search error:", error);
result = [];
setPanelVisibility(false, isDesktop);
} finally {
isSearching = false;
}
}, 300); // 300ms debounce
};
// --- Initialization onMount ---
onMount(() => {
if (searchMethod === NavBarSearchMethod.MeiliSearch) {
try {
meiliClient = new MeiliSearch({
host: meiliSearchConfig.PUBLIC_MEILI_HOST,
apiKey: meiliSearchConfig.PUBLIC_MEILI_SEARCH_KEY,
});
initialized = true;
console.log("MeiliSearch client initialized.");
} catch (e) {
console.error("Failed to initialize MeiliSearch:", e);
}
} else if (searchMethod === NavBarSearchMethod.PageFind) {
const initializePagefind = () => {
initialized = true;
if (keywordDesktop) search(keywordDesktop, true);
if (keywordMobile) search(keywordMobile, false);
};
if (import.meta.env.DEV) {
console.log("Pagefind mock enabled in development mode.");
initializePagefind();
} else {
if (window.pagefind) {
// If script already loaded
initializePagefind();
} else {
// Listen for the event
document.addEventListener("pagefindready", initializePagefind, {
once: true,
});
document.addEventListener("pagefindloaderror", initializePagefind, {
once: true,
});
}
}
}
});
// --- Reactive Statements ---
$: if (initialized && (keywordDesktop || keywordDesktop === "")) {
search(keywordDesktop, true);
}
$: if (initialized && (keywordMobile || keywordMobile === "")) {
search(keywordMobile, false);
}
</script>
<!-- search bar for desktop view -->
<div id="search-bar" class="hidden lg:flex transition-all items-center h-11 mr-2 rounded-lg
bg-black/[0.04] hover:bg-black/[0.06] focus-within:bg-black/[0.06]
dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10
">
<Icon icon="material-symbols:search"
class="absolute text-[1.25rem] pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
<input placeholder="{i18n(I18nKey.search)}" bind:value={keywordDesktop}
on:focus={() => search(keywordDesktop, true)}
class="transition-all pl-10 text-sm bg-transparent outline-0
h-full w-40 active:w-60 focus:w-60 text-black/50 dark:text-white/50"
>
</div>
<!-- toggle btn for phone/tablet view -->
<button on:click={togglePanel} aria-label="Search Panel" id="search-switch"
class="btn-plain scale-animation lg:!hidden rounded-lg w-11 h-11 active:scale-90">
<Icon icon="material-symbols:search" class="text-[1.25rem]"></Icon>
</button>
<!-- search panel -->
<div id="search-panel" class="float-panel float-panel-closed search-panel absolute md:w-[30rem]
top-20 left-4 md:left-[unset] right-4 shadow-2xl rounded-2xl p-2">
<!-- search bar inside panel for phone/tablet -->
<div id="search-bar-inside" class="flex relative lg:hidden transition-all items-center h-11 rounded-xl
bg-black/[0.04] hover:bg-black/[0.06] focus-within:bg-black/[0.06]
dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10
">
<Icon icon="material-symbols:search"
class="absolute text-[1.25rem] pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
<input placeholder={i18n(I18nKey.search)} bind:value={keywordMobile}
class="pl-10 absolute inset-0 text-sm bg-transparent outline-0
focus:w-60 text-black/50 dark:text-white/50"
>
</div>
<!-- search results -->
{#if isSearching}
<div class="transition first-of-type:mt-2 lg:first-of-type:mt-0 block rounded-xl text-lg px-3 py-2 text-50">
{i18n(I18nKey.searchLoading)}
</div>
{:else if result.length > 0}
{#each result.slice(0, 5) as item}
<a href={item.url}
on:click={(e) => handleResultClick(e, item.url)}
class="transition first-of-type:mt-2 lg:first-of-type:mt-0 group block
rounded-xl text-lg px-3 py-2 hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)]">
<div class="transition text-90 inline-flex font-bold group-hover:text-[var(--primary)]">
{@html item.meta.title}
<Icon icon="fa6-solid:chevron-right"
class="transition text-[0.75rem] translate-x-1 my-auto text-[var(--primary)]"></Icon>
</div>
{#if item.excerpt.includes('<mark>')}
<div class="transition text-sm text-50" style="display: flex; align-items: flex-start; margin-top: 0.1rem">
<span style="display: inline-block; background-color: var(--btn-plain-bg-hover); color: var(--primary); padding: 0.1em 0.4em; border-radius: 5px; font-size: 0.75em; font-weight: 600; margin-right: 0.5em; flex-shrink: 0;">
{i18n(I18nKey.searchSummary)}
</span>
<div>
{@html item.excerpt}
</div>
</div>
{/if}
{#if item.content && item.content.includes('<mark>')}
<div class="transition text-sm text-30" style="display: flex; align-items: flex-start; margin-top: 0.1rem">
<span style="display: inline-block; background-color: var(--btn-plain-bg-active); color: var(--primary); padding: 0.1em 0.4em; border-radius: 5px; font-size: 0.75em; font-weight: 600; margin-right: 0.5em; flex-shrink: 0;">
{i18n(I18nKey.searchContent)}
</span>
<div>
{@html item.content}
</div>
</div>
{/if}
</a>
{/each}
{#if result.length > 5}
<a href="/search/?q={encodeURIComponent(keywordDesktop || keywordMobile)}"
on:click={(e) => handleResultClick(e, `/search/?q=${encodeURIComponent(keywordDesktop || keywordMobile)}`)}
class="transition first-of-type:mt-2 lg:first-of-type:mt-0 group block rounded-xl text-lg px-3 py-2 hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] text-[var(--primary)] font-bold text-center">
<span class="inline-flex items-center">
{i18n(I18nKey.searchViewMore).replace('{count}', (result.length - 5).toString())}
<Icon icon="fa6-solid:arrow-right" class="transition text-[0.75rem] ml-1"></Icon>
</span>
</a>
{/if}
{:else if result.length === 0}
<div class="transition first-of-type:mt-2 lg:first-of-type:mt-0 block rounded-xl text-lg px-3 py-2 text-50">
{i18n(I18nKey.searchNoResults)}
</div>
{:else if keywordDesktop || keywordMobile}
<div class="transition first-of-type:mt-2 lg:first-of-type:mt-0 block rounded-xl text-lg px-3 py-2 text-50">
{i18n(I18nKey.searchTypeSomething)}
</div>
{/if}
</div>
<style>
input:focus {
outline: 0;
}
.search-panel {
max-height: calc(100vh - 100px);
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import {
WALLPAPER_BANNER,
WALLPAPER_NONE,
WALLPAPER_OVERLAY,
} from "@constants/constants";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import Icon from "@iconify/svelte";
import { getStoredWallpaperMode, setWallpaperMode } from "@utils/setting-utils";
import { onMount } from "svelte";
import DropdownItem from "@/components/common/base/DropdownItem.svelte";
import DropdownPanel from "@/components/common/base/DropdownPanel.svelte";
import { backgroundWallpaper } from "@/config";
import type { WALLPAPER_MODE } from "@/types/config";
let mode: WALLPAPER_MODE = $state(backgroundWallpaper.mode);
// 在组件挂载时从localStorage读取保存的模式
onMount(() => {
mode = getStoredWallpaperMode();
});
function switchWallpaperMode(newMode: WALLPAPER_MODE) {
mode = newMode;
setWallpaperMode(newMode);
}
</script>
<!-- z-50 make the panel higher than other float panels -->
<div class="relative z-50" role="menu" tabindex="-1">
<button aria-label="Wallpaper Mode" role="menuitem" class="relative btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="wallpaper-mode-switch">
<div class="absolute" class:opacity-0={mode !== WALLPAPER_BANNER}>
<Icon icon="material-symbols:image-outline" class="text-[1.25rem]"></Icon>
</div>
<div class="absolute" class:opacity-0={mode !== WALLPAPER_OVERLAY}>
<Icon icon="material-symbols:wallpaper" class="text-[1.25rem]"></Icon>
</div>
<div class="absolute" class:opacity-0={mode !== WALLPAPER_NONE}>
<Icon icon="material-symbols:hide-image-outline" class="text-[1.25rem]"></Icon>
</div>
</button>
<div id="wallpaper-mode-panel" class="absolute transition float-panel-closed top-11 -right-2 pt-5 z-50">
<DropdownPanel>
<DropdownItem
isActive={mode === WALLPAPER_BANNER}
isLast={false}
onclick={() => switchWallpaperMode(WALLPAPER_BANNER)}
>
<Icon icon="material-symbols:image-outline" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.wallpaperBannerMode)}
</DropdownItem>
<DropdownItem
isActive={mode === WALLPAPER_OVERLAY}
isLast={false}
onclick={() => switchWallpaperMode(WALLPAPER_OVERLAY)}
>
<Icon icon="material-symbols:wallpaper" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.wallpaperOverlayMode)}
</DropdownItem>
<DropdownItem
isActive={mode === WALLPAPER_NONE}
isLast={true}
onclick={() => switchWallpaperMode(WALLPAPER_NONE)}
>
<Icon icon="material-symbols:hide-image-outline" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.wallpaperNoneMode)}
</DropdownItem>
</DropdownPanel>
</div>
</div>

View File

@@ -0,0 +1,11 @@
---
import { backgroundWallpaper, siteConfig } from "@/config";
---
<!-- 全局配置载体 -->
<div
id="config-carrier"
data-hue={siteConfig.themeColor.hue}
data-wallpaper-mode={backgroundWallpaper.mode}
>
</div>

View File

@@ -0,0 +1,201 @@
---
import { Icon } from "astro-icon/components";
import DropdownItem from "@/components/common/base/DropdownItem.astro";
import DropdownPanel from "@/components/common/base/DropdownPanel.astro";
import { LinkPresets } from "@/constants/link-presets";
import { LinkPreset, type NavBarLink } from "@/types/config";
import { url } from "@/utils/url-utils";
interface Props {
link: NavBarLink;
class?: string;
}
const { link, class: className } = Astro.props;
// 检查 link 是否存在
if (!link) {
return null;
}
// 转换子菜单中的LinkPreset为NavBarLink
const processedLink = {
...link,
children: link.children
?.map((child: NavBarLink | LinkPreset): NavBarLink | null => {
if (typeof child === "number") {
// 检查 LinkPreset 是否存在于 LinkPresets 中
if (child in LinkPresets) {
return LinkPresets[child as LinkPreset];
}
return null;
}
return child;
})
.filter((child): child is NavBarLink => child !== null),
};
const hasChildren = processedLink.children && processedLink.children.length > 0;
---
<div class:list={["dropdown-container", className]} data-dropdown>
{hasChildren ? (
<button
class="btn-plain scale-animation rounded-lg h-11 font-bold px-5 active:scale-95 dropdown-trigger"
aria-expanded="false"
aria-haspopup="true"
data-dropdown-trigger
>
<div class="flex items-center">
{processedLink.icon && <Icon name={processedLink.icon} class="text-[1.1rem] mr-2 navbar-icon" />}
{processedLink.name}
<Icon name="material-symbols:keyboard-arrow-down-rounded" class="text-[1.25rem] transition-transform duration-200 dropdown-arrow ml-1" />
</div>
</button>
<div class="dropdown-menu" data-dropdown-menu>
<DropdownPanel class="dropdown-content">
{processedLink.children?.map((child, index) => (
<DropdownItem
href={child.external ? child.url : url(child.url)}
target={child.external ? "_blank" : undefined}
isLast={index === (processedLink.children?.length || 0) - 1}
class="dropdown-item"
>
{child.icon && <Icon name={child.icon} class="text-[1.25rem] mr-3 navbar-icon" />}
<span>{child.name}</span>
{child.external && (
<Icon name="fa6-solid:arrow-up-right-from-square" class="text-[0.75rem] text-black/25 dark:text-white/25 ml-auto" />
)}
</DropdownItem>
))}
</DropdownPanel>
</div>
) : (
<a
aria-label={processedLink.name}
href={processedLink.external ? processedLink.url : url(processedLink.url)}
target={processedLink.external ? "_blank" : null}
class="btn-plain scale-animation rounded-lg h-11 font-bold px-5 active:scale-95"
>
<div class="flex items-center">
{processedLink.icon && <Icon name={processedLink.icon} class="text-[1.1rem] mr-2 navbar-icon" />}
{processedLink.name}
{processedLink.external && <Icon name="fa6-solid:arrow-up-right-from-square" class="text-[0.875rem] transition -translate-y-[1px] ml-1 text-black/[0.2] dark:text-white/[0.2]" />}
</div>
</a>
)}
</div>
<style>
.dropdown-container {
@apply relative;
}
.dropdown-menu {
@apply absolute top-full left-0 pt-2 opacity-0 invisible pointer-events-none transition-all duration-200 ease-out transform translate-y-[-8px] z-50;
}
.dropdown-container:hover .dropdown-menu,
.dropdown-container:focus-within .dropdown-menu {
@apply opacity-100 visible pointer-events-auto translate-y-0;
}
.dropdown-container:hover .dropdown-arrow,
.dropdown-container:focus-within .dropdown-arrow {
@apply rotate-180;
}
.dropdown-content {
@apply min-w-[12rem];
}
/* 移动端隐藏下拉菜单 */
@media (max-width: 768px) {
.dropdown-container {
@apply hidden;
}
}
</style>
<script>
// 键盘导航支持
document.addEventListener('DOMContentLoaded', function() {
const dropdowns = document.querySelectorAll('[data-dropdown]');
dropdowns.forEach(dropdown => {
const trigger = dropdown.querySelector('[data-dropdown-trigger]') as HTMLElement | null;
const menu = dropdown.querySelector('[data-dropdown-menu]');
const items = dropdown.querySelectorAll('.dropdown-item') as NodeListOf<HTMLElement>;
if (!trigger || !menu) return;
// 键盘事件处理
trigger.addEventListener('keydown', function(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleDropdown(dropdown, trigger, menu);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
openDropdown(dropdown, trigger, menu);
if (items.length > 0) {
items[0].focus();
}
} else if (e.key === 'Escape') {
closeDropdown(dropdown, trigger, menu);
}
});
// 菜单项键盘导航
items.forEach((item, index) => {
item.addEventListener('keydown', function(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
const nextIndex = (index + 1) % items.length;
items[nextIndex].focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prevIndex = (index - 1 + items.length) % items.length;
items[prevIndex].focus();
} else if (e.key === 'Escape') {
closeDropdown(dropdown, trigger, menu);
trigger.focus();
}
});
});
});
// 点击外部关闭下拉菜单
document.addEventListener('click', function(e) {
dropdowns.forEach(dropdown => {
if (!dropdown.contains(e.target as Node)) {
const trigger = dropdown.querySelector('[data-dropdown-trigger]');
const menu = dropdown.querySelector('[data-dropdown-menu]');
if (trigger && menu) {
closeDropdown(dropdown, trigger, menu);
}
}
});
});
});
function toggleDropdown(_dropdown: Element, trigger: Element, menu: Element) {
const isOpen = trigger.getAttribute('aria-expanded') === 'true';
if (isOpen) {
closeDropdown(_dropdown, trigger, menu);
} else {
openDropdown(_dropdown, trigger, menu);
}
}
function openDropdown(_dropdown: Element, trigger: Element, menu: Element) {
trigger.setAttribute('aria-expanded', 'true');
menu.classList.remove('opacity-0', 'invisible', 'pointer-events-none', 'translate-y-[-8px]');
menu.classList.add('opacity-100', 'visible', 'pointer-events-auto', 'translate-y-0');
}
function closeDropdown(_dropdown: Element, trigger: Element, menu: Element) {
trigger.setAttribute('aria-expanded', 'false');
menu.classList.add('opacity-0', 'invisible', 'pointer-events-none', 'translate-y-[-8px]');
menu.classList.remove('opacity-100', 'visible', 'pointer-events-auto', 'translate-y-0');
}
</script>

View File

@@ -0,0 +1,69 @@
---
import fs from "node:fs";
import path from "node:path";
import { footerConfig, profileConfig } from "@/config";
import { url } from "@/utils/url-utils";
const currentYear = new Date().getFullYear();
// 读取FooterConfig.html文件内容
let customFooterHtml = "";
if (footerConfig.enable) {
try {
const footerConfigPath = path.join(
process.cwd(),
"src",
"config",
"FooterConfig.html",
);
customFooterHtml = fs.readFileSync(footerConfigPath, "utf-8");
// 移除HTML注释
customFooterHtml = customFooterHtml.replace(/<!--[\s\S]*?-->/g, "").trim();
} catch (error) {
console.warn("FooterConfig.html文件读取失败:", error.message);
}
}
---
<!--<div class="border-t border-[var(&#45;&#45;primary)] mx-16 border-dashed py-8 max-w-[var(&#45;&#45;page-width)] flex flex-col items-center justify-center px-6">-->
<div
class="transition border-t border-black/10 dark:border-white/15 my-10 border-dashed mx-32"
>
</div>
<!--<div class="transition bg-[oklch(92%_0.01_var(&#45;&#45;hue))] dark:bg-black rounded-2xl py-8 mt-4 mb-8 flex flex-col items-center justify-center px-6">-->
<div
class="transition border-dashed border-[oklch(85%_0.01_var(--hue))] dark:border-white/15 rounded-2xl mb-12 flex flex-col items-center justify-center px-6"
>
<div class="transition text-50 text-sm text-center">
{customFooterHtml && <div class="mb-2" set:html={customFooterHtml} />}
&copy; <span id="copyright-year">{currentYear}</span>
{profileConfig.name}. All Rights Reserved. /
<a
class="transition link text-[var(--primary)] font-medium"
target="_blank"
href={url("rss.xml")}
>RSS</a
> /
<a
class="transition link text-[var(--primary)] font-medium"
target="_blank"
href={url("sitemap-index.xml")}>Sitemap</a
><br />
Powered by
<a
class="transition link text-[var(--primary)] font-medium"
target="_blank"
href="https://astro.build">Astro</a
> &
<a
class="transition link text-[var(--primary)] font-medium"
target="_blank"
href="https://github.com/CuteLeaf/Firefly">Firefly</a
><br />
<!--
注意:请勿随意修改或删除 "Powered by Astro & Firefly" 部分,这是对开源项目的尊重和支持。
如果您需要在底部增加内容请在src/config/footerConfig.ts中修改enable属性为true然后编辑src/config/FooterConfig.html文件。
您可以随意在src/config/FooterConfig.html中随意添加自定义内容不需要修改Footer.astro文件。
-->
</div>
</div>

View File

@@ -0,0 +1,3 @@
---
---

View File

@@ -0,0 +1,148 @@
---
import type { MarkdownHeading } from "astro";
import Profile from "@/components/content/Profile.astro";
import Advertisement from "@/components/widget/Advertisement.astro";
import Announcement from "@/components/widget/Announcement.astro";
import Calendar from "@/components/widget/Calendar.astro";
import Categories from "@/components/widget/Categories.astro";
import SiteStats from "@/components/widget/SiteStats.astro";
import Tags from "@/components/widget/Tags.astro";
import type { WidgetComponentConfig } from "@/types/config";
import { widgetManager } from "@/utils/widget-manager";
interface Props {
class?: string;
headings?: MarkdownHeading[];
}
const { class: className, headings: _headings } = Astro.props;
// 获取左侧边栏的组件列表
const topComponents = widgetManager.getComponentsByPosition("top", "left");
const stickyComponents = widgetManager.getComponentsByPosition(
"sticky",
"left",
);
// 提取客户端需要的数据
const sidebarConfig = {
shouldShowSidebar: {
mobile: widgetManager.shouldShowSidebar("mobile"),
tablet: widgetManager.shouldShowSidebar("tablet"),
desktop: widgetManager.shouldShowSidebar("desktop"),
},
};
// 组件映射表
const componentMap = {
profile: Profile,
announcement: Announcement,
categories: Categories,
tags: Tags,
advertisement: Advertisement,
stats: SiteStats,
calendar: Calendar,
};
// 渲染组件的辅助函数
function renderComponent(
component: WidgetComponentConfig,
index: number,
_components: WidgetComponentConfig[],
) {
const ComponentToRender =
componentMap[component.type as keyof typeof componentMap];
if (!ComponentToRender) return null;
const componentClass = widgetManager.getComponentClass(
component,
"left",
index,
);
const componentStyle = widgetManager.getComponentStyle(component, index);
return {
Component: ComponentToRender,
props: {
class: componentClass,
style: componentStyle,
headings: undefined,
configId: component.configId,
...component.customProps,
},
};
}
---
<div id="left-sidebar" class:list={[className, "w-full"]}>
<!-- 顶部固定组件区域 -->
{
topComponents.length > 0 && (
<div class="flex flex-col w-full gap-4 mb-4">
{topComponents.map((component, index) => {
const renderData = renderComponent(component, index, topComponents);
if (!renderData) return null;
const { Component, props } = renderData;
return <Component {...props} />;
})}
</div>
)
}
<!-- 粘性组件区域 -->
{
stickyComponents.length > 0 && (
<div
id="left-sidebar-sticky"
class="transition-all duration-700 flex flex-col w-full gap-4 sticky top-4"
>
{stickyComponents.map((component, index) => {
const renderData = renderComponent(
component,
index,
stickyComponents
);
if (!renderData) return null;
const { Component, props } = renderData;
return <Component {...props} />;
})}
</div>
)
}
</div>
<!-- 响应式样式和JavaScript -->
<style>
/* 响应式断点样式 */
@media (max-width: 768px) {
#left-sidebar {
display: var(--sidebar-mobile-display, block);
}
}
@media (min-width: 769px) and (max-width: 1024px) {
#left-sidebar {
display: var(--sidebar-tablet-display, block);
}
}
@media (min-width: 1025px) {
#left-sidebar {
display: var(--sidebar-desktop-display, block);
}
}
</style>
<script is:inline define:vars={{ sidebarConfig }}>
// 响应式布局管理
class LeftSidebarManager {
constructor() {
this.config = sidebarConfig;
}
}
// 初始化左侧边栏管理器
new LeftSidebarManager();
</script>

View File

@@ -0,0 +1,129 @@
---
import Icon from "@/components/misc/Icon.astro";
import { LinkPresets } from "@/constants/link-presets";
import { LinkPreset, type NavBarLink } from "@/types/config";
import { url } from "@/utils/url-utils";
interface Props {
links: NavBarLink[];
}
// 处理links中的LinkPreset转换
const processedLinks = Astro.props.links.map((link: NavBarLink) => ({
...link,
children: link.children?.map((child: NavBarLink | LinkPreset): NavBarLink => {
if (typeof child === "number") {
return LinkPresets[child];
}
return child;
}),
}));
---
<div id="nav-menu-panel" class:list={["float-panel float-panel-closed absolute transition-all fixed right-4 px-2 py-2 max-h-[80vh] overflow-y-auto"]}>
{processedLinks.map((link) => (
<div class="mobile-menu-item">
{link.children && link.children.length > 0 ? (
<!-- 有子菜单的项目 -->
<div class="mobile-dropdown" data-mobile-dropdown>
<button
class="group flex justify-between items-center py-2 pl-3 pr-1 rounded-lg gap-8 w-full text-left
hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition"
data-mobile-dropdown-trigger
aria-expanded="false"
>
<div class="flex items-center transition text-black/75 dark:text-white/75 font-bold group-hover:text-[var(--primary)] group-active:text-[var(--primary)]">
{link.icon && <Icon icon={link.icon} class="text-[1.1rem] mr-2" />}
{link.name}
</div>
<Icon icon="material-symbols:keyboard-arrow-down-rounded"
class="transition text-[1.25rem] text-[var(--primary)] mobile-dropdown-arrow duration-200"
/>
</button>
<div class="mobile-submenu" data-mobile-submenu>
{link.children.map((child) => (
<a href={child.external ? child.url : url(child.url)}
class="group flex justify-between items-center py-2 pl-6 pr-1 rounded-lg gap-8
hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition"
target={child.external ? "_blank" : null}
>
<div class="flex items-center transition text-black/60 dark:text-white/60 font-medium group-hover:text-[var(--primary)] group-active:text-[var(--primary)]">
{child.icon && <Icon icon={child.icon} class="text-[1.1rem] mr-2" />}
{child.name}
</div>
{child.external && <Icon icon="fa6-solid:arrow-up-right-from-square"
class="transition text-[0.75rem] text-black/25 dark:text-white/25 -translate-x-1"
/>}
</a>
))}
</div>
</div>
) : (
<!-- 普通链接项目 -->
<a href={link.external ? link.url : url(link.url)} class="group flex justify-between items-center py-2 pl-3 pr-1 rounded-lg gap-8
hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition
"
target={link.external ? "_blank" : null}
>
<div class="flex items-center transition text-black/75 dark:text-white/75 font-bold group-hover:text-[var(--primary)] group-active:text-[var(--primary)]">
{link.icon && <Icon icon={link.icon} class="text-[1.1rem] mr-2" />}
{link.name}
</div>
{!link.external && <Icon icon="material-symbols:chevron-right-rounded"
class="transition text-[1.25rem] text-[var(--primary)]"
/>}
{link.external && <Icon icon="fa6-solid:arrow-up-right-from-square"
class="transition text-[0.75rem] text-black/25 dark:text-white/25 -translate-x-1"
/>}
</a>
)}
</div>
))}
</div>
<style>
.mobile-submenu {
@apply max-h-0 overflow-hidden transition-all duration-300 ease-in-out;
}
.mobile-dropdown[data-expanded="true"] .mobile-submenu {
@apply max-h-96;
}
.mobile-dropdown[data-expanded="true"] .mobile-dropdown-arrow {
@apply rotate-180;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const mobileDropdowns = document.querySelectorAll('[data-mobile-dropdown]');
mobileDropdowns.forEach(dropdown => {
const trigger = dropdown.querySelector('[data-mobile-dropdown-trigger]');
const submenu = dropdown.querySelector('[data-mobile-submenu]');
if (!trigger || !submenu) return;
trigger.addEventListener('click', function(e) {
e.preventDefault();
const isExpanded = dropdown.getAttribute('data-expanded') === 'true';
// 关闭其他打开的下拉菜单
mobileDropdowns.forEach(otherDropdown => {
if (otherDropdown !== dropdown) {
otherDropdown.setAttribute('data-expanded', 'false');
const otherTrigger = otherDropdown.querySelector('[data-mobile-dropdown-trigger]');
if (otherTrigger) {
otherTrigger.setAttribute('aria-expanded', 'false');
}
}
});
// 切换当前下拉菜单
const newState = !isExpanded;
dropdown.setAttribute('data-expanded', newState.toString());
trigger.setAttribute('aria-expanded', newState.toString());
});
});
});
</script>

View File

@@ -0,0 +1,261 @@
---
import { Icon } from "astro-icon/components";
import DisplaySettings from "@/components/interactive/DisplaySettings.svelte";
import LayoutSwitchButton from "@/components/interactive/LayoutSwitchButton.svelte";
import LightDarkSwitch from "@/components/interactive/LightDarkSwitch.svelte";
import Search from "@/components/interactive/Search.svelte";
import WallpaperSwitch from "@/components/interactive/WallpaperSwitch.svelte";
import {
backgroundWallpaper,
navBarConfig,
navBarSearchConfig,
siteConfig,
} from "@/config";
import { LinkPresets } from "@/constants/link-presets";
import {
LinkPreset,
type NavBarLink,
NavBarSearchMethod,
} from "@/types/config";
import { isHomePage } from "@/utils/layout-utils";
import { url } from "@/utils/url-utils";
import DropdownMenu from "./DropdownMenu.astro";
import NavMenuPanel from "./NavMenuPanel.astro";
const className = Astro.props.class;
// 检查是否允许切换壁纸模式
const isWallpaperSwitchable = backgroundWallpaper.switchable ?? true;
// 检查是否允许切换布局(双侧栏模式下也允许切换,但切换到网格时会自动隐藏右侧边栏)
const allowLayoutSwitch = siteConfig.postListLayout.allowSwitch;
// 获取导航栏透明模式配置
const navbarTransparentMode =
backgroundWallpaper.banner?.navbar?.transparentMode || "semi";
// 获取导航栏标题,如果没有设置则使用 siteConfig.title
const navbarTitle = siteConfig.navbar.title || siteConfig.title;
// 获取导航栏宽度配置
const navbarWidthFull = siteConfig.navbar.widthFull ?? false;
// 检查是否为首页
const isHomePageCheck = isHomePage(Astro.url.pathname);
let links: NavBarLink[] = navBarConfig.links.map(
(item: NavBarLink | LinkPreset): NavBarLink => {
if (typeof item === "number") {
return LinkPresets[item];
}
return item;
},
);
---
<div id="navbar" class="z-50 onload-animation" data-transparent-mode={navbarTransparentMode} data-is-home={isHomePageCheck} data-full-width={navbarWidthFull}>
<div class="absolute h-8 left-0 right-0 -top-8 bg-[var(--card-bg)] transition"></div> <!-- used for onload animation -->
<div class:list={[
className,
"!overflow-visible h-[4.5rem] mx-auto flex items-center px-4",
navbarWidthFull ? "" : "justify-between max-w-[var(--page-width)]"
]}>
<a href={url('/')} class="btn-plain scale-animation rounded-lg h-[3.25rem] px-5 font-bold active:scale-95">
<div class:list={[
"flex flex-row items-center text-md",
siteConfig.navbar.followTheme ? "text-[var(--primary)]" : "dark:text-white text-black"
]}>
{siteConfig.navbar.logo?.type === "icon" ? (
<Icon name={siteConfig.navbar.logo.value || "material-symbols:home-pin-outline"} class="text-[1.75rem] mb-1 mr-2" />
) : siteConfig.navbar.logo?.type === "image" ? (
<img src={url(siteConfig.navbar.logo.value)} alt={siteConfig.navbar.logo.alt || siteConfig.title} class="h-[1.75rem] w-[1.75rem] mb-1 mr-2 object-contain" loading="lazy" />
) : (
<Icon name="material-symbols:home-pin-outline" class="text-[1.75rem] mb-1 mr-2" />
)}
{navbarTitle}
</div>
</a>
<div class="hidden md:flex items-center space-x-1">
{links.map((l) => {
return <DropdownMenu link={l} />;
})}
</div>
<div class:list={["flex", navbarWidthFull ? "ml-auto" : ""]}>
<!--<SearchPanel client:load>-->
<Search
client:load
searchMethod={navBarSearchConfig.method}
meiliSearchConfig={navBarSearchConfig.meiliSearchConfig}
/>
{!siteConfig.themeColor.fixed && (
<button aria-label="Display Settings" class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="display-settings-switch">
<Icon name="material-symbols:palette-outline" class="text-[1.25rem]"></Icon>
</button>
)}
{/* @ts-ignore */}
{isWallpaperSwitchable && <WallpaperSwitch client:load></WallpaperSwitch>}
{allowLayoutSwitch && <LayoutSwitchButton client:load currentLayout={siteConfig.postListLayout.defaultMode}></LayoutSwitchButton>}
{/* @ts-ignore */}
<LightDarkSwitch client:load></LightDarkSwitch>
<button aria-label="Menu" name="Nav Menu" class="btn-plain scale-animation rounded-lg w-11 h-11 active:scale-90 md:!hidden" id="nav-menu-switch">
<Icon name="material-symbols:menu-rounded" class="text-[1.25rem]"></Icon>
</button>
</div>
<NavMenuPanel links={links}></NavMenuPanel>
<DisplaySettings client:load></DisplaySettings>
</div>
</div>
<script>
function loadButtonScript() {
let settingBtn = document.getElementById("display-settings-switch");
if (settingBtn) {
settingBtn.onclick = function () {
let settingPanel = document.getElementById("display-setting");
if (settingPanel) {
settingPanel.classList.toggle("float-panel-closed");
}
};
}
let menuBtn = document.getElementById("nav-menu-switch");
if (menuBtn) {
menuBtn.onclick = function () {
let menuPanel = document.getElementById("nav-menu-panel");
if (menuPanel) {
menuPanel.classList.toggle("float-panel-closed");
}
};
}
let wallpaperSwitchBtn = document.getElementById("wallpaper-mode-switch");
if (wallpaperSwitchBtn) {
wallpaperSwitchBtn.onclick = function () {
let wallpaperPanel = document.getElementById("wallpaper-mode-panel");
if (wallpaperPanel) {
wallpaperPanel.classList.toggle("float-panel-closed");
}
};
}
let themeSwitchBtn = document.getElementById("scheme-switch");
if (themeSwitchBtn) {
themeSwitchBtn.onclick = function () {
let themePanel = document.getElementById("theme-mode-panel");
if (themePanel) {
themePanel.classList.toggle("float-panel-closed");
}
};
}
}
loadButtonScript();
// 为semifull模式添加滚动检测逻辑
function initSemifullScrollDetection() {
const navbar = document.getElementById('navbar');
if (!navbar) return;
const transparentMode = navbar.getAttribute('data-transparent-mode');
if (transparentMode !== 'semifull') return;
const isHomePage = navbar.getAttribute('data-is-home') === 'true';
// 如果不是首页,移除滚动事件监听器并设置为半透明状态
if (!isHomePage) {
// 移除之前的滚动事件监听器(如果存在)
if (window.semifullScrollHandler) {
window.removeEventListener('scroll', window.semifullScrollHandler as () => void);
window.semifullScrollHandler = undefined;
}
// 设置为半透明状态
navbar.classList.add('scrolled');
return;
}
// 移除现有的scrolled类重置状态
navbar.classList.remove('scrolled');
let ticking = false;
function updateNavbarState() {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const threshold = 50; // 滚动阈值,可以根据需要调整
// 使用批量DOM操作优化性能
if (scrollTop > threshold) {
navbar!.classList.add('scrolled');
} else {
navbar!.classList.remove('scrolled');
}
ticking = false;
}
function requestTick() {
if (!ticking) {
requestAnimationFrame(updateNavbarState);
ticking = true;
}
}
// 移除之前的滚动事件监听器(如果存在)
if (window.semifullScrollHandler) {
window.removeEventListener('scroll', window.semifullScrollHandler as () => void);
}
// 保存新的事件处理器引用
window.semifullScrollHandler = requestTick as unknown as (() => void);
// 监听滚动事件
window.addEventListener('scroll', requestTick, { passive: true });
// 初始化状态
updateNavbarState();
}
// 将函数暴露到全局对象,供页面切换时调用
window.initSemifullScrollDetection = initSemifullScrollDetection;
// 页面加载完成后初始化滚动检测
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSemifullScrollDetection);
} else {
initSemifullScrollDetection();
}
</script>
{
navBarSearchConfig.method === NavBarSearchMethod.PageFind && import.meta.env.PROD && (
<script is:inline define:vars={{ scriptUrl: url('/pagefind/pagefind.js') }}>
{/* 你的 loadPagefind 函数的完整内容放在这里 */}
async function loadPagefind() {
try {
const response = await fetch(scriptUrl, { method: 'HEAD' });
if (!response.ok) {
throw new Error(`Pagefind script not found: ${response.status}`);
}
const pagefind = await import(scriptUrl);
await pagefind.options({
excerptLength: 20
});
window.pagefind = pagefind;
document.dispatchEvent(new CustomEvent('pagefindready'));
console.log('Pagefind loaded and initialized successfully, event dispatched.');
} catch (error) {
console.error('Failed to load Pagefind:', error);
window.pagefind = {
search: () => Promise.resolve({ results: [] }),
options: () => Promise.resolve(),
};
document.dispatchEvent(new CustomEvent('pagefindloaderror'));
console.log('Pagefind load error, event dispatched.');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadPagefind);
} else {
loadPagefind();
}
</script>
)
}

View File

@@ -0,0 +1,452 @@
---
import type { CollectionEntry } from "astro:content";
import { getPostUrlBySlug } from "@utils/url-utils";
import PostCard from "@/components/content/PostCard.astro";
import { sidebarLayoutConfig, siteConfig } from "@/config";
const { page } = Astro.props;
let delay = 0;
const interval = 50;
// 类型别名避免Fragment语法问题
type PostEntry = CollectionEntry<"posts">;
// 检查是否启用双侧边栏
const isBothSidebars = sidebarLayoutConfig.position === "both";
const masonryEnabled = siteConfig.postListLayout.grid.masonry;
const gridColumns = siteConfig.postListLayout.grid.columns || 2;
// 根据配置设置初始布局模式,避免闪烁
const defaultLayout = siteConfig.postListLayout.defaultMode || "list";
const gridCols =
!isBothSidebars && gridColumns === 3
? "md:grid-cols-2 lg:grid-cols-3"
: "md:grid-cols-2";
const initialLayoutClass =
defaultLayout === "grid"
? `grid grid-cols-1 ${gridCols} gap-4 grid-mode`
: "flex flex-col gap-4 md:gap-4 list-mode";
---
<div
id="post-list-container"
class={`transition-all duration-500 ease-in-out mb-4 ${initialLayoutClass}`}
data-default-layout={defaultLayout}
data-both-sidebars={isBothSidebars}
data-masonry-enabled={masonryEnabled}
data-grid-columns={gridColumns}
>
{
page.data.map((entry: PostEntry) => (
<PostCard
entry={entry}
title={entry.data.title}
tags={entry.data.tags}
category={entry.data.category}
published={entry.data.published}
updated={entry.data.updated}
url={getPostUrlBySlug(entry.id)}
image={entry.data.image}
description={entry.data.description}
draft={entry.data.draft}
pinned={entry.data.pinned}
class:list="onload-animation post-card-item"
style={`animation-delay: calc(var(--content-delay) + ${delay++ * interval}ms);`}
/>
))
}
</div>
<!-- 立即执行脚本:防止刷新时的布局闪烁 -->
<script is:inline define:vars={{ defaultLayout, isBothSidebars }}>
(function() {
const savedLayout = localStorage.getItem('postListLayout');
// 如果有保存的布局且与默认布局不同,更新容器布局
if (savedLayout && savedLayout !== defaultLayout) {
const container = document.getElementById('post-list-container');
if (container) {
// 禁用过渡动画
container.style.transition = 'none';
// 移除所有布局类
container.classList.remove('list-mode', 'grid-mode', 'flex', 'flex-col', 'grid', 'grid-cols-1', 'md:grid-cols-2', 'gap-4', 'md:gap-4');
if (savedLayout === 'grid') {
container.classList.add('grid-mode', 'grid', 'grid-cols-1', 'md:grid-cols-2', 'gap-4');
} else {
container.classList.add('list-mode', 'flex', 'flex-col', 'gap-4', 'md:gap-4');
}
// 强制重排后恢复过渡动画
container.offsetHeight;
container.style.transition = '';
}
}
})();
</script>
<script>
// 动态布局切换脚本
function initLayout() {
const postListContainer = document.getElementById("post-list-container");
if (!postListContainer) return;
// 检查屏幕宽度
const screenWidth = window.innerWidth;
const isSmallScreen = screenWidth < 1200;
// 从localStorage读取用户偏好
const savedLayout = localStorage.getItem("postListLayout");
const defaultLayout =
postListContainer.getAttribute("data-default-layout") || "list";
let currentLayout = savedLayout || defaultLayout;
// 如果屏幕宽度小于1200px强制使用列表模式
if (isSmallScreen) {
currentLayout = "list";
}
// 应用布局
updatePostListLayout(currentLayout);
}
function updatePostListLayout(layout: string) {
const postListContainer = document.getElementById("post-list-container");
if (!postListContainer) return;
// 添加切换动画类
postListContainer.classList.add("layout-switching");
// 移除现有布局类
postListContainer.classList.remove("list-mode", "grid-mode");
// 检查是否启用双侧边栏
const isBothSidebars = postListContainer.getAttribute("data-both-sidebars") === "true";
const masonryEnabled = postListContainer.getAttribute("data-masonry-enabled") === "true";
const gridColumns = parseInt(postListContainer.getAttribute("data-grid-columns") || "2");
// 添加新布局类
if (layout === "grid") {
postListContainer.classList.add("grid-mode");
postListContainer.classList.remove("flex", "flex-col");
// 应用瀑布流布局
if (masonryEnabled) {
// 瀑布流模式
postListContainer.classList.remove("grid", "grid-cols-1", "md:grid-cols-2", "lg:grid-cols-3", "gap-4");
applyMasonryLayout();
} else {
// 普通网格模式
postListContainer.classList.add("grid", "grid-cols-1", "md:grid-cols-2", "gap-4");
if (!isBothSidebars && gridColumns === 3) {
postListContainer.classList.add("lg:grid-cols-3");
}
resetMasonryLayout();
}
} else {
postListContainer.classList.add("list-mode");
// 列表模式:单列布局
postListContainer.classList.add("flex", "flex-col", "gap-4", "md:gap-4");
postListContainer.classList.remove("grid", "grid-cols-1", "md:grid-cols-2", "lg:grid-cols-3");
// 重置瀑布流样式
resetMasonryLayout();
}
// 移除切换动画类
setTimeout(() => {
postListContainer.classList.remove("layout-switching");
}, 500);
}
function resetMasonryLayout() {
const container = document.getElementById("post-list-container");
if (!container) return;
container.style.height = "";
container.style.position = "";
container.style.display = "";
const items = container.querySelectorAll(".post-card-item");
items.forEach((item) => {
// @ts-ignore
item.style.position = "";
// @ts-ignore
item.style.top = "";
// @ts-ignore
item.style.left = "";
// @ts-ignore
item.style.width = "";
});
}
function applyMasonryLayout() {
const container = document.getElementById("post-list-container");
if (!container) return;
const masonryEnabled = container.getAttribute("data-masonry-enabled") === "true";
if (!masonryEnabled) return;
// 仅在 grid 模式下应用
if (!container.classList.contains("grid-mode")) return;
const items = Array.from(container.querySelectorAll(".post-card-item"));
if (items.length === 0) return;
// 瀑布流配置
const gap = 16; // 1rem = 16px
const isBothSidebars = container.getAttribute("data-both-sidebars") === "true";
const gridColumns = parseInt(container.getAttribute("data-grid-columns") || "2");
let colCount = 2;
if (!isBothSidebars && gridColumns === 3 && window.innerWidth >= 1024) {
colCount = 3;
}
// 重置容器样式以允许绝对定位
container.style.position = "relative";
container.style.display = "block"; // 覆盖 grid display
// 使用 offsetWidth 避免 transform: scale 的影响
const containerWidth = container.offsetWidth;
const itemWidth = (containerWidth - (colCount - 1) * gap) / colCount;
const colHeights = new Array(colCount).fill(0);
items.forEach((item, index) => {
const colIndex = index % colCount; // Z字形分布左右交替
// @ts-ignore
item.style.position = "absolute";
// @ts-ignore
item.style.width = `${itemWidth}px`;
// @ts-ignore
item.style.setProperty('height', 'auto', 'important');
// 获取高度,优先使用 offsetHeight 避免 transform 影响
// @ts-ignore
const height = item.offsetHeight;
const top = colHeights[colIndex];
const left = colIndex * (itemWidth + gap);
// @ts-ignore
item.style.top = `${top}px`;
// @ts-ignore
item.style.left = `${left}px`;
colHeights[colIndex] += height + gap;
});
container.style.height = `${Math.max(...colHeights)}px`;
}
// 页面加载时初始化布局
document.addEventListener("DOMContentLoaded", function () {
// 延迟一点确保DOM完全加载
setTimeout(initLayout, 50);
// 监听图片加载以重新计算布局
const imgs = document.querySelectorAll('#post-list-container img');
imgs.forEach(img => {
// @ts-ignore
if(img.complete) return;
img.addEventListener('load', () => {
applyMasonryLayout();
});
});
});
// 页面显示时也初始化布局(处理页面切换)
document.addEventListener("visibilitychange", function () {
if (!document.hidden) {
setTimeout(initLayout, 100);
}
});
// 监听布局变化事件
window.addEventListener("layoutChange", function (event) {
// @ts-ignore
const newLayout = event.detail.layout;
const postListContainer = document.getElementById("post-list-container");
if (!postListContainer) return;
// 检查屏幕宽度如果小于1200px则强制使用列表模式
const screenWidth = window.innerWidth;
const isSmallScreen = screenWidth < 1200;
if (isSmallScreen) {
updatePostListLayout("list");
} else {
updatePostListLayout(newLayout);
}
});
// 监听窗口大小变化
let resizeTimeout: any;
window.addEventListener("resize", function () {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(function () {
initLayout();
}, 250);
});
// 监听页面导航事件Astro的客户端路由
document.addEventListener("astro:page-load", function () {
setTimeout(initLayout, 50);
// 监听图片加载
const imgs = document.querySelectorAll('#post-list-container img');
imgs.forEach(img => {
// @ts-ignore
if(img.complete) return;
img.addEventListener('load', () => {
applyMasonryLayout();
});
});
});
document.addEventListener("astro:after-swap", function () {
setTimeout(initLayout, 50);
});
// 立即执行一次(处理页面刷新)
setTimeout(initLayout, 0);
</script>
<style>
/* 布局切换动画 */
#post-list-container {
/* 限制过渡属性为 opacity 和 transform避免响应父元素位置变化 */
transition: opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 文章卡片的过渡动画 */
#post-list-container > :global(*) {
transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 布局切换时的动画效果 */
#post-list-container.layout-switching {
opacity: 0.95;
}
#post-list-container.layout-switching > :global(*) {
transform: scale(0.98);
}
/* 列表模式的特殊动画 - 从左到右 */
#post-list-container.list-mode > :global(*) {
animation: fadeInSlide 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes fadeInSlide {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* List Mode Customization */
#post-list-container.list-mode :global(.post-card-title) {
font-size: 1.5rem !important;
line-height: 2rem !important;
}
#post-list-container.list-mode :global(.post-card-title::before) {
top: 2.25rem !important;
height: 1rem !important;
}
/* 列表模式下,有封面的文章,摘要最多显示两行 */
#post-list-container.list-mode :global(.has-cover .description) {
display: -webkit-box !important;
-webkit-box-orient: vertical !important;
overflow: hidden !important;
-webkit-line-clamp: 2 !important;
line-clamp: 2 !important;
}
/* Grid Mode Customization */
#post-list-container.grid-mode :global(.post-card-wrapper) {
flex-direction: column-reverse !important;
}
#post-list-container.grid-mode :global(.post-card-image) {
width: 100% !important;
position: relative !important;
top: auto !important;
right: auto !important;
bottom: auto !important;
border-radius: var(--radius-large) var(--radius-large) 0 0 !important;
/*aspect-ratio: 16/9 !important;*/
}
#post-list-container.grid-mode :global(.post-card-content) {
width: 100% !important;
padding: 1rem !important;
}
#post-list-container.grid-mode :global(.post-card-title) {
font-size: 1.125rem !important;
line-height: 1.75rem !important;
margin-bottom: 0.5rem !important;
}
#post-list-container.grid-mode :global(.post-card-title::before) {
display: none !important;
}
#post-list-container.grid-mode :global(.post-card-enter-btn) {
display: none !important;
}
#post-list-container.grid-mode :global(.description) {
font-size: 0.875rem !important;
margin-bottom: 0.75rem !important;
padding-right: 0 !important;
}
/* 网格模式下,有封面的文章,摘要最多显示两行 */
#post-list-container.grid-mode :global(.has-cover .description) {
display: -webkit-box !important;
-webkit-box-orient: vertical !important;
overflow: hidden !important;
-webkit-line-clamp: 3 !important;
line-clamp: 3 !important;
}
#post-list-container.grid-mode :global(.post-meta) {
margin-bottom: 0.5rem !important;
gap: 0.5rem !important;
}
#post-list-container.grid-mode :global(.post-meta .text-xl) {
font-size: 1rem !important;
line-height: 1.25rem !important;
}
#post-list-container.grid-mode :global(.meta-icon) {
width: 1.5rem !important;
height: 1.5rem !important;
margin-right: 0.25rem !important;
}
#post-list-container.grid-mode :global(.post-meta .text-sm) {
font-size: 0.75rem !important;
line-height: 1rem !important;
}
#post-list-container.grid-mode :global(.post-meta .pinned-btn) {
padding: 0.25rem 0.375rem !important;
}
</style>

View File

@@ -0,0 +1,201 @@
---
import type { MarkdownHeading } from "astro";
import Profile from "@/components/content/Profile.astro";
import Advertisement from "@/components/widget/Advertisement.astro";
import Announcement from "@/components/widget/Announcement.astro";
import Calendar from "@/components/widget/Calendar.astro";
import Categories from "@/components/widget/Categories.astro";
import SidebarTOC from "@/components/widget/SidebarTOC.astro";
import SiteStats from "@/components/widget/SiteStats.astro";
import Tags from "@/components/widget/Tags.astro";
import type { WidgetComponentConfig } from "@/types/config";
import { widgetManager } from "@/utils/widget-manager";
interface Props {
class?: string;
headings?: MarkdownHeading[];
}
const { class: className, headings } = Astro.props;
// 获取右侧边栏的组件列表
const topComponents = widgetManager.getComponentsByPosition("top", "right");
const stickyComponents = widgetManager.getComponentsByPosition(
"sticky",
"right",
);
// 组件类型到ID的映射
const componentTypeToId = {
stats: "site-stats",
calendar: "calendar-widget",
sidebarToc: "sidebar-toc",
profile: "profile",
announcement: "announcement",
categories: "categories",
tags: "tags",
advertisement: "advertisement",
};
// 提取客户端需要的数据 - 包含组件的 showOnPostPage 配置
const rightComponentsConfig = widgetManager
.getConfig()
.rightComponents.filter((c) => c.enable)
.map((c) => ({
id: componentTypeToId[c.type as keyof typeof componentTypeToId],
showOnPostPage: c.showOnPostPage ?? true, // 默认为 true
}));
// 组件映射表
const componentMap = {
profile: Profile,
announcement: Announcement,
categories: Categories,
tags: Tags,
sidebarToc: SidebarTOC,
advertisement: Advertisement,
stats: SiteStats,
calendar: Calendar,
};
// 渲染组件的辅助函数
function renderComponent(
component: WidgetComponentConfig,
index: number,
_components: WidgetComponentConfig[],
) {
const ComponentToRender =
componentMap[component.type as keyof typeof componentMap];
if (!ComponentToRender) return null;
const componentClass = widgetManager.getComponentClass(
component,
"right",
index,
);
const componentStyle = widgetManager.getComponentStyle(component, index);
return {
Component: ComponentToRender,
props: {
class: componentClass,
style: componentStyle,
headings: headings ?? [],
configId: component.configId,
showOnPostPage: component.showOnPostPage,
...component.customProps,
},
};
}
---
<div id="right-sidebar" class:list={[className, "w-full"]}>
<!-- 顶部固定组件区域 -->
{
topComponents.length > 0 && (
<div class="flex flex-col w-full gap-4 mb-4">
{topComponents.map((component, index) => {
const renderData = renderComponent(component, index, topComponents);
if (!renderData) return null;
const { Component, props } = renderData;
return <Component {...props} />;
})}
</div>
)
}
<!-- 粘性组件区域 -->
{
stickyComponents.length > 0 && (
<div
id="right-sidebar-sticky"
class="transition-all duration-700 flex flex-col w-full gap-4 sticky top-4"
>
{stickyComponents.map((component, index) => {
const renderData = renderComponent(
component,
index,
stickyComponents
);
if (!renderData) return null;
const { Component, props } = renderData;
return <Component {...props} />;
})}
</div>
)
}
</div>
<!-- 响应式样式和JavaScript -->
<style>
/* 响应式断点样式 */
@media (max-width: 768px) {
#right-sidebar {
display: var(--sidebar-mobile-display, block);
}
}
@media (min-width: 769px) and (max-width: 1024px) {
#right-sidebar {
display: var(--sidebar-tablet-display, block);
}
}
@media (min-width: 1025px) {
#right-sidebar {
display: var(--sidebar-desktop-display, block);
}
}
</style>
<script is:inline define:vars={{ rightComponentsConfig }}>
// 根据 showOnPostPage 配置控制组件显示
function toggleComponentsVisibility() {
const isPostPage = window.location.pathname.includes("/posts/");
// 获取所有右侧边栏组件
const rightSidebar = document.getElementById('right-sidebar');
if (!rightSidebar) return;
// 查找所有 widget-layout 元素
const widgets = rightSidebar.querySelectorAll('widget-layout');
widgets.forEach((widget) => {
const widgetId = widget.getAttribute('data-id');
// 从配置中查找该组件的 showOnPostPage 设置
const componentConfig = rightComponentsConfig.find(c => c.id === widgetId);
if (componentConfig) {
// 特殊处理侧边栏TOC只在文章详情页显示
if (widgetId === 'sidebar-toc') {
widget.style.display = isPostPage ? '' : 'none';
}
// 其他组件的 showOnPostPage 含义:
// true: 在文章页和非文章页都显示(默认行为)
// false: 只在非文章页显示,文章页隐藏
else if (isPostPage && !componentConfig.showOnPostPage) {
// 文章页 且 showOnPostPage 为 false隐藏
widget.style.display = 'none';
} else {
// 其他情况:显示
widget.style.display = '';
}
}
});
}
// 页面加载时执行
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', toggleComponentsVisibility);
} else {
toggleComponentsVisibility();
}
// 页面切换时重新检查
document.addEventListener('swup:contentReplaced', () => {
setTimeout(toggleComponentsVisibility, 100);
});
</script>

View File

@@ -0,0 +1,275 @@
---
import type { MarkdownHeading } from "astro";
import Profile from "@/components/content/Profile.astro";
import Advertisement from "@/components/widget/Advertisement.astro";
import Announcement from "@/components/widget/Announcement.astro";
import Calendar from "@/components/widget/Calendar.astro";
import Categories from "@/components/widget/Categories.astro";
import SidebarTOC from "@/components/widget/SidebarTOC.astro";
import SiteStats from "@/components/widget/SiteStats.astro";
import Tags from "@/components/widget/Tags.astro";
import type { WidgetComponentConfig } from "@/types/config";
import { widgetManager } from "@/utils/widget-manager";
interface Props {
class?: string;
headings?: MarkdownHeading[];
}
const { class: className, headings } = Astro.props;
// 获取配置的侧边栏位置
const sidebarPosition = widgetManager.getConfig().position;
const targetSidebar = sidebarPosition === "left" ? "left" : undefined;
// 获取配置的组件列表 - 根据 position 配置决定显示哪一侧
const topComponents = widgetManager.getComponentsByPosition(
"top",
targetSidebar,
);
const stickyComponents = widgetManager.getComponentsByPosition(
"sticky",
targetSidebar,
);
// 组件类型到ID的映射
const componentTypeToId = {
stats: "site-stats",
calendar: "calendar-widget",
sidebarToc: "sidebar-toc",
profile: "profile",
announcement: "announcement",
categories: "categories",
tags: "tags",
advertisement: "advertisement",
};
// 提取客户端需要的数据
const sidebarConfig = {
shouldShowSidebar: {
mobile: widgetManager.shouldShowSidebar("mobile"),
tablet: widgetManager.shouldShowSidebar("tablet"),
desktop: widgetManager.shouldShowSidebar("desktop"),
},
breakpoints: {
mobile: 768,
tablet: 1024,
desktop: 1280,
},
};
// 提取组件的 showOnPostPage 配置
const componentsConfig = (() => {
const components =
targetSidebar === "left"
? widgetManager.getConfig().leftComponents
: [
...widgetManager.getConfig().leftComponents,
...widgetManager.getConfig().rightComponents,
];
return components
.filter((c) => c.enable)
.map((c) => ({
id: componentTypeToId[c.type as keyof typeof componentTypeToId],
showOnPostPage: c.showOnPostPage ?? true,
}));
})();
// 组件映射表
const componentMap = {
profile: Profile,
announcement: Announcement,
categories: Categories,
tags: Tags,
sidebarToc: SidebarTOC,
advertisement: Advertisement,
stats: SiteStats,
calendar: Calendar,
};
// 渲染组件的辅助函数
function renderComponent(
component: WidgetComponentConfig,
index: number,
_components: WidgetComponentConfig[],
) {
const ComponentToRender =
componentMap[component.type as keyof typeof componentMap];
if (!ComponentToRender) return null;
// 使用配置的侧边栏位置,如果是 both 则默认为 left
const sidebar = targetSidebar || "left";
const componentClass = widgetManager.getComponentClass(
component,
sidebar,
index,
);
const componentStyle = widgetManager.getComponentStyle(component, index);
return {
Component: ComponentToRender,
props: {
class: componentClass,
style: componentStyle,
headings: headings ?? [],
configId: component.configId, // 传递configId给Advertisement组件
...component.customProps,
},
};
}
---
<div id="sidebar" class:list={[className, "w-full"]}>
<!-- 顶部固定组件区域 -->
{
topComponents.length > 0 && (
<div class="flex flex-col w-full gap-4 mb-4">
{topComponents.map((component, index) => {
const renderData = renderComponent(component, index, topComponents);
if (!renderData) return null;
const { Component, props } = renderData;
return <Component {...props} />;
})}
</div>
)
}
<!-- 粘性组件区域 -->
{
stickyComponents.length > 0 && (
<div
id="sidebar-sticky"
class="transition-all duration-700 flex flex-col w-full gap-4 sticky top-4"
>
{stickyComponents.map((component, index) => {
const renderData = renderComponent(
component,
index,
stickyComponents
);
if (!renderData) return null;
const { Component, props } = renderData;
return <Component {...props} />;
})}
</div>
)
}
</div>
<!-- 响应式样式和JavaScript -->
<style>
/* 响应式断点样式 */
@media (max-width: 768px) {
#sidebar {
display: var(--sidebar-mobile-display, block);
}
}
@media (min-width: 769px) and (max-width: 1024px) {
#sidebar {
display: var(--sidebar-tablet-display, block);
}
}
@media (min-width: 1025px) {
#sidebar {
display: var(--sidebar-desktop-display, block);
}
}
</style>
<script is:inline define:vars={{ sidebarConfig, componentsConfig }}>
// 根据 showOnPostPage 配置控制组件显示
function toggleComponentsVisibility() {
const isPostPage = window.location.pathname.includes("/posts/");
const sidebar = document.getElementById('sidebar');
if (!sidebar) return;
const widgets = sidebar.querySelectorAll('widget-layout');
widgets.forEach((widget) => {
const widgetId = widget.getAttribute('data-id');
const componentConfig = componentsConfig.find(c => c.id === widgetId);
if (componentConfig) {
// 特殊处理侧边栏TOC只在文章详情页显示
if (widgetId === 'sidebar-toc') {
widget.style.display = isPostPage ? '' : 'none';
}
// 其他组件的 showOnPostPage 含义:
// true: 在文章页和非文章页都显示(默认行为)
// false: 只在非文章页显示,文章页隐藏
else if (isPostPage && !componentConfig.showOnPostPage) {
widget.style.display = 'none';
} else {
widget.style.display = '';
}
}
});
}
// 响应式布局管理
class SidebarManager {
constructor() {
this.config = sidebarConfig;
this.init();
}
init() {
this.updateResponsiveDisplay();
toggleComponentsVisibility();
window.addEventListener("resize", () => this.updateResponsiveDisplay());
// 监听SWUP内容替换事件
if (typeof window !== "undefined" && window.swup) {
window.swup.hooks.on("content:replace", () => {
// 延迟执行以确保DOM已更新
setTimeout(() => {
this.updateResponsiveDisplay();
toggleComponentsVisibility();
}, 100);
});
}
}
updateResponsiveDisplay() {
const breakpoints = this.config.breakpoints;
const width = window.innerWidth;
let deviceType;
if (width < breakpoints.mobile) {
deviceType = "mobile";
} else if (width < breakpoints.tablet) {
deviceType = "tablet";
} else {
deviceType = "desktop";
}
const shouldShow = this.config.shouldShowSidebar[deviceType];
const sidebar = document.getElementById("sidebar");
if (sidebar) {
sidebar.style.setProperty(
`--sidebar-${deviceType}-display`,
shouldShow ? "block" : "none"
);
}
}
}
// 页面加载时执行
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
toggleComponentsVisibility();
new SidebarManager();
});
} else {
toggleComponentsVisibility();
new SidebarManager();
}
</script>

View File

@@ -0,0 +1,105 @@
---
// 可靠的图标组件
// 提供加载状态管理和错误处理
export interface Props {
icon: string;
class?: string;
style?: string;
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
color?: string;
fallback?: string; // 备用图标或文本
loading?: "lazy" | "eager";
}
const {
icon,
class: className = "",
style = "",
size = "md",
color,
fallback = "●",
loading = "lazy",
} = Astro.props;
// 尺寸映射
const sizeClasses = {
xs: "text-xs",
sm: "text-sm",
md: "text-base",
lg: "text-lg",
xl: "text-xl",
"2xl": "text-2xl",
};
const sizeClass = sizeClasses[size] || sizeClasses.md;
const colorStyle = color ? `color: ${color};` : "";
const combinedStyle = `${colorStyle}${style}`;
const combinedClass = `${sizeClass} ${className}`.trim();
// 生成唯一ID
const iconId = `icon-${Math.random().toString(36).slice(2, 11)}`;
---
<span
class={`inline-flex items-center justify-center ${combinedClass}`}
style={combinedStyle}
data-icon-container={iconId}
>
<!-- 加载状态指示器 -->
<span
class="icon-loading animate-pulse opacity-50"
data-loading-indicator
>
{fallback}
</span>
<!-- 实际图标 -->
<iconify-icon
icon={icon}
class="icon-content opacity-0 transition-opacity duration-200"
style="color: currentColor;"
data-icon-element
loading={loading}
></iconify-icon>
</span>
<style>
.icon-loading {
min-width: 1em;
min-height: 1em;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-content {
display: inline-flex;
align-items: center;
justify-content: center;
color: currentColor;
}
/* 确保 iconify-icon 能够继承颜色 */
iconify-icon {
color: currentColor;
}
[data-icon-container] {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1em;
min-height: 1em;
}
[data-icon-container] .icon-loading,
[data-icon-container] .icon-content {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,270 @@
---
// 全局Iconify加载器组件
// 在页面头部加载,确保图标库尽早可用
export interface Props {
preloadIcons?: string[]; // 需要预加载的图标列表
timeout?: number; // 加载超时时间
retryCount?: number; // 重试次数
}
const { preloadIcons = [], timeout = 10000, retryCount = 3 } = Astro.props;
---
<!-- Iconify图标库加载器 -->
<script is:inline define:vars={{ preloadIcons, timeout, retryCount }}>
// 全局图标加载逻辑
(function() {
'use strict';
// 避免重复加载
if (window.__iconifyLoaderInitialized) {
return;
}
window.__iconifyLoaderInitialized = true;
// 图标加载器类
class IconifyLoader {
constructor() {
this.isLoaded = false;
this.isLoading = false;
this.loadPromise = null;
this.observers = new Set();
this.preloadQueue = new Set();
}
async load(options = {}) {
const { timeout: loadTimeout = timeout, retryCount: maxRetries = retryCount } = options;
if (this.isLoaded) {
return Promise.resolve();
}
if (this.isLoading && this.loadPromise) {
return this.loadPromise;
}
this.isLoading = true;
this.loadPromise = this.loadWithRetry(loadTimeout, maxRetries);
try {
await this.loadPromise;
this.isLoaded = true;
this.notifyObservers();
await this.processPreloadQueue();
} catch (error) {
console.error('Failed to load Iconify:', error);
throw error;
} finally {
this.isLoading = false;
}
}
async loadWithRetry(timeout, retryCount) {
for (let attempt = 1; attempt <= retryCount; attempt++) {
try {
await this.loadScript(timeout);
return;
} catch (error) {
console.warn(`Iconify load attempt ${attempt} failed:`, error);
if (attempt === retryCount) {
throw new Error(`Failed to load Iconify after ${retryCount} attempts`);
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
}
loadScript(timeout) {
return new Promise((resolve, reject) => {
// 检查是否已经存在
if (this.isIconifyReady()) {
resolve();
return;
}
const existingScript = document.querySelector('script[src*="iconify-icon"]');
if (existingScript) {
this.waitForIconifyReady().then(resolve).catch(reject);
return;
}
const script = document.createElement('script');
script.src = 'https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js';
script.async = true;
script.crossOrigin = 'anonymous';
const timeoutId = setTimeout(() => {
script.remove();
reject(new Error('Script load timeout'));
}, timeout);
script.onload = () => {
clearTimeout(timeoutId);
this.waitForIconifyReady().then(resolve).catch(reject);
};
script.onerror = () => {
clearTimeout(timeoutId);
script.remove();
reject(new Error('Script load error'));
};
document.head.appendChild(script);
});
}
waitForIconifyReady(maxWait = 5000) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const checkReady = () => {
if (this.isIconifyReady()) {
resolve();
return;
}
if (Date.now() - startTime > maxWait) {
reject(new Error('Iconify initialization timeout'));
return;
}
setTimeout(checkReady, 50);
};
checkReady();
});
}
isIconifyReady() {
return typeof window !== 'undefined' &&
'customElements' in window &&
customElements.get('iconify-icon') !== undefined;
}
onLoad(callback) {
if (this.isLoaded) {
callback();
} else {
this.observers.add(callback);
}
}
notifyObservers() {
this.observers.forEach(callback => {
try {
callback();
} catch (error) {
console.error('Error in icon load observer:', error);
}
});
this.observers.clear();
}
addToPreloadQueue(icons) {
if (Array.isArray(icons)) {
icons.forEach(icon => this.preloadQueue.add(icon));
} else {
this.preloadQueue.add(icons);
}
if (this.isLoaded) {
this.processPreloadQueue();
}
}
async processPreloadQueue() {
if (this.preloadQueue.size === 0) return;
const iconsToLoad = Array.from(this.preloadQueue);
this.preloadQueue.clear();
await this.preloadIcons(iconsToLoad);
}
async preloadIcons(icons) {
if (!this.isLoaded || icons.length === 0) return;
return new Promise((resolve) => {
let loadedCount = 0;
const totalIcons = icons.length;
const tempElements = [];
const cleanup = () => {
tempElements.forEach(el => {
if (el.parentNode) {
el.parentNode.removeChild(el);
}
});
};
const checkComplete = () => {
loadedCount++;
if (loadedCount >= totalIcons) {
cleanup();
resolve();
}
};
icons.forEach(icon => {
const tempIcon = document.createElement('iconify-icon');
tempIcon.setAttribute('icon', icon);
tempIcon.style.cssText = 'position:absolute;top:-9999px;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none;';
tempIcon.addEventListener('load', checkComplete);
tempIcon.addEventListener('error', checkComplete);
tempElements.push(tempIcon);
document.body.appendChild(tempIcon);
});
// 设置超时清理
setTimeout(() => {
cleanup();
resolve();
}, 3000);
});
}
}
// 创建全局实例
window.__iconifyLoader = new IconifyLoader();
// 立即开始加载
window.__iconifyLoader.load().catch(error => {
console.error('Failed to initialize Iconify:', error);
});
// 如果有预加载图标,添加到队列
if (preloadIcons && preloadIcons.length > 0) {
window.__iconifyLoader.addToPreloadQueue(preloadIcons);
}
// 导出便捷函数到全局
window.loadIconify = () => window.__iconifyLoader.load();
window.preloadIcons = (icons) => window.__iconifyLoader.addToPreloadQueue(icons);
window.onIconifyReady = (callback) => window.__iconifyLoader.onLoad(callback);
// 页面可见性变化时重新检查
document.addEventListener('visibilitychange', () => {
if (!document.hidden && !window.__iconifyLoader.isLoaded) {
window.__iconifyLoader.load().catch(console.error);
}
});
})();
</script>
<!-- 为不支持JavaScript的情况提供备用方案 -->
<noscript>
<style>
iconify-icon {
display: none;
}
.icon-fallback {
display: inline-block;
}
</style>
</noscript>

View File

@@ -0,0 +1,53 @@
---
import { Image } from "astro:assets";
import * as path from "node:path";
import type { ImageMetadata } from "astro";
import { url } from "@/utils/url-utils";
interface Props {
id?: string;
src: string;
class?: string;
alt?: string;
position?: string;
basePath?: string;
}
const { id, src, alt, position = "center", basePath = "/" } = Astro.props;
const className = Astro.props.class;
const isLocal = !(
src.startsWith("/") ||
src.startsWith("http") ||
src.startsWith("https") ||
src.startsWith("data:")
);
const isPublic = src.startsWith("/");
// TODO temporary workaround for images dynamic import
// https://github.com/withastro/astro/issues/3373
let img: ImageMetadata | null = null;
if (isLocal) {
const files = import.meta.glob<ImageMetadata>("../../**", {
import: "default",
});
let normalizedPath = path
.normalize(path.join("../../", basePath, src))
.replace(/\\/g, "/");
const file = files[normalizedPath];
if (!file) {
console.error(
`\n[ERROR] Image file not found: ${normalizedPath.replace("../../", "src/")}`,
);
}
img = await file();
}
const imageClass = "w-full h-full object-cover";
const imageStyle = `object-position: ${position}`;
---
<div id={id} class:list={[className, 'overflow-hidden relative']}>
<div class="transition absolute inset-0 dark:bg-black/10 bg-opacity-50 pointer-events-none"></div>
{isLocal && img && <Image src={img} alt={alt || ""} class={imageClass} style={imageStyle}/>}
{!isLocal && <img src={isPublic ? url(src) : src} alt={alt || ""} class={imageClass} style={imageStyle}/>}
</div>

View File

@@ -0,0 +1,70 @@
---
import { Icon } from "astro-icon/components";
import { licenseConfig } from "@/config/licenseConfig";
import { profileConfig } from "@/config/profileConfig";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import { formatDateToYYYYMMDD } from "@/utils/date-utils";
interface Props {
title: string;
id: string;
pubDate: Date;
class: string;
author: string;
sourceLink: string;
licenseName: string;
licenseUrl: string;
}
const { title, pubDate, author, sourceLink, licenseName, licenseUrl } =
Astro.props;
const className = Astro.props.class;
const profileConf = profileConfig;
const licenseConf = licenseConfig;
const postUrl = sourceLink || decodeURIComponent(Astro.url.toString());
---
<div
class={`relative transition overflow-hidden bg-[var(--license-block-bg)] py-5 px-6 ${className}`}
>
<div class="transition font-bold text-black/75 dark:text-white/75">
{title}
</div>
<a href={postUrl} class="link text-[var(--primary)]">
{postUrl}
</a>
<div class="flex gap-6 mt-2">
<div>
<div class="transition text-black/30 dark:text-white/30 text-sm">
{i18n(I18nKey.author)}
</div>
<div class="transition text-black/75 dark:text-white/75 line-clamp-2">
{author || profileConf.name}
</div>
</div>
<div>
<div class="transition text-black/30 dark:text-white/30 text-sm">
{i18n(I18nKey.publishedAt)}
</div>
<div class="transition text-black/75 dark:text-white/75 line-clamp-2">
{formatDateToYYYYMMDD(pubDate)}
</div>
</div>
<div>
<div class="transition text-black/30 dark:text-white/30 text-sm">
{i18n(I18nKey.license)}
</div>
<a
href={licenseName ? licenseUrl || undefined : licenseConf.url}
target="_blank"
class="link text-[var(--primary)] line-clamp-2"
>{licenseName || licenseConf.name}</a
>
</div>
</div>
<Icon
name="fa6-brands:creative-commons"
class="transition text-[15rem] absolute pointer-events-none right-6 top-1/2 -translate-y-1/2 text-black/5 dark:text-white/5"
/>
</div>

View File

@@ -0,0 +1,23 @@
---
// 只加载基础的等宽字体,减少加载时间
import "@fontsource-variable/jetbrains-mono";
interface Props {
class: string;
}
const className = Astro.props.class;
---
<div data-pagefind-body class={`prose dark:prose-invert prose-base !max-w-none custom-md ${className}`}>
<slot/>
</div>
<script>
import { handleCodeCopy } from "@/utils/code-copy-utils";
document.addEventListener("click", function (e: MouseEvent) {
const target = e.target as Element | null;
if (target && target.classList.contains("copy-btn")) {
handleCodeCopy(target);
}
});
</script>

View File

@@ -0,0 +1,755 @@
---
import { Image } from "astro:assets";
import * as path from "node:path";
import { coverImageConfig } from "@/config/coverImageConfig";
import { generateApiUrls } from "@/utils/image-utils";
import { url } from "@/utils/url-utils";
const { randomCoverImage } = coverImageConfig;
interface Props {
id?: string;
src: string;
class?: string;
alt?: string;
position?: string;
basePath?: string;
seed?: string; // 用于生成随机图API的种子文章slug
preview?: boolean; // 是否是预览模式文章列表页true为预览模式小尺寸false为详情页大尺寸
fallback?: string; // 图片加载失败时的备用图片
}
const {
id,
src,
alt,
position = "center",
basePath = "/",
seed,
preview = false,
fallback,
} = Astro.props;
const className = Astro.props.class;
const isLocal = !(
src.startsWith("/") ||
src.startsWith("http") ||
src.startsWith("https") ||
src.startsWith("data:")
);
const isPublic = src.startsWith("/");
// 检查是否是随机图API包含query参数v=
const isRandomApiImage =
(src.startsWith("http://") || src.startsWith("https://")) &&
src.includes("?v=");
// TODO temporary workaround for images dynamic import
// https://github.com/withastro/astro/issues/3373
let img: ImageMetadata | null = null;
if (isLocal) {
const files = import.meta.glob<ImageMetadata>("../../**", {
import: "default",
});
let normalizedPath = path
.normalize(path.join("../../", basePath, src))
.replace(/\\/g, "/");
const file = files[normalizedPath];
if (!file) {
console.error(
`\n[ERROR] Image file not found: ${normalizedPath.replace("../../", "src/")}`,
);
img = null; // 设置为 null 而不是继续调用
} else {
img = await file();
}
}
// 如果是随机图API生成所有API URL列表用于客户端重试
let allApiUrls: string[] = [];
if (
isRandomApiImage &&
randomCoverImage.enable &&
randomCoverImage.apis &&
randomCoverImage.apis.length > 0
) {
allApiUrls = generateApiUrls(seed);
}
// 确定fallback图片路径
let fallbackSrc = "";
if (isRandomApiImage) {
if (randomCoverImage.enable) {
fallbackSrc = fallback || randomCoverImage.fallback || "";
}
} else {
fallbackSrc = fallback || "";
}
// 处理fallback URL
const getFallbackUrl = (src: string): string => {
if (!src) return "";
if (src.startsWith("http://") || src.startsWith("https://")) {
return src;
}
if (src.startsWith("/")) {
return url(src);
}
return url(`/${src}`);
};
// 图片样式
const imageClass = "w-full h-full object-cover";
const imageStyle = `object-position: ${position || "center"}; image-rendering: -webkit-optimize-contrast;`;
// 水印配置
const watermark = randomCoverImage.watermark;
const showWatermark =
isRandomApiImage && randomCoverImage.enable && watermark?.enable;
// 生成水印位置样式和类名
const getWatermarkStyles = (
pos?: string,
): { classes: string; styles: string } => {
const position = pos || "bottom-right";
let classes = "";
let styles = "";
switch (position) {
case "top-left":
classes = "top-2 left-2";
break;
case "top-right":
classes = "top-2 right-2";
break;
case "bottom-left":
classes = "top-2 left-2 md:top-auto md:bottom-2 md:left-2";
break;
case "bottom-right":
classes = "top-2 right-2 md:top-auto md:bottom-2 md:right-2";
break;
case "center":
classes = "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2";
break;
default:
classes = "top-2 right-2 md:top-auto md:bottom-2 md:right-2";
}
styles = `padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: ${watermark?.fontSize || "0.75rem"}; color: ${watermark?.color || "#ffffff"}; background-color: ${watermark?.backgroundColor || "rgba(0, 0, 0, 0.4)"}; opacity: ${watermark?.opacity || 0.6}; pointer-events: none; z-index: 10; white-space: nowrap; user-select: none;`;
return { classes, styles };
};
const watermarkStyles = showWatermark
? getWatermarkStyles(watermark?.position)
: { classes: "", styles: "" };
---
<div id={id} class:list={[
className,
'overflow-hidden relative',
preview ? 'min-h-[150px] md:min-h-0' : 'min-h-[300px]'
]}>
<!-- 加载指示器:对所有文章封面图片显示 -->
{randomCoverImage.enable && randomCoverImage.loading?.enable !== false && (
<div
class="image-loading-indicator absolute inset-0 flex items-center justify-center z-20 transition-opacity duration-300"
style={`background-color: ${randomCoverImage.loading?.backgroundColor || '#fefefe'};`}
>
<img
src={url(randomCoverImage.loading?.image || "/assets/images/loading.gif")}
alt="Loading..."
class="w-24 h-24 md:w-32 md:h-32 opacity-80"
/>
</div>
)}
<!-- 本地图片:使用 Astro Image 组件 -->
{isLocal && img && (
<Image
src={img}
alt={alt || ""}
class:list={[
imageClass,
preview ? 'random-cover-preview-image' : 'random-cover-full-image'
]}
style={imageStyle}
width={preview ? 400 : 1200}
height={preview ? 300 : 800}
loading="lazy"
format="webp"
quality={preview ? 80 : 90}
widths={preview ? [200, 400, 600] : [800, 1200, 1600, 2000]}
sizes={preview ? "(max-width: 768px) 100vw, 28vw" : "(max-width: 768px) 100vw, (max-width: 1200px) 90vw, 1200px"}
onloadstart={`(function(img){
const container = img.parentElement;
if (container) {
const loadingIndicator = container.querySelector('.image-loading-indicator');
if (loadingIndicator) {
loadingIndicator.classList.remove('hidden');
loadingIndicator.style.removeProperty('opacity');
loadingIndicator.style.removeProperty('display');
}
}
})(this);`}
onload={`(function(img){
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
setTimeout(function() {
const container = img.parentElement;
if (container) {
const loadingIndicator = container.querySelector('.image-loading-indicator');
if (loadingIndicator) {
loadingIndicator.style.setProperty('opacity', '0', 'important');
loadingIndicator.style.setProperty('transition', 'opacity 0.3s ease-out', 'important');
setTimeout(function() {
loadingIndicator.style.setProperty('display', 'none', 'important');
loadingIndicator.classList.add('hidden');
}, 300);
}
}
}, 800);
}
})(this);`}
/>
)}
<!-- 远程图片包括随机图API和普通远程图片 -->
{!isLocal && (
<img
src={isPublic ? url(src) : src}
alt={alt || ""}
class:list={[
imageClass,
preview ? 'random-cover-preview-image' : 'random-cover-full-image'
]}
style={imageStyle}
loading="lazy"
decoding="async"
data-preview={preview ? "true" : "false"}
data-seed={seed || ""}
data-api-urls={allApiUrls.length > 0 ? JSON.stringify(allApiUrls) : ""}
data-fallback={fallbackSrc ? getFallbackUrl(fallbackSrc) : ""}
data-api-index={allApiUrls.length > 0 ? "0" : ""}
data-enable={isRandomApiImage ? (randomCoverImage.enable ? "true" : "false") : ""}
data-need-check-fallback="true"
onloadstart={`(function(img){
const container = img.parentElement;
if (container) {
const loadingIndicator = container.querySelector('.image-loading-indicator');
if (loadingIndicator) {
loadingIndicator.classList.remove('hidden');
loadingIndicator.style.removeProperty('opacity');
loadingIndicator.style.removeProperty('display');
}
}
})(this);`}
onload={`(function(img){
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
setTimeout(function() {
const container = img.parentElement;
if (container) {
const loadingIndicator = container.querySelector('.image-loading-indicator');
if (loadingIndicator) {
loadingIndicator.style.setProperty('opacity', '0', 'important');
loadingIndicator.style.setProperty('transition', 'opacity 0.3s ease-out', 'important');
setTimeout(function() {
loadingIndicator.style.setProperty('display', 'none', 'important');
loadingIndicator.classList.add('hidden');
}, 300);
}
const watermarkEl = container.querySelector('[data-watermark]');
if (watermarkEl && watermarkEl.getAttribute('data-watermark-visible') !== 'true') {
watermarkEl.setAttribute('data-watermark-visible', 'true');
watermarkEl.classList.remove('opacity-0');
watermarkEl.classList.add('opacity-100');
const originalOpacity = watermarkEl.getAttribute('data-original-opacity') || '0.6';
watermarkEl.style.opacity = originalOpacity;
}
}
}, 800);
}
})(this);`}
onerror={`(function(img){
try {
const apiUrls = img.dataset.apiUrls ? JSON.parse(img.dataset.apiUrls) : [];
let currentIndex = parseInt(img.dataset.apiIndex || '0');
const isEnabled = img.dataset.enable !== 'false';
const fallbackUrl = img.dataset.fallback;
if (apiUrls.length > 0 && currentIndex < apiUrls.length - 1) {
currentIndex = currentIndex + 1;
img.dataset.apiIndex = currentIndex.toString();
img.src = apiUrls[currentIndex];
}
else if (isEnabled && fallbackUrl && fallbackUrl.length > 0) {
const seed = img.dataset.seed;
if (seed) {
try {
localStorage.setItem('api_image_failed_' + seed, 'true');
} catch (e) {}
}
const container = img.parentElement;
if (container) {
const watermarkEl = container.querySelector('[data-watermark]');
if (watermarkEl) {
watermarkEl.textContent = 'Image API Error';
watermarkEl.setAttribute('data-error', 'true');
}
}
img.onerror = null;
img.src = fallbackUrl;
img.addEventListener('load', function() {
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
const container = img.parentElement;
if (container) {
const loadingIndicator = container.querySelector('.image-loading-indicator');
if (loadingIndicator) {
loadingIndicator.style.opacity = '0';
setTimeout(function() {
loadingIndicator.style.display = 'none';
}, 300);
}
const watermarkEl = container.querySelector('[data-watermark]');
if (watermarkEl && watermarkEl.getAttribute('data-watermark-visible') !== 'true') {
watermarkEl.setAttribute('data-watermark-visible', 'true');
watermarkEl.classList.remove('opacity-0');
watermarkEl.classList.add('opacity-100');
const originalOpacity = watermarkEl.getAttribute('data-original-opacity') || '0.6';
watermarkEl.style.opacity = originalOpacity;
watermarkEl.style.setProperty('opacity', originalOpacity, 'important');
}
}
}
}, { once: true });
}
else {
img.onerror = null;
img.style.display = 'none';
const container = img.parentElement;
if (container) {
const loadingIndicator = container.querySelector('.image-loading-indicator');
if (loadingIndicator) {
loadingIndicator.style.opacity = '0';
setTimeout(function() {
loadingIndicator.style.display = 'none';
}, 300);
}
}
}
} catch(e) {
const isEnabled = img.dataset.enable !== 'false';
const fallbackUrl = img.dataset.fallback;
const seed = img.dataset.seed;
if (isEnabled && fallbackUrl && fallbackUrl.length > 0) {
if (seed) {
try {
localStorage.setItem('api_image_failed_' + seed, 'true');
} catch (e) {}
}
const container = img.parentElement;
if (container) {
const watermarkEl = container.querySelector('[data-watermark]');
if (watermarkEl) {
watermarkEl.textContent = 'Image API Error';
watermarkEl.setAttribute('data-error', 'true');
}
}
img.onerror = null;
img.src = fallbackUrl;
} else {
img.onerror = null;
img.style.display = 'none';
const container = img.parentElement;
if (container) {
const loadingIndicator = container.querySelector('.image-loading-indicator');
if (loadingIndicator) {
loadingIndicator.style.opacity = '0';
setTimeout(function() {
loadingIndicator.style.display = 'none';
}, 300);
}
}
}
}
})(this);`}
/>
)}
{showWatermark && (
<div
data-watermark="true"
data-watermark-visible="false"
data-original-opacity={watermark?.opacity || "0.6"}
class:list={["absolute", watermarkStyles.classes, "pointer-events-none", "z-10"]}
style={`${watermarkStyles.styles} opacity: 0 !important;`}
>
{watermark?.text || "Random Cover"}
</div>
)}
</div>
<style>
.image-loading-indicator {
opacity: 1 !important;
display: flex !important;
visibility: visible !important;
z-index: 20;
}
.image-loading-indicator.hidden {
opacity: 0 !important;
display: none !important;
visibility: hidden !important;
}
img.random-cover-preview-image,
img[data-preview="true"] {
min-height: 150px;
@media (min-width: 768px) {
min-height: 0;
}
image-rendering: -webkit-optimize-contrast !important;
image-rendering: auto !important;
transform: translateZ(0);
-webkit-transform: translateZ(0);
will-change: transform;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
-ms-interpolation-mode: bicubic;
object-fit: cover !important;
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
}
img.random-cover-full-image {
min-height: 300px;
image-rendering: -webkit-optimize-contrast !important;
image-rendering: auto !important;
transform: translateZ(0);
-webkit-transform: translateZ(0);
object-fit: cover !important;
width: 100%;
height: 100%;
}
</style>
<script is:inline>
(function() {
function isPageRefresh() {
try {
if (window.performance && window.performance.getEntriesByType) {
const navEntries = window.performance.getEntriesByType('navigation');
if (navEntries.length > 0) {
const navType = navEntries[0].type;
if (navType === 'reload' || navType === 'navigate') {
return true;
}
if (navType === 'back_forward') {
return false;
}
}
}
const perf = /** @type {any} */ (window.performance);
if (perf && perf.navigation) {
const navType = perf.navigation.type;
if (navType === perf.navigation.TYPE_RELOAD) {
return true;
}
if (navType === 2) {
return false;
}
}
const refreshCheckKey = 'random_cover_image_last_init_time';
const navigationCheckKey = 'random_cover_image_navigation_type';
const now = Date.now();
const lastInitTime = sessionStorage.getItem(refreshCheckKey);
const lastNavType = sessionStorage.getItem(navigationCheckKey);
if (!lastInitTime || (now - parseInt(lastInitTime)) > 10000) {
sessionStorage.setItem(refreshCheckKey, now.toString());
sessionStorage.setItem(navigationCheckKey, 'refresh');
return true;
}
if (lastNavType === 'back_forward' && (now - parseInt(lastInitTime)) < 5000) {
return false;
}
sessionStorage.setItem(refreshCheckKey, now.toString());
sessionStorage.setItem(navigationCheckKey, 'refresh');
return true;
} catch (e) {
console.warn('Unable to detect page refresh type, clearing cache as fallback', e);
return true;
}
}
function clearApiFailureCache() {
try {
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('api_image_failed_')) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(function(key) {
localStorage.removeItem(key);
});
if (keysToRemove.length > 0) {
console.log('Cleared ' + keysToRemove.length + ' API failure records on page refresh');
}
} catch (e) {
console.warn('Failed to clear API failure cache', e);
}
}
function checkAndUseFallbackForFailedApis() {
const allApiImages = document.querySelectorAll('img[data-seed][data-fallback][data-enable="true"]');
allApiImages.forEach(function(img) {
const seed = img.dataset.seed;
const fallbackUrl = img.dataset.fallback;
if (seed && fallbackUrl) {
try {
const failureKey = 'api_image_failed_' + seed;
if (localStorage.getItem(failureKey) === 'true') {
if (img.src !== fallbackUrl && !img.src.includes(fallbackUrl.split('?')[0])) {
img.src = fallbackUrl;
}
const container = img.parentElement;
if (container) {
const watermarkEl = container.querySelector('[data-watermark]');
if (watermarkEl) {
watermarkEl.textContent = 'Image API Error';
watermarkEl.setAttribute('data-error', 'true');
const loadingIndicator = container.querySelector('.image-loading-indicator');
if (img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) {
if (loadingIndicator) {
loadingIndicator.style.opacity = '0';
setTimeout(function() {
loadingIndicator.style.display = 'none';
}, 300);
}
watermarkEl.setAttribute('data-watermark-visible', 'true');
watermarkEl.classList.remove('opacity-0');
watermarkEl.classList.add('opacity-100');
const originalOpacity = watermarkEl.getAttribute('data-original-opacity') || '0.6';
watermarkEl.style.opacity = originalOpacity;
watermarkEl.style.setProperty('opacity', originalOpacity, 'important');
} else {
img.addEventListener('load', function() {
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
if (loadingIndicator) {
loadingIndicator.style.opacity = '0';
setTimeout(function() {
loadingIndicator.style.display = 'none';
}, 300);
}
watermarkEl.setAttribute('data-watermark-visible', 'true');
watermarkEl.classList.remove('opacity-0');
watermarkEl.classList.add('opacity-100');
const originalOpacity = watermarkEl.getAttribute('data-original-opacity') || '0.6';
watermarkEl.style.opacity = originalOpacity;
watermarkEl.style.setProperty('opacity', originalOpacity, 'important');
}
}, { once: true });
}
}
}
}
} catch (e) {}
}
});
}
function showLoadingIndicator(img) {
if (!img.dataset.seed && img.dataset.preview !== 'true') {
return;
}
const container = img.closest('[id]') || img.parentElement;
if (container) {
const loadingIndicator = container.querySelector('.image-loading-indicator');
if (loadingIndicator) {
loadingIndicator.classList.remove('hidden');
loadingIndicator.style.removeProperty('opacity');
loadingIndicator.style.removeProperty('display');
}
}
}
function hideLoadingIndicator(img) {
if (!img.dataset.seed && img.dataset.preview !== 'true') {
return;
}
const container = img.closest('[id]') || img.parentElement;
if (container) {
const loadingIndicator = container.querySelector('.image-loading-indicator');
if (loadingIndicator) {
setTimeout(function() {
loadingIndicator.style.setProperty('opacity', '0', 'important');
loadingIndicator.style.setProperty('transition', 'opacity 0.3s ease-out', 'important');
setTimeout(function() {
loadingIndicator.style.setProperty('display', 'none', 'important');
loadingIndicator.classList.add('hidden');
}, 300);
}, 800);
}
}
}
function showWatermark(img) {
if (!img.complete || img.naturalWidth === 0 || img.naturalHeight === 0) {
return;
}
hideLoadingIndicator(img);
const container = img.closest('[id]') || img.parentElement;
if (container) {
const watermarkEl = container.querySelector('[data-watermark]');
if (watermarkEl && watermarkEl.getAttribute('data-watermark-visible') !== 'true') {
watermarkEl.setAttribute('data-watermark-visible', 'true');
watermarkEl.classList.remove('opacity-0');
watermarkEl.classList.add('opacity-100');
const originalOpacity = watermarkEl.getAttribute('data-original-opacity') || '0.6';
watermarkEl.style.opacity = originalOpacity;
watermarkEl.style.setProperty('opacity', originalOpacity, 'important');
}
}
}
function optimizePreviewImages() {
const previewImages = document.querySelectorAll('img[data-preview="true"]');
previewImages.forEach(function(img) {
if (!img.complete || img.naturalWidth === 0 || img.naturalHeight === 0) {
showLoadingIndicator(img);
img.addEventListener('loadstart', function() {
showLoadingIndicator(img);
}, { once: true });
}
});
}
function setupObserver() {
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node;
const apiImages = [];
if (element.tagName === 'IMG' && element.dataset.seed && element.dataset.fallback) {
apiImages.push(element);
}
const childApiImages = element.querySelectorAll ? element.querySelectorAll('img[data-seed][data-fallback]') : [];
childApiImages.forEach(function(img) {
if (!apiImages.includes(img)) {
apiImages.push(img);
}
});
apiImages.forEach(function(img) {
showLoadingIndicator(img);
if (img.getAttribute('data-need-check-fallback') === 'true') {
checkAndUseFallbackForFailedApis();
img.removeAttribute('data-need-check-fallback');
}
if (img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) {
hideLoadingIndicator(img);
showWatermark(img);
} else {
img.addEventListener('loadstart', function() {
showLoadingIndicator(img);
}, { once: true });
img.addEventListener('load', function() {
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
hideLoadingIndicator(img);
showWatermark(img);
}
}, { once: true });
img.addEventListener('error', function() {
setTimeout(function() {
hideLoadingIndicator(img);
}, 500);
}, { once: true });
}
});
}
});
});
});
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true });
} else {
document.addEventListener('DOMContentLoaded', function() {
observer.observe(document.body, { childList: true, subtree: true });
});
}
}
function initializeImages() {
if (isPageRefresh()) {
clearApiFailureCache();
} else {
checkAndUseFallbackForFailedApis();
}
optimizePreviewImages();
const allApiImages = document.querySelectorAll('img[data-seed][data-fallback]');
allApiImages.forEach(function(img) {
showLoadingIndicator(img);
img.addEventListener('loadstart', function() {
showLoadingIndicator(img);
}, { once: true });
if (img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) {
hideLoadingIndicator(img);
showWatermark(img);
} else {
img.addEventListener('load', function() {
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
hideLoadingIndicator(img);
showWatermark(img);
}
}, { once: true });
img.addEventListener('error', function() {
setTimeout(function() {
hideLoadingIndicator(img);
}, 500);
}, { once: true });
}
});
setupObserver();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeImages);
} else {
initializeImages();
}
// 监听页面可见性变化(从其他页面返回时重新检查)
document.addEventListener('visibilitychange', function() {
if (!document.hidden) {
if (!isPageRefresh()) {
setTimeout(checkAndUseFallbackForFailedApis, 100);
}
}
});
// 监听popstate事件浏览器前进/后退)
window.addEventListener('popstate', function() {
try {
sessionStorage.setItem('random_cover_image_navigation_type', 'back_forward');
sessionStorage.setItem('random_cover_image_last_init_time', Date.now().toString());
} catch (e) {
// 忽略错误
}
setTimeout(function() {
checkAndUseFallbackForFailedApis();
}, 50);
});
})();
</script>

View File

@@ -0,0 +1,536 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import QRCode from "qrcode";
import { onMount } from "svelte";
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
export let title: string;
export let author: string;
export let description = "";
export let pubDate: string;
export let coverImage: string | null = null;
export let url: string;
export let siteTitle: string;
export let avatar: string | null = null;
let showModal = false;
let posterImage: string | null = null;
let generating = false;
let themeColor = "#558e88"; // Default blue
onMount(() => {
// Get theme color from CSS variable
const temp = document.createElement("div");
temp.style.color = "var(--primary)";
temp.style.display = "none";
document.body.appendChild(temp);
const computedColor = getComputedStyle(temp).color;
document.body.removeChild(temp);
if (computedColor) {
themeColor = computedColor;
}
});
function loadImage(src: string): Promise<HTMLImageElement | null> {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = () => {
if (!src.includes("images.weserv.nl")) {
const proxyUrl = `https://images.weserv.nl/?url=${encodeURIComponent(src)}&output=png`;
const proxyImg = new Image();
proxyImg.crossOrigin = "anonymous";
proxyImg.onload = () => resolve(proxyImg);
proxyImg.onerror = () => {
resolve(null);
};
proxyImg.src = proxyUrl;
} else {
resolve(null);
}
};
img.src = src;
});
}
function getLines(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
): string[] {
const chars = text.split("");
const lines: string[] = [];
let currentLine = "";
for (let i = 0; i < chars.length; i++) {
const char = chars[i];
const width = ctx.measureText(currentLine + char).width;
if (width < maxWidth) {
currentLine += char;
} else {
lines.push(currentLine);
currentLine = char;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines;
}
function drawRoundedRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
async function generatePoster() {
showModal = true;
if (posterImage) return;
generating = true;
try {
const scale = 2;
const width = 425 * scale;
const padding = 24 * scale;
// 1. Prepare resources
const qrCodeUrl = await QRCode.toDataURL(url, {
margin: 1,
width: 100 * scale,
color: { dark: "#000000", light: "#ffffff" },
});
const [qrImg, coverImg, avatarImg] = await Promise.all([
loadImage(qrCodeUrl),
coverImage ? loadImage(coverImage) : Promise.resolve(null),
avatar ? loadImage(avatar) : Promise.resolve(null),
]);
// 2. Setup Canvas for measuring
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas context not available");
canvas.width = width;
// Initial height estimation, will be adjusted
canvas.height = 1000 * scale;
// 3. Layout Calculation
const contentWidth = width - padding * 2;
let currentY = 0;
// Cover
const coverHeight = (coverImage ? 200 : 120) * scale;
currentY += coverHeight;
currentY += padding; // Gap after cover
// Meta (Date on Cover) - No extra height needed
// Title
ctx.font = `700 ${24 * scale}px 'Roboto', sans-serif`;
const titleLines = getLines(ctx, title, contentWidth);
const titleLineHeight = 30 * scale;
const titleHeight = titleLines.length * titleLineHeight;
currentY += titleHeight;
currentY += 16 * scale; // Gap
// Description
let descHeight = 0;
if (description) {
ctx.font = `${14 * scale}px 'Roboto', sans-serif`;
const descLines = getLines(ctx, description, contentWidth - 16 * scale); // minus border width and gap
// Limit to 6 lines
const maxDescLines = 6;
const displayDescLines = descLines.slice(0, maxDescLines);
const descLineHeight = 25 * scale; // 1.8 line-height approx
descHeight = displayDescLines.length * descLineHeight;
currentY += descHeight;
// currentY += 24 * scale; // Gap to footer (Removed to reduce whitespace)
} else {
currentY += 8 * scale; // Smaller gap if no desc
}
// Footer (Author + QR)
// Footer top border + padding
currentY += 24 * scale;
const footerHeight = 64 * scale; // Avatar/QR height
currentY += footerHeight;
currentY += padding; // Bottom padding
// 4. Resize Canvas to fit content
canvas.height = currentY;
// 5. Draw Content
// Fill Background
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw Decorative Circles
ctx.save();
ctx.globalAlpha = 0.1;
ctx.fillStyle = themeColor;
// Top Right Circle
// CSS: top: -50px, right: -50px, width: 150px, height: 150px
// Radius = 75px
// Center X = width + 50 - 75 = width - 25
// Center Y = -50 + 75 = 25
ctx.beginPath();
ctx.arc(width - 25 * scale, 25 * scale, 75 * scale, 0, Math.PI * 2);
ctx.fill();
// Bottom Left Circle
// Adjusted to cover the avatar
ctx.beginPath();
ctx.arc(10 * scale, canvas.height - 10 * scale, 50 * scale, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Parse Date
let dateObj: { day: string; month: string; year: string } | null = null;
try {
const d = new Date(pubDate);
if (!Number.isNaN(d.getTime())) {
dateObj = {
day: d.getDate().toString().padStart(2, "0"),
month: (d.getMonth() + 1).toString().padStart(2, "0"),
year: d.getFullYear().toString(),
};
}
} catch (e) {}
// Draw Cover
if (coverImg) {
// Object-fit: cover implementation
const imgRatio = coverImg.width / coverImg.height;
const targetRatio = width / coverHeight;
let sx: number;
let sy: number;
let sWidth: number;
let sHeight: number;
if (imgRatio > targetRatio) {
sHeight = coverImg.height;
sWidth = sHeight * targetRatio;
sx = (coverImg.width - sWidth) / 2;
sy = 0;
} else {
sWidth = coverImg.width;
sHeight = sWidth / targetRatio;
sx = 0;
sy = (coverImg.height - sHeight) / 2;
}
ctx.drawImage(
coverImg,
sx,
sy,
sWidth,
sHeight,
0,
0,
width,
coverHeight,
);
} else {
ctx.save();
ctx.fillStyle = themeColor;
ctx.globalAlpha = 0.2;
ctx.fillRect(0, 0, width, coverHeight);
ctx.restore();
}
// Draw Date Overlay
if (dateObj) {
const dateBoxW = 60 * scale;
const dateBoxH = 60 * scale;
const dateBoxX = padding;
const dateBoxY = coverHeight - dateBoxH;
// Background (Semi-transparent black)
ctx.fillStyle = "rgba(0, 0, 0, 0.3)";
drawRoundedRect(ctx, dateBoxX, dateBoxY, dateBoxW, dateBoxH, 4 * scale);
ctx.fill();
// Day
ctx.fillStyle = "#ffffff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = `700 ${30 * scale}px 'Roboto', sans-serif`;
ctx.fillText(dateObj.day, dateBoxX + dateBoxW / 2, dateBoxY + 24 * scale);
// Line
ctx.beginPath();
ctx.strokeStyle = "rgba(255, 255, 255, 0.6)";
ctx.lineWidth = 1 * scale;
ctx.moveTo(dateBoxX + 10 * scale, dateBoxY + 42 * scale);
ctx.lineTo(dateBoxX + dateBoxW - 10 * scale, dateBoxY + 42 * scale);
ctx.stroke();
// Year Month
ctx.font = `${10 * scale}px 'Roboto', sans-serif`;
ctx.fillText(
`${dateObj.year} ${dateObj.month}`,
dateBoxX + dateBoxW / 2,
dateBoxY + 51 * scale,
);
}
// Reset Y for drawing
let drawY = coverHeight + padding;
// Draw Title
ctx.textBaseline = "top";
ctx.textAlign = "left";
ctx.font = `700 ${24 * scale}px 'Roboto', sans-serif`;
ctx.fillStyle = "#111827";
titleLines.forEach((line) => {
ctx.fillText(line, padding, drawY);
drawY += titleLineHeight;
});
drawY += 16 * scale - (titleLineHeight - 24 * scale); // Adjust for line-height diff
// Draw Description
if (description) {
// Draw vertical line
ctx.fillStyle = "#e5e7eb";
const descLineH = descHeight; // Approximate
// Extend the line slightly above and below the text
drawRoundedRect(
ctx,
padding,
drawY - 8 * scale,
4 * scale,
descLineH + 8 * scale,
2 * scale,
);
ctx.fill();
ctx.font = `${14 * scale}px 'Roboto', sans-serif`;
ctx.fillStyle = "#4b5563";
const descLines = getLines(ctx, description, contentWidth - 16 * scale);
const maxDescLines = 6;
descLines.slice(0, maxDescLines).forEach((line) => {
ctx.fillText(line, padding + 16 * scale, drawY);
drawY += 25 * scale; // line height
});
// drawY += 24 * scale; // Removed to reduce whitespace
} else {
drawY += 8 * scale;
}
// Draw Footer Divider
drawY += 24 * scale; // Spacing before line
ctx.beginPath();
ctx.strokeStyle = "#f3f4f6";
ctx.lineWidth = 1 * scale;
ctx.moveTo(padding, drawY);
ctx.lineTo(width - padding, drawY);
ctx.stroke();
drawY += 24 * scale; // Spacing after line
// Draw Footer Content
const footerY = drawY;
// Left: Author
if (avatarImg) {
ctx.save();
const avatarSize = 64 * scale;
const avatarX = padding;
// Circle clip
ctx.beginPath();
ctx.arc(
avatarX + avatarSize / 2,
footerY + avatarSize / 2,
avatarSize / 2,
0,
Math.PI * 2,
);
ctx.closePath();
ctx.clip();
ctx.drawImage(avatarImg, avatarX, footerY, avatarSize, avatarSize);
ctx.restore();
// Border for avatar
ctx.beginPath();
ctx.arc(
avatarX + (64 * scale) / 2,
footerY + (64 * scale) / 2,
(64 * scale) / 2,
0,
Math.PI * 2,
);
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2 * scale;
ctx.stroke();
}
const authorTextX = padding + (avatar ? 64 * scale + 16 * scale : 0);
const textCenterY = footerY + 32 * scale;
ctx.fillStyle = "#9ca3af";
ctx.font = `${12 * scale}px 'Roboto', sans-serif`;
ctx.fillText(i18n(I18nKey.author), authorTextX, textCenterY - 20 * scale);
ctx.fillStyle = "#1f2937";
ctx.font = `700 ${20 * scale}px 'Roboto', sans-serif`;
ctx.fillText(author, authorTextX, textCenterY + 4 * scale);
// Right: QR Code
const qrSize = 64 * scale;
const qrX = width - padding - qrSize;
// QR Background/Shadow effect (simplified as border)
ctx.fillStyle = "#ffffff";
// Shadow simulation
ctx.shadowColor = "rgba(0, 0, 0, 0.05)";
ctx.shadowBlur = 4 * scale;
ctx.shadowOffsetY = 2 * scale;
drawRoundedRect(ctx, qrX, footerY, qrSize, qrSize, 4 * scale);
ctx.fill();
ctx.shadowColor = "transparent"; // Reset shadow
// Draw QR
const qrInnerSize = 56 * scale;
const qrPadding = (qrSize - qrInnerSize) / 2;
if (qrImg) {
ctx.drawImage(
qrImg,
qrX + qrPadding,
footerY + qrPadding,
qrInnerSize,
qrInnerSize,
);
}
// Site Info (Left of QR)
const siteInfoX = qrX - 16 * scale;
ctx.textAlign = "right";
ctx.fillStyle = "#9ca3af";
ctx.font = `${12 * scale}px 'Roboto', sans-serif`;
ctx.fillText(i18n(I18nKey.scanToRead), siteInfoX, textCenterY - 20 * scale);
ctx.fillStyle = "#1f2937";
ctx.font = `700 ${20 * scale}px 'Roboto', sans-serif`;
ctx.fillText(siteTitle, siteInfoX, textCenterY + 4 * scale);
// Finalize
posterImage = canvas.toDataURL("image/png");
generating = false;
} catch (error) {
console.error("Failed to generate poster:", error);
generating = false;
}
}
function downloadPoster() {
if (posterImage) {
const a = document.createElement("a");
a.href = posterImage;
a.download = `poster-${title.replace(/\s+/g, "-")}.png`;
a.click();
}
}
function closeModal() {
showModal = false;
}
let copied = false;
function copyLink() {
navigator.clipboard.writeText(url);
copied = true;
setTimeout(() => {
copied = false;
}, 2000);
}
function portal(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
},
};
}
</script>
<!-- Trigger Button -->
<button
class="btn-regular rounded-lg h-12 px-6 gap-2 hover:scale-105 active:scale-95 whitespace-nowrap"
on:click={generatePoster}
aria-label="Generate Share Poster"
>
<Icon icon="material-symbols:share" width="20" height="20" />
<span>{i18n(I18nKey.shareArticle)}</span>
</button>
<!-- Modal -->
{#if showModal}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div use:portal class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 transition-opacity" on:click={closeModal}>
<div class="bg-white dark:bg-gray-800 rounded-2xl max-w-sm w-full max-h-[90vh] overflow-y-auto flex flex-col shadow-2xl transform transition-all" on:click|stopPropagation>
<div class="p-6 flex justify-center bg-gray-50 dark:bg-gray-900 min-h-[200px] items-center">
{#if posterImage}
<img src={posterImage} alt="Poster" class="max-w-full h-auto shadow-lg rounded-lg" />
{:else}
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 border-2 border-gray-200 rounded-full animate-spin" style="border-top-color: {themeColor}"></div>
<span class="text-sm text-gray-500">{i18n(I18nKey.generatingPoster)}</span>
</div>
{/if}
</div>
<div class="p-4 border-t border-gray-100 dark:border-gray-700 grid grid-cols-2 gap-3">
<button
class="py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-600 active:scale-[0.98] transition-all flex items-center justify-center gap-2"
on:click={copyLink}
>
{#if copied}
<Icon icon="material-symbols:check" width="20" height="20" />
<span>{i18n(I18nKey.copied)}</span>
{:else}
<Icon icon="material-symbols:link" width="20" height="20" />
<span>{i18n(I18nKey.copyLink)}</span>
{/if}
</button>
<button
class="py-3 text-white rounded-xl font-medium active:scale-[0.98] transition-all flex items-center justify-center gap-2 shadow-lg disabled:opacity-50 disabled:cursor-not-allowed hover:brightness-90"
style="background-color: {themeColor};"
on:click={downloadPoster}
disabled={!posterImage}
>
<Icon icon="material-symbols:download" width="20" height="20" />
{i18n(I18nKey.savePoster)}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,191 @@
<script lang="ts">
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import type { SearchResult } from "@/global";
import { url as formatUrl } from "@/utils/url-utils";
// --- Props ---
export let title = i18n(I18nKey.search);
export let description = "";
// --- State ---
let keyword = "";
let results: SearchResult[] = [];
let isSearching = false;
let initialized = false;
// 在客户端获取 URL 参数
const getInitialKeyword = (): string => {
if (typeof window !== "undefined") {
const searchParams = new URLSearchParams(window.location.search);
return searchParams.get("q") || "";
}
return "";
};
// --- Mocks for Dev Mode ---
const fakeResult: SearchResult[] = [
{
url: formatUrl("/"),
meta: { title: "Dev Mode Search Result 1" },
excerpt: "This is a <mark>mock</mark> result for development.",
},
{
url: formatUrl("/"),
meta: { title: "Dev Mode Search Result 2" },
excerpt: "Pagefind only works in <mark>production</mark> build.",
},
];
// --- Core Search Logic ---
const search = async () => {
if (!initialized || !keyword.trim()) {
results = [];
return;
}
isSearching = true;
try {
if (import.meta.env.PROD && window.pagefind) {
const response = await window.pagefind.search(keyword);
const rawResults = await Promise.all(
response.results.map((item) => item.data()),
);
results = rawResults;
} else if (import.meta.env.DEV) {
// 开发模式下的模拟结果
results = fakeResult.filter(
(item) =>
item.excerpt.toLowerCase().includes(keyword.toLowerCase()) ||
item.meta.title.toLowerCase().includes(keyword.toLowerCase()),
);
}
} catch (error) {
console.error("Search error:", error);
results = [];
} finally {
isSearching = false;
}
};
// --- Initialization onMount ---
onMount(() => {
const initialize = async () => {
initialized = true;
// 从 URL 获取初始关键词
const initialKeyword = getInitialKeyword();
if (initialKeyword) {
keyword = initialKeyword;
}
// 如果有关键词,自动执行搜索
if (keyword.trim()) {
await search();
}
};
// 开发环境直接初始化
if (import.meta.env.DEV) {
initialize();
} else {
// 生产环境等待 Pagefind 加载
if (window.pagefind) {
initialize();
} else {
document.addEventListener("pagefindready", initialize, {
once: true,
});
}
}
});
let debounceTimer: NodeJS.Timeout;
const handleInput = () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
search();
}, 300);
};
</script>
<div class="card-base px-6 py-6 md:px-9 md:py-6 mb-4 rounded-[var(--radius-large)]">
<!-- Title Section -->
<div class="mb-4">
<div class="flex items-center gap-3 mb-3">
<div class="h-8 w-8 rounded-lg bg-[var(--primary)] flex items-center justify-center text-white dark:text-black/70">
<Icon icon="material-symbols:search" class="text-[1.5rem]"></Icon>
</div>
<h1 class="text-3xl font-bold text-90">
{title}
</h1>
</div>
{#if description}
<p class="text-base text-50 leading-relaxed">
{description}
</p>
{/if}
</div>
<!-- Search Bar -->
<div class="relative flex">
<div class="relative flex-1">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Icon icon="material-symbols:search" class="text-2xl text-50" />
</div>
<input
type="text"
class="block w-full p-4 pl-10 text-sm bg-transparent border border-black/10 dark:border-white/10 rounded-lg focus:ring-[var(--primary)] focus:border-[var(--primary)] hover:border-black/20 dark:hover:border-white/20 text-75 placeholder-50 transition-colors outline-0"
placeholder={i18n(I18nKey.search)}
bind:value={keyword}
on:input={handleInput}
>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4">
<!-- Results Area -->
<div>
{#if isSearching}
<div class="flex justify-center py-10">
<Icon icon="svg-spinners:ring-resize" class="text-4xl text-[var(--primary)]" />
</div>
{:else if results.length > 0}
<div class="space-y-4">
{#each results as result}
<div class="card-base p-6 block rounded-[var(--radius-large)]">
<a href={result.url} class="block group">
<h5 class="mb-2 text-2xl font-bold tracking-tight text-90 group-hover:text-[var(--primary)] transition-colors">
{@html result.meta.title}
</h5>
<p class="font-normal text-75">
{@html result.excerpt}
</p>
</a>
</div>
{/each}
</div>
{:else if keyword}
<div class="card-base p-10 text-center text-50 rounded-[var(--radius-large)]">
{i18n(I18nKey.searchNoResults)}
</div>
{:else}
<div class="card-base p-10 text-center text-50 rounded-[var(--radius-large)]">
{i18n(I18nKey.searchTypeSomething)}
</div>
{/if}
</div>
</div>
<style>
/* 关键字高亮效果 - 主题色 */
:global(mark) {
background: transparent;
color: var(--primary);
font-weight: 600;
padding: 0 0.1em;
}
</style>

View File

@@ -0,0 +1,149 @@
---
import ClientPagination from "@/components/common/controls/ClientPagination.astro";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import type { UserSubjectCollection } from "@/types/bangumi";
import Card from "./Card.astro";
import FilterControls from "./FilterControls.astro";
interface Props {
sectionId: string;
items: UserSubjectCollection[];
isActive: boolean;
itemsPerPage?: number;
}
const { sectionId, items, isActive, itemsPerPage = 12 } = Astro.props;
// 状态映射
const statusMap = {
1: "wish",
2: "collect",
3: "doing",
4: "on_hold",
5: "dropped",
};
// Get status filters with counts
const statusCounts = items.reduce(
(acc, item) => {
const status = statusMap[item.type as keyof typeof statusMap] || "unknown";
acc[status] = (acc[status] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
const isGame = sectionId === "game";
const isBook = sectionId === "book";
const isMusic = sectionId === "music";
const getFilterLabel = (type: "collect" | "doing" | "wish") => {
if (isGame) {
switch (type) {
case "collect":
return i18n(I18nKey.bangumiFilterGamePlayed);
case "doing":
return i18n(I18nKey.bangumiFilterGamePlaying);
case "wish":
return i18n(I18nKey.bangumiFilterGameWish);
}
}
if (isBook) {
switch (type) {
case "collect":
return i18n(I18nKey.bangumiFilterBookRead);
case "doing":
return i18n(I18nKey.bangumiFilterBookReading);
case "wish":
return i18n(I18nKey.bangumiFilterBookWish);
}
}
if (isMusic) {
switch (type) {
case "collect":
return i18n(I18nKey.bangumiFilterMusicListened);
case "doing":
return i18n(I18nKey.bangumiFilterMusicListening);
case "wish":
return i18n(I18nKey.bangumiFilterMusicWish);
}
}
// Default (Anime/Real)
switch (type) {
case "collect":
return i18n(I18nKey.bangumiFilterWatched);
case "doing":
return i18n(I18nKey.bangumiFilterWatching);
case "wish":
return i18n(I18nKey.bangumiFilterWish);
}
};
const filters = [
{ value: "all", label: i18n(I18nKey.bangumiFilterAll), count: items.length },
{
value: "collect",
label: getFilterLabel("collect"),
count: statusCounts.collect || 0,
},
{
value: "doing",
label: getFilterLabel("doing"),
count: statusCounts.doing || 0,
},
{
value: "wish",
label: getFilterLabel("wish"),
count: statusCounts.wish || 0,
},
{
value: "on_hold",
label: i18n(I18nKey.bangumiFilterOnHold),
count: statusCounts.on_hold || 0,
},
{
value: "dropped",
label: i18n(I18nKey.bangumiFilterDropped),
count: statusCounts.dropped || 0,
},
].filter((filter) => filter.value === "all" || filter.count > 0);
const defaultFilter = "all"; // 默认显示全部,用户可以通过筛选器选择
---
<div class:list={["bangumi-section", { "hidden": !isActive }]} data-section={sectionId}>
{items.length > 0 ? (
<>
<FilterControls
filters={filters}
activeFilter={defaultFilter}
sectionId={sectionId}
/>
<div class="grid grid-cols-2 md:grid-cols-3 gap-6 md:gap-8">
{items.map((item) => (
<div
class="bangumi-item"
data-item-section={sectionId}
data-item-status={statusMap[item.type as keyof typeof statusMap] || "unknown"}
>
<Card item={item} />
</div>
))}
</div>
<ClientPagination
totalItems={items.length}
itemsPerPage={itemsPerPage}
currentPage={1}
sectionId={sectionId}
/>
</>
) : (
<div class="text-center py-12">
<h3 class="text-xl font-medium text-gray-600 dark:text-gray-400 mb-2">{i18n(I18nKey.bangumiNoData)}</h3>
<p class="text-gray-500 dark:text-gray-500">{i18n(I18nKey.bangumiNoDataDescription)}</p>
</div>
)}
</div>

View File

@@ -0,0 +1,135 @@
---
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import type { UserSubjectCollection } from "@/types/bangumi";
interface Props {
item: UserSubjectCollection;
}
const { item } = Astro.props;
const subject_base_url = "https://bgm.tv/subject/";
const getStatusColor = (type: number) => {
switch (type) {
case 1:
return "bg-blue-500";
case 2:
return "bg-green-500";
case 3:
return "bg-yellow-500";
case 4:
return "bg-orange-500";
case 5:
return "bg-red-500";
default:
return "bg-gray-500";
}
};
const getStatusText = (type: number) => {
const subjectType = item.subject.type;
// 1: Book, 2: Anime, 3: Music, 4: Game, 6: Real
switch (type) {
case 1: // Wish
if (subjectType === 1) return i18n(I18nKey.bangumiStatusBookWish);
if (subjectType === 3) return i18n(I18nKey.bangumiStatusMusicWish);
if (subjectType === 4) return i18n(I18nKey.bangumiStatusGameWish);
return i18n(I18nKey.bangumiStatusWish);
case 2: // Collect (Played/Watched/Read/Listened)
if (subjectType === 1) return i18n(I18nKey.bangumiStatusBookRead);
if (subjectType === 3) return i18n(I18nKey.bangumiStatusMusicListened);
if (subjectType === 4) return i18n(I18nKey.bangumiStatusGamePlayed);
return i18n(I18nKey.bangumiStatusWatched);
case 3: // Doing (Playing/Watching/Reading/Listening)
if (subjectType === 1) return i18n(I18nKey.bangumiStatusBookReading);
if (subjectType === 3) return i18n(I18nKey.bangumiStatusMusicListening);
if (subjectType === 4) return i18n(I18nKey.bangumiStatusGamePlaying);
return i18n(I18nKey.bangumiStatusWatching);
case 4:
return i18n(I18nKey.bangumiStatusOnHold);
case 5:
return i18n(I18nKey.bangumiStatusDropped);
default:
return i18n(I18nKey.bangumiStatusUnknown);
}
};
// 获取标签:优先用户标签,否则使用条目标签
const displayTags =
item.tags && item.tags.length > 0
? item.tags
: (item.subject.tags || []).map((t) => t.name).slice(0, 5);
---
<a
href={`${subject_base_url}${item.subject.id}`}
target="_blank"
rel="noopener noreferrer nofollow"
class="group relative overflow-hidden rounded-xl bg-white dark:bg-gray-800 shadow-lg hover:shadow-2xl transition-all duration-200 hover:scale-105 block"
>
<div class="aspect-[2/3] relative overflow-hidden" >
{item.subject?.images?.medium ? (
<img
src={item.subject.images.medium}
alt={item.subject.name_cn || item.subject.name}
class="w-full h-full object-cover pointer-events-none"
loading="lazy"
/>
) : (
<div class="w-full h-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<div class="text-gray-400 text-4xl">📖</div>
</div>
)}
<!-- Status badge -->
<div class={`absolute top-2 left-2 px-2 py-1 rounded-full text-xs text-white font-medium ${getStatusColor(item.type)}`}>
{getStatusText(item.type)}
</div>
</div>
<!-- Info overlay on hover -->
<div class="absolute inset-x-0 bottom-0 bg-black/80 text-white p-4 transform translate-y-full group-hover:translate-y-0 transition-transform duration-200">
<h3 class="font-bold text-sm mb-1 line-clamp-2">
{item.subject.name_cn || item.subject.name}
</h3>
{(item.subject.score || item.comment) && (
<div class="flex items-center justify-between mb-2">
{item.subject.score && (
<div class="flex items-center gap-1">
<div class="text-yellow-400">⭐</div>
<span class="text-sm">{item.subject.score}</span>
</div>
)}
{item.comment && (
<div class="relative group/comment">
<div class="text-sm text-gray-300 cursor-help">💬</div>
<div class="absolute bottom-full right-0 mb-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg opacity-0 group-hover/comment:opacity-100 transition-opacity duration-150 w-32 sm:w-44 xl:w-52 z-10 pointer-events-none">
{item.comment}
</div>
</div>
)}
</div>
)}
<!-- Tags display -->
{displayTags && displayTags.length > 0 && (
<div class="flex flex-wrap gap-1 mt-2">
{displayTags.slice(0, 5).map((tag: string) => (
<span class="text-xs px-2 py-0.5 bg-white/20 rounded-full text-white/90">
{tag}
</span>
))}
{displayTags.length > 5 && (
<span class="text-xs px-2 py-0.5 bg-white/20 rounded-full text-white/60">
+{displayTags.length - 5}
</span>
)}
</div>
)}
</div>
</a>

View File

@@ -0,0 +1,97 @@
---
interface Filter {
value: string;
label: string;
count?: number;
}
interface Props {
filters: Filter[];
activeFilter: string;
sectionId: string;
}
const { filters, activeFilter, sectionId } = Astro.props;
---
<div class="flex flex-wrap gap-1.5 mb-4">
{filters.map((filter) => (
<button
class:list={[
"px-3 py-1 rounded-full text-xs font-medium transition-all duration-200",
{
"bg-[var(--primary)] text-white shadow-md": filter.value === activeFilter,
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600": filter.value !== activeFilter
}
]}
data-filter={filter.value}
data-section={sectionId}
type="button"
>
{filter.label}
{filter.count !== undefined && (
<span class="ml-1">({filter.count})</span>
)}
</button>
))}
</div>
<script is:inline define:vars={{ sectionId }}>
function initFilterControls() {
const filterButtons = document.querySelectorAll(`[data-section="${sectionId}"][data-filter]`);
filterButtons.forEach(button => {
button.addEventListener('click', function() {
const filter = this.dataset.filter;
const currentSectionId = this.dataset.section;
// Update active filter button for this section
const sectionButtons = document.querySelectorAll(`[data-section="${currentSectionId}"][data-filter]`);
sectionButtons.forEach(btn => {
btn.classList.remove('bg-[var(--primary)]', 'text-white', 'shadow-md');
btn.classList.add('bg-gray-100', 'text-gray-700', 'hover:bg-gray-200', 'dark:bg-gray-700', 'dark:text-gray-300', 'dark:hover:bg-gray-600');
});
this.classList.remove('bg-gray-100', 'text-gray-700', 'hover:bg-gray-200', 'dark:bg-gray-700', 'dark:text-gray-300', 'dark:hover:bg-gray-600');
this.classList.add('bg-[var(--primary)]', 'text-white', 'shadow-md');
// Filter items
const items = document.querySelectorAll(`[data-item-section="${currentSectionId}"]`);
items.forEach(item => {
const itemStatus = item.dataset.itemStatus;
if (filter === 'all' || itemStatus === filter) {
item.classList.remove('hidden');
item.style.display = 'block';
} else {
item.classList.add('hidden');
item.style.display = 'none';
}
});
// Update pagination
updatePagination(currentSectionId);
});
});
}
function updatePagination(sectionId) {
const visibleItems = document.querySelectorAll(`[data-item-section="${sectionId}"]:not(.hidden)`);
const pagination = document.querySelector(`[data-pagination-section="${sectionId}"]`);
if (pagination) {
// Trigger pagination update
const event = new CustomEvent('updatePagination', {
detail: { visibleCount: visibleItems.length }
});
pagination.dispatchEvent(event);
}
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initFilterControls);
} else {
initFilterControls();
}
</script>

View File

@@ -0,0 +1,79 @@
---
interface Tab {
id: string;
name: string;
count?: number;
}
interface Props {
tabs: Tab[];
activeTab: string;
}
const { tabs, activeTab } = Astro.props;
---
<div class="border-b border-gray-200 dark:border-gray-700 mb-3">
<nav class="flex space-x-8" aria-label="Tabs">
{tabs.map((tab) => (
<button
class:list={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200",
{
"border-[var(--primary)] text-[var(--primary)]": tab.id === activeTab,
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300": tab.id !== activeTab
}
]}
data-tab={tab.id}
type="button"
>
{tab.name}
{tab.count !== undefined && (
<span class="ml-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 py-0.5 px-2 rounded-full text-xs">
{tab.count}
</span>
)}
</button>
))}
</nav>
</div>
<script>
function initTabNavigation() {
const tabButtons = document.querySelectorAll('[data-tab]');
const sections = document.querySelectorAll('[data-section]');
tabButtons.forEach(button => {
button.addEventListener('click', (event) => {
const currentButton = event.currentTarget as HTMLButtonElement;
const targetTab = currentButton.dataset.tab;
// Update active tab
tabButtons.forEach(btn => {
btn.classList.remove('border-[var(--primary)]', 'text-[var(--primary)]');
btn.classList.add('border-transparent', 'text-gray-500', 'hover:text-gray-700', 'hover:border-gray-300', 'dark:text-gray-400', 'dark:hover:text-gray-300');
});
currentButton.classList.remove('border-transparent', 'text-gray-500', 'hover:text-gray-700', 'hover:border-gray-300', 'dark:text-gray-400', 'dark:hover:text-gray-300');
currentButton.classList.add('border-[var(--primary)]', 'text-[var(--primary)]');
// Show/hide sections
sections.forEach(section => {
const htmlSection = section as HTMLElement;
if (htmlSection.dataset.section === targetTab) {
htmlSection.classList.remove('hidden');
} else {
htmlSection.classList.add('hidden');
}
});
});
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTabNavigation);
} else {
initTabNavigation();
}
</script>

View File

@@ -0,0 +1,376 @@
---
import { adConfig1, adConfig2 } from "@/config/adConfig";
import type { AdConfig } from "@/types/config";
import { url } from "@/utils/url-utils";
export interface Props {
class?: string;
configId?: string;
}
const { class: className = "", configId } = Astro.props as Props;
// 根据configId选择对应的广告配置
const getAdConfig = (id?: string): AdConfig => {
switch (id) {
case "ad1":
return adConfig1;
case "ad2":
return adConfig2;
default:
return adConfig1; // 默认配置
}
};
const currentAdConfig = getAdConfig(configId);
// 检查广告是否过期
const isExpired = currentAdConfig.expireDate
? new Date() > new Date(currentAdConfig.expireDate)
: false;
// 如果过期则不显示
if (isExpired) {
return null;
}
// 处理自定义边距
const getPaddingStyle = () => {
if (!currentAdConfig.padding) return "p-4"; // 默认边距
if (currentAdConfig.padding.all !== undefined) {
// 统一边距
return currentAdConfig.padding.all === "0" ? "p-0" : "";
}
// 单独边距
const { top, right, bottom, left } = currentAdConfig.padding;
return (
[
top !== undefined ? `pt-[${top}]` : "",
right !== undefined ? `pr-[${right}]` : "",
bottom !== undefined ? `pb-[${bottom}]` : "",
left !== undefined ? `pl-[${left}]` : "",
]
.filter(Boolean)
.join(" ") || "p-4"
);
};
const paddingClass = getPaddingStyle();
---
<div
class:list={[
"card-base",
"advertisement-widget",
"relative",
"overflow-hidden",
className,
]}
data-display-count={currentAdConfig.displayCount}
data-closable={currentAdConfig.closable}
>
<!-- 关闭按钮 -->
{
currentAdConfig.closable && (
<button
class="absolute top-2 right-2 w-6 h-6 flex items-center justify-center rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-700 dark:hover:bg-neutral-600 text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200 transition-all duration-200 z-10 close-ad-btn"
title="关闭广告"
aria-label="关闭广告"
>
<svg
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)
}
<!-- 广告内容 -->
<div class={paddingClass}>
<!-- 标题 -->
{
currentAdConfig.title && (
<h3 class="text-lg font-bold mb-3 text-center text-neutral-900 dark:text-neutral-50 transition">
{currentAdConfig.title}
</h3>
)
}
<!-- 图片 -->
{
currentAdConfig.image && (
<div
class={
currentAdConfig.title ||
currentAdConfig.content ||
currentAdConfig.link
? "mb-3"
: ""
}
>
{currentAdConfig.image.link ? (
<a
href={currentAdConfig.image.link}
target={currentAdConfig.image.external ? "_blank" : "_self"}
rel={currentAdConfig.image.external ? "noopener noreferrer" : ""}
class="block group"
>
<img
src={currentAdConfig.image.src.startsWith('/') ? url(currentAdConfig.image.src) : currentAdConfig.image.src}
alt={currentAdConfig.image.alt || "广告图片"}
class={`w-full h-auto transition-transform duration-300 group-hover:scale-105 ${
paddingClass === "p-0"
? "rounded-[var(--radius-large)]"
: "rounded-lg"
}`}
loading="lazy"
/>
</a>
) : (
<img
src={currentAdConfig.image.src.startsWith('/') ? url(currentAdConfig.image.src) : currentAdConfig.image.src}
alt={currentAdConfig.image.alt || "广告图片"}
class={`w-full h-auto ${
paddingClass === "p-0"
? "rounded-[var(--radius-large)]"
: "rounded-lg"
}`}
loading="lazy"
/>
)}
</div>
)
}
<!-- 文本内容 -->
{
currentAdConfig.content && (
<p class="text-sm text-center mb-3 leading-relaxed text-neutral-600 dark:text-neutral-300 transition">
{currentAdConfig.content}
</p>
)
}
<!-- 链接按钮 -->
{
currentAdConfig.link && (
<div class="text-center">
<a
href={currentAdConfig.link.url}
target={currentAdConfig.link.external ? "_blank" : "_self"}
rel={currentAdConfig.link.external ? "noopener noreferrer" : ""}
class="inline-flex items-center gap-2 px-4 py-2 bg-[oklch(0.75_0.14_var(--hue))] hover:bg-[oklch(0.7_0.16_var(--hue))] text-white rounded-lg transition-all duration-200 font-medium text-sm shadow-lg shadow-[oklch(0.75_0.14_var(--hue))]/25 hover:shadow-xl hover:shadow-[oklch(0.75_0.14_var(--hue))]/30 hover:scale-105 transform"
>
<span>{currentAdConfig.link.text}</span>
{currentAdConfig.link.external && (
<svg
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
)}
</a>
</div>
)
}
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
// 清理旧的localStorage关闭状态确保广告可以重新显示
localStorage.removeItem("ad-closed");
const adWidgets = document.querySelectorAll(
".advertisement-widget"
) as NodeListOf<HTMLElement>;
adWidgets.forEach((widget) => {
const closeBtn = widget.querySelector(".close-ad-btn");
const displayCount = parseInt(
widget.getAttribute("data-display-count") || "-1"
);
const isClosable = widget.getAttribute("data-closable") === "true";
// 检查显示次数限制
if (displayCount > 0) {
const storageKey = "ad-display-count";
const currentCount = parseInt(localStorage.getItem(storageKey) || "0");
if (currentCount >= displayCount) {
widget.style.display = "none";
return;
}
localStorage.setItem(storageKey, (currentCount + 1).toString());
}
// 绑定关闭按钮事件(关闭后刷新页面会重新显示)
if (closeBtn && isClosable) {
closeBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
// 添加关闭动画
widget.style.transform = "translateX(100%)";
widget.style.opacity = "0";
widget.style.transition = "all 0.3s ease-out";
setTimeout(() => {
widget.style.display = "none";
// 不再使用localStorage保存关闭状态刷新页面后会重新显示
}, 300);
});
}
});
});
</script>
<style>
.advertisement-widget {
/* 完全跟随主题的背景和边框 */
background: var(--card-bg);
border: 1px solid var(--line-divider);
border-radius: var(--radius-large);
color: var(--primary-text-color);
/* padding由配置控制不在CSS中设置 */
transition: all 0.3s ease;
/* 阴影效果跟随主题 */
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
/* 深色模式下的特殊处理 */
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
/* 深色模式阴影 */
:global(.dark) .advertisement-widget {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
/* 文字颜色由Tailwind类处理这里不需要额外的CSS */
.advertisement-widget:hover {
/* 浅色模式阴影 */
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.08),
0 0 0 1px var(--line-divider);
transform: translateY(-2px);
border-color: oklch(0.75 0.14 var(--hue));
}
/* 深色模式特殊效果 */
:global(.dark) .advertisement-widget:hover {
box-shadow:
0 4px 20px rgba(0, 0, 0, 0.3),
0 0 0 1px oklch(0.75 0.14 var(--hue));
background: color-mix(
in srgb,
var(--card-bg),
oklch(0.75 0.14 var(--hue)) 5%
);
}
.close-ad-btn {
opacity: 0;
transition: all 0.2s ease;
backdrop-filter: blur(8px);
}
.advertisement-widget:hover .close-ad-btn {
opacity: 1;
}
/* 主题色按钮效果 */
.advertisement-widget a[class*="bg-[oklch"] {
background: oklch(0.75 0.14 var(--hue));
transition: all 0.2s ease;
}
.advertisement-widget a[class*="bg-[oklch"]:hover {
background: oklch(0.7 0.16 var(--hue));
box-shadow: 0 4px 12px
color-mix(in srgb, oklch(0.75 0.14 var(--hue)), transparent 70%);
}
/* 图片悬停效果 */
.advertisement-widget img {
transition: all 0.3s ease;
}
.advertisement-widget:hover img {
filter: brightness(1.05) saturate(1.1);
}
:global(.dark) .advertisement-widget:hover img {
filter: brightness(1.1) saturate(1.15);
}
/* 响应式调整 */
@media (max-width: 768px) {
.advertisement-widget {
margin: 0; /* 与其他组件保持一致的边距(由外层布局控制) */
border-radius: var(--radius-large);
/* 保持左右边框,避免与其他卡片不一致 */
backdrop-filter: blur(8px);
}
.advertisement-widget:hover {
transform: none;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.06),
0 0 0 1px var(--line-divider);
}
:global(.dark) .advertisement-widget:hover {
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.2),
0 0 0 1px oklch(0.75 0.14 var(--hue));
}
}
/* 高对比度模式支持 */
@media (prefers-contrast: high) {
.advertisement-widget {
border-width: 2px;
}
.advertisement-widget:hover {
border-color: oklch(0.6 0.2 var(--hue));
}
}
/* 减少动画模式支持 */
@media (prefers-reduced-motion: reduce) {
.advertisement-widget,
.close-ad-btn,
.advertisement-widget img,
.advertisement-widget a {
transition: none;
}
.advertisement-widget:hover {
transform: none;
}
}
</style>

View File

@@ -0,0 +1,89 @@
---
import { Icon } from "astro-icon/components";
import { announcementConfig } from "@/config/announcementConfig";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import WidgetLayout from "./WidgetLayout.astro";
const config = announcementConfig;
interface Props {
class?: string;
style?: string;
}
const className = Astro.props.class;
const style = Astro.props.style;
---
<!-- 组件显示现在由sidebarLayoutConfig统一控制无需检查config.enable -->
<WidgetLayout
name={config.title || i18n(I18nKey.announcement)}
id="announcement"
class={className}
style={style}
>
<div>
<!-- 公告栏内容 -->
<div class="text-neutral-600 dark:text-neutral-300 leading-relaxed mb-3">
{config.content}
</div>
<!-- 可选链接和关闭按钮 -->
<div class="flex items-center justify-between gap-3">
<div>
{
config.link && config.link.enable !== false && (
<a
href={config.link.url}
target={config.link.external ? "_blank" : "_self"}
rel={config.link.external ? "noopener noreferrer" : undefined}
class="btn-regular rounded-lg px-3 py-1.5 text-sm font-medium active:scale-95 transition-transform"
>
{config.link.text}
</a>
)
}
</div>
{
config.closable && (
<button
class="btn-regular rounded-lg h-8 w-8 text-sm hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
onclick="closeAnnouncement()"
aria-label={i18n(I18nKey.announcementClose)}
>
<Icon name="fa6-solid:xmark" class="text-sm" />
</button>
)
}
</div>
</div>
</WidgetLayout>
<script>
function closeAnnouncement() {
// 通过data-id属性找到整个widget-layout元素
const widgetLayout = document.querySelector(
'widget-layout[data-id="announcement"]'
) as HTMLElement | null;
if (widgetLayout) {
// 隐藏整个widget-layout元素
widgetLayout.style.display = "none";
// 保存关闭状态到localStorage
localStorage.setItem("announcementClosed", "true");
}
}
// 页面加载时检查是否已关闭
document.addEventListener("DOMContentLoaded", function () {
const widgetLayout = document.querySelector(
'widget-layout[data-id="announcement"]'
) as HTMLElement | null;
if (widgetLayout && localStorage.getItem("announcementClosed") === "true") {
widgetLayout.style.display = "none";
}
});
// 使公告栏函数全局可用
window.closeAnnouncement = closeAnnouncement;
</script>

View File

@@ -0,0 +1,496 @@
---
import { Icon } from "astro-icon/components";
import { siteConfig } from "@/config/siteConfig";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import { url } from "@/utils/url-utils";
import WidgetLayout from "./WidgetLayout.astro";
interface Props {
class?: string;
style?: string;
}
const { class: className, style } = Astro.props;
// 月份名称(使用 i18n
const monthNames = [
i18n(I18nKey.calendarJanuary),
i18n(I18nKey.calendarFebruary),
i18n(I18nKey.calendarMarch),
i18n(I18nKey.calendarApril),
i18n(I18nKey.calendarMay),
i18n(I18nKey.calendarJune),
i18n(I18nKey.calendarJuly),
i18n(I18nKey.calendarAugust),
i18n(I18nKey.calendarSeptember),
i18n(I18nKey.calendarOctober),
i18n(I18nKey.calendarNovember),
i18n(I18nKey.calendarDecember),
];
// 星期名称(简写,使用 i18n
const weekDays = [
i18n(I18nKey.calendarSunday),
i18n(I18nKey.calendarMonday),
i18n(I18nKey.calendarTuesday),
i18n(I18nKey.calendarWednesday),
i18n(I18nKey.calendarThursday),
i18n(I18nKey.calendarFriday),
i18n(I18nKey.calendarSaturday),
];
// 年份文本
const yearText = i18n(I18nKey.year);
// 获取当前语言
const currentLang = siteConfig.lang || "en";
const calendarDataUrl = url("/api/calendar.json");
const postUrlPrefix = url("/posts/");
---
<WidgetLayout id="calendar-widget" class={"!pt-4 [&>.widget-title]:hidden " + className} style={style}>
<div class="calendar-container">
<div class="flex justify-between items-center mb-4">
<button id="prev-month-btn" class="btn-plain rounded-lg w-8 h-8 flex items-center justify-center hover:bg-[var(--btn-plain-bg-hover)] transition-colors" aria-label="Previous Month">
<Icon name="fa6-solid:chevron-left" class="text-sm" />
</button>
<div id="current-month-display" class="text-lg font-bold text-neutral-900 dark:text-neutral-100 cursor-pointer hover:text-[var(--primary)] transition-colors select-none"></div>
<div class="flex gap-2">
<button id="reset-month-btn" class="btn-plain rounded-lg w-8 h-8 flex items-center justify-center hover:bg-[var(--btn-plain-bg-hover)] transition-colors" aria-label="Back to Today">
<Icon name="fa6-solid:arrow-rotate-left" class="text-sm" />
</button>
<button id="next-month-btn" class="btn-plain rounded-lg w-8 h-8 flex items-center justify-center hover:bg-[var(--btn-plain-bg-hover)] transition-colors" aria-label="Next Month">
<Icon name="fa6-solid:chevron-right" class="text-sm" />
</button>
</div>
</div>
<!-- 日历视图容器 -->
<div id="calendar-view-container">
<!-- 星期标题 -->
<div class="weekdays grid grid-cols-7 gap-1 mb-2">
{weekDays.map(day => (
<div class="text-center text-xs text-neutral-500 dark:text-neutral-400 font-medium">
{day}
</div>
))}
</div>
<!-- 日历格子(由客户端动态生成) -->
<div class="calendar-grid grid grid-cols-7 gap-1" id="calendar-grid">
<!-- 将由 JavaScript 填充 -->
</div>
</div>
<!-- 月份选择视图 -->
<div id="month-view-container" class="hidden grid grid-cols-3 gap-2">
<!-- 将由 JavaScript 填充 -->
</div>
<!-- 年份选择视图 -->
<div id="year-view-container" class="hidden grid grid-cols-3 gap-2">
<!-- 将由 JavaScript 填充 -->
</div>
<!-- 文章列表 -->
<div id="calendar-posts" class="mt-3">
<div class="border-t border-neutral-200 dark:border-neutral-700 mb-2" id="calendar-posts-divider" style="display: none;"></div>
<div class="flex flex-col gap-1" id="calendar-posts-list">
<!-- 将由 JavaScript 填充 -->
</div>
</div>
</div>
</WidgetLayout>
<script is:inline define:vars={{ monthNames, weekDays, yearText, currentLang, calendarDataUrl, postUrlPrefix }}>
// State variables
let displayYear = new Date().getFullYear();
let displayMonth = new Date().getMonth();
let currentView = 'day'; // 'day' | 'month' | 'year'
let postDateMap = {};
let allPostsData = [];
let availableYears = [];
async function fetchData() {
try {
const response = await fetch(calendarDataUrl);
allPostsData = await response.json();
// Reconstruct postDateMap and availableYears
postDateMap = {};
const yearsSet = new Set();
allPostsData.forEach(post => {
const date = new Date(post.published);
const dateKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
if (!postDateMap[dateKey]) {
postDateMap[dateKey] = [];
}
postDateMap[dateKey].push({ id: post.id, title: post.title, published: post.published });
yearsSet.add(date.getFullYear());
});
availableYears = Array.from(yearsSet).sort((a, b) => b - a);
renderCalendar();
} catch (error) {
console.error("Failed to fetch calendar data", error);
}
}
// 客户端动态渲染日历
function renderCalendar() {
const container = document.getElementById('calendar-view-container');
const monthContainer = document.getElementById('month-view-container');
const yearContainer = document.getElementById('year-view-container');
const postsContainer = document.getElementById('calendar-posts');
// Update visibility
if (container) container.style.display = currentView === 'day' ? 'block' : 'none';
if (monthContainer) monthContainer.style.display = currentView === 'month' ? 'grid' : 'none';
if (yearContainer) yearContainer.style.display = currentView === 'year' ? 'grid' : 'none';
if (postsContainer) postsContainer.style.display = currentView === 'day' ? 'block' : 'none';
updateHeader();
if (currentView === 'day') {
renderDayView();
} else if (currentView === 'month') {
renderMonthView();
} else if (currentView === 'year') {
renderYearView();
}
}
function updateHeader() {
const navDisplay = document.getElementById('current-month-display');
const resetBtn = document.getElementById('reset-month-btn');
const prevBtn = document.getElementById('prev-month-btn');
const nextBtn = document.getElementById('next-month-btn');
if (navDisplay) {
if (currentView === 'day') {
if (currentLang.startsWith('zh') || currentLang.startsWith('ja')) {
navDisplay.textContent = `${displayYear}${yearText}${monthNames[displayMonth]}`;
} else {
navDisplay.textContent = `${monthNames[displayMonth]} ${displayYear}`;
}
} else if (currentView === 'month') {
navDisplay.textContent = `${displayYear}${yearText}`;
} else if (currentView === 'year') {
navDisplay.textContent = yearText;
}
}
if (resetBtn) {
const now = new Date();
const isCurrent = displayYear === now.getFullYear() && displayMonth === now.getMonth();
resetBtn.style.display = (currentView === 'day' && isCurrent) ? 'none' : 'flex';
}
// Hide prev/next buttons in year view as we show all years
if (prevBtn) prevBtn.style.visibility = currentView === 'year' ? 'hidden' : 'visible';
if (nextBtn) nextBtn.style.visibility = currentView === 'year' ? 'hidden' : 'visible';
}
function renderDayView() {
const now = new Date();
const currentYear = displayYear;
const currentMonth = displayMonth;
const currentDate = now.getDate();
const isCurrentMonth = currentYear === now.getFullYear() && currentMonth === now.getMonth();
// 获取月份的第一天是星期几
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();
// 获取当月天数
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
// 生成日历格子
const calendarGrid = document.getElementById('calendar-grid');
if (!calendarGrid) return;
const calendarDays = [];
// 添加空白格子(月初空白)
for (let i = 0; i < firstDayOfMonth; i++) {
calendarDays.push({ day: null, hasPost: false, count: 0, dateKey: "" });
}
// 添加每一天
for (let day = 1; day <= daysInMonth; day++) {
const dateKey = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const posts = postDateMap[dateKey] || [];
const count = posts.length;
calendarDays.push({
day,
hasPost: count > 0,
count,
dateKey
});
}
// 渲染日历格子
calendarGrid.innerHTML = calendarDays.map(({day, hasPost, count, dateKey}) => {
const isToday = day === currentDate && isCurrentMonth;
const classes = [
"calendar-day aspect-square flex items-center justify-center rounded text-sm relative cursor-pointer"
];
if (!day) {
classes.push("text-neutral-400 dark:text-neutral-600");
} else if (!hasPost) {
classes.push("text-neutral-700 dark:text-neutral-300");
} else {
classes.push("text-neutral-900 dark:text-neutral-100 font-bold");
}
if (isToday) {
classes.push("ring-2 ring-[var(--primary)]");
}
return `
<div
class="${classes.join(' ')}"
data-date="${dateKey}"
data-has-post="${hasPost}"
>
${day || ''}
${hasPost ? '<span class="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-[var(--primary)]"></span>' : ''}
${hasPost && count > 1 ? `<span class="absolute top-0 right-0 text-[10px] text-[var(--primary)] font-bold">${count}</span>` : ''}
</div>
`;
}).join('');
// 获取当月所有文章
const currentMonthPosts = allPostsData.filter(post => {
const date = new Date(post.published);
return date.getFullYear() === currentYear && date.getMonth() === currentMonth;
});
// 显示当月文章列表
showMonthlyPosts(currentMonthPosts);
// 添加点击事件监听
setupClickHandlers(currentMonthPosts);
}
function renderMonthView() {
const container = document.getElementById('month-view-container');
if (!container) return;
// Calculate which months have posts for the currently displayed year
const monthsWithPosts = new Set();
allPostsData.forEach(post => {
const date = new Date(post.published);
if (date.getFullYear() === displayYear) {
monthsWithPosts.add(date.getMonth());
}
});
container.innerHTML = monthNames.map((name, index) => {
const isCurrent = index === displayMonth;
const hasPost = monthsWithPosts.has(index);
const classes = [
"p-2 text-center text-sm rounded cursor-pointer hover:bg-[var(--btn-plain-bg-hover)] transition-colors relative"
];
if (isCurrent) {
classes.push("text-[var(--primary)] font-bold bg-[var(--btn-plain-bg-hover)]");
} else {
classes.push("text-neutral-700 dark:text-neutral-300");
}
const dotHtml = hasPost ? '<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-[var(--primary)]"></span>' : '';
return `<div class="${classes.join(' ')}" data-month="${index}">${name}${dotHtml}</div>`;
}).join('');
container.querySelectorAll('[data-month]').forEach(el => {
el.addEventListener('click', () => {
displayMonth = parseInt(el.getAttribute('data-month'));
currentView = 'day';
renderCalendar();
});
});
}
function renderYearView() {
const container = document.getElementById('year-view-container');
if (!container) return;
container.innerHTML = availableYears.map(year => {
const isCurrent = year === displayYear;
const classes = [
"p-2 text-center text-sm rounded cursor-pointer hover:bg-[var(--btn-plain-bg-hover)] transition-colors"
];
if (isCurrent) {
classes.push("text-[var(--primary)] font-bold bg-[var(--btn-plain-bg-hover)]");
} else {
classes.push("text-neutral-700 dark:text-neutral-300");
}
return `<div class="${classes.join(' ')}" data-year="${year}">${year}</div>`;
}).join('');
container.querySelectorAll('[data-year]').forEach(el => {
el.addEventListener('click', () => {
displayYear = parseInt(el.getAttribute('data-year'));
currentView = 'month';
renderCalendar();
});
});
}
// 显示当月所有文章
function showMonthlyPosts(currentMonthPosts) {
const postsList = document.getElementById('calendar-posts-list');
const divider = document.getElementById('calendar-posts-divider');
if (postsList) {
postsList.innerHTML = currentMonthPosts.map(post => {
const date = new Date(post.published);
const dateStr = `${date.getMonth() + 1}-${date.getDate()}`;
return `
<a href="${postUrlPrefix}${post.id}/" class="flex justify-between items-center text-sm text-neutral-700 dark:text-neutral-300 hover:text-[var(--primary)] dark:hover:text-[var(--primary)] transition-colors px-2 py-1 rounded hover:bg-[var(--btn-plain-bg-hover)]">
<span class="truncate">${post.title}</span>
<span class="text-xs text-neutral-500 dark:text-neutral-400 ml-2 whitespace-nowrap">${dateStr}</span>
</a>
`}).join('');
// 显示/隐藏分割线
if (divider) {
divider.style.display = currentMonthPosts.length > 0 ? 'block' : 'none';
}
}
}
// 设置日历格子点击事件
function setupClickHandlers(currentMonthPosts) {
const calendarDays = document.querySelectorAll('.calendar-day[data-date]');
const postsList = document.getElementById('calendar-posts-list');
const divider = document.getElementById('calendar-posts-divider');
let currentSelectedDay = null;
calendarDays.forEach(dayElement => {
dayElement.addEventListener('click', () => {
const dateKey = dayElement.getAttribute('data-date');
const hasPost = dayElement.getAttribute('data-has-post') === 'true';
if (!hasPost || !dateKey) return;
// 切换选中状态
if (currentSelectedDay === dayElement) {
// 取消选中,恢复显示当月所有文章
dayElement.classList.remove('bg-[var(--primary)]', 'bg-opacity-10');
currentSelectedDay = null;
showMonthlyPosts(currentMonthPosts);
return;
}
// 移除之前选中的样式
if (currentSelectedDay) {
currentSelectedDay.classList.remove('bg-[var(--primary)]', 'bg-opacity-10');
}
// 添加选中样式
dayElement.classList.add('bg-[var(--primary)]', 'bg-opacity-10');
currentSelectedDay = dayElement;
// 获取该日期的文章
const posts = postDateMap[dateKey] || [];
if (posts.length > 0 && postsList) {
// 渲染文章列表
postsList.innerHTML = posts.map(post => {
const date = new Date(post.published);
const dateStr = `${date.getMonth() + 1}-${date.getDate()}`;
return `
<a href="${postUrlPrefix}${post.id}/" class="flex justify-between items-center text-sm text-neutral-700 dark:text-neutral-300 hover:text-[var(--primary)] dark:hover:text-[var(--primary)] transition-colors px-2 py-1 rounded hover:bg-[var(--btn-plain-bg-hover)]">
<span class="truncate">${post.title}</span>
<span class="text-xs text-neutral-500 dark:text-neutral-400 ml-2 whitespace-nowrap">${dateStr}</span>
</a>
`}).join('');
// 显示分割线
if (divider) {
divider.style.display = 'block';
}
}
});
});
}
function changeMonth(delta) {
if (currentView === 'day') {
displayMonth += delta;
if (displayMonth > 11) {
displayMonth = 0;
displayYear++;
} else if (displayMonth < 0) {
displayMonth = 11;
displayYear--;
}
} else if (currentView === 'month') {
displayYear += delta;
}
renderCalendar();
}
function resetToToday() {
const now = new Date();
displayYear = now.getFullYear();
displayMonth = now.getMonth();
currentView = 'day';
renderCalendar();
}
function initCalendar() {
// Reset to current date on init
const now = new Date();
displayYear = now.getFullYear();
displayMonth = now.getMonth();
fetchData();
// Bind events
const prevBtn = document.getElementById('prev-month-btn');
const nextBtn = document.getElementById('next-month-btn');
const resetBtn = document.getElementById('reset-month-btn');
const navDisplay = document.getElementById('current-month-display');
if (prevBtn) prevBtn.onclick = () => changeMonth(-1);
if (nextBtn) nextBtn.onclick = () => changeMonth(1);
if (resetBtn) resetBtn.onclick = () => resetToToday();
if (navDisplay) {
navDisplay.onclick = () => {
if (currentView === 'day') {
currentView = 'month';
} else if (currentView === 'month') {
currentView = 'year';
}
renderCalendar();
};
}
}
// 页面加载时渲染日历
initCalendar();
// 页面切换时重新渲染
document.addEventListener("swup:contentReplaced", () => {
setTimeout(initCalendar, 100);
});
</script>
<style>
.calendar-day {
transition: all 0.2s ease;
min-height: 32px;
}
.calendar-day[data-has-post="true"]:hover {
transform: translateY(-2px);
}
</style>

View File

@@ -0,0 +1,43 @@
---
import ButtonLink from "@/components/common/controls/ButtonLink.astro";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import { getCategoryList } from "@/utils/content-utils";
import { widgetManager } from "@/utils/widget-manager";
import WidgetLayout from "./WidgetLayout.astro";
const categories = await getCategoryList();
const COLLAPSED_HEIGHT = "7.5rem";
// 使用统一的组件管理器检查是否应该折叠
const allComponents = [
...widgetManager.getConfig().leftComponents,
...widgetManager.getConfig().rightComponents,
];
const categoriesComponent = allComponents.find((c) => c.type === "categories");
const isCollapsed = categoriesComponent
? widgetManager.isCollapsed(categoriesComponent, categories.length)
: false;
interface Props {
class?: string;
style?: string;
}
const className = Astro.props.class;
const style = Astro.props.style;
---
<WidgetLayout name={i18n(I18nKey.categories)} id="categories" isCollapsed={isCollapsed} collapsedHeight={COLLAPSED_HEIGHT}
class={className} style={style}
>
{categories.map((c) =>
<ButtonLink
url={c.url}
badge={String(c.count)}
label={`View all posts in the ${c.name.trim()} category`}
>
{c.name.trim()}
</ButtonLink>
)}
</WidgetLayout>

View File

@@ -0,0 +1,453 @@
---
import type { Live2DModelConfig } from "@/types/config";
import { url } from "@/utils/url-utils";
import MessageBox from "./PioMessageBox.astro";
interface Props {
config: Live2DModelConfig;
}
const { config } = Astro.props;
// 获取位置和尺寸配置
const position = config.position || {
corner: "bottom-right" as const,
offsetX: 20,
offsetY: 20,
};
const size = config.size || { width: 280, height: 250 };
---
<div
id="live2d-widget"
class="live2d-widget"
style={`
width: ${size.width}px;
height: ${size.height}px;
${position.corner?.includes("right") ? "right" : "left"}: ${position.offsetX}px;
${position.corner?.includes("top") ? "top" : "bottom"}: ${position.offsetY}px;
`}
>
<canvas id="live2d-canvas" width={size.width} height={size.height}></canvas>
</div>
<!-- 引入消息框组件 -->
<MessageBox />
<script is:inline define:vars={{ config, modelPathUrl: url(config.model.path), sdkPath: url("/pio/static/live2d-sdk/live2d.min.js") }}>
let motionGroups = {};
let hitAreas = {};
let currentMotionGroup = "idle";
let currentMotionIndex = 0;
// Use loadlive2d function from the working project
function initLive2D() {
if (!window.loadlive2d) {
console.error("loadlive2d function not available");
return;
}
if (!config.model || !config.model.path) {
console.error("No model path configured");
return;
}
const modelPath = modelPathUrl; // 使用重命名后的变量
// Load model data first to get motion groups and hit areas
fetch(modelPath)
.then((response) => response.json())
.then((data) => {
motionGroups = data.motions || {};
hitAreas = data.hit_areas_custom || data.hit_areas || {};
console.log("Loaded model data:", {
motionGroups: Object.keys(motionGroups),
hitAreas: Object.keys(hitAreas),
});
// Load the model using loadlive2d
window.loadlive2d("live2d-canvas", modelPath);
// Setup interactions after a delay to ensure model is loaded
setTimeout(() => {
setupInteractions();
}, 2000);
})
.catch((error) => {
console.error("Failed to load model data:", error);
});
}
// Setup click interactions and drag functionality
function setupInteractions() {
const canvas = document.getElementById("live2d-canvas");
const container = document.getElementById("live2d-widget");
if (!canvas || !container) return;
canvas.addEventListener("click", handleClick);
// 添加拖拽功能
let isDragging = false;
let dragStart = { x: 0, y: 0 };
let containerStart = { x: 0, y: 0 };
// 鼠标事件
container.addEventListener("mousedown", (e) => {
if (e.button !== 0) return; // 只响应左键
isDragging = true;
dragStart = { x: e.clientX, y: e.clientY };
const rect = container.getBoundingClientRect();
containerStart = { x: rect.left, y: rect.top };
container.style.cursor = "grabbing";
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
const newX = containerStart.x + deltaX;
const newY = containerStart.y + deltaY;
// 边界检查
const maxX = window.innerWidth - container.offsetWidth;
const maxY = window.innerHeight - container.offsetHeight;
const clampedX = Math.max(0, Math.min(newX, maxX));
const clampedY = Math.max(0, Math.min(newY, maxY));
container.style.left = clampedX + "px";
container.style.right = "auto";
container.style.top = clampedY + "px";
container.style.bottom = "auto";
});
document.addEventListener("mouseup", () => {
if (isDragging) {
isDragging = false;
container.style.cursor = "grab";
}
});
// 触摸事件(移动端支持)
container.addEventListener("touchstart", (e) => {
if (e.touches.length !== 1) return;
isDragging = true;
const touch = e.touches[0];
dragStart = { x: touch.clientX, y: touch.clientY };
const rect = container.getBoundingClientRect();
containerStart = { x: rect.left, y: rect.top };
e.preventDefault();
});
document.addEventListener("touchmove", (e) => {
if (!isDragging || e.touches.length !== 1) return;
const touch = e.touches[0];
const deltaX = touch.clientX - dragStart.x;
const deltaY = touch.clientY - dragStart.y;
const newX = containerStart.x + deltaX;
const newY = containerStart.y + deltaY;
// 边界检查
const maxX = window.innerWidth - container.offsetWidth;
const maxY = window.innerHeight - container.offsetHeight;
const clampedX = Math.max(0, Math.min(newX, maxX));
const clampedY = Math.max(0, Math.min(newY, maxY));
container.style.left = clampedX + "px";
container.style.right = "auto";
container.style.top = clampedY + "px";
container.style.bottom = "auto";
e.preventDefault();
});
document.addEventListener("touchend", () => {
isDragging = false;
});
// 设置初始光标样式
container.style.cursor = "grab";
// 窗口大小变化时重新检查边界
window.addEventListener("resize", () => {
const rect = container.getBoundingClientRect();
const maxX = window.innerWidth - container.offsetWidth;
const maxY = window.innerHeight - container.offsetHeight;
if (rect.left > maxX) {
container.style.left = maxX + "px";
container.style.right = "auto";
}
if (rect.top > maxY) {
container.style.top = maxY + "px";
container.style.bottom = "auto";
}
});
console.log("Live2D interactions and drag functionality setup complete");
}
// Handle click events
function handleClick(event) {
if (!motionGroups || Object.keys(motionGroups).length === 0) {
console.log("No motion groups available");
return;
}
const rect = event.target.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Convert to normalized coordinates
const normalizedX = (x / rect.width) * 2 - 1;
const normalizedY = -((y / rect.height) * 2 - 1);
console.log("Click at:", { x: normalizedX, y: normalizedY });
// Determine which motion to play based on hit areas
let motionGroup = "tap_body"; // default
// Check head area
if (hitAreas.head_x && hitAreas.head_y) {
const [headXMin, headXMax] = hitAreas.head_x;
const [headYMin, headYMax] = hitAreas.head_y;
if (
normalizedX >= headXMin &&
normalizedX <= headXMax &&
normalizedY >= headYMin &&
normalizedY <= headYMax
) {
motionGroup = "flick_head";
console.log("Head area clicked - playing flick_head motion");
}
}
// Check body area (if not head)
if (motionGroup === "tap_body" && hitAreas.body_x && hitAreas.body_y) {
const [bodyXMin, bodyXMax] = hitAreas.body_x;
const [bodyYMin, bodyYMax] = hitAreas.body_y;
if (
normalizedX >= bodyXMin &&
normalizedX <= bodyXMax &&
normalizedY >= bodyYMin &&
normalizedY <= bodyYMax
) {
console.log("Body area clicked - playing tap_body motion");
}
}
// Play motion
playMotion(motionGroup);
// Show message
showMessage();
}
// 消息框功能已移至公共组件 MessageBox.astro
// Show random message using the common MessageBox component
function showMessage() {
const messages = config.interactive?.clickMessages || [
"你好!伊利雅~",
"有什么需要帮助的吗?",
"今天天气真不错呢!",
"要不要一起玩游戏?",
"记得按时休息哦!",
];
const randomMessage = messages[Math.floor(Math.random() * messages.length)];
// 使用公共消息框组件
if (window.showModelMessage) {
window.showModelMessage(randomMessage, {
containerId: "live2d-widget",
displayTime: config.interactive?.messageDisplayTime || 3000
});
}
}
// Play motion from a specific group
function playMotion(groupName) {
if (!motionGroups[groupName] || motionGroups[groupName].length === 0) {
console.log(`No motions available for group: ${groupName}`);
// Fallback to any available motion group
const availableGroups = Object.keys(motionGroups).filter(
(key) => motionGroups[key].length > 0
);
if (availableGroups.length > 0) {
groupName = availableGroups[0];
console.log(`Using fallback group: ${groupName}`);
} else {
return;
}
}
const motions = motionGroups[groupName];
let motionIndex;
if (groupName === currentMotionGroup) {
// Cycle through motions in the same group
currentMotionIndex = (currentMotionIndex + 1) % motions.length;
motionIndex = currentMotionIndex;
} else {
// Random motion from new group
motionIndex = Math.floor(Math.random() * motions.length);
currentMotionIndex = motionIndex;
}
currentMotionGroup = groupName;
console.log(`Playing motion ${motionIndex} from group ${groupName}`);
// Trigger motion change by reloading model with different parameters
// This is a workaround since we can't directly control motions in loadlive2d
const canvas = document.getElementById("live2d-canvas");
if (canvas && window.loadlive2d) {
// Add motion info to canvas data for potential future use
canvas.dataset.currentMotionGroup = groupName;
canvas.dataset.currentMotionIndex = motionIndex;
// Trigger a visual feedback
canvas.style.transform = "scale(1.05)";
setTimeout(() => {
canvas.style.transform = "scale(1)";
}, 150);
}
}
// Load Live2D and initialize
function loadLive2DSDK() {
// 检查移动端显示设置,如果隐藏则不加载运行时
if (
config.responsive?.hideOnMobile &&
window.innerWidth <= (config.responsive.mobileBreakpoint || 768)
) {
console.log("📱 Mobile device detected, skipping Live2D model initialization");
const widget = document.getElementById("live2d-widget");
if (widget) widget.style.display = "none";
return;
}
// Check if Live2D SDK is already loaded
if (window.loadlive2d) {
initLive2D();
return;
}
// Load Live2D SDK
const script = document.createElement("script");
script.src = sdkPath;
script.onload = () => {
// Wait a bit for the SDK to initialize
setTimeout(() => {
if (window.loadlive2d) {
initLive2D();
} else {
console.error("loadlive2d function not found after loading SDK");
}
}, 100);
};
script.onerror = () => {
console.error("Failed to load Live2D SDK");
};
document.head.appendChild(script);
}
// Handle responsive display
function handleResponsive() {
const widget = document.getElementById("live2d-widget");
if (!widget) return;
const responsive = config.responsive;
if (responsive?.hideOnMobile) {
const breakpoint = responsive.mobileBreakpoint || 768;
if (window.innerWidth <= breakpoint) {
widget.style.display = "none";
} else {
widget.style.display = "block";
}
}
}
// Initialize when ready (only once)
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
// 检查是否已经初始化
if (!window.live2dModelInitialized) {
loadLive2DSDK();
window.live2dModelInitialized = true;
}
handleResponsive();
});
} else {
// 检查是否已经初始化
if (!window.live2dModelInitialized) {
loadLive2DSDK();
window.live2dModelInitialized = true;
}
handleResponsive();
}
// Handle window resize for responsive behavior
window.addEventListener("resize", handleResponsive);
// 监听 Swup 页面切换事件(如果使用了 Swup
if (typeof window.swup !== "undefined" && window.swup.hooks) {
window.swup.hooks.on("content:replace", () => {
// 只更新响应式显示,不重新创建模型
setTimeout(() => {
handleResponsive();
}, 100);
});
} else {
// 如果 Swup 还未加载,监听启用事件
document.addEventListener("swup:enable", () => {
if (window.swup && window.swup.hooks) {
window.swup.hooks.on("content:replace", () => {
setTimeout(() => {
handleResponsive();
}, 100);
});
}
});
}
</script>
<style>
.live2d-widget {
position: fixed;
z-index: 999;
pointer-events: auto; /* 启用指针事件以支持拖拽 */
cursor: grab; /* 默认显示拖拽光标 */
}
#live2d-canvas {
pointer-events: auto;
cursor: pointer;
transition: all 0.3s ease;
width: 100%;
height: 100%;
}
.live2d-widget:hover #live2d-canvas {
opacity: 1;
}
/* 拖拽时的光标样式 */
.live2d-widget:active {
cursor: grabbing;
}
</style>

View File

@@ -0,0 +1,547 @@
---
import { musicPlayerConfig } from "@/config/musicConfig";
import { url } from "@/utils/url-utils";
const config = musicPlayerConfig;
// 预先生成本地资源路径,确保在非根目录部署时也能正确加载
const aplayerCssPath = url("/assets/css/APlayer.min.css");
const aplayerCustomCssPath = url("/assets/css/APlayer.custom.css");
const aplayerJsPath = url("/assets/js/APlayer.min.js");
// MetingJS 路径处理
// 如果配置的是相对路径(以 / 开头),使用 url() 处理以确保非根目录部署时正确
// 如果是完整的 URLhttp/https直接使用
const metingJsPath = config.meting?.jsPath
? config.meting.jsPath.startsWith("http://") ||
config.meting.jsPath.startsWith("https://")
? config.meting.jsPath
: url(config.meting.jsPath)
: "https://cdn.jsdelivr.net/npm/meting@2/dist/Meting.min.js"; // 默认 CDN 路径
// 预处理本地音乐列表的路径(如果使用本地模式)
const processedLocalPlaylist =
config.mode === "local" && config.local?.playlist
? config.local.playlist.map((song) => {
// 辅助函数:判断是否为完整 URL
const isFullUrl = (path: string): boolean => {
return /^https?:\/\//.test(path);
};
return {
...song,
// 仅对相对路径使用 url() 处理,完整 URL 直接使用
url: isFullUrl(song.url) ? song.url : url(song.url),
cover: song.cover
? isFullUrl(song.cover)
? song.cover
: url(song.cover)
: undefined,
};
})
: null;
---
{config.enable && (
<>
<!-- APlayer CSS -->
<link
rel="stylesheet"
href={aplayerCssPath}
/>
<link
rel="stylesheet"
href={aplayerCustomCssPath}
/>
<!-- 音乐播放器容器 -->
<div
id="aplayer-container"
class:mobile-hide={config.responsive?.mobile?.hide}
>
{config.mode === "meting" && config.meting ? (
<!-- 使用 MetingJS -->
<meting-js
server={config.meting.server || "netease"}
type={config.meting.type || "playlist"}
id={config.meting.id || ""}
api={config.meting.api}
auth={config.meting.auth}
fixed={(config.player?.fixed ?? true) ? "true" : "false"}
mini={(config.player?.mini ?? true) ? "true" : "false"}
autoplay={config.player?.autoplay ? "true" : "false"}
theme={config.player?.theme || "#b7daff"}
loop={config.player?.loop || "all"}
order={config.player?.order || "list"}
preload={config.player?.preload || "auto"}
volume={String(config.player?.volume ?? 0.7)}
mutex={config.player?.mutex !== false ? "true" : "false"}
list-folded={config.player?.listFolded ? "true" : "false"}
list-max-height={config.player?.listMaxHeight || "340px"}
storage-name={config.player?.storageName || "aplayer-setting"}
/>
) : config.mode === "local" && processedLocalPlaylist && processedLocalPlaylist.length > 0 ? (
<!-- 使用本地音乐列表,统一使用 APlayer 直接初始化 -->
<div id="local-aplayer"></div>
) : null}
</div>
</>
)}
<script define:vars={{ config, aplayerJsPath, metingJsPath, processedLocalPlaylist }} is:inline>
// 动态加载 APlayer 和 MetingJS
(function() {
if (!config.enable) return;
// 确保在浏览器环境中运行
if (typeof window === 'undefined') return;
// 加载 APlayer JS
function loadAPlayer() {
return new Promise((resolve) => {
if (window.APlayer) {
resolve(window.APlayer);
return;
}
const aplayerScript = document.createElement("script");
aplayerScript.src = aplayerJsPath;
aplayerScript.async = true;
aplayerScript.onload = () => resolve(window.APlayer);
aplayerScript.onerror = () => resolve(null);
document.head.appendChild(aplayerScript);
});
}
// 全局用户交互状态
if (!window.hasMusicInteracted) {
window.hasMusicInteracted = false;
}
// 全局播放器实例存储(防止页面切换时重复创建)
if (!window.__globalAPlayer) {
window.__globalAPlayer = null;
}
function tryAutoplay(aplayer) {
if (!config.player?.autoplay) return;
// 如果用户已经交互过,立即尝试播放
if (window.hasMusicInteracted) {
if (aplayer?.paused) {
aplayer.play().catch(() => {});
}
} else {
// 等待用户第一次交互后自动播放
const enableAutoplay = () => {
if (window.hasMusicInteracted) return;
window.hasMusicInteracted = true;
if (aplayer?.paused) {
aplayer.play().catch(() => {});
}
};
// 监听各种用户交互事件(只监听一次)
['click', 'keydown', 'touchstart', 'scroll'].forEach(eventType => {
document.addEventListener(eventType, enableAutoplay, { once: true, passive: true });
});
}
}
// 检查 MetingJS 元素是否成功加载了音乐
function checkMetingSuccess(metingElement, timeout = 5000) {
return new Promise((resolve) => {
const startTime = Date.now();
const checkSuccess = setInterval(() => {
const aplayer = metingElement?.aplayer;
// 检查是否有 APlayer 实例且有音频列表
if (aplayer && aplayer.list && aplayer.list.audios && aplayer.list.audios.length > 0) {
clearInterval(checkSuccess);
resolve(true);
return;
}
// 超时检查
if (Date.now() - startTime >= timeout) {
clearInterval(checkSuccess);
resolve(false);
}
}, 100);
});
}
// 重新创建 MetingJS 元素以使用新的 API
function recreateMetingElement(container, apiUrl, config) {
// 移除旧的元素
const oldElement = container.querySelector('meting-js');
if (oldElement) {
if (oldElement.aplayer) {
oldElement.aplayer.destroy();
}
oldElement.remove();
}
// 创建新元素
const newElement = document.createElement('meting-js');
newElement.setAttribute('server', config.meting?.server || 'netease');
newElement.setAttribute('type', config.meting?.type || 'playlist');
newElement.setAttribute('id', config.meting?.id || '');
newElement.setAttribute('api', apiUrl);
if (config.meting?.auth) {
newElement.setAttribute('auth', config.meting.auth);
}
newElement.setAttribute('fixed', (config.player?.fixed ?? true) ? 'true' : 'false');
newElement.setAttribute('mini', (config.player?.mini ?? true) ? 'true' : 'false');
newElement.setAttribute('autoplay', config.player?.autoplay ? 'true' : 'false');
newElement.setAttribute('theme', config.player?.theme || '#b7daff');
newElement.setAttribute('loop', config.player?.loop || 'all');
newElement.setAttribute('order', config.player?.order || 'list');
newElement.setAttribute('preload', config.player?.preload || 'auto');
newElement.setAttribute('volume', String(config.player?.volume ?? 0.7));
newElement.setAttribute('mutex', config.player?.mutex !== false ? 'true' : 'false');
newElement.setAttribute('list-folded', config.player?.listFolded ? 'true' : 'false');
newElement.setAttribute('list-max-height', config.player?.listMaxHeight || '340px');
newElement.setAttribute('storage-name', config.player?.storageName || 'aplayer-setting');
container.appendChild(newElement);
return newElement;
}
// 处理移动端显示/隐藏(统一处理容器和播放器)
function handleMobileVisibility(aplayer) {
const container = document.getElementById('aplayer-container');
const shouldHide = config.responsive?.mobile?.hide === true;
const breakpoint = config.responsive?.mobile?.breakpoint || 768;
const isMobile = window.innerWidth <= breakpoint;
// 处理容器
if (container) {
if (shouldHide && isMobile) {
container.style.display = 'none';
container.classList.add('mobile-hide');
} else {
container.style.display = '';
container.classList.remove('mobile-hide');
}
}
// 处理播放器(如果已初始化)
if (aplayer?.container) {
if (shouldHide && isMobile) {
aplayer.container.style.display = 'none';
aplayer.container.classList.add('mobile-hide');
} else {
aplayer.container.style.display = '';
aplayer.container.classList.remove('mobile-hide');
}
}
}
// 播放器初始化完成后的通用设置
function setupPlayerAfterInit(aplayer, isRestore = false) {
if (!aplayer?.container) return;
// 监听播放状态,更新封面动画
const updatePlayingState = () => {
const isPlaying = !aplayer.paused;
aplayer.container.setAttribute('data-playing', String(isPlaying));
aplayer.container.classList.toggle('aplayer-playing', isPlaying);
};
// 只在首次初始化时绑定事件(避免重复绑定)
if (!isRestore) {
aplayer.audio?.addEventListener('play', updatePlayingState);
aplayer.audio?.addEventListener('pause', updatePlayingState);
aplayer.audio?.addEventListener('ended', updatePlayingState);
}
// 立即设置右侧定位
aplayer.container.setAttribute('data-positioned', 'right');
requestAnimationFrame(() => {
aplayer.container.style.right = '0';
aplayer.container.style.left = 'unset';
updatePlayingState();
// 处理移动端显示/隐藏(页面恢复时也需要重新处理)
handleMobileVisibility(aplayer);
// 只在首次初始化时绑定 resize 事件
if (!isRestore) {
window.addEventListener('resize', () => handleMobileVisibility(aplayer));
}
// 延迟后恢复展开动画
setTimeout(() => {
aplayer.container.setAttribute('data-initialized', 'true');
// 隐藏歌词(如果配置)
if (config.player?.lrcHidden && aplayer.lrc) {
aplayer.lrc.hide();
const lrcButton = aplayer.container.querySelector('.aplayer-icon-lrc');
lrcButton?.classList.add('aplayer-icon-lrc-inactivity');
}
// 尝试自动播放(只在首次初始化时)
if (!isRestore && config.player?.autoplay && aplayer.paused) {
tryAutoplay(aplayer);
}
}, 100);
});
}
// 初始化 MetingJS 元素并设置播放器
function setupMetingElement(metingElement) {
return new Promise((resolve) => {
// 监听 aplayer 初始化
const checkAPlayer = setInterval(() => {
const aplayer = metingElement.aplayer;
if (aplayer?.container) {
clearInterval(checkAPlayer);
setupPlayerAfterInit(aplayer);
resolve(true);
}
}, 50);
// 10秒后停止检查
setTimeout(() => {
clearInterval(checkAPlayer);
resolve(false);
}, 10000);
});
}
// 使用备用 API 重试加载
async function retryWithFallbackAPI(container, fallbackApis, currentIndex = 0) {
if (currentIndex >= fallbackApis.length) {
return null;
}
const fallbackApi = fallbackApis[currentIndex];
// 替换 API URL 中的占位符
const server = config.meting?.server || 'netease';
const type = config.meting?.type || 'playlist';
const id = config.meting?.id || '';
let apiUrl = fallbackApi
.replace(':server', server)
.replace(':type', type)
.replace(':id', id);
// 设置全局 API
window.meting_api = apiUrl;
// 重新创建元素
const newElement = recreateMetingElement(container, apiUrl, config);
// 等待元素初始化并检查是否成功
await new Promise(resolve => setTimeout(resolve, 500));
const success = await checkMetingSuccess(newElement, 5000);
if (success) {
return newElement;
} else {
// 如果失败,尝试下一个备用 API
return retryWithFallbackAPI(container, fallbackApis, currentIndex + 1);
}
}
// 加载 MetingJS
function loadMetingJS() {
return new Promise((resolve) => {
if (window.customElements?.get("meting-js")) {
resolve(true);
return;
}
const metingScript = document.createElement("script");
metingScript.src = metingJsPath;
metingScript.async = true;
metingScript.onload = async () => {
// 等待 MetingJS 元素创建
await new Promise(resolve => setTimeout(resolve, 100));
const container = document.getElementById('aplayer-container');
if (!container) {
resolve(false);
return;
}
let metingElement = container.querySelector('meting-js');
if (config.mode === "meting" && config.meting?.api && metingElement) {
const server = config.meting.server || 'netease';
const type = config.meting.type || 'playlist';
const id = config.meting.id || '';
// 替换 API URL 中的占位符
let mainApiUrl = config.meting.api
.replace(':server', server)
.replace(':type', type)
.replace(':id', id)
.replace(':r', Math.random().toString());
// 设置全局 API
window.meting_api = mainApiUrl;
// 等待元素初始化并检查主 API 是否成功
await new Promise(resolve => setTimeout(resolve, 500));
const mainApiSuccess = await checkMetingSuccess(metingElement, 5000);
if (!mainApiSuccess && config.meting.fallbackApis?.length > 0) {
// 主 API 失败,尝试备用 API
const newElement = await retryWithFallbackAPI(container, config.meting.fallbackApis);
if (newElement) {
metingElement = newElement;
}
}
}
// 设置播放器(如果元素存在)
if (metingElement) {
await setupMetingElement(metingElement);
}
resolve(true);
};
metingScript.onerror = () => {
resolve(false);
};
document.head.appendChild(metingScript);
});
}
// 初始化本地音乐播放器
async function initLocalPlayer() {
if (config.mode !== "local" || !processedLocalPlaylist || processedLocalPlaylist.length === 0) {
return;
}
// 如果全局已有播放器实例,检查是否还在 DOM 中
if (window.__globalAPlayer) {
const existingContainer = window.__globalAPlayer.container;
// 如果容器还在页面中,说明播放器还存在,直接恢复状态
if (existingContainer && document.body.contains(existingContainer)) {
setupPlayerAfterInit(window.__globalAPlayer);
return;
}
// 否则销毁旧实例
try {
window.__globalAPlayer.destroy();
} catch (e) {}
window.__globalAPlayer = null;
}
const APlayerClass = await loadAPlayer();
if (!APlayerClass) return;
const container = document.getElementById("local-aplayer");
if (!container) return;
const audioList = processedLocalPlaylist.map((song) => ({
name: song.name,
artist: song.artist,
url: song.url,
cover: song.cover,
lrc: song.lrc,
type: "auto",
}));
try {
const aplayer = new APlayerClass({
container,
audio: audioList,
mutex: config.player?.mutex !== false,
lrcType: config.player?.lrcType ?? 0,
fixed: config.player?.fixed ?? true,
mini: config.player?.mini ?? true,
autoplay: config.player?.autoplay || false,
theme: config.player?.theme || "#b7daff",
loop: config.player?.loop || "all",
order: config.player?.order || "list",
preload: config.player?.preload || "auto",
volume: config.player?.volume ?? 0.7,
listFolded: config.player?.listFolded || false,
listMaxHeight: config.player?.listMaxHeight || "340px",
storageName: config.player?.storageName || "aplayer-setting",
});
// 保存到全局
window.__globalAPlayer = aplayer;
setupPlayerAfterInit(aplayer);
} catch (error) {
// 初始化失败时静默处理
}
}
// 初始化容器的移动端显示/隐藏
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => handleMobileVisibility(null));
} else {
handleMobileVisibility(null);
}
// 监听 Astro 页面切换事件
document.addEventListener('astro:after-swap', () => {
// 页面切换后,如果有全局播放器实例,恢复其状态
if (window.__globalAPlayer && window.__globalAPlayer.container) {
setupPlayerAfterInit(window.__globalAPlayer, true);
}
});
// 根据模式初始化播放器
if (config.mode === "meting") {
// Meting 模式
loadAPlayer().then(() => loadMetingJS());
} else if (config.mode === "local") {
// 本地模式:统一使用 APlayer
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initLocalPlayer);
} else {
initLocalPlayer();
}
}
})();
</script>
<style>
#aplayer-container {
position: relative;
z-index: 1000;
}
/* 确保播放器在固定模式下正确显示 */
#aplayer-container .aplayer-fixed {
z-index: 9999;
}
/* 禁用 APlayer 初始化时的过渡动画,避免收缩效果 */
#aplayer-container .aplayer.aplayer-fixed {
animation: none !important;
}
#aplayer-container .aplayer.aplayer-fixed .aplayer-body {
animation: none !important;
transition: none !important;
}
/* 确保播放器初始化时就在右侧位置 */
#aplayer-container .aplayer.aplayer-fixed[data-positioned="right"] {
right: 0 !important;
left: unset !important;
}
/* 移动端隐藏 - 通过 JavaScript 动态添加类 */
.aplayer-fixed.mobile-hide {
display: none !important;
}
/* 移动端隐藏容器 */
#aplayer-container.mobile-hide {
display: none !important;
}
</style>

View File

@@ -0,0 +1,309 @@
---
// 消息框公共组件
// 用于Live2D和Spine模型的消息显示
---
<script>
// 全局变量,跟踪当前显示的消息容器和隐藏定时器
let currentMessageContainer: HTMLDivElement | null = null;
let hideMessageTimer: number | null = null;
// 消息显示函数
export function showMessage(message: string, options: { containerId?: string; displayTime?: number } = {}) {
// 防止空消息或重复调用
if (!message || !message.trim()) {
return;
}
// 立即清除之前的消息
if (currentMessageContainer) {
if (hideMessageTimer !== null) {
clearTimeout(hideMessageTimer);
}
if (currentMessageContainer.parentNode) {
currentMessageContainer.parentNode.removeChild(currentMessageContainer);
}
currentMessageContainer = null;
}
// 确保DOM中没有残留的消息容器
const existingMessages = document.querySelectorAll(
".model-message-container"
);
existingMessages.forEach((msg) => {
if (msg.parentNode) {
msg.parentNode.removeChild(msg);
}
});
// 检测暗色主题
const isDarkMode =
document.documentElement.classList.contains("dark") ||
window.matchMedia("(prefers-color-scheme: dark)").matches;
// 创建消息容器
const messageContainer = document.createElement("div");
messageContainer.className = "model-message-container";
// 创建消息元素
const messageEl = document.createElement("div");
messageEl.className = "model-message";
messageEl.textContent = message;
// 创建箭头元素
const arrowEl = document.createElement("div");
arrowEl.className = "model-message-arrow";
// 设置容器样式
Object.assign(messageContainer.style, {
position: "fixed",
zIndex: "1001",
pointerEvents: "none",
opacity: "0",
transform: "translateY(15px) translateX(-50%) scale(0.9)",
transition: "all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)",
});
// 设置消息框美化样式(支持暗色主题)
const messageStyles = {
position: "relative",
background: isDarkMode
? "linear-gradient(135deg, rgba(45, 55, 72, 0.95), rgba(26, 32, 44, 0.9))"
: "linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(240, 248, 255, 0.9))",
color: isDarkMode ? "#e2e8f0" : "#2c3e50",
padding: "12px 16px",
borderRadius: "16px",
fontSize: "14px",
fontWeight: "500",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
maxWidth: "240px",
minWidth: "100px",
wordWrap: "break-word",
textAlign: "center",
whiteSpace: "pre-wrap",
boxShadow: isDarkMode
? "0 8px 32px rgba(0, 0, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.2)"
: "0 8px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08)",
border: isDarkMode
? "1px solid rgba(255, 255, 255, 0.1)"
: "1px solid rgba(255, 255, 255, 0.6)",
backdropFilter: "blur(12px)",
letterSpacing: "0.3px",
lineHeight: "1.4",
};
Object.assign(messageEl.style, messageStyles);
// 设置箭头样式(居中显示)
Object.assign(arrowEl.style, {
position: "absolute",
top: "100%",
left: "50%",
transform: "translateX(-50%)", // 箭头居中
width: "0",
height: "0",
borderLeft: "8px solid transparent",
borderRight: "8px solid transparent",
borderTop: isDarkMode
? "8px solid rgba(45, 55, 72, 0.95)"
: "8px solid rgba(255, 255, 255, 0.95)",
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1))",
});
// 组装消息框元素
messageEl.appendChild(arrowEl);
messageContainer.appendChild(messageEl);
// 添加到页面并保存引用
document.body.appendChild(messageContainer);
currentMessageContainer = messageContainer;
// 将消息显示在模型头顶居中
const container = document.getElementById(options.containerId || "model-container");
if (container) {
const rect = container.getBoundingClientRect();
// 消息框居中显示在模型上方
const containerCenterX = rect.left + rect.width / 2;
// 使用估算的消息框尺寸进行初步定位
const estimatedMessageWidth = 240; // 使用maxWidth作为估算
const estimatedMessageHeight = 60; // 估算高度
const screenPadding = 10; // 距离屏幕边缘的最小距离
// 计算消息框的实际位置考虑translateX(-50%)的影响)
let messageX = containerCenterX;
let messageY = rect.top - estimatedMessageHeight - 25; // 距离模型顶部25px
// 屏幕边界检查 - 水平方向
const minX = screenPadding + estimatedMessageWidth / 2; // 考虑translateX(-50%)
const maxX =
window.innerWidth - screenPadding - estimatedMessageWidth / 2;
if (messageX < minX) {
messageX = minX;
} else if (messageX > maxX) {
messageX = maxX;
}
// 屏幕边界检查 - 垂直方向
const minY = screenPadding;
const maxY = window.innerHeight - estimatedMessageHeight - screenPadding;
if (messageY < minY) {
// 如果上方空间不够,显示在模型下方
messageY = rect.bottom + 25;
// 调整箭头方向(显示在下方)
arrowEl.style.top = "0";
arrowEl.style.bottom = "auto";
arrowEl.style.borderTop = "none";
arrowEl.style.borderBottom = isDarkMode
? "8px solid rgba(45, 55, 72, 0.95)"
: "8px solid rgba(255, 255, 255, 0.95)";
} else if (messageY > maxY) {
messageY = maxY;
}
// 设置位置
messageContainer.style.left = messageX + "px";
messageContainer.style.top = messageY + "px";
// 在消息框渲染后,进行精确的边界调整
setTimeout(() => {
const actualMessageRect = messageContainer.getBoundingClientRect();
const actualWidth = actualMessageRect.width;
const actualHeight = actualMessageRect.height;
// 重新计算水平位置
let adjustedX = containerCenterX;
const actualMinX = screenPadding + actualWidth / 2;
const actualMaxX = window.innerWidth - screenPadding - actualWidth / 2;
if (adjustedX < actualMinX) {
adjustedX = actualMinX;
} else if (adjustedX > actualMaxX) {
adjustedX = actualMaxX;
}
// 重新计算垂直位置
let adjustedY = rect.top - actualHeight - 25;
const actualMinY = screenPadding;
const actualMaxY = window.innerHeight - actualHeight - screenPadding;
let isAboveModel = true; // 标记消息框是否在模型上方
if (adjustedY < actualMinY) {
adjustedY = rect.bottom + 25;
isAboveModel = false;
} else if (adjustedY > actualMaxY) {
adjustedY = actualMaxY;
}
// 计算箭头应该指向的位置(模型中心)
const modelCenterX = rect.left + rect.width / 2;
const messageCenterX = adjustedX; // 消息框中心位置
const arrowOffsetX = modelCenterX - messageCenterX; // 箭头相对于消息框中心的偏移
// 限制箭头偏移范围,避免超出消息框边界
const maxOffset = actualWidth / 2 - 20; // 留出20px边距
const clampedOffsetX = Math.max(
-maxOffset,
Math.min(maxOffset, arrowOffsetX)
);
// 根据最终位置调整箭头方向和位置
if (isAboveModel) {
// 消息框在模型上方,箭头向下
Object.assign(arrowEl.style, {
position: "absolute",
top: "100%",
left: "50%",
bottom: "auto",
transform: `translateX(calc(-50% + ${clampedOffsetX}px))`,
width: "0",
height: "0",
borderLeft: "8px solid transparent",
borderRight: "8px solid transparent",
borderTop: isDarkMode
? "8px solid rgba(45, 55, 72, 0.95)"
: "8px solid rgba(255, 255, 255, 0.95)",
borderBottom: "none",
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1))",
});
} else {
// 消息框在模型下方,箭头向上
Object.assign(arrowEl.style, {
position: "absolute",
top: "0",
left: "50%",
bottom: "auto",
transform: `translateX(calc(-50% + ${clampedOffsetX}px))`,
width: "0",
height: "0",
borderLeft: "8px solid transparent",
borderRight: "8px solid transparent",
borderTop: "none",
borderBottom: isDarkMode
? "8px solid rgba(45, 55, 72, 0.95)"
: "8px solid rgba(255, 255, 255, 0.95)",
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1))",
});
}
// 应用调整后的位置
messageContainer.style.left = adjustedX + "px";
messageContainer.style.top = adjustedY + "px";
}, 50); // 增加延迟确保消息框完全渲染
}
// 显示动画
setTimeout(() => {
messageContainer.style.opacity = "1";
messageContainer.style.transform =
"translateY(0) translateX(-50%) scale(1)";
}, 100); // 延迟到边界调整完成后
// 自动隐藏
const displayTime = options.displayTime || 3000;
hideMessageTimer = window.setTimeout(() => {
messageContainer.style.opacity = "0";
messageContainer.style.transform =
"translateY(-15px) translateX(-50%) scale(0.95)";
setTimeout(() => {
if (messageContainer.parentNode) {
messageContainer.parentNode.removeChild(messageContainer);
}
// 清除引用
if (currentMessageContainer === messageContainer) {
currentMessageContainer = null;
}
}, 400);
}, displayTime);
}
// 清理消息函数
export function clearMessage() {
if (currentMessageContainer) {
if (hideMessageTimer !== null) {
clearTimeout(hideMessageTimer);
}
if (currentMessageContainer.parentNode) {
currentMessageContainer.parentNode.removeChild(currentMessageContainer);
}
currentMessageContainer = null;
}
// 清理所有消息容器
const existingMessages = document.querySelectorAll(
".model-message-container"
);
existingMessages.forEach((msg) => {
if (msg.parentNode) {
msg.parentNode.removeChild(msg);
}
});
}
// 将函数暴露到全局作用域
(window as any).showModelMessage = showMessage;
(window as any).clearModelMessage = clearMessage;
</script>

View File

@@ -0,0 +1,122 @@
---
import type { MarkdownHeading } from "astro";
import TOCStyles from "@/components/common/styles/TOCStyles.astro";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import WidgetLayout from "./WidgetLayout.astro";
interface Props {
headings: MarkdownHeading[];
class?: string;
style?: string;
}
const { headings: _headings = [], class: className, style } = Astro.props;
---
<WidgetLayout
name={i18n(I18nKey.tableOfContents)}
id="sidebar-toc"
class={className}
style={style}
>
<div class="toc-scroll-container">
<div
class="toc-content"
id="sidebar-toc-content"
>
<!-- TOC内容将由JavaScript动态生成 -->
</div>
</div>
</WidgetLayout>
<TOCStyles />
<style lang="stylus">
/* SidebarTOC 特定样式 */
.toc-scroll-container
max-height: calc(100vh - 20rem)
</style>
<script>
import { TOCManager } from "@/utils/tocUtils";
if (typeof window.SidebarTOC === "undefined") {
window.SidebarTOC = {
manager: null
};
}
async function initSidebarTOC() {
const tocContent = document.getElementById("sidebar-toc-content");
if (!tocContent) return;
// 检查是否为文章页面
const isCurrentlyPostPage = window.location.pathname.includes("/posts/");
const sidebarTocWidget = document.getElementById("sidebar-toc");
if (!isCurrentlyPostPage) {
// 非文章页,隐藏侧边栏目录
if (sidebarTocWidget) {
sidebarTocWidget.style.display = 'none';
}
return;
} else {
// 文章页,显示侧边栏目录
if (sidebarTocWidget) {
sidebarTocWidget.style.display = '';
}
}
try {
// 清理旧实例
if (window.SidebarTOC.manager) {
window.SidebarTOC.manager.cleanup();
}
// 创建新实例
window.SidebarTOC.manager = new TOCManager({
contentId: "sidebar-toc-content",
indicatorId: "sidebar-active-indicator",
maxLevel: 3,
scrollOffset: 80,
});
// 初始化
window.SidebarTOC.manager.init();
} catch (error) {
console.error("Failed to load TOCManager for SidebarTOC:", error);
}
}
// 初始化
// 确保 DOM 准备好后再执行
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initSidebarTOC);
} else {
initSidebarTOC();
}
// 页面切换时重新初始化
document.addEventListener("swup:contentReplaced", () => {
setTimeout(initSidebarTOC, 100);
});
// Astro 页面切换事件
document.addEventListener("astro:page-load", () => {
setTimeout(initSidebarTOC, 100);
});
// 浏览器导航事件
window.addEventListener("popstate", () => {
setTimeout(initSidebarTOC, 200);
});
// 监听 hashchange但排除 TOC 内部导航)
window.addEventListener("hashchange", () => {
if (!window.tocInternalNavigation) {
setTimeout(initSidebarTOC, 100);
}
window.tocInternalNavigation = false;
});
</script>

View File

@@ -0,0 +1,180 @@
---
import { Icon } from "astro-icon/components";
import { siteConfig } from "@/config";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import {
getCategoryList,
getSortedPosts,
getTagList,
} from "@/utils/content-utils";
import WidgetLayout from "./WidgetLayout.astro";
interface Props {
class?: string;
style?: string;
}
const { class: className, style } = Astro.props;
// 从配置中获取站点开始日期
const siteStartDate = siteConfig.siteStartDate || "2025-01-01";
// 获取所有文章
const posts = await getSortedPosts();
const categories = await getCategoryList();
const tags = await getTagList();
// 计算总字数
let totalWords = 0;
for (const post of posts) {
if (post.body) {
// 移除 Markdown 空白字符后计算字数
const text = post.body
.replace(/\s+/g, " ") // 合并空白
.trim();
// 分别计算中文字符和英文单词
const chineseChars = text.match(/[\u4e00-\u9fa5]/g) || [];
const englishWords = text.match(/[a-zA-Z]+/g) || [];
totalWords += chineseChars.length + englishWords.length;
}
}
// 格式化数字(添加千位分隔符)
function formatNumber(num: number): string {
return num.toLocaleString();
}
// 获取最新文章日期(用于客户端计算)
const latestPost = posts.reduce((latest, post) => {
if (!latest) return post;
return post.data.published > latest.data.published ? post : latest;
}, posts[0]);
const lastPostDate = latestPost
? latestPost.data.published.toISOString()
: null;
const todayText = i18n(I18nKey.today);
const stats = [
{
icon: "material-symbols:article-outline",
label: i18n(I18nKey.siteStatsPostCount),
value: posts.length,
},
{
icon: "material-symbols:folder-outline",
label: i18n(I18nKey.siteStatsCategoryCount),
value: categories.length,
},
{
icon: "material-symbols:label-outline",
label: i18n(I18nKey.siteStatsTagCount),
value: tags.length,
},
{
icon: "material-symbols:text-ad-outline-rounded",
label: i18n(I18nKey.siteStatsTotalWords),
value: totalWords,
formatted: true,
},
{
icon: "material-symbols:calendar-clock-outline",
label: i18n(I18nKey.siteStatsRunningDays),
value: 0, // 将由客户端更新
suffix: i18n(I18nKey.siteStatsDays).replace("{days}", ""),
dynamic: true,
id: "running-days",
},
{
icon: "material-symbols:ecg-heart-outline",
label: i18n(I18nKey.siteStatsLastUpdate),
value: 0, // 将由客户端更新
suffix: i18n(I18nKey.siteStatsDaysAgo).replace("{days}", ""),
dynamic: true,
id: "last-update",
},
];
---
<WidgetLayout name={i18n(I18nKey.siteStats)} id="site-stats" class={className} style={style}>
<div class="flex flex-col gap-2">
{stats.map((stat) => (
<div class="flex items-center justify-between px-3 py-1.5">
<div class="flex items-center gap-2.5">
<div class="text-[var(--primary)] text-xl">
<Icon name={stat.icon} />
</div>
<span class="text-neutral-700 dark:text-neutral-300 font-medium text-sm">
{stat.label}
</span>
</div>
<div class="flex items-center">
<span
class="text-base font-bold text-neutral-900 dark:text-neutral-100"
data-stat-id={stat.id}
>
{stat.formatted ? formatNumber(stat.value) : stat.value}
</span>
{stat.suffix && (
<span class="text-sm text-neutral-500 dark:text-neutral-400 ml-1">
{stat.suffix}
</span>
)}
</div>
</div>
))}
</div>
</WidgetLayout>
<script is:inline define:vars={{ siteStartDate, lastPostDate, todayText }}>
function updateDynamicStats() {
const today = new Date();
// 更新运行天数
const startDate = new Date(siteStartDate);
const diffTime = Math.abs(today.getTime() - startDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
const runningDaysElement = document.querySelector('[data-stat-id="running-days"]');
if (runningDaysElement) {
runningDaysElement.textContent = diffDays.toString();
}
// 更新最后活动时间
if (lastPostDate) {
const lastPost = new Date(lastPostDate);
const timeSinceLastPost = Math.abs(today.getTime() - lastPost.getTime());
const daysSinceLastUpdate = Math.floor(timeSinceLastPost / (1000 * 60 * 60 * 24));
const lastUpdateElement = document.querySelector('[data-stat-id="last-update"]');
if (lastUpdateElement) {
if (daysSinceLastUpdate === 0) {
lastUpdateElement.textContent = todayText;
if (lastUpdateElement.nextElementSibling) {
lastUpdateElement.nextElementSibling.style.display = 'none';
}
} else {
lastUpdateElement.textContent = daysSinceLastUpdate.toString();
if (lastUpdateElement.nextElementSibling) {
lastUpdateElement.nextElementSibling.style.display = '';
}
}
}
}
}
// 页面加载时更新
updateDynamicStats();
// 每小时更新一次(可选,因为天数变化较慢)
setInterval(updateDynamicStats, 60 * 60 * 1000);
// 页面切换时重新更新
document.addEventListener("swup:contentReplaced", () => {
setTimeout(updateDynamicStats, 100);
});
</script>

View File

@@ -0,0 +1,399 @@
---
import { spineModelConfig } from "@/config/pioConfig";
import { url } from "@/utils/url-utils";
import MessageBox from "./PioMessageBox.astro";
---
<!-- Spine Web Player CSS 将在 script 中动态加载 -->{
spineModelConfig.enable && (
<div
id="spine-model-container"
style={`
position: fixed;
${spineModelConfig.position.corner.includes("right") ? "right" : "left"}: ${spineModelConfig.position.offsetX}px;
${spineModelConfig.position.corner.includes("top") ? "top" : "bottom"}: ${spineModelConfig.position.offsetY}px;
width: ${spineModelConfig.size.width}px;
height: ${spineModelConfig.size.height}px;
pointer-events: auto;
z-index: 1000;
`}
>
<div id="spine-player-container" style="width: 100%; height: 100%;" />
<div id="spine-error" style="display: none;" />
</div>
)
}
<!-- 引入消息框组件 -->
<MessageBox />
<script is:inline define:vars={{ spineModelConfig, modelPath: url(spineModelConfig.model.path), atlasPath: url(spineModelConfig.model.path.replace(".json", ".atlas")), cssPath: url("/pio/static/spine-player.min.css"), jsPath: url("/pio/static/spine-player.min.js") }}>
// 动态加载 Spine CSS带本地备用
function loadSpineCSS() {
if (!spineModelConfig.enable) return;
// 检查是否已经加载
const existingLink = document.querySelector('link[href*="spine-player"]');
if (existingLink) return;
// 首先尝试加载 CDN CSS
const cdnLink = document.createElement("link");
cdnLink.rel = "stylesheet";
cdnLink.href =
"https://unpkg.com/@esotericsoftware/spine-player@4.2.*/dist/spine-player.min.css";
// 监听加载失败事件,自动回退到本地文件
cdnLink.onerror = function () {
console.warn("⚠️ Spine CSS CDN failed, trying local fallback...");
// 移除失败的 CDN link
if (cdnLink.parentNode) {
cdnLink.parentNode.removeChild(cdnLink);
}
// 创建本地备用 CSS link
const localLink = document.createElement("link");
localLink.rel = "stylesheet";
localLink.href = cssPath;
localLink.onerror = function () {
console.error("❌ Failed to load Spine CSS");
};
document.head.appendChild(localLink);
};
document.head.appendChild(cdnLink);
}
// 消息框功能已移至公共组件 MessageBox.astro
let isClickProcessing = false; // 防止重复点击的标志
let lastClickTime = 0; // 记录最后一次点击时间
// 全局变量,防止重复初始化
window.spineModelInitialized = window.spineModelInitialized || false;
window.spinePlayerInstance = window.spinePlayerInstance || null;
// 消息显示函数 - 使用公共消息框组件
function showMessage(message) {
// 使用公共消息框组件
if (window.showModelMessage) {
window.showModelMessage(message, {
containerId: "spine-model-container",
displayTime: spineModelConfig.interactive.messageDisplayTime || 3000
});
}
}
// 更新响应式显示
function updateResponsiveDisplay() {
if (!spineModelConfig.enable) return;
const container = document.getElementById("spine-model-container");
if (!container) return;
// 检查移动端显示设置
if (
spineModelConfig.responsive.hideOnMobile &&
window.innerWidth <= spineModelConfig.responsive.mobileBreakpoint
) {
container.style.display = "none";
} else {
container.style.display = "block";
}
}
// 清理函数
function cleanupSpineModel() {
console.log("🧹 Cleaning up existing Spine model...");
// 清理消息显示(使用公共组件)
if (window.clearModelMessage) {
window.clearModelMessage();
}
// 清理现有的播放器实例
if (window.spinePlayerInstance) {
try {
if (window.spinePlayerInstance.dispose) {
window.spinePlayerInstance.dispose();
}
} catch (e) {
console.warn("Error disposing spine player:", e);
}
window.spinePlayerInstance = null;
}
// 清理容器内容
const playerContainer = document.getElementById("spine-player-container");
if (playerContainer) {
playerContainer.innerHTML = "";
}
// 重置初始化标志
window.spineModelInitialized = false;
}
async function initSpineModel() {
if (!spineModelConfig.enable) return;
// 检查移动端显示设置,如果隐藏则不加载运行时
if (
spineModelConfig.responsive.hideOnMobile &&
window.innerWidth <= spineModelConfig.responsive.mobileBreakpoint
) {
console.log("📱 Mobile device detected, skipping Spine model initialization");
const container = document.getElementById("spine-model-container");
if (container) container.style.display = "none";
return;
}
// 检查是否已经初始化
if (window.spineModelInitialized) {
console.log("⏭️ Spine model already initialized, skipping...");
return;
}
console.log("🎯 Initializing Spine Model...");
// 先清理可能存在的旧实例
cleanupSpineModel();
// 首先加载 CSS
loadSpineCSS();
// 加载 Spine Web Player 运行时
const loadSpineRuntime = () => {
return new Promise((resolve, reject) => {
if (typeof window.spine !== "undefined") {
console.log("✅ Spine runtime already loaded");
resolve();
return;
}
console.log("📦 Loading Spine runtime...");
const script = document.createElement("script");
script.src =
"https://unpkg.com/@esotericsoftware/spine-player@4.2.*/dist/iife/spine-player.min.js";
script.onload = () => {
console.log("✅ Spine runtime loaded from CDN");
resolve();
};
script.onerror = (_error) => {
console.warn("⚠️ CDN failed, trying local fallback...");
// 尝试本地回退
const fallbackScript = document.createElement("script");
fallbackScript.src = jsPath;
fallbackScript.onload = () => {
console.log("✅ Spine runtime loaded from local fallback");
resolve();
};
fallbackScript.onerror = () => {
reject(new Error("Failed to load Spine runtime"));
};
document.head.appendChild(fallbackScript);
};
document.head.appendChild(script);
});
};
// 等待 Spine 库加载
const waitForSpine = () => {
return new Promise((resolve, reject) => {
let attempts = 0;
const maxAttempts = 50;
const check = () => {
attempts++;
if (typeof window.spine !== "undefined" && window.spine.SpinePlayer) {
console.log("✅ Spine runtime loaded");
resolve();
} else if (attempts >= maxAttempts) {
reject(new Error("Spine runtime loading timeout"));
} else {
setTimeout(check, 100);
}
};
check();
});
};
try {
// 首先加载 Spine 运行时
await loadSpineRuntime();
// 然后等待 Spine 对象可用
await waitForSpine();
// 标记为已初始化
window.spineModelInitialized = true;
// 创建 SpinePlayer
new window.spine.SpinePlayer("spine-player-container", {
skeleton: modelPath,
atlas: atlasPath,
animation: "idle",
backgroundColor: "#00000000", // 透明背景
showControls: false, // 隐藏控件
alpha: true,
premultipliedAlpha: false,
success: (player) => {
console.log("🎉 Spine model loaded successfully!");
// 保存播放器实例引用
window.spinePlayerInstance = player;
// 初始化完成后设置默认姿态
setTimeout(() => {
if (player.skeleton) {
try {
player.skeleton.updateWorldTransform();
player.skeleton.setToSetupPose();
} catch (e) {
console.warn("Error positioning skeleton:", e);
}
}
}, 500);
// 设置交互功能
if (spineModelConfig.interactive.enabled) {
const canvas = document.querySelector(
"#spine-player-container canvas"
);
if (canvas) {
canvas.addEventListener("click", () => {
// 防抖处理:防止重复点击
const currentTime = Date.now();
if (isClickProcessing || currentTime - lastClickTime < 500) {
return; // 500ms 内重复点击忽略
}
isClickProcessing = true;
lastClickTime = currentTime;
// 随机播放点击动画
const clickAnims =
spineModelConfig.interactive.clickAnimations ||
(spineModelConfig.interactive.clickAnimation
? [spineModelConfig.interactive.clickAnimation]
: []);
if (clickAnims.length > 0) {
try {
const randomClickAnim =
clickAnims[Math.floor(Math.random() * clickAnims.length)];
player.setAnimation(randomClickAnim, false);
// 动画播放完成后回到待机状态
setTimeout(() => {
const idleAnims =
spineModelConfig.interactive.idleAnimations;
const randomIdle =
idleAnims[Math.floor(Math.random() * idleAnims.length)];
player.setAnimation(randomIdle, true);
}, 2000);
} catch (e) {
console.warn("Failed to play click animation:", e);
}
}
// 显示随机消息
const messages = spineModelConfig.interactive.clickMessages;
if (messages && messages.length > 0) {
const randomMessage =
messages[Math.floor(Math.random() * messages.length)];
showMessage(randomMessage);
}
// 500ms 后重置防抖标志
setTimeout(() => {
isClickProcessing = false;
}, 500);
});
// 设置待机动画循环
if (spineModelConfig.interactive.idleAnimations.length > 1) {
setInterval(() => {
try {
const idleAnims =
spineModelConfig.interactive.idleAnimations;
const randomIdle =
idleAnims[Math.floor(Math.random() * idleAnims.length)];
player.setAnimation(randomIdle, true);
} catch (e) {
console.warn("Failed to play idle animation:", e);
}
}, spineModelConfig.interactive.idleInterval);
}
}
}
console.log("✅ Spine model setup complete!");
},
error: (_player, reason) => {
console.error("❌ Spine model loading error:", reason);
const errorDiv = document.getElementById("spine-error");
if (errorDiv) {
errorDiv.style.display = "block";
errorDiv.innerHTML = `
<div style="color: #ff4444; padding: 20px; text-align: center; font-size: 14px;">
<div>⚠️ Spine 模型加载失败</div>
<div style="font-size: 12px; margin-top: 8px; color: #888;">${reason}</div>
</div>
`;
}
const canvas = document.getElementById("spine-canvas");
if (canvas) canvas.style.display = "none";
},
});
} catch (error) {
console.error("Spine model initialization error:", error);
// 重置初始化标志,允许重试
window.spineModelInitialized = false;
const errorDiv = document.getElementById("spine-error");
if (errorDiv) {
errorDiv.style.display = "block";
errorDiv.innerHTML = `
<div style="color: #ff4444; padding: 20px; text-align: center; font-size: 14px;">
<div>⚠️ Spine 运行时加载失败</div>
<div style="font-size: 12px; margin-top: 8px; color: #888;">${error instanceof Error ? error.message : "未知错误"}</div>
</div>
`;
}
}
}
// 监听页面卸载事件,清理资源
window.addEventListener("beforeunload", cleanupSpineModel);
// 监听 Swup 页面切换事件(如果使用了 Swup
if (typeof window.swup !== "undefined" && window.swup.hooks) {
window.swup.hooks.on("content:replace", () => {
// 只更新响应式显示,不重新创建模型
setTimeout(() => {
updateResponsiveDisplay();
}, 100);
});
}
// 监听 popstate 事件(浏览器前进后退)
window.addEventListener("popstate", () => {
setTimeout(() => {
updateResponsiveDisplay();
}, 100);
});
// 监听窗口大小变化
window.addEventListener("resize", updateResponsiveDisplay);
// 页面加载完成后初始化(只初始化一次)
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initSpineModel);
} else {
initSpineModel();
}
</script>

View File

@@ -0,0 +1,40 @@
---
import ButtonTag from "@/components/common/controls/ButtonTag.astro";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import { getTagList } from "@/utils/content-utils";
import { getTagUrl } from "@/utils/url-utils";
import { widgetManager } from "@/utils/widget-manager";
import WidgetLayout from "./WidgetLayout.astro";
const tags = await getTagList();
const COLLAPSED_HEIGHT = "7.5rem";
// 使用统一的组件管理器检查是否应该折叠
const allComponents = [
...widgetManager.getConfig().leftComponents,
...widgetManager.getConfig().rightComponents,
];
const tagsComponent = allComponents.find((c) => c.type === "tags");
const isCollapsed = tagsComponent
? widgetManager.isCollapsed(tagsComponent, tags.length)
: false;
interface Props {
class?: string;
style?: string;
}
const className = Astro.props.class;
const style = Astro.props.style;
---
<WidgetLayout name={i18n(I18nKey.tags)} id="tags" isCollapsed={isCollapsed} collapsedHeight={COLLAPSED_HEIGHT} class={className} style={style}>
<div class="flex gap-2 flex-wrap">
{tags.map(t => (
<ButtonTag href={getTagUrl(t.name)} label={`View all posts with the ${t.name.trim()} tag`}>
{t.name.trim()}
</ButtonTag>
))}
</div>
</WidgetLayout>

View File

@@ -0,0 +1,63 @@
---
import { Icon } from "astro-icon/components";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
interface Props {
id: string;
name?: string;
isCollapsed?: boolean;
collapsedHeight?: string;
class?: string;
style?: string;
}
const { id, name, isCollapsed, collapsedHeight, style } = Astro.props;
const className = Astro.props.class;
---
<widget-layout data-id={id} data-is-collapsed={String(isCollapsed)} class={"pb-4 card-base " + className} style={style}>
<div class="widget-title font-bold transition text-lg text-neutral-900 dark:text-neutral-100 relative ml-8 mt-4 mb-2
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
before:absolute before:left-[-16px] before:top-[5.5px] flex items-center justify-between">
<span class="widget-name">{name}</span>
<slot name="title-icon" />
</div>
<div id={id} class:list={["collapse-wrapper px-4 overflow-hidden", {"collapsed": isCollapsed}]}>
<slot></slot>
</div>
{isCollapsed && <div class="expand-btn px-4 -mb-2">
<button class="btn-plain rounded-lg w-full h-9">
<div class="text-[var(--primary)] flex items-center justify-center gap-2 -translate-x-2">
<Icon name="material-symbols:more-horiz" class="text-[1.75rem]"></Icon> {i18n(I18nKey.more)}
</div>
</button>
</div>}
</widget-layout>
<style define:vars={{ collapsedHeight }}>
.collapsed {
height: var(--collapsedHeight);
}
</style>
<script>
class WidgetLayout extends HTMLElement {
constructor() {
super();
if (this.dataset.isCollapsed !== "true")
return;
const id = this.dataset.id;
const btn = this.querySelector('.expand-btn');
const wrapper = this.querySelector(`#${id}`)
btn!.addEventListener('click', () => {
wrapper!.classList.remove('collapsed');
btn!.classList.add('hidden');
})
}
}
if (!customElements.get("widget-layout")) {
customElements.define("widget-layout", WidgetLayout);
}
</script>

View File

@@ -0,0 +1 @@
这里是HTML注入示例你可以在这个文件中添加自定义的HTML内容

66
src/config/README.md Normal file
View File

@@ -0,0 +1,66 @@
# 配置文件说明
本目录包含 Firefly 主题的所有配置文件,采用模块化设计,每个文件负责特定的功能模块。
## 📁 配置文件结构
```
src/config/
├── index.ts # 配置索引文件 - 统一导出
├── siteConfig.ts # 站点基础配置
├── backgroundWallpaper.ts # 背景壁纸配置
├── profileConfig.ts # 用户资料配置
├── musicConfig.ts # 音乐播放器配置
├── sakuraConfig.ts # 樱花特效配置
├── commentConfig.ts # 评论系统配置
├── announcementConfig.ts # 公告配置
├── licenseConfig.ts # 许可证配置
├── footerConfig.ts # 页脚配置
├── expressiveCodeConfig.ts # 代码高亮配置
├── fontConfig.ts # 字体配置
├── sidebarConfig.ts # 侧边栏配置
├── navBarConfig.ts # 导航栏配置
├── pioConfig.ts # Pio 模型配置
├── adConfig.ts # 广告配置
├── friendsConfig.ts # 友链配置
├── sponsorConfig.ts # 赞助配置
├── coverImageConfig.ts # 封面图配置
└── README.md # 本文件
```
## 🚀 使用方式
### 推荐:使用配置索引(统一导入)
```typescript
import { siteConfig, profileConfig } from '../config';
```
### 直接导入单个配置
```typescript
import { siteConfig } from '../config/siteConfig';
import { profileConfig } from '../config/profileConfig';
```
## 📋 配置文件列表
- `siteConfig.ts` - 站点基础配置(标题、描述、主题色等)
- `backgroundWallpaper.ts` - 背景壁纸配置(壁纸模式、图片、横幅文字等)
- `profileConfig.ts` - 用户资料配置(头像、姓名、社交链接等)
- `musicConfig.ts` - 音乐播放器配置(支持本地音乐和 Meting API
- `sakuraConfig.ts` - 樱花特效配置(数量、速度、尺寸等)
- `commentConfig.ts` - 评论系统配置Twikoo 评论和文章访问量统计)
- `announcementConfig.ts` - 公告配置(标题、内容、链接等)
- `licenseConfig.ts` - 许可证配置CC 协议等)
- `footerConfig.ts` - 页脚配置HTML 注入等)
- `expressiveCodeConfig.ts` - 代码高亮配置(主题等)
- `fontConfig.ts` - 字体配置(字体族、大小等)
- `sidebarConfig.ts` - 侧边栏配置(组件布局等)
- `navBarConfig.ts` - 导航栏配置(链接、样式等)
- `pioConfig.ts` - Pio 模型配置Spine、Live2D 等)
- `adConfig.ts` - 广告配置(广告位设置等)
- `friendsConfig.ts` - 友链配置(友链列表等)
- `sponsorConfig.ts` - 赞助配置(赞助方式、二维码等)
- `coverImageConfig.ts` - 封面图配置(随机封面图列表等)
```

63
src/config/adConfig.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { AdConfig } from "../types/config";
// 这里只是配置广告内容如果要开关请在sidebarConfig.ts中控制侧边栏组件的的启用组件即可
// 广告配置1 - 纯图片广告(无边距)
export const adConfig1: AdConfig = {
image: {
src: "/assets/images/d1.webp",
alt: "广告横幅",
link: "#",
external: true,
},
// 是否允许关闭广告
closable: true,
// 显示次数限制,-1为无限制
displayCount: -1,
// 组件内边距配置,可通过取消注释生效
padding: {
// 零边距,图片占满整个组件
all: "0",
// 四边1rem边距
// all: "1rem",
// 顶部无边距
// top: "0",
// 右侧无边距
// right: "1rem",
// 底部无边距
// bottom: "1rem",
// 左侧无边距
// left: "1rem",
},
};
// 广告配置2 - 完整内容广告
export const adConfig2: AdConfig = {
title: "支持博主",
content:
"如果您觉得本站内容对您有帮助,欢迎支持我们的创作!您的支持是我们持续更新的动力。",
image: {
src: "/assets/images/d2.webp",
alt: "支持博主",
link: "about/",
external: false,
},
link: {
text: "支持一下",
url: "about/",
external: false,
},
closable: true,
displayCount: -1,
padding: {
// all: "1rem",
},
};

View File

@@ -0,0 +1,23 @@
import type { AnnouncementConfig } from "../types/config";
export const announcementConfig: AnnouncementConfig = {
// 公告标题
title: "公告",
// 公告内容
content: "欢迎来到我的博客!这是一则示例公告。",
// 是否允许用户关闭公告
closable: true,
link: {
// 启用链接
enable: true,
// 链接文本
text: "了解更多",
// 链接 URL
url: "/about/",
// 内部链接
external: false,
},
};

View File

@@ -0,0 +1,110 @@
import type { BackgroundWallpaperConfig } from "@/types/config";
export const backgroundWallpaper: BackgroundWallpaperConfig = {
// 壁纸模式:"banner" 横幅壁纸,"overlay" 全屏透明,"none" 纯色背景无壁纸
mode: "banner",
// 是否允许用户通过导航栏切换壁纸模式设为false可提升性能只渲染当前模式
switchable: true,
// 背景图片配置
src: {
// 桌面背景图片
desktop: "/assets/images/d1.webp",
// 移动背景图片
mobile: "/assets/images/m1.webp",
},
// Banner模式特有配置
banner: {
// 图片位置
// 支持所有CSS object-position值如: 'top', 'center', 'bottom', 'left top', 'right bottom', '25% 75%', '10px 20px'..
// 如果不知道怎么配置百分百之类的配置,推荐直接使用:'center'居中,'top'顶部居中,'bottom' 底部居中,'left'左侧居中,'right'右侧居中
position: "0% 20%",
// 主页横幅文字
homeText: {
// 是否启用主页横幅文字
enable: true,
// 主页横幅主标题
title: "Lovely firefly!",
// 主页横幅主标题字体大小
titleSize: "3.8rem",
// 主页横幅副标题
subtitle: [
"In Reddened Chrysalis, I Once Rest",
"From Shattered Sky, I Free Fall",
"Amidst Silenced Stars, I Deep Sleep",
"Upon Lighted Fyrefly, I Soon Gaze",
"From Undreamt Night, I Thence Shine",
"In Finalized Morrow, I Full Bloom",
],
// 主页横幅副标题字体大小
subtitleSize: "1.5rem",
typewriter: {
// 是否启用打字机效果
// 打字机开启 → 循环显示所有副标题
// 打字机关闭 → 每次刷新随机显示一条副标题
enable: true,
// 打字速度(毫秒)
speed: 100,
// 删除速度(毫秒)
deleteSpeed: 50,
// 完全显示后的暂停时间(毫秒)
pauseTime: 2000,
},
},
// 图片来源
credit: {
enable: {
// 桌面端显示横幅图片来源文本
desktop: true,
// 移动端显示横幅图片来源文本
mobile: true,
},
text: {
// 桌面端要显示的来源文本
desktop: "Pixiv - 晚晚喵",
// 移动端要显示的来源文本
mobile: "Pixiv - KiraraShss",
},
url: {
// 桌面端原始艺术品或艺术家页面的 URL 链接
desktop: "https://www.pixiv.net/artworks/135490046",
// 移动端原始艺术品或艺术家页面的 URL 链接
mobile: "https://www.pixiv.net/users/42715864",
},
},
// 横幅导航栏配置
navbar: {
// 横幅导航栏透明模式:"semi" 半透明加圆角,"full" 完全透明,"semifull" 动态透明
transparentMode: "semifull",
},
// 波浪动画效果配置,开启可能会影响页面性能,请根据实际情况开启
waves: {
enable: {
// 桌面端是否启用波浪动画效果
desktop: true,
// 移动端是否启用波浪动画效果
mobile: true,
},
performance: {
// 性能优化说明:
// quality: "high" - 最佳视觉效果但GPU占用较高适合高性能设备
// quality: "medium" - 平衡性能和质量,适合中等性能设备
// quality: "low" - 最低GPU占用动画更简单适合低性能设备
// hardwareAcceleration: true - 启用GPU加速提升性能但增加GPU占用
// hardwareAcceleration: false - 禁用GPU加速降低GPU占用但可能影响性能
quality: "high",
// 是否启用硬件加速
hardwareAcceleration: true,
},
},
},
// 全屏透明覆盖模式特有配置
overlay: {
// 层级,确保壁纸在背景层
zIndex: -1,
// 壁纸透明度
opacity: 0.8,
// 背景模糊程度
blur: 1,
},
};

View File

@@ -0,0 +1,72 @@
import type { CommentConfig } from "../types/config";
export const commentConfig: CommentConfig = {
// 评论系统类型: none, twikoo, waline, giscus, disqus, artalk默认为none即不启用评论系统
type: "none",
//twikoo评论系统配置
twikoo: {
envId: "https://twikoo.vercel.app",
// 设置 Twikoo 评论系统语言
lang: "zh-CN",
// 是否启用文章访问量统计功能
visitorCount: true,
},
//waline评论系统配置
waline: {
// waline 后端服务地址
serverURL: "https://waline.vercel.app",
// 设置 Waline 评论系统语言
lang: "zh-CN",
// 评论登录模式。可选值如下:
// 'enable' —— 默认,允许访客匿名评论和用第三方 OAuth 登录评论,兼容性最佳。
// 'force' —— 强制必须登录后才能评论,适合严格社区,关闭匿名评论。
// 'disable' —— 禁止所有登录和 OAuth仅允许匿名评论填写昵称/邮箱),适用于极简留言。
login: "enable",
// 是否启用文章访问量统计功能
visitorCount: true,
},
// artalk评论系统配置
artalk: {
// artalk后端程序 API 地址
server: "https://artalk.example.com/",
// 设置 Artalk 语言
locale: "zh-CN",
// 是否启用文章访问量统计功能
visitorCount: true,
},
//giscus评论系统配置
giscus: {
// 设置 Giscus 评论系统仓库
repo: "CuteLeaf/Firefly",
// 设置 Giscus 评论系统仓库ID
repoId: "R_kgD2gfdFGd",
// 设置 Giscus 评论系统分类
category: "General",
// 获取 Giscus 评论系统分类ID
categoryId: "DIC_kwDOKy9HOc4CegmW",
// 获取 Giscus 评论系统映射方式
mapping: "title",
// 获取 Giscus 评论系统严格模式
strict: "0",
// 获取 Giscus 评论系统反应功能
reactionsEnabled: "1",
// 获取 Giscus 评论系统元数据功能
emitMetadata: "1",
// 获取 Giscus 评论系统输入位置
inputPosition: "top",
// 获取 Giscus 评论系统语言
lang: "zh-CN",
// 获取 Giscus 评论系统加载方式
loading: "lazy",
},
//disqus评论系统配置
disqus: {
// 获取 Disqus 评论系统
shortname: "firefly",
},
};

View File

@@ -0,0 +1,79 @@
import type { CoverImageConfig } from "../types/config";
/**
* 文章封面图配置
*
* enableInPost - 是否在文章详情页显示封面图
*
* 随机封面图使用说明:
* 1. 在文章的 Frontmatter 中添加 image: "api" 即可使用随机图功能
* 2. 系统会依次尝试所有配置的 API全部失败后使用备用图片
* 3. 如果 enable 为 false则直接不显示封面图也不会显示备用图
*
* // 文章 Frontmatter 示例:
* ---
* title: 文章标题
* image: "api"
* ---
*/
export const coverImageConfig: CoverImageConfig = {
// 是否在文章详情页显示封面图
enableInPost: true,
randomCoverImage: {
// 随机封面图功能开关
enable: true,
// 封面图API列表
apis: [
"https://t.alcy.cc/pc",
"https://www.dmoe.cc/random.php",
"https://uapis.cn/api/v1/random/image?category=acg&type=pc",
],
// 备用图片路径
fallback: "/assets/images/cover.webp",
/**
* 加载指示器配置
* - 自定义加载图片和背景色,用于在图片加载过程中显示
* - 如果不配置,将使用默认的 loading.gif 和 #fefefe 背景色
*/
loading: {
// 加载指示器开关
enable: false,
// 自定义加载图片路径(相对于 public 目录)
image: "/assets/images/loading.gif",
// 加载指示器背景颜色,应与加载图片的背景色一致,避免在暗色模式下显得突兀
backgroundColor: "#fefefe",
},
/**
* 水印配置
* - 仅在随机图API成功加载时显示水印
* - 当使用备用图片时,水印文字会自动更新为 "Image API Error"
* - 移动端会自动调整位置bottom位置会显示在top避免被裁剪
*/
watermark: {
// 水印开关
enable: true,
// 水印文本
text: "Random Cover",
/**
* 水印位置
* - "top-left": 左上角
* - "top-right": 右上角
* - "bottom-left": 左下角(移动端显示在左上角,桌面端显示在左下角)
* - "bottom-right": 右下角(移动端显示在右上角,桌面端显示在右下角)
* - "center": 居中
*/
position: "bottom-right",
// 水印透明度
opacity: 0.6,
// 字体大小
fontSize: "0.75rem",
// 字体颜色
color: "#ffffff",
// 背景颜色
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
},
};

View File

@@ -0,0 +1,12 @@
import type { ExpressiveCodeConfig } from "../types/config";
export const expressiveCodeConfig: ExpressiveCodeConfig = {
// 暗色主题(用于暗色模式)
darkTheme: "one-dark-pro",
// 亮色主题(用于亮色模式)
lightTheme: "one-light",
// 更多样式请看expressive-code的官方文档
// https://expressive-code.com/guides/themes/
};

69
src/config/fontConfig.ts Normal file
View File

@@ -0,0 +1,69 @@
// 字体配置
export const fontConfig = {
// 是否启用自定义字体功能
enable: true,
// 是否预加载字体文件
preload: true,
// 当前选择的字体,支持多个字体组合
selected: ["system"],
// 字体列表
fonts: {
// 系统字体
system: {
id: "system",
name: "系统字体",
src: "", // 系统字体无需 src
family:
"system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif",
},
// Google Fonts - Zen Maru Gothic
"zen-maru-gothic": {
id: "zen-maru-gothic",
name: "Zen Maru Gothic",
src: "https://fonts.googleapis.com/css2?family=Zen+Maru+Gothic:wght@300;400;500;700;900&display=swap",
family: "Zen Maru Gothic",
display: "swap" as const,
},
// Google Fonts - Inter
inter: {
id: "inter",
name: "Inter",
src: "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap",
family: "Inter",
display: "swap" as const,
},
// 小米字体 - MiSans Normal
"misans-normal": {
id: "misans-normal",
name: "MiSans Normal",
src: "https://unpkg.com/misans@4.1.0/lib/Normal/MiSans-Normal.min.css",
family: "MiSans",
weight: 400,
display: "swap" as const,
},
// 小米字体 - MiSans Semibold
"misans-semibold": {
id: "misans-semibold",
name: "MiSans Semibold",
src: "https://unpkg.com/misans@4.1.0/lib/Normal/MiSans-Semibold.min.css",
family: "MiSans",
weight: 600,
display: "swap" as const,
},
},
// 全局字体回退
fallback: [
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"sans-serif",
],
};

View File

@@ -0,0 +1,8 @@
import type { FooterConfig } from "../types/config";
export const footerConfig: FooterConfig = {
// 是否启用Footer HTML注入功能
enable: false,
};
// 直接编辑 config/FooterConfig.html 文件来添加备案号等自定义内容

View File

@@ -0,0 +1,47 @@
import type { FriendLink, FriendsPageConfig } from "../types/config";
// 可以在src/content/spec/friends.md中编写友链页面下方的自定义内容
// 友链页面配置
export const friendsPageConfig: FriendsPageConfig = {
// 显示列数2列或3列
columns: 2,
};
// 友链配置
export const friendsConfig: FriendLink[] = [
{
title: "夏夜流萤",
imgurl: "https://q1.qlogo.cn/g?b=qq&nk=7618557&s=640",
desc: "飞萤之火自无梦的长夜亮起,绽放在终竟的明天。",
siteurl: "https://blog.cuteleaf.cn",
tags: ["Blog"],
weight: 10, // 权重,数字越大排序越靠前
enabled: true, // 是否启用
},
{
title: "Firefly Docs",
imgurl: "https://docs-firefly.cuteleaf.cn/logo.png",
desc: "Firefly主题模板文档",
siteurl: "https://docs-firefly.cuteleaf.cn",
tags: ["Docs"],
weight: 9,
enabled: true,
},
{
title: "Astro",
imgurl: "https://avatars.githubusercontent.com/u/44914786?v=4&s=640",
desc: "The web framework for content-driven websites. ⭐️ Star to support our work!",
siteurl: "https://github.com/withastro/astro",
tags: ["Framework"],
weight: 8,
enabled: true,
},
];
// 获取启用的友链并按权重排序
export const getEnabledFriends = (): FriendLink[] => {
return friendsConfig
.filter((friend) => friend.enabled)
.sort((a, b) => b.weight - a.weight);
};

47
src/config/index.ts Normal file
View File

@@ -0,0 +1,47 @@
// 配置索引文件 - 统一导出所有配置
// 这样组件可以一次性导入多个相关配置,减少重复的导入语句
// 类型导出
export type {
AnnouncementConfig,
BackgroundWallpaperConfig,
CommentConfig,
CoverImageConfig,
ExpressiveCodeConfig,
FooterConfig,
LicenseConfig,
MusicPlayerConfig,
NavBarConfig,
ProfileConfig,
SakuraConfig,
SidebarLayoutConfig,
SiteConfig,
SponsorConfig,
SponsorItem,
SponsorMethod,
WidgetComponentConfig,
WidgetComponentType,
} from "../types/config";
export { adConfig1, adConfig2 } from "./adConfig"; // 广告配置
export { announcementConfig } from "./announcementConfig"; // 公告配置
// 样式配置
export { backgroundWallpaper } from "./backgroundWallpaper"; // 背景壁纸配置
// 功能配置
export { commentConfig } from "./commentConfig"; // 评论系统配置
export { coverImageConfig } from "./coverImageConfig"; // 封面图配置
export { expressiveCodeConfig } from "./expressiveCodeConfig"; // 代码高亮配置
export { fontConfig } from "./fontConfig"; // 字体配置
export { footerConfig } from "./footerConfig"; // 页脚配置
export { friendsPageConfig, getEnabledFriends } from "./friendsConfig"; // 友链配置
export { licenseConfig } from "./licenseConfig"; // 许可证配置
// 组件配置
export { musicPlayerConfig } from "./musicConfig"; // 音乐播放器配置
export { navBarConfig, navBarSearchConfig } from "./navBarConfig"; // 导航栏配置与搜索配置
export { live2dModelConfig, spineModelConfig } from "./pioConfig"; // 看板娘配置
export { profileConfig } from "./profileConfig"; // 用户资料配置
export { sakuraConfig } from "./sakuraConfig"; // 樱花特效配置
// 布局配置
export { sidebarLayoutConfig } from "./sidebarConfig"; // 侧边栏布局配置
// 核心配置
export { siteConfig } from "./siteConfig"; // 站点基础配置
export { sponsorConfig } from "./sponsorConfig"; // 赞助配置

View File

@@ -0,0 +1,10 @@
import type { LicenseConfig } from "../types/config";
export const licenseConfig: LicenseConfig = {
// 是否启用文章顶部许可证信息显示
enable: true,
// 许可证名称及链接
name: "CC BY-NC-SA 4.0",
url: "https://creativecommons.org/licenses/by-nc-sa/4.0/",
};

89
src/config/musicConfig.ts Normal file
View File

@@ -0,0 +1,89 @@
import type { MusicPlayerConfig } from "../types/config";
// 音乐播放器配置
export const musicPlayerConfig: MusicPlayerConfig = {
// 音乐播放器功能开关
enable: true,
// 使用方式:"meting" 使用 Meting API"local" 使用本地音乐列表
mode: "meting",
// Meting API 配置
meting: {
// Meting API 地址
// 默认使用官方 API也可以使用自定义 API
api: "https://api.i-meto.com/meting/api?server=:server&type=:type&id=:id&r=:r",
// 音乐平台netease=网易云音乐, tencent=QQ音乐, kugou=酷狗音乐, xiami=虾米音乐, baidu=百度音乐
server: "netease",
// 类型song=单曲, playlist=歌单, album=专辑, search=搜索, artist=艺术家
type: "playlist",
// 歌单/专辑/单曲 ID 或搜索关键词
id: "10046455237",
// 认证 token可选
auth: "",
// 备用 API 配置(当主 API 失败时使用)
fallbackApis: [
"https://api.injahow.cn/meting/?server=:server&type=:type&id=:id",
"https://api.moeyao.cn/meting/?server=:server&type=:type&id=:id",
],
// MetingJS 脚本路径
// 默认使用 CDNhttps://cdn.jsdelivr.net/npm/meting@2/dist/Meting.min.js
// 备用CDNhttps://unpkg.com/meting@2/dist/Meting.min.js
// 也可配置为本地路径
jsPath: "https://unpkg.com/meting@2/dist/Meting.min.js",
},
// 本地音乐配置(当 mode 为 'local' 时使用)
local: {
playlist: [
{
name: "使一颗心免于哀伤",
artist: "知更鸟 / HOYO-MiX / Chevy",
url: "/assets/music/使一颗心免于哀伤-哼唱.wav",
cover: "/assets/music/cover/109951169585655912.jpg",
// 歌词内容,支持 LRC 格式
lrc: "",
},
],
},
// APlayer 配置选项
player: {
// 是否自动播放 浏览器可能会阻止,需用户交互一次网页后才自动播放
autoplay: false,
// 主题色
theme: "var(--btn-regular-bg)",
// 循环模式:'all'=列表循环, 'one'=单曲循环, 'none'=不循环
loop: "all",
// 播放顺序:'list'=列表顺序, 'random'=随机播放
order: "list",
// 预加载:'none'=不预加载, 'metadata'=预加载元数据, 'auto'=自动
preload: "auto",
// 默认音量 (0-1)
volume: 0.7,
// 是否互斥播放(同时只能播放一个播放器)
mutex: true,
// local歌词类型0=不显示, 1=显示(需要提供 lrc 字段), 2=显示(从 HTML 内容读取)
lrcType: 1,
// 歌词是否默认隐藏(当 lrcType 不为 0 时,可以通过此选项控制初始显示状态)
// true=默认隐藏(用户可以通过歌词按钮手动显示), false=默认显示
lrcHidden: true,
// 播放列表是否默认折叠
listFolded: false,
// 播放列表最大高度
listMaxHeight: "340px",
// localStorage 存储键名
storageName: "aplayer-setting",
},
// 响应式配置
responsive: {
// 移动端配置
mobile: {
// 在移动端是否隐藏
hide: false,
// 移动端断点(小于此宽度时应用移动端配置)
breakpoint: 768,
},
},
};

View File

@@ -0,0 +1,91 @@
import {
LinkPreset,
type NavBarConfig,
type NavBarLink,
type NavBarSearchConfig,
NavBarSearchMethod,
} from "../types/config";
import { siteConfig } from "./siteConfig";
// 根据页面开关动态生成导航栏配置
const getDynamicNavBarConfig = (): NavBarConfig => {
// 基础导航栏链接
const links: (NavBarLink | LinkPreset)[] = [
// 主页
LinkPreset.Home,
// 归档
LinkPreset.Archive,
];
// 自定义导航栏链接,并且支持多级菜单
links.push({
name: "链接",
url: "/links/",
icon: "material-symbols:link",
// 子菜单
children: [
{
name: "GitHub",
url: "https://github.com/CuteLeaf/Firefly",
external: true,
icon: "fa6-brands:github",
},
{
name: "Bilibili",
url: "https://space.bilibili.com/38932988",
external: true,
icon: "fa6-brands:bilibili",
},
],
});
// 友链
links.push(LinkPreset.Friends);
// 根据配置决定是否添加留言板在siteConfig关闭pages.guestbook时导航栏不显示留言板
if (siteConfig.pages.guestbook) {
links.push(LinkPreset.Guestbook);
}
// 关于及其子菜单
links.push({
name: "关于",
url: "/content/",
icon: "material-symbols:info",
children: [
// 根据配置决定是否添加赞助在siteConfig关闭pages.sponsor时导航栏不显示赞助
...(siteConfig.pages.sponsor ? [LinkPreset.Sponsor] : []),
// 关于页面
LinkPreset.About,
// 根据配置决定是否添加番组计划在siteConfig关闭pages.bangumi时导航栏不显示番组计划
...(siteConfig.pages.bangumi ? [LinkPreset.Bangumi] : []),
],
});
// 仅返回链接,其它导航搜索相关配置在模块顶层常量中独立导出
return { links } as NavBarConfig;
};
// 导航搜索配置
export const navBarSearchConfig: NavBarSearchConfig = {
// 可选PageFind MeiliSearch
// 选择PageFind时NavBarSearchMethod.PageFind,
// 选择MeiliSearch时NavBarSearchMethod.MeiliSearch,
method: NavBarSearchMethod.PageFind,
// 当选择 MeiliSearch 时的配置
meiliSearchConfig: {
INDEX_NAME: "posts",
CONTENT_DIR: "src/content/posts",
MEILI_HOST: "http://localhost:7700",
PUBLIC_MEILI_HOST: "http://localhost:7700",
PUBLIC_MEILI_SEARCH_KEY:
"41134b15079da66ca545375edbea848a9b7173dff13be2028318fefa41ae8f2b",
},
};
export const navBarConfig: NavBarConfig = getDynamicNavBarConfig();

138
src/config/pioConfig.ts Normal file
View File

@@ -0,0 +1,138 @@
import type { Live2DModelConfig, SpineModelConfig } from "../types/config";
// Spine 看板娘配置
export const spineModelConfig: SpineModelConfig = {
// Spine 看板娘开关
enable: false,
// Spine模型配置
model: {
// Spine模型文件路径
path: "/pio/models/spine/firefly/1310.json",
// 模型缩放比例
scale: 1.0,
// X轴偏移
x: 0,
// Y轴偏移
y: 0,
},
// 位置配置
position: {
// 显示位置 bottom-leftbottom-righttop-lefttop-right注意在右下角可能会挡住返回顶部按钮
corner: "bottom-left",
// 距离边缘0px
offsetX: 0,
// 距离下边缘0px
offsetY: 0,
},
// 尺寸配置
size: {
// 容器宽度
width: 135,
// 容器高度
height: 165,
},
// 交互配置
interactive: {
// 交互功能开关
enabled: true,
// 点击时随机播放的动画列表
clickAnimations: [
"emoji_0",
"emoji_1",
"emoji_2",
"emoji_3",
"emoji_4",
"emoji_5",
"emoji_6",
],
// 点击时随机显示的文字消息
clickMessages: [
"你好呀!我是流萤~",
"今天也要加油哦!✨",
"想要一起去看星空吗?🌟",
"记得要好好休息呢~",
"有什么想对我说的吗?💫",
"让我们一起探索未知的世界吧!🚀",
"每一颗星星都有自己的故事~⭐",
"希望能带给你温暖和快乐!💖",
],
// 文字显示时间(毫秒)
messageDisplayTime: 3000,
// 待机动画列表
idleAnimations: ["idle", "emoji_0", "emoji_1", "emoji_3", "emoji_4"],
// 待机动画切换间隔(毫秒)
idleInterval: 8000,
},
// 响应式配置
responsive: {
// 在移动端隐藏
hideOnMobile: true,
// 移动端断点
mobileBreakpoint: 768,
},
// 层级
zIndex: 1000, // 层级
// 透明度
opacity: 1.0,
};
// Live2D 看板娘配置
export const live2dModelConfig: Live2DModelConfig = {
// Live2D 看板娘开关
enable: false,
// Live2D模型配置
model: {
// Live2D模型文件路径
path: "/pio/models/live2d/snow_miku/model.json",
// path: "/pio/models/live2d/illyasviel/illyasviel.model.json",
},
// 位置配置
position: {
// 显示位置 bottom-leftbottom-righttop-lefttop-right注意在右下角可能会挡住返回顶部按钮
corner: "bottom-left",
// 距离边缘0px
offsetX: 0,
// 距离下边缘0px
offsetY: 0,
},
// 尺寸配置
size: {
// 容器宽度
width: 135,
// 容器高度
height: 165,
},
// 交互配置
interactive: {
// 交互功能开关
enabled: true,
// 点击时随机显示的文字消息motions 和 expressions 将从模型 JSON 文件中自动读取
clickMessages: [
"你好我是Miku~",
"有什么需要帮助的吗?",
"今天天气真不错呢!",
"要不要一起玩游戏?",
"记得按时休息哦!",
],
// 随机显示的文字消息显示时间(毫秒)
messageDisplayTime: 3000,
},
// 响应式配置
responsive: {
// 在移动端隐藏
hideOnMobile: true,
// 移动端断点
mobileBreakpoint: 768,
},
};

View File

@@ -0,0 +1,45 @@
import type { ProfileConfig } from "../types/config";
export const profileConfig: ProfileConfig = {
// 头像
avatar: "/assets/images/avatar.webp",
// 名字
name: "Firefly",
// 个人签名
bio: "Hello, I'm Firefly.",
// 链接配置
// 已经预装的图标集fa6-brandsfa6-regularfa6-solidmaterial-symbolssimple-icons
// 访问https://icones.js.org/ 获取图标代码,
// 如果想使用尚未包含相应的图标集,则需要安装它
// `pnpm add @iconify-json/<icon-set-name>`
// showName: true 时显示图标和名称false 时只显示图标
links: [
{
name: "Bilibli",
icon: "fa6-brands:bilibili",
url: "https://space.bilibili.com/38932988",
showName: false,
},
{
name: "GitHub",
icon: "fa6-brands:github",
url: "https://github.com/CuteLeaf",
showName: false,
},
{
name: "Email",
icon: "fa6-solid:envelope",
url: "mailto:xiaye@msn.com",
showName: false,
},
{
name: "RSS",
icon: "fa6-solid:rss",
url: "/rss/",
showName: false,
},
],
};

View File

@@ -0,0 +1,53 @@
import type { SakuraConfig } from "../types/config";
export const sakuraConfig: SakuraConfig = {
// 是否启用樱花特效
enable: false,
// 樱花数量
sakuraNum: 21,
// 樱花越界限制次数,-1为无限循环
limitTimes: -1,
// 樱花尺寸
size: {
// 樱花最小尺寸倍数
min: 0.5,
// 樱花最大尺寸倍数
max: 1.1,
},
// 樱花不透明度
opacity: {
// 樱花最小不透明度
min: 0.3,
// 樱花最大不透明度
max: 0.9,
},
// 樱花移动速度
speed: {
// 水平移动
horizontal: {
// 水平移动速度最小值
min: -1.7,
// 水平移动速度最大值
max: -1.2,
},
// 垂直移动
vertical: {
// 垂直移动速度最小值
min: 1.5,
// 垂直移动速度最大值
max: 2.2,
},
// 旋转速度
rotation: 0.03,
// 消失速度,不应大于最小不透明度
fadeSpeed: 0.03,
},
// 层级,确保樱花在合适的层级显示
zIndex: 100,
};

201
src/config/sidebarConfig.ts Normal file
View File

@@ -0,0 +1,201 @@
import type { SidebarLayoutConfig } from "../types/config";
/**
* 侧边栏布局配置
* 用于控制侧边栏组件的显示、排序、动画和响应式行为
*/
export const sidebarLayoutConfig: SidebarLayoutConfig = {
// 是否启用侧边栏功能
enable: true,
// 侧边栏位置left=左侧both=双侧
// 开启双侧边栏后右侧组件会在宽度低于1200px时隐藏
position: "both",
// 使用左侧单侧栏时,是否在文章详情页显示右侧边栏
// 当position为left时开启此项后文章详情页将显示双侧边栏主页等其他页面保持左侧单侧边栏
// 适用在只想用左侧单侧栏,但在文章详情页想用右侧栏的目录等组件的场景
showRightSidebarOnPostPage: true,
// 左侧边栏组件配置列表
// 组件位置positiontop=顶部sticky=粘性定位(会跟随页面滚动)
leftComponents: [
{
// 组件类型:用户资料组件
type: "profile",
// 是否启用该组件
enable: true,
// 组件显示顺序(数字越小越靠前)
order: 1,
// 组件位置
position: "top",
// CSS 类名,用于应用样式和动画
class: "onload-animation",
// 动画延迟时间(毫秒),用于错开动画效果
animationDelay: 0,
},
{
// 组件类型:公告组件
type: "announcement",
// 是否启用该组件
enable: true,
// 组件显示顺序
order: 2,
// 组件位置
position: "top",
// CSS 类名
class: "onload-animation",
// 动画延迟时间
animationDelay: 50,
},
{
// 组件类型:分类组件
type: "categories",
// 是否启用该组件
enable: true,
// 组件显示顺序
order: 3,
// 组件位置
position: "sticky",
// CSS 类名
class: "onload-animation",
// 动画延迟时间
animationDelay: 150,
// 响应式配置
responsive: {
// 折叠阈值当分类数量超过5个时自动折叠
collapseThreshold: 5,
},
},
{
// 组件类型:标签组件
type: "tags",
// 是否启用该组件
enable: true,
// 组件显示顺序
order: 4,
// 组件位置
position: "sticky",
// CSS 类名
class: "onload-animation",
// 动画延迟时间
animationDelay: 250,
// 响应式配置
responsive: {
// 折叠阈值当标签数量超过20个时自动折叠
collapseThreshold: 20,
},
},
{
// 组件类型:广告栏组件 1
type: "advertisement",
// 是否启用该组件
enable: false,
// 组件显示顺序
order: 5,
// 组件位置
position: "sticky",
// CSS 类名
class: "onload-animation",
// 动画延迟时间
animationDelay: 300,
// 配置ID使用第一个广告配置
configId: "ad1",
},
],
// 右侧边栏组件配置列表
rightComponents: [
{
// 组件类型:站点统计组件
type: "stats",
// 是否启用该组件
enable: true,
// 组件显示顺序
order: 1,
// 组件位置
position: "top",
// 是否在文章详情页显示
showOnPostPage: true,
// CSS 类名
class: "onload-animation",
// 动画延迟时间
animationDelay: 200,
},
{
// 组件类型:日历组件
type: "calendar",
// 是否启用该组件
enable: true,
// 组件显示顺序
order: 2,
// 组件位置
position: "sticky",
// 是否在文章详情页显示
showOnPostPage: false,
// CSS 类名
class: "onload-animation",
// 动画延迟时间
animationDelay: 250,
},
{
// 组件类型:侧边栏目录组件(只在文章详情页显示)
type: "sidebarToc",
// 是否启用该组件
enable: true,
// 组件显示顺序
order: 3,
// 组件位置
position: "sticky",
// 是否在文章详情页显示
showOnPostPage: true,
// CSS 类名
class: "onload-animation",
// 动画延迟时间
animationDelay: 250,
},
{
// 组件类型:广告栏组件 2
type: "advertisement",
// 是否启用该组件
enable: false,
// 组件显示顺序
order: 4,
// 组件位置
position: "sticky",
// 是否在文章详情页显示
showOnPostPage: true,
// CSS 类名
class: "onload-animation",
// 动画延迟时间
animationDelay: 350,
// 配置ID使用第二个广告配置
configId: "ad2",
},
],
// 默认动画配置
defaultAnimation: {
// 是否启用默认动画
enable: true,
// 基础延迟时间(毫秒)
baseDelay: 0,
// 递增延迟时间(毫秒),每个组件依次增加的延迟
increment: 50,
},
// 响应式布局配置
responsive: {
// 不同设备的布局模式
// hidden:不显示侧边栏 drawer:抽屉模式(移动端不显示) sidebar:显示侧边栏
// 使用 Tailwind 标准断点mobile(<768px), tablet(768px-1023px), desktop(>=1024px)
layout: {
// 移动端:<768px
mobile: "sidebar",
// 平板端768px-1023px
tablet: "sidebar",
// 桌面端:>=1024px
desktop: "sidebar",
},
},
};

145
src/config/siteConfig.ts Normal file
View File

@@ -0,0 +1,145 @@
import type { SiteConfig } from "@/types/config";
import { fontConfig } from "./fontConfig";
// 定义站点语言
// 语言代码,例如:'zh_CN', 'zh_TW', 'en', 'ja', 'ru'。
const SITE_LANG = "zh_CN";
export const siteConfig: SiteConfig = {
// 站点标题
title: "5426的技术分享基地",
// 站点副标题
subtitle: "",
// 站点 URL
site_url: "https://blog.micar9.com",
// 站点描述
description:
"欢迎来到5426",
// 站点关键词
keywords: [
"Firefly",
"Fuwari",
"Astro",
"ACGN",
"博客",
"技术博客",
"静态博客",
"AI"
],
// 主题色
themeColor: {
// 主题色的默认色相,范围从 0 到 360。例如红色0青色200蓝绿色250粉色345
hue: 165,
// 是否对访问者隐藏主题色选择器
fixed: false,
// 默认模式:"light" 亮色,"dark" 暗色,"system" 跟随系统
defaultMode: "system",
},
// Favicon 配置
favicon: [
{
// 图标文件路径
src: "/assets/images/favicon.ico",
// 可选,指定主题 'light' | 'dark'
// theme: "light",
// 可选,图标大小
// sizes: "32x32",
},
],
// 导航栏配置
navbar: {
// 导航栏Logo
// 支持三种类型Astro图标库本地图片网络图片
// { type: "icon", value: "material-symbols:home-pin-outline" }
// { type: "image", value: "/assets/images/logo.webp", alt: "Firefly Logo" }
// { type: "image", value: "https://example.com/logo.png", alt: "Firefly Logo" }
logo: {
type: "image",
value: "/assets/images/firefly.png",
alt: "🍀",
},
// 导航栏标题
title: "Firefly",
// 全宽导航栏导航栏是否占满屏幕宽度true占满false不占满
widthFull: false,
// 导航栏图标和标题是否跟随主题色
followTheme: false,
},
// 站点开始日期,用于统计运行天数
siteStartDate: "2026-01-07",
// 文章页底部的"上次编辑时间"卡片开关
showLastModified: true,
// 文章过期阈值(天数),超过此天数才显示"上次编辑"卡片
outdatedThreshold: 30,
// 是否开启分享海报生成功能
sharePoster: true,
// OpenGraph图片功能,注意开启后要渲染很长时间,不建议本地调试的时候开启
generateOgImages: false,
// bangumi配置
bangumi: {
// Bangumi用户ID
userId: "1163581",
},
// 页面开关配置 - 控制特定页面的访问权限设为false会返回404
// bangumi的数据为编译时获取的所以不是实时数据请配置bangumi.userId
pages: {
// 赞助页面开关
sponsor: true,
// 留言板页面开关,需要配置评论系统
guestbook: true,
// 番组计划页面开关含追番、游戏、书籍和音乐dev调试时只获取一页数据build才会获取全部数据
bangumi: true,
},
// 文章列表布局配置
postListLayout: {
// 默认布局模式:"list" 列表模式(单列布局),"grid" 网格模式(多列布局)
defaultMode: "list",
// 是否允许用户切换布局
allowSwitch: true,
// 网格布局配置,仅在 defaultMode 为 "grid" 或允许切换布局时生效
grid: {
// 是否开启瀑布流布局,同时有封面图和无封面图的混合文章推荐开启
masonry: false,
// 网格模式列数2 或 3
// 2列是默认模式在任何侧边栏配置下均可生效
// 3列模式仅在单侧边栏或无侧边栏时生效
columns: 3,
},
},
// 分页配置
pagination: {
// 每页显示的文章数量
postsPerPage: 10,
},
// 统计分析
analytics: {
// Google Analytics ID
googleAnalyticsId: "G-P7GBNJKJKL",
// Microsoft Clarity ID
microsoftClarityId: "tx9equrgr6",
},
// 字体配置
// 在src/config/fontConfig.ts中配置具体字体
font: fontConfig,
// 站点语言在本配置文件顶部SITE_LANG定义
lang: SITE_LANG,
};

View File

@@ -0,0 +1,74 @@
import type { SponsorConfig } from "../types/config";
export const sponsorConfig: SponsorConfig = {
// 页面标题,如果留空则使用 i18n 中的翻译
title: "",
// 页面描述文本,如果留空则使用 i18n 中的翻译
description: "",
// 赞助用途说明
usage:
"您的赞助将用于服务器维护、内容创作和功能开发,帮助我持续提供优质内容。",
// 是否显示赞助者列表
showSponsorsList: true,
// 是否在文章详情页底部显示赞助按钮
showButtonInPost: true,
// 赞助方式列表
methods: [
{
name: "支付宝",
icon: "fa6-brands:alipay",
// 收款码图片路径(需要放在 public 目录下)
qrCode: "/assets/images/sponsor/alipay.png",
link: "",
description: "使用 支付宝 扫码赞助",
enabled: true,
},
{
name: "微信",
icon: "fa6-brands:weixin",
qrCode: "/assets/images/sponsor/wechat.png",
link: "",
description: "使用 微信 扫码赞助",
enabled: true,
},
{
name: "爱发电",
icon: "simple-icons:afdian",
qrCode: "",
link: "https://afdian.com/a/cuteleaf",
description: "通过 爱发电 进行赞助",
enabled: true,
},
{
name: "Github",
icon: "fa6-brands:github",
qrCode: "",
link: "https://github.com/CuteLeaf/Firefly",
description: "点个Star就是最大的支持",
enabled: true,
},
],
// 赞助者列表(可选)
sponsors: [
// 示例:已实名赞助者
{
name: "夏叶",
amount: "¥50",
date: "2025-10-01",
message: "感谢分享!",
},
// 示例:匿名赞助者
{
name: "匿名用户",
amount: "¥20",
date: "2025-10-01",
},
],
};

View File

@@ -0,0 +1,25 @@
export const PAGE_SIZE = 8;
export const LIGHT_MODE = "light",
DARK_MODE = "dark",
SYSTEM_MODE = "system";
export const DEFAULT_THEME = LIGHT_MODE; // 仅作为向后兼容的默认值,实际使用 siteConfig.themeColor.defaultMode
// Wallpaper modes
export const WALLPAPER_BANNER = "banner",
WALLPAPER_OVERLAY = "overlay",
WALLPAPER_NONE = "none";
// Banner height unit: vh
export const BANNER_HEIGHT = 35;
export const BANNER_HEIGHT_EXTEND = 30;
export const BANNER_HEIGHT_HOME = BANNER_HEIGHT + BANNER_HEIGHT_EXTEND;
// The height the main panel overlaps the banner, unit: rem
export const MAIN_PANEL_OVERLAPS_BANNER_HEIGHT = 3.5;
// Page width: rem
export const PAGE_WIDTH = 90;
// Category constants
export const UNCATEGORIZED = "uncategorized";

44
src/constants/icon.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { Favicon } from "@/types/config.ts";
export const defaultFavicons: Favicon[] = [
{
src: "/favicon/favicon-light-32.png",
theme: "light",
sizes: "32x32",
},
{
src: "/favicon/favicon-light-128.png",
theme: "light",
sizes: "128x128",
},
{
src: "/favicon/favicon-light-180.png",
theme: "light",
sizes: "180x180",
},
{
src: "/favicon/favicon-light-192.png",
theme: "light",
sizes: "192x192",
},
{
src: "/favicon/favicon-dark-32.png",
theme: "dark",
sizes: "32x32",
},
{
src: "/favicon/favicon-dark-128.png",
theme: "dark",
sizes: "128x128",
},
{
src: "/favicon/favicon-dark-180.png",
theme: "dark",
sizes: "180x180",
},
{
src: "/favicon/favicon-dark-192.png",
theme: "dark",
sizes: "192x192",
},
];

View File

@@ -0,0 +1,41 @@
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import { LinkPreset, type NavBarLink } from "@/types/config";
export const LinkPresets: { [key in LinkPreset]: NavBarLink } = {
[LinkPreset.Home]: {
name: i18n(I18nKey.home),
url: "/",
icon: "material-symbols:home",
},
[LinkPreset.About]: {
name: i18n(I18nKey.about),
url: "/about/",
icon: "material-symbols:person",
},
[LinkPreset.Archive]: {
name: i18n(I18nKey.archive),
url: "/archive/",
icon: "material-symbols:archive",
},
[LinkPreset.Friends]: {
name: i18n(I18nKey.friends),
url: "/friends/",
icon: "material-symbols:group",
},
[LinkPreset.Sponsor]: {
name: i18n(I18nKey.sponsor),
url: "/sponsor/",
icon: "material-symbols:favorite",
},
[LinkPreset.Guestbook]: {
name: i18n(I18nKey.guestbook),
url: "/guestbook/",
icon: "material-symbols:chat",
},
[LinkPreset.Bangumi]: {
name: i18n(I18nKey.bangumi),
url: "/bangumi/",
icon: "material-symbols:movie",
},
};

40
src/content.config.ts Normal file
View File

@@ -0,0 +1,40 @@
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const postsCollection = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts" }),
schema: z.object({
title: z.string(),
published: z.date(),
updated: z.date().optional(),
draft: z.boolean().optional().default(false),
description: z.string().optional().default(""),
image: z.string().optional().default(""),
tags: z.array(z.string()).optional().default([]),
category: z.string().optional().nullable().default(""),
lang: z.string().optional().default(""),
pinned: z.boolean().optional().default(false),
author: z.string().optional().default(""),
sourceLink: z.string().optional().default(""),
licenseName: z.string().optional().default(""),
licenseUrl: z.string().optional().default(""),
comment: z.boolean().optional().default(true),
/* For internal use */
prevTitle: z.string().default(""),
prevSlug: z.string().default(""),
nextTitle: z.string().default(""),
nextSlug: z.string().default(""),
}),
});
const specCollection = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/spec" }),
schema: z.object({}),
});
export const collections = {
posts: postsCollection,
spec: specCollection,
};

228
src/content/posts/agent.md Normal file
View File

@@ -0,0 +1,228 @@
---
title: Agent应用框架
published: 2025-01-11
pinned: false
description: Agent应用框架
tags: [Agent]
category: 学习日志
draft: true
---
# 一、Agent应用架构深度解析
下面有两个Agent的方案请对比一下哪个方案更好
方案一:
```mermaid
graph LR
规划--> 行动1 --> 行动2 --> 结束
```
方案二:
```mermaid
graph LR
思考1--> 行动1 --> 思考2 --> 行动2 --> 结束
```
方案二比方案一好因为思考2可以判断行动1的执行效果是否达成目的达成目的时继续下一个行动否则需要修改行动1并重新执行。
## Agent应用架构
```mermaid
graph LR
规划--> 思考;
行动 --> 思考;
思考-->行动;
思考-->结束;
```
运行机制:
```mermaid
graph LR
Thought--> Observation;
Observation-->Action;
Action-->Thought;
```
Though决定下一步要做的动作并给出理由
Action执行下一步动作
Observation观察执行动作结果的观察用于反馈思考
> React智能体感知环境变化做出相应调整的机制
```mermaid
graph LR
subgraph Tools
Calendar
Calculator
CodeInterpreter
Search
...more
end
A[Tools] -->Calendar;
A -->Calculator;
A -->CodeInterpreter;
A -->Search;
A -->...more;
subgraph Memory
C[Short-term memory]
D[Long-term memory]
end
E[大模型]-->A
E --> B[Memory]
E --> F[Action];
B-->C
B-->D
E --> G[Planning];
G --> H[Reflection];
G --> I[Self-critics];
G --> J[Chain of thoughts];
G --> K[Subgoal decomposition];
A-.->F
B-.->G
B-.->H
```
### Tools:工具库
Memory:记忆
Action:行动
### Planning规划
- 理解任务,给出完成任务的具体步骤
- 反思和优化,下一步行动的一个推理
规划阶段的关键技术:
- Chain-of-Thought Prompting(COT)
通过Prompt激发大模型的潜能引导LLM做出更好的规划。
例子:
Q:我有5个羽毛球。我买了2罐羽毛球一罐3个。我现在有多少个羽毛球
GPT-4o:你原本有 5 个羽毛球,买了 2 瓶,每瓶 3 个。所以,你现在总共有:
5 + 2 × 3 = 5 + 6 = 11 个羽毛球。
- 反思和细化
- 记忆增强
记忆增强通常有两种方法RAG和微调。目的是注入背景知识让模型能更好地完成任务。
<aside>
💡
什么时候用RAG什么时候用微调
当数据更新快、数据量较少时使用RAG更好。
</aside>
1. RAG的核心技术有
- 数据工程:收集数据,需要确保数据高质量
- 数据切分切分数据切分的每个chunk尽量高质量
- 一阶段检索:语义检索,尽可能多的找知识
- 二阶段精排:重排序,返回高质量的数据
- 用户意图识别:理解用户意图
- 效果评估
### Action行动
- 语言模型通过推理触发
- 执行器调用工具执行
```mermaid
graph LR
工具库-->|注册| LLM --> |触发|执行器
```
函数调用执行全过程:
[![agent.png](https://tuchuang.bluishhao.top/i/2024/12/24/676ac9cfbe407.png)](https://tuchuang.bluishhao.top/i/2024/12/24/676ac9cfbe407.png)
#### 1.定义函数
编写函数,在注释说明该函数的功能、需要的参数、返回的数据。
```python
def add(num1,num2):
"""
该函数计算两个数字的和
:param num1: 必要参数,表示需要计算的第一个数字
:param num2: 必要参数,表示需要计算的第二个数字
:return: add函数计算后的结果返回结果为float对象
"""
return float(num1+num2)
```
注册函数
```python
add_tool={
"type":"function",
"function":{
"name":"add",
"description":"用于执行add算法函数定义了一种将两个数字加起来的计算过程",
"parameters":{"type":"object","parameters":{"num1":{"type":"float","description":"需要计算的第一个数字"},"num2":{"type":"float","description":"需要计算的第二个数字"}},"required":["num1","num2"]}
}
}
tools=[add_tool]
```
#### 2.函数描述和模型调用(第一次)
模型去匹配对应的函数并调用。
```python
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=messages,
tools=tools,
tool_choice="auto",
)
response.choices[0].message
```
回复的内容如下:
```python
ChatCompletionMessage(content=None, role='assistant', function_call=None, tool_calls=[ChatCompletionMessageToolCall(id='call_OqKaG4mk8Z5oEVKCYsd5rSCO', function=Function(arguments='{"num1":"1","num2":"2"}', name='add_tool'), type='function')])
```
这里的id是唯一的用于匹配询问与回复。
#### 3.解析模型返回数据并完成函数调用
```python
tool_calls = response.choices[0].message.tool_calls
for tool_call in tool_calls:
function_name = tool_call.function.name
function_to_call = available_tools[function_name]
function_args = json.loads(tool_call.function.arguments)
function_response = function_to_call(**function_args)
print(function_name)
print(function_args)
print(function_response)
```
输出为:
```python
add_tool
{'num1':'1','num2':'2'}
"3"
```
#### 4.结果增强模型生成(第二次)
将函数调用的结果添加到消息中,增强模型生成。
```python
messages.append(
{
"tool_call_id": tool_call.id,
"role": "tool",
"name": function_name,
"content": function_response,
}
)
```
### Observation

View File

@@ -0,0 +1,313 @@
---
title: Firefly 代码块示例
published: 1970-01-03
pinned: false
description: 在Firefly中使用表达性代码的代码块在 Markdown 中的外观。
tags: [Markdown, Firefly]
category: 文章示例
draft: false
image: ./images/firefly3.webp
---
在这里,我们将探索如何使用 [Expressive Code](https://expressive-code.com/) 展示代码块。提供的示例基于官方文档,您可以参考以获取更多详细信息。
## 表达性代码
### 语法高亮
[语法高亮](https://expressive-code.com/key-features/syntax-highlighting/)
#### 常规语法高亮
```js
console.log('此代码有语法高亮!')
```
#### 渲染 ANSI 转义序列
```ansi
ANSI colors:
- Regular: Red Green Yellow Blue Magenta Cyan
- Bold: Red Green Yellow Blue Magenta Cyan
- Dimmed: Red Green Yellow Blue Magenta Cyan
256 colors (showing colors 160-177):
160 161 162 163 164 165
166 167 168 169 170 171
172 173 174 175 176 177
Full RGB colors:
ForestGreen - RGB(34, 139, 34)
Text formatting: Bold Dimmed Italic Underline
```
### 编辑器和终端框架
[编辑器和终端框架](https://expressive-code.com/key-features/frames/)
#### 代码编辑器框架
```js title="my-test-file.js"
console.log('标题属性示例')
```
---
```html
<!-- src/content/index.html -->
<div>文件名注释示例</div>
```
#### 终端框架
```bash
echo "此终端框架没有标题"
```
---
```powershell title="PowerShell 终端示例"
Write-Output "这个有标题!"
```
#### 覆盖框架类型
```sh frame="none"
echo "看,没有框架!"
```
---
```ps frame="code" title="PowerShell Profile.ps1"
# 如果不覆盖,这将是一个终端框架
function Watch-Tail { Get-Content -Tail 20 -Wait $args }
New-Alias tail Watch-Tail
```
### 文本和行标记
[文本和行标记](https://expressive-code.com/key-features/text-markers/)
#### 标记整行和行范围
```js {1, 4, 7-8}
// 第1行 - 通过行号定位
// 第2行
// 第3行
// 第4行 - 通过行号定位
// 第5行
// 第6行
// 第7行 - 通过范围 "7-8" 定位
// 第8行 - 通过范围 "7-8" 定位
```
#### 选择行标记类型 (mark, ins, del)
```js title="line-markers.js" del={2} ins={3-4} {6}
function demo() {
console.log('此行标记为已删除')
// 此行和下一行标记为已插入
console.log('这是第二个插入行')
return '此行使用中性默认标记类型'
}
```
#### 为行标记添加标签
```jsx {"1":5} del={"2":7-8} ins={"3":10-12}
// labeled-line-markers.jsx
<button
role="button"
{...props}
value={value}
className={buttonClassName}
disabled={disabled}
active={active}
>
{children &&
!active &&
(typeof children === 'string' ? <span>{children}</span> : children)}
</button>
```
#### 在单独行上添加长标签
```jsx {"1. Provide the value prop here:":5-6} del={"2. Remove the disabled and active states:":8-10} ins={"3. Add this to render the children inside the button:":12-15}
// labeled-line-markers.jsx
<button
role="button"
{...props}
value={value}
className={buttonClassName}
disabled={disabled}
active={active}
>
{children &&
!active &&
(typeof children === 'string' ? <span>{children}</span> : children)}
</button>
```
#### 使用类似 diff 的语法
```diff
+此行将标记为已插入
-此行将标记为已删除
这是常规行
```
---
```diff
--- a/README.md
+++ b/README.md
@@ -1,3 +1,4 @@
+this is an actual diff file
-all contents will remain unmodified
no whitespace will be removed either
```
#### 结合语法高亮和类似 diff 的语法
```diff lang="js"
function thisIsJavaScript() {
// 整个块都会以 JavaScript 高亮显示,
// 并且我们仍然可以为其添加 diff 标记!
- console.log('要删除的旧代码')
+ console.log('新的闪亮代码!')
}
```
#### 标记行内的单独文本
```js "given text"
function demo() {
// 标记行内的任何给定文本
return '支持给定文本的多个匹配项';
}
```
#### 正则表达式
```ts /ye[sp]/
console.log('单词 yes 和 yep 将被标记。')
```
#### 转义正斜杠
```sh /\/ho.*\//
echo "Test" > /home/test.txt
```
#### 选择内联标记类型 (mark, ins, del)
```js "return true;" ins="inserted" del="deleted"
function demo() {
console.log('这些是插入和删除的标记类型');
// return 语句使用默认标记类型
return true;
}
```
### 自动换行
[自动换行](https://expressive-code.com/key-features/word-wrap/)
#### 为每个块配置自动换行
```js wrap
// 启用换行的示例
function getLongString() {
return '这是一个非常长的字符串,除非容器极宽,否则很可能无法适应可用空间'
}
```
---
```js wrap=false
// wrap=false 的示例
function getLongString() {
return '这是一个非常长的字符串,除非容器极宽,否则很可能无法适应可用空间'
}
```
#### 配置换行的缩进
```js wrap preserveIndent
// preserveIndent 示例(默认启用)
function getLongString() {
return '这是一个非常长的字符串,除非容器极宽,否则很可能无法适应可用空间'
}
```
---
```js wrap preserveIndent=false
// preserveIndent=false 的示例
function getLongString() {
return '这是一个非常长的字符串,除非容器极宽,否则很可能无法适应可用空间'
}
```
## 可折叠部分
[可折叠部分](https://expressive-code.com/plugins/collapsible-sections/)
```js collapse={1-5, 12-14, 21-24}
// 所有这些样板设置代码将被折叠
import { someBoilerplateEngine } from '@example/some-boilerplate'
import { evenMoreBoilerplate } from '@example/even-more-boilerplate'
const engine = someBoilerplateEngine(evenMoreBoilerplate())
// 这部分代码默认可见
engine.doSomething(1, 2, 3, calcFn)
function calcFn() {
// 您可以有多个折叠部分
const a = 1
const b = 2
const c = a + b
// 这将保持可见
console.log(`计算结果: ${a} + ${b} = ${c}`)
return c
}
// 直到块末尾的所有代码将再次被折叠
engine.closeConnection()
engine.freeMemory()
engine.shutdown({ reason: '示例样板代码结束' })
```
## 行号
[行号](https://expressive-code.com/plugins/line-numbers/)
### 为每个块显示行号
```js showLineNumbers
// 此代码块将显示行号
console.log('来自第2行的问候!')
console.log('我在第3行')
```
---
```js showLineNumbers=false
// 此块禁用行号
console.log('你好?')
console.log('抱歉,你知道我在第几行吗?')
```
### 更改起始行号
```js showLineNumbers startLineNumber=5
console.log('来自第5行的问候!')
console.log('我在第6行')
```

150
src/content/posts/docker.md Normal file
View File

@@ -0,0 +1,150 @@
---
title: Docker安装教程Ubuntu
published: 2025-01-02
pinned: false
description: Docker安装教程Ubuntu
tags: [Docker]
category: 教程系列
draft: false
---
# 一、前期准备
## 1、防火墙设置
1.如果您使用 ufw 或 firewalld 来管理防火墙设置,请注意,当您使用 Docker 公开容器端口时,这些端口会绕过您的防火墙规则。当您使用 Docker 发布容器的端口时,该容器的进出流量在通过 ufw 防火墙设置之前就会被重定向。Docker 在 `nat` 表中路由容器流量,这意味着数据包在到达 ufw 使用的 `INPUT` 和 `OUTPUT` 链之前就会被重定向。数据包在防火墙规则生效之前就被路由,实际上忽略了您的防火墙配置。
2.Docker 仅兼容 `iptables-nft` 和 `iptables-legacy` 。使用 `nft` 创建的防火墙规则在安装了 Docker 的系统上不受支持。请确保您使用的任何防火墙规则集都是使用 `iptables` 或 `ip6tables` 创建的,并将它们添加到 `DOCKER-USER` 链中,参见数据包过滤和防火墙。
具体点击下面链接查看:
[Packet filtering and firewalls](https://docs.docker.com/engine/network/packet-filtering-firewalls/)
## 2、操作系统要求
要安装 Docker 引擎,您需要以下 Ubuntu 版本之一的 64 位版本:
- Ubuntu Oracular 24.10
- Ubuntu Noble 24.04 (LTS)
- Ubuntu Jammy 22.04 (LTS)
- Ubuntu Focal 20.04 (LTS)
Docker 引擎适用于 Ubuntu兼容 x86_64或 amd64、armhf、arm64、s390x 和 ppc64leppc64el架构。
# 二、安装方法
[Docker安装官方教程](https://docs.docker.com/engine/install/ubuntu/#installation-methods)
# 三、非root用户运行Docker
Docker 守护进程绑定到 Unix 套接字,而不是 TCP 端口。默认情况下Unix 套接字属于 `root` 用户,其他用户只能使用 `sudo` 访问它。Docker 守护进程始终以 `root` 用户身份运行。
如果您不想在 `docker` 命令前加上 `sudo` ,请创建一个名为 `docker` 的 Unix 组并将用户添加到其中。当 Docker 守护进程启动时,它会创建一个 Unix 套接字,该套接字可供 `docker` 组的成员访问。在某些 Linux 发行版中,使用软件包管理器安装 Docker Engine 时,系统会自动创建此组。在这种情况下,您无需手动创建组。
1、创建 `docker` 群组并添加您的用户:
```bash
sudo groupadd docker
```
2、添加您的用户到 `docker` 组。
```bash
sudo usermod -aG docker $USER
```
3、退出并重新登录或者运行下面命令。
```bash
newgrp docker
```
4、在不使用 `sudo` 的情况下运行 `docker` 命令。
```bash
docker run hello-world
```
> 如果出现以下错误:
>
>
> WARNING: Error loading config file: /home/user/.docker/config.json -
> stat /home/user/.docker/config.json: permission denied
>
> 此错误表示由于之前使用了 `sudo` 命令, `~/.docker/` 目录的权限设置不正确。
> 要解决这个问题,要么删除 `~/.docker/` 目录(它将自动重新创建,但任何自定义设置都将丢失),要么使用以下命令更改其所有权和权限:
>
> ```bash
> sudo chown "$USER":"$USER" /home/"$USER"/.docker -R
> sudo chmod g+rwx "$HOME/.docker" -R
> ```
>
# 四、Docker配置
## 1、修改镜像源
创建或修改/etc/docker/daemon.json文件
```bash
vim /etc/docker/daemon.json
```
或:
```bash
nano /etc/docker/daemon.json
```
添加下面内容:
```json
{
"registry-mirrors": [
"https://registry-1.docker.io"
]
}
```
> 请修改地址为可用的镜像源
>
重启docker
```bash
service docker restart
```
查看是否更换成功
```bash
docker info
```
## 2、配置默认日志驱动程序
Docker 为主机上运行的所有容器提供日志驱动程序,用于收集和查看日志数据。默认的日志驱动程序 `json-file` 将日志数据写入主机文件系统中的 JSON 格式文件。随着时间的推移,这些日志文件会不断增大,可能导致磁盘资源耗尽。
为避免因过度使用磁盘存储日志数据而产生问题,请考虑以下选项之一:
- 配置 `json-file` 日志驱动程序以启用[日志轮转](https://docs.docker.com/engine/logging/drivers/json-file/)。
- 使用默认执行日志轮转的[替代日志驱动程序](https://docs.docker.com/engine/logging/configure/#configure-the-default-logging-driver),例如[“local”日志驱动程序](https://docs.docker.com/engine/logging/drivers/local/)。
- 使用将日志发送到远程日志聚合器的日志驱动程序。
# Docker卸载
1.卸载 Docker Engine、CLI、containerd 和 Docker Compose 软件包:
```bash
sudo apt-get purge docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras
```
2.镜像、容器、卷或自定义配置文件在您的宿主机上不会被自动删除。要删除所有镜像、容器和卷:
```bash
sudo rm -rf /var/lib/docker
sudo rm -rf /var/lib/containerd
```
3.删除源列表和密钥环
```bash
sudo rm /etc/apt/sources.list.d/docker.list
sudo rm /etc/apt/keyrings/docker.asc
```

View File

@@ -0,0 +1,22 @@
---
title: 草稿示例
published: 1970-01-01
tags: [Markdown, 博客, 演示]
category: 文章示例
draft: true
---
# 这篇文章是草稿
这篇文章目前处于草稿状态,尚未发布。因此,它不会对普通读者可见。内容仍在进行中,可能需要进一步编辑和审查。
当文章准备发布时,您可以在 Frontmatter 中将 "draft" 字段更新为 "false"
```markdown
---
title: 草稿示例
published: 2024-01-11T04:40:26.381Z
tags: [Markdown, 博客, 演示]
category: 示例
draft: false
---

Some files were not shown because too many files have changed in this diff Show More