first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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