first commit
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user