first commit
This commit is contained in:
134
src/components/README.md
Normal file
134
src/components/README.md
Normal 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/** - 页面特效和动画
|
||||
---
|
||||
|
||||
19
src/components/analytics/GoogleAnalytics.astro
Normal file
19
src/components/analytics/GoogleAnalytics.astro
Normal 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>
|
||||
15
src/components/analytics/MicrosoftClarity.astro
Normal file
15
src/components/analytics/MicrosoftClarity.astro
Normal 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>
|
||||
50
src/components/comment/Artalk.astro
Normal file
50
src/components/comment/Artalk.astro
Normal 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>
|
||||
37
src/components/comment/Disqus.astro
Normal file
37
src/components/comment/Disqus.astro
Normal 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>
|
||||
63
src/components/comment/Giscus.astro
Normal file
63
src/components/comment/Giscus.astro
Normal 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>
|
||||
88
src/components/comment/Twikoo.astro
Normal file
88
src/components/comment/Twikoo.astro
Normal 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>
|
||||
25
src/components/comment/Waline.astro
Normal file
25
src/components/comment/Waline.astro
Normal 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>
|
||||
70
src/components/comment/index.astro
Normal file
70
src/components/comment/index.astro
Normal 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>
|
||||
)
|
||||
}
|
||||
196
src/components/common/README.md
Normal file
196
src/components/common/README.md
Normal 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)
|
||||
41
src/components/common/base/DropdownItem.astro
Normal file
41
src/components/common/base/DropdownItem.astro
Normal 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>
|
||||
44
src/components/common/base/DropdownItem.svelte
Normal file
44
src/components/common/base/DropdownItem.svelte
Normal 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>
|
||||
15
src/components/common/base/DropdownPanel.astro
Normal file
15
src/components/common/base/DropdownPanel.astro
Normal 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>
|
||||
20
src/components/common/base/DropdownPanel.svelte
Normal file
20
src/components/common/base/DropdownPanel.svelte
Normal 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>
|
||||
223
src/components/common/controls/BackToTop.astro
Normal file
223
src/components/common/controls/BackToTop.astro
Normal 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>
|
||||
43
src/components/common/controls/ButtonLink.astro
Normal file
43
src/components/common/controls/ButtonLink.astro
Normal 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>
|
||||
13
src/components/common/controls/ButtonTag.astro
Normal file
13
src/components/common/controls/ButtonTag.astro
Normal 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>
|
||||
404
src/components/common/controls/ClientPagination.astro
Normal file
404
src/components/common/controls/ClientPagination.astro
Normal 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>
|
||||
480
src/components/common/controls/FloatingTOC.astro
Normal file
480
src/components/common/controls/FloatingTOC.astro
Normal 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>
|
||||
123
src/components/common/controls/Pagination.astro
Normal file
123
src/components/common/controls/Pagination.astro
Normal 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>
|
||||
114
src/components/common/styles/TOCStyles.astro
Normal file
114
src/components/common/styles/TOCStyles.astro
Normal 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>
|
||||
259
src/components/content/PostCard.astro
Normal file
259
src/components/content/PostCard.astro
Normal 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>
|
||||
223
src/components/content/PostMeta.astro
Normal file
223
src/components/content/PostMeta.astro
Normal 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>
|
||||
106
src/components/content/Profile.astro
Normal file
106
src/components/content/Profile.astro
Normal 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>
|
||||
171
src/components/content/StatCard.astro
Normal file
171
src/components/content/StatCard.astro
Normal 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>
|
||||
176
src/components/content/TypewriterText.astro
Normal file
176
src/components/content/TypewriterText.astro
Normal 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>
|
||||
105
src/components/effects/FancyboxManager.astro
Normal file
105
src/components/effects/FancyboxManager.astro
Normal 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>
|
||||
16
src/components/effects/KatexManager.astro
Normal file
16
src/components/effects/KatexManager.astro
Normal 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>
|
||||
357
src/components/effects/SakuraEffect.astro
Normal file
357
src/components/effects/SakuraEffect.astro
Normal 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>
|
||||
)}
|
||||
156
src/components/interactive/ArchivePanel.svelte
Normal file
156
src/components/interactive/ArchivePanel.svelte
Normal 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>
|
||||
93
src/components/interactive/DisplaySettings.svelte
Normal file
93
src/components/interactive/DisplaySettings.svelte
Normal 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>
|
||||
173
src/components/interactive/FontManager.astro
Normal file
173
src/components/interactive/FontManager.astro
Normal 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>
|
||||
159
src/components/interactive/LayoutSwitchButton.svelte
Normal file
159
src/components/interactive/LayoutSwitchButton.svelte
Normal 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>
|
||||
154
src/components/interactive/LightDarkSwitch.svelte
Normal file
154
src/components/interactive/LightDarkSwitch.svelte
Normal 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_MODE,displayedMode会自动更新
|
||||
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>
|
||||
68
src/components/interactive/OverlayWallpaper.astro
Normal file
68
src/components/interactive/OverlayWallpaper.astro
Normal 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>
|
||||
|
||||
291
src/components/interactive/Search.svelte
Normal file
291
src/components/interactive/Search.svelte
Normal 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>
|
||||
|
||||
71
src/components/interactive/WallpaperSwitch.svelte
Normal file
71
src/components/interactive/WallpaperSwitch.svelte
Normal 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>
|
||||
11
src/components/layout/ConfigCarrier.astro
Normal file
11
src/components/layout/ConfigCarrier.astro
Normal 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>
|
||||
201
src/components/layout/DropdownMenu.astro
Normal file
201
src/components/layout/DropdownMenu.astro
Normal 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>
|
||||
69
src/components/layout/Footer.astro
Normal file
69
src/components/layout/Footer.astro
Normal 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(--primary)] mx-16 border-dashed py-8 max-w-[var(--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(--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} />}
|
||||
© <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>
|
||||
3
src/components/layout/GlobalStyles.astro
Normal file
3
src/components/layout/GlobalStyles.astro
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
|
||||
---
|
||||
148
src/components/layout/LeftSideBar.astro
Normal file
148
src/components/layout/LeftSideBar.astro
Normal 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>
|
||||
129
src/components/layout/NavMenuPanel.astro
Normal file
129
src/components/layout/NavMenuPanel.astro
Normal 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>
|
||||
261
src/components/layout/Navbar.astro
Normal file
261
src/components/layout/Navbar.astro
Normal 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>
|
||||
)
|
||||
}
|
||||
452
src/components/layout/PostPage.astro
Normal file
452
src/components/layout/PostPage.astro
Normal 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>
|
||||
201
src/components/layout/RightSideBar.astro
Normal file
201
src/components/layout/RightSideBar.astro
Normal 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>
|
||||
275
src/components/layout/SideBar.astro
Normal file
275
src/components/layout/SideBar.astro
Normal 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>
|
||||
105
src/components/misc/Icon.astro
Normal file
105
src/components/misc/Icon.astro
Normal 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>
|
||||
270
src/components/misc/IconifyLoader.astro
Normal file
270
src/components/misc/IconifyLoader.astro
Normal 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>
|
||||
53
src/components/misc/ImageWrapper.astro
Normal file
53
src/components/misc/ImageWrapper.astro
Normal 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>
|
||||
70
src/components/misc/License.astro
Normal file
70
src/components/misc/License.astro
Normal 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>
|
||||
23
src/components/misc/Markdown.astro
Normal file
23
src/components/misc/Markdown.astro
Normal 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>
|
||||
755
src/components/misc/RandomCoverImage.astro
Normal file
755
src/components/misc/RandomCoverImage.astro
Normal 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>
|
||||
536
src/components/misc/SharePoster.svelte
Normal file
536
src/components/misc/SharePoster.svelte
Normal 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}
|
||||
191
src/components/pages/AdvancedSearch.svelte
Normal file
191
src/components/pages/AdvancedSearch.svelte
Normal 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>
|
||||
149
src/components/pages/bangumi/BangumiSection.astro
Normal file
149
src/components/pages/bangumi/BangumiSection.astro
Normal 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>
|
||||
135
src/components/pages/bangumi/Card.astro
Normal file
135
src/components/pages/bangumi/Card.astro
Normal 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>
|
||||
97
src/components/pages/bangumi/FilterControls.astro
Normal file
97
src/components/pages/bangumi/FilterControls.astro
Normal 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>
|
||||
79
src/components/pages/bangumi/TabNav.astro
Normal file
79
src/components/pages/bangumi/TabNav.astro
Normal 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>
|
||||
376
src/components/widget/Advertisement.astro
Normal file
376
src/components/widget/Advertisement.astro
Normal 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>
|
||||
89
src/components/widget/Announcement.astro
Normal file
89
src/components/widget/Announcement.astro
Normal 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>
|
||||
496
src/components/widget/Calendar.astro
Normal file
496
src/components/widget/Calendar.astro
Normal 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>
|
||||
43
src/components/widget/Categories.astro
Normal file
43
src/components/widget/Categories.astro
Normal 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>
|
||||
453
src/components/widget/Live2DWidget.astro
Normal file
453
src/components/widget/Live2DWidget.astro
Normal 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>
|
||||
547
src/components/widget/MusicPlayer.astro
Normal file
547
src/components/widget/MusicPlayer.astro
Normal 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() 处理以确保非根目录部署时正确
|
||||
// 如果是完整的 URL(http/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>
|
||||
|
||||
309
src/components/widget/PioMessageBox.astro
Normal file
309
src/components/widget/PioMessageBox.astro
Normal 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>
|
||||
122
src/components/widget/SidebarTOC.astro
Normal file
122
src/components/widget/SidebarTOC.astro
Normal 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>
|
||||
180
src/components/widget/SiteStats.astro
Normal file
180
src/components/widget/SiteStats.astro
Normal 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>
|
||||
399
src/components/widget/SpineModel.astro
Normal file
399
src/components/widget/SpineModel.astro
Normal 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>
|
||||
40
src/components/widget/Tags.astro
Normal file
40
src/components/widget/Tags.astro
Normal 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>
|
||||
63
src/components/widget/WidgetLayout.astro
Normal file
63
src/components/widget/WidgetLayout.astro
Normal 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>
|
||||
1
src/config/FooterConfig.html
Normal file
1
src/config/FooterConfig.html
Normal file
@@ -0,0 +1 @@
|
||||
这里是HTML注入示例,你可以在这个文件中添加自定义的HTML内容
|
||||
66
src/config/README.md
Normal file
66
src/config/README.md
Normal 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
63
src/config/adConfig.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
23
src/config/announcementConfig.ts
Normal file
23
src/config/announcementConfig.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
110
src/config/backgroundWallpaper.ts
Normal file
110
src/config/backgroundWallpaper.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
72
src/config/commentConfig.ts
Normal file
72
src/config/commentConfig.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
79
src/config/coverImageConfig.ts
Normal file
79
src/config/coverImageConfig.ts
Normal 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)",
|
||||
},
|
||||
},
|
||||
};
|
||||
12
src/config/expressiveCodeConfig.ts
Normal file
12
src/config/expressiveCodeConfig.ts
Normal 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
69
src/config/fontConfig.ts
Normal 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",
|
||||
],
|
||||
};
|
||||
8
src/config/footerConfig.ts
Normal file
8
src/config/footerConfig.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { FooterConfig } from "../types/config";
|
||||
|
||||
export const footerConfig: FooterConfig = {
|
||||
// 是否启用Footer HTML注入功能
|
||||
enable: false,
|
||||
};
|
||||
|
||||
// 直接编辑 config/FooterConfig.html 文件来添加备案号等自定义内容
|
||||
47
src/config/friendsConfig.ts
Normal file
47
src/config/friendsConfig.ts
Normal 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
47
src/config/index.ts
Normal 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"; // 赞助配置
|
||||
10
src/config/licenseConfig.ts
Normal file
10
src/config/licenseConfig.ts
Normal 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
89
src/config/musicConfig.ts
Normal 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 脚本路径
|
||||
// 默认使用 CDN:https://cdn.jsdelivr.net/npm/meting@2/dist/Meting.min.js
|
||||
// 备用CDN:https://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,
|
||||
},
|
||||
},
|
||||
};
|
||||
91
src/config/navBarConfig.ts
Normal file
91
src/config/navBarConfig.ts
Normal 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
138
src/config/pioConfig.ts
Normal 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-left,bottom-right,top-left,top-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-left,bottom-right,top-left,top-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,
|
||||
},
|
||||
};
|
||||
45
src/config/profileConfig.ts
Normal file
45
src/config/profileConfig.ts
Normal 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-brands,fa6-regular,fa6-solid,material-symbols,simple-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,
|
||||
},
|
||||
],
|
||||
};
|
||||
53
src/config/sakuraConfig.ts
Normal file
53
src/config/sakuraConfig.ts
Normal 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
201
src/config/sidebarConfig.ts
Normal 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,
|
||||
|
||||
// 左侧边栏组件配置列表
|
||||
// 组件位置position:top=顶部,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
145
src/config/siteConfig.ts
Normal 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,
|
||||
};
|
||||
74
src/config/sponsorConfig.ts
Normal file
74
src/config/sponsorConfig.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
25
src/constants/constants.ts
Normal file
25
src/constants/constants.ts
Normal 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
44
src/constants/icon.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
41
src/constants/link-presets.ts
Normal file
41
src/constants/link-presets.ts
Normal 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
40
src/content.config.ts
Normal 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
228
src/content/posts/agent.md
Normal 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 --> |触发|执行器
|
||||
```
|
||||
函数调用执行全过程:
|
||||
[](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
|
||||
313
src/content/posts/code-examples.md
Normal file
313
src/content/posts/code-examples.md
Normal 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: [31mRed[0m [32mGreen[0m [33mYellow[0m [34mBlue[0m [35mMagenta[0m [36mCyan[0m
|
||||
- Bold: [1;31mRed[0m [1;32mGreen[0m [1;33mYellow[0m [1;34mBlue[0m [1;35mMagenta[0m [1;36mCyan[0m
|
||||
- Dimmed: [2;31mRed[0m [2;32mGreen[0m [2;33mYellow[0m [2;34mBlue[0m [2;35mMagenta[0m [2;36mCyan[0m
|
||||
|
||||
256 colors (showing colors 160-177):
|
||||
[38;5;160m160 [38;5;161m161 [38;5;162m162 [38;5;163m163 [38;5;164m164 [38;5;165m165[0m
|
||||
[38;5;166m166 [38;5;167m167 [38;5;168m168 [38;5;169m169 [38;5;170m170 [38;5;171m171[0m
|
||||
[38;5;172m172 [38;5;173m173 [38;5;174m174 [38;5;175m175 [38;5;176m176 [38;5;177m177[0m
|
||||
|
||||
Full RGB colors:
|
||||
[38;2;34;139;34mForestGreen - RGB(34, 139, 34)[0m
|
||||
|
||||
Text formatting: [1mBold[0m [2mDimmed[0m [3mItalic[0m [4mUnderline[0m
|
||||
```
|
||||
|
||||
### 编辑器和终端框架
|
||||
|
||||
[编辑器和终端框架](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
150
src/content/posts/docker.md
Normal 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 和 ppc64le(ppc64el)架构。
|
||||
|
||||
# 二、安装方法
|
||||
|
||||
[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
|
||||
```
|
||||
22
src/content/posts/draft.md
Normal file
22
src/content/posts/draft.md
Normal 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
Reference in New Issue
Block a user