修复导航栏高亮代码不会变化和切换主题按钮闪烁
This commit is contained in:
parent
0d48cc8591
commit
7dcf3f2a8b
88
src/components/Footer.astro
Normal file
88
src/components/Footer.astro
Normal file
@ -0,0 +1,88 @@
|
||||
---
|
||||
interface Props {
|
||||
icp?: string;
|
||||
psbIcp?: string;
|
||||
psbIcpUrl?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
icp = "",
|
||||
psbIcp = "",
|
||||
psbIcpUrl = "http://www.beian.gov.cn/portal/registerSystemInfo",
|
||||
} = Astro.props;
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer
|
||||
class="w-full py-6 px-4 bg-gray-50 dark:bg-dark-bg border-t border-gray-200 dark:border-gray-800 mt-auto"
|
||||
>
|
||||
<div
|
||||
class="max-w-5xl mx-auto flex flex-col items-center justify-center space-y-4"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-center gap-4 text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
{
|
||||
icp && (
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label="工信部备案信息"
|
||||
>
|
||||
{icp}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
psbIcp && (
|
||||
<a
|
||||
href={psbIcpUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label="公安部备案信息"
|
||||
>
|
||||
<img
|
||||
src="/images/national.png"
|
||||
alt="公安备案"
|
||||
class="h-4 mr-1"
|
||||
width="14"
|
||||
height="16"
|
||||
loading="lazy"
|
||||
/>
|
||||
{psbIcp}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-sm text-gray-500 dark:text-gray-500 font-light flex flex-wrap items-center justify-center gap-2"
|
||||
>
|
||||
<a
|
||||
href="https://blog.lsy22.com"
|
||||
class="hover:text-primary-600 dark:hover:text-primary-400"
|
||||
>
|
||||
© {currentYear} New Echoes. All rights reserved.
|
||||
</a>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="hidden sm:inline"
|
||||
>·</span
|
||||
>
|
||||
<a
|
||||
href="/sitemap-index.xml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label="网站地图"
|
||||
>
|
||||
Sitemap
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
@ -1,65 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface FooterProps {
|
||||
icp?: string;
|
||||
psbIcp?: string;
|
||||
psbIcpUrl?: string;
|
||||
}
|
||||
|
||||
export function Footer({
|
||||
icp = "",
|
||||
psbIcp = "",
|
||||
psbIcpUrl = "http://www.beian.gov.cn/portal/registerSystemInfo",
|
||||
}: FooterProps) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="w-full py-6 px-4 bg-gray-50 dark:bg-dark-bg border-t border-gray-200 dark:border-gray-800 mt-auto">
|
||||
<div className="max-w-5xl mx-auto flex flex-col items-center justify-center space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
|
||||
{icp && (
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label="工信部备案信息"
|
||||
>
|
||||
{icp}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{psbIcp && (
|
||||
<a
|
||||
href={psbIcpUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label="公安部备案信息"
|
||||
>
|
||||
<img src="/images/national.png" alt="公安备案" className="h-4 mr-1" width="14" height="16" loading="lazy" />
|
||||
{psbIcp}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500 dark:text-gray-500 font-light flex flex-wrap items-center justify-center gap-2">
|
||||
<a href="https://blog.lsy22.com" className="hover:text-primary-600 dark:hover:text-primary-400">
|
||||
© {currentYear} New Echoes. All rights reserved.
|
||||
</a>
|
||||
<span aria-hidden="true" className="hidden sm:inline">·</span>
|
||||
<a
|
||||
href="/sitemap-index.xml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label="网站地图"
|
||||
>
|
||||
Sitemap
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
767
src/components/Header.astro
Normal file
767
src/components/Header.astro
Normal file
@ -0,0 +1,767 @@
|
||||
---
|
||||
import { SITE_NAME, NAV_LINKS } from "@/consts.ts";
|
||||
import ThemeToggle from "./ThemeToggle.astro";
|
||||
|
||||
// 获取当前路径
|
||||
const currentPath = Astro.url.pathname;
|
||||
|
||||
// 移除结尾的斜杠以统一路径格式(保留根路径的斜杠)
|
||||
const normalizedPath =
|
||||
currentPath === "/"
|
||||
? "/"
|
||||
: currentPath.endsWith("/")
|
||||
? currentPath.slice(0, -1)
|
||||
: currentPath;
|
||||
|
||||
// 定义导航链接
|
||||
---
|
||||
|
||||
<header
|
||||
class="fixed w-full top-0 z-50"
|
||||
id="main-header"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-gray-50/95 dark:bg-dark-bg/95"
|
||||
id="header-bg"
|
||||
>
|
||||
</div>
|
||||
<nav class="relative">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<!-- Logo 部分 -->
|
||||
<div class="flex items-center">
|
||||
<a
|
||||
href="/"
|
||||
class="text-xl md:text-2xl font-bold tracking-tight bg-gradient-to-r from-primary-600 to-primary-400 bg-clip-text text-transparent hover:from-primary-500 hover:to-primary-300 dark:from-primary-400 dark:to-primary-200 dark:hover:from-primary-300 dark:hover:to-primary-100"
|
||||
>
|
||||
{SITE_NAME}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 导航链接 -->
|
||||
<div class="hidden md:flex md:items-center md:space-x-8">
|
||||
<!-- 桌面端搜索框 -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="desktop-search"
|
||||
class="w-48 pl-10 pr-4 py-1.5 rounded-full text-sm text-gray-700 dark:text-gray-200 placeholder-gray-500 dark:placeholder-gray-400 bg-gray-50/80 dark:bg-gray-800/60 border border-gray-200/60 dark:border-gray-700/40 focus:outline-none focus:ring-1 focus:ring-primary-400 dark:focus:ring-primary-500 focus:bg-white dark:focus:bg-gray-800 focus:border-primary-300 dark:focus:border-primary-600"
|
||||
placeholder="搜索文章..."
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400 dark:text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果容器(默认隐藏) -->
|
||||
<div
|
||||
id="desktop-search-results"
|
||||
class="absolute top-full left-0 right-0 mt-2 max-h-80 overflow-y-auto rounded-lg bg-white/95 dark:bg-gray-800/95 shadow-md border border-gray-200/70 dark:border-gray-700/70 backdrop-blur-sm z-50 hidden"
|
||||
>
|
||||
<!-- 结果将通过JS动态填充 -->
|
||||
<div
|
||||
class="p-4 text-center text-gray-500 dark:text-gray-400"
|
||||
id="desktop-search-message"
|
||||
>
|
||||
<p>输入关键词开始搜索</p>
|
||||
</div>
|
||||
<ul
|
||||
class="divide-y divide-gray-200/70 dark:divide-gray-700/70"
|
||||
id="desktop-search-list"
|
||||
>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
NAV_LINKS.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class:list={[
|
||||
"inline-flex items-center px-1 pt-1 text-sm font-medium",
|
||||
normalizedPath === link.href
|
||||
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-600 dark:border-primary-400"
|
||||
: "text-secondary-600 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400 hover:border-b-2 hover:border-primary-300 dark:hover:border-primary-700",
|
||||
]}
|
||||
>
|
||||
{link.text}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<!-- 移动端菜单按钮 -->
|
||||
<div class="flex items-center md:hidden">
|
||||
<!-- 移动端搜索按钮 -->
|
||||
<button
|
||||
type="button"
|
||||
id="mobile-search-button"
|
||||
class="inline-flex items-center justify-center p-2 rounded-md text-secondary-400 dark:text-secondary-500 hover:text-secondary-500 dark:hover:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-dark-card focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500 mr-2"
|
||||
aria-expanded="false"
|
||||
aria-label="搜索"
|
||||
>
|
||||
<span class="sr-only">搜索</span>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center p-2 rounded-md text-secondary-400 dark:text-secondary-500 hover:text-secondary-500 dark:hover:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-dark-card focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
|
||||
id="mobile-menu-button"
|
||||
aria-expanded="false"
|
||||
aria-label="打开菜单"
|
||||
>
|
||||
<span class="sr-only">打开菜单</span>
|
||||
<svg
|
||||
class="h-6 w-6 block"
|
||||
id="menu-open-icon"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
class="h-6 w-6 hidden"
|
||||
id="menu-close-icon"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端搜索面板 -->
|
||||
<div
|
||||
id="mobile-search-panel"
|
||||
class="hidden md:hidden fixed inset-x-0 top-16 p-4 bg-white dark:bg-gray-800 shadow-md z-50 border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="mobile-search"
|
||||
class="w-full pl-10 pr-10 py-2 rounded-full text-sm text-gray-700 dark:text-gray-200 placeholder-gray-500 dark:placeholder-gray-400 bg-gray-50/80 dark:bg-gray-800/60 border border-gray-200/60 dark:border-gray-700/40 focus:outline-none focus:ring-1 focus:ring-primary-400 dark:focus:ring-primary-500 focus:bg-white dark:focus:bg-gray-800 focus:border-primary-300 dark:focus:border-primary-600"
|
||||
placeholder="搜索文章..."
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400 dark:text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
id="mobile-search-close"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
aria-label="关闭搜索"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 移动端搜索结果 -->
|
||||
<div
|
||||
id="mobile-search-results"
|
||||
class="mt-3 max-h-80 overflow-y-auto rounded-lg bg-white/95 dark:bg-gray-800/95 shadow-md border border-gray-200/70 dark:border-gray-700/70 backdrop-blur-sm hidden"
|
||||
>
|
||||
<!-- 结果将通过JS动态填充 -->
|
||||
<div
|
||||
class="p-4 text-center text-gray-500 dark:text-gray-400"
|
||||
id="mobile-search-message"
|
||||
>
|
||||
<p>输入关键词开始搜索</p>
|
||||
</div>
|
||||
<ul
|
||||
class="divide-y divide-gray-200/70 dark:divide-gray-700/70"
|
||||
id="mobile-search-list"
|
||||
>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端菜单 -->
|
||||
<div
|
||||
class="hidden md:hidden fixed inset-x-0 top-16 z-40"
|
||||
id="mobile-menu"
|
||||
>
|
||||
<div id="mobile-menu-bg">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2">
|
||||
<div class="grid gap-1">
|
||||
{
|
||||
NAV_LINKS.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class:list={[
|
||||
"flex items-center px-3 py-3 rounded-lg text-base font-medium",
|
||||
normalizedPath === link.href
|
||||
? "text-white bg-primary-600 dark:bg-primary-500 shadow-sm"
|
||||
: "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800/70",
|
||||
]}
|
||||
>
|
||||
{link.text}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<div
|
||||
class="mt-2 pt-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800/70 rounded-lg px-3 py-2"
|
||||
id="theme-toggle-container"
|
||||
>
|
||||
<span class="text-sm font-medium text-gray-600 dark:text-gray-300"
|
||||
>切换主题</span
|
||||
>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
// 确保脚本适用于视图转换
|
||||
function initHeader() {
|
||||
const header = document.getElementById("header-bg");
|
||||
const scrollThreshold = 50;
|
||||
|
||||
// 记录当前路径和导航状态
|
||||
const currentUrl = window.location.pathname;
|
||||
const normalizedPath =
|
||||
currentUrl === "/"
|
||||
? "/"
|
||||
: currentUrl.endsWith("/")
|
||||
? currentUrl.slice(0, -1)
|
||||
: currentUrl;
|
||||
|
||||
// 获取所有导航链接
|
||||
const navLinks = document.querySelectorAll("nav a[href]");
|
||||
|
||||
// 更新导航高亮状态
|
||||
function updateNavHighlight() {
|
||||
const currentUrl = window.location.pathname;
|
||||
const normalizedPath =
|
||||
currentUrl === "/"
|
||||
? "/"
|
||||
: currentUrl.endsWith("/")
|
||||
? currentUrl.slice(0, -1)
|
||||
: currentUrl;
|
||||
|
||||
navLinks.forEach((link) => {
|
||||
const href = link.getAttribute("href");
|
||||
|
||||
if (href === normalizedPath) {
|
||||
// 添加高亮类
|
||||
link.classList.add(
|
||||
"text-primary-600",
|
||||
"dark:text-primary-400",
|
||||
"border-b-2",
|
||||
"border-primary-600",
|
||||
"dark:border-primary-400",
|
||||
);
|
||||
link.classList.remove(
|
||||
"text-secondary-600",
|
||||
"dark:text-secondary-400",
|
||||
"hover:text-primary-600",
|
||||
"dark:hover:text-primary-400",
|
||||
"hover:border-b-2",
|
||||
"hover:border-primary-300",
|
||||
"dark:hover:border-primary-700",
|
||||
);
|
||||
} else {
|
||||
// 移除高亮类
|
||||
link.classList.remove(
|
||||
"text-primary-600",
|
||||
"dark:text-primary-400",
|
||||
"border-b-2",
|
||||
"border-primary-600",
|
||||
"dark:border-primary-400",
|
||||
);
|
||||
link.classList.add(
|
||||
"text-secondary-600",
|
||||
"dark:text-secondary-400",
|
||||
"hover:text-primary-600",
|
||||
"dark:hover:text-primary-400",
|
||||
"hover:border-b-2",
|
||||
"hover:border-primary-300",
|
||||
"dark:hover:border-primary-700",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateHeaderBackground() {
|
||||
if (window.scrollY > scrollThreshold) {
|
||||
header?.classList.add("scrolled");
|
||||
} else {
|
||||
header?.classList.remove("scrolled");
|
||||
}
|
||||
}
|
||||
|
||||
// 初始检查
|
||||
updateHeaderBackground();
|
||||
// 初始化导航高亮
|
||||
updateNavHighlight();
|
||||
|
||||
// 添加滚动事件监听
|
||||
window.addEventListener("scroll", updateHeaderBackground);
|
||||
|
||||
// 移动端菜单逻辑
|
||||
const mobileMenuButton = document.getElementById("mobile-menu-button");
|
||||
const mobileMenu = document.getElementById("mobile-menu");
|
||||
const menuOpenIcon = document.getElementById("menu-open-icon");
|
||||
const menuCloseIcon = document.getElementById("menu-close-icon");
|
||||
|
||||
if (mobileMenuButton && mobileMenu && menuOpenIcon && menuCloseIcon) {
|
||||
mobileMenuButton.addEventListener("click", () => {
|
||||
const expanded =
|
||||
mobileMenuButton.getAttribute("aria-expanded") === "true";
|
||||
|
||||
// 切换菜单状态
|
||||
mobileMenuButton.setAttribute("aria-expanded", (!expanded).toString());
|
||||
|
||||
if (expanded) {
|
||||
// 直接隐藏菜单,不使用过渡效果
|
||||
mobileMenu.classList.add("hidden");
|
||||
} else {
|
||||
// 直接显示菜单,不使用过渡效果
|
||||
mobileMenu.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// 切换图标
|
||||
menuOpenIcon.classList.toggle("hidden");
|
||||
menuCloseIcon.classList.toggle("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
// 移动端主题切换容器点击处理
|
||||
const themeToggleContainer = document.getElementById(
|
||||
"theme-toggle-container",
|
||||
);
|
||||
|
||||
if (themeToggleContainer) {
|
||||
themeToggleContainer.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const themeToggleButton =
|
||||
themeToggleContainer.querySelector('[role="button"]');
|
||||
|
||||
// 如果点击的不是主题切换按钮本身,而是容器或文本
|
||||
if (
|
||||
themeToggleButton instanceof HTMLElement &&
|
||||
target !== themeToggleButton &&
|
||||
!themeToggleButton.contains(target)
|
||||
) {
|
||||
// 手动触发主题切换按钮的点击
|
||||
themeToggleButton.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 移动端搜索按钮
|
||||
const mobileSearchButton = document.getElementById("mobile-search-button");
|
||||
const mobileSearchPanel = document.getElementById("mobile-search-panel");
|
||||
const mobileSearch = document.getElementById("mobile-search");
|
||||
const mobileSearchClose = document.getElementById("mobile-search-close");
|
||||
|
||||
if (mobileSearchButton && mobileSearchPanel) {
|
||||
mobileSearchButton.addEventListener("click", () => {
|
||||
mobileSearchPanel.classList.remove("hidden");
|
||||
mobileSearchPanel.classList.add("show");
|
||||
if (mobileSearch) mobileSearch.focus();
|
||||
});
|
||||
|
||||
if (mobileSearchClose) {
|
||||
mobileSearchClose.addEventListener("click", () => {
|
||||
mobileSearchPanel.classList.add("hidden");
|
||||
mobileSearchPanel.classList.remove("show");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索功能逻辑
|
||||
function initSearch() {
|
||||
// 搜索节流函数
|
||||
function debounce<T extends (...args: any[]) => void>(
|
||||
func: T,
|
||||
wait: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
return function (this: any, ...args: Parameters<T>): void {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
// 获取DOM元素
|
||||
const desktopSearch = document.getElementById("desktop-search");
|
||||
const desktopResults = document.getElementById("desktop-search-results");
|
||||
const desktopList = document.getElementById("desktop-search-list");
|
||||
const desktopMessage = document.getElementById("desktop-search-message");
|
||||
|
||||
const mobileSearch = document.getElementById("mobile-search");
|
||||
const mobileResults = document.getElementById("mobile-search-results");
|
||||
const mobileList = document.getElementById("mobile-search-list");
|
||||
const mobileMessage = document.getElementById("mobile-search-message");
|
||||
|
||||
// 文章对象的接口定义
|
||||
interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string | Date;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
image?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
let articles: Article[] = [];
|
||||
let isArticlesLoaded = false;
|
||||
|
||||
// 获取文章数据
|
||||
async function fetchArticles() {
|
||||
if (isArticlesLoaded && articles.length > 0) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/search");
|
||||
if (!response.ok) {
|
||||
throw new Error("获取文章数据失败");
|
||||
}
|
||||
articles = await response.json();
|
||||
isArticlesLoaded = true;
|
||||
} catch (error) {
|
||||
console.error("获取文章失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 高亮文本中的匹配部分
|
||||
function highlightText(text: string, query: string): string {
|
||||
if (!text || !query.trim()) return text;
|
||||
|
||||
// 转义正则表达式中的特殊字符
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||
|
||||
return text.replace(
|
||||
regex,
|
||||
'<mark class="bg-yellow-100 dark:bg-yellow-900/30 text-gray-900 dark:text-gray-100 px-0.5 rounded">$1</mark>',
|
||||
);
|
||||
}
|
||||
|
||||
// 搜索文章
|
||||
function searchArticles(
|
||||
query: string,
|
||||
resultsList: HTMLElement,
|
||||
resultsMessage: HTMLElement,
|
||||
) {
|
||||
if (!query.trim()) {
|
||||
resultsList.innerHTML = "";
|
||||
resultsMessage.textContent = "输入关键词开始搜索";
|
||||
resultsMessage.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
if (articles.length === 0) {
|
||||
resultsMessage.textContent = "正在加载数据...";
|
||||
resultsMessage.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
// 过滤并排序结果
|
||||
const filteredArticles = articles
|
||||
.filter((article: Article) => {
|
||||
const title = article.title.toLowerCase();
|
||||
const tags = article.tags
|
||||
? article.tags.map((tag: string) => tag.toLowerCase())
|
||||
: [];
|
||||
const summary = article.summary ? article.summary.toLowerCase() : "";
|
||||
const content = article.content ? article.content.toLowerCase() : "";
|
||||
|
||||
return (
|
||||
title.includes(lowerQuery) ||
|
||||
tags.some((tag: string) => tag.includes(lowerQuery)) ||
|
||||
summary.includes(lowerQuery) ||
|
||||
content.includes(lowerQuery)
|
||||
);
|
||||
})
|
||||
.sort((a: Article, b: Article) => {
|
||||
// 标题匹配优先
|
||||
const aTitle = a.title.toLowerCase();
|
||||
const bTitle = b.title.toLowerCase();
|
||||
|
||||
if (aTitle.includes(lowerQuery) && !bTitle.includes(lowerQuery)) {
|
||||
return -1;
|
||||
}
|
||||
if (!aTitle.includes(lowerQuery) && bTitle.includes(lowerQuery)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 内容匹配次之
|
||||
const aContent = a.content ? a.content.toLowerCase() : "";
|
||||
const bContent = b.content ? b.content.toLowerCase() : "";
|
||||
|
||||
if (aContent.includes(lowerQuery) && !bContent.includes(lowerQuery)) {
|
||||
return -1;
|
||||
}
|
||||
if (!aContent.includes(lowerQuery) && bContent.includes(lowerQuery)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 日期排序
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||
})
|
||||
.slice(0, 10); // 限制结果数量
|
||||
|
||||
if (filteredArticles.length === 0) {
|
||||
resultsList.innerHTML = "";
|
||||
resultsMessage.textContent = "没有找到相关内容";
|
||||
resultsMessage.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示结果
|
||||
resultsMessage.style.display = "none";
|
||||
resultsList.innerHTML = filteredArticles
|
||||
.map((article: Article) => {
|
||||
// 生成匹配的内容片段
|
||||
let contentMatch = "";
|
||||
if (
|
||||
article.content &&
|
||||
article.content.toLowerCase().includes(lowerQuery)
|
||||
) {
|
||||
// 找到匹配文本在内容中的位置
|
||||
const matchIndex = article.content
|
||||
.toLowerCase()
|
||||
.indexOf(lowerQuery);
|
||||
// 计算片段的起始和结束位置
|
||||
const startPos = Math.max(0, matchIndex - 50);
|
||||
const endPos = Math.min(article.content.length, matchIndex + 100);
|
||||
// 提取片段
|
||||
let snippet = article.content.substring(startPos, endPos);
|
||||
// 如果不是从文章开头开始,添加省略号
|
||||
if (startPos > 0) {
|
||||
snippet = "..." + snippet;
|
||||
}
|
||||
// 如果不是到文章结尾,添加省略号
|
||||
if (endPos < article.content.length) {
|
||||
snippet = snippet + "...";
|
||||
}
|
||||
// 高亮匹配的文本
|
||||
snippet = highlightText(snippet, query);
|
||||
contentMatch = `<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">${snippet}</p>`;
|
||||
}
|
||||
|
||||
// 高亮标题和摘要中的匹配文本
|
||||
const highlightedTitle = highlightText(article.title, query);
|
||||
const highlightedSummary = article.summary
|
||||
? highlightText(article.summary, query)
|
||||
: "";
|
||||
|
||||
return `
|
||||
<li>
|
||||
<a href="/articles/${article.id}" class="block px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700/70">
|
||||
<h3 class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">${highlightedTitle}</h3>
|
||||
${article.summary ? `<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate">${highlightedSummary}</p>` : ""}
|
||||
${contentMatch}
|
||||
${
|
||||
article.tags && article.tags.length > 0
|
||||
? `
|
||||
<div class="flex flex-wrap gap-1 mt-1.5">
|
||||
${article.tags
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(tag: string) => `
|
||||
<span class="inline-block text-xs bg-primary-50/50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400 py-0.5 px-1.5 rounded-full">#${tag}</span>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
${article.tags.length > 3 ? `<span class="text-xs text-gray-400 dark:text-gray-500">+${article.tags.length - 3}</span>` : ""}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// 节流搜索
|
||||
const debouncedDesktopSearch = debounce((value: string) => {
|
||||
if (desktopList && desktopMessage) {
|
||||
searchArticles(
|
||||
value,
|
||||
desktopList as HTMLElement,
|
||||
desktopMessage as HTMLElement,
|
||||
);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const debouncedMobileSearch = debounce((value: string) => {
|
||||
if (mobileList && mobileMessage) {
|
||||
searchArticles(
|
||||
value,
|
||||
mobileList as HTMLElement,
|
||||
mobileMessage as HTMLElement,
|
||||
);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// 桌面端搜索逻辑
|
||||
if (desktopSearch && desktopResults) {
|
||||
desktopSearch.addEventListener("focus", () => {
|
||||
desktopResults.classList.remove("hidden");
|
||||
if (!isArticlesLoaded) fetchArticles();
|
||||
});
|
||||
|
||||
desktopSearch.addEventListener("input", (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target && target.value !== undefined) {
|
||||
debouncedDesktopSearch(target.value);
|
||||
}
|
||||
});
|
||||
|
||||
// 点击外部关闭结果
|
||||
document.addEventListener("click", (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
desktopSearch &&
|
||||
!desktopSearch.contains(target) &&
|
||||
!desktopResults.contains(target)
|
||||
) {
|
||||
desktopResults.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
// ESC键关闭结果
|
||||
desktopSearch.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
desktopResults.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 移动端搜索逻辑
|
||||
if (mobileSearch && mobileResults) {
|
||||
mobileSearch.addEventListener("input", (e: Event) => {
|
||||
mobileResults.classList.remove("hidden");
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target && target.value !== undefined) {
|
||||
debouncedMobileSearch(target.value);
|
||||
if (!isArticlesLoaded) fetchArticles();
|
||||
}
|
||||
});
|
||||
|
||||
// ESC键关闭搜索面板
|
||||
mobileSearch.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
const mobileSearchPanel = document.getElementById(
|
||||
"mobile-search-panel",
|
||||
);
|
||||
if (mobileSearchPanel) {
|
||||
mobileSearchPanel.classList.add("hidden");
|
||||
mobileSearchPanel.classList.remove("show");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化函数
|
||||
function setupHeader() {
|
||||
initHeader();
|
||||
initSearch();
|
||||
}
|
||||
|
||||
// 在文档加载时初始化一次
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", setupHeader);
|
||||
} else {
|
||||
setupHeader();
|
||||
}
|
||||
|
||||
// 支持 Astro 视图转换
|
||||
document.addEventListener("astro:after-swap", setupHeader);
|
||||
|
||||
document.addEventListener("astro:page-load", setupHeader);
|
||||
|
||||
// 原事件监听 - 保留以兼容可能的旧版本
|
||||
document.addEventListener("astro:swup:page:view", setupHeader);
|
||||
|
||||
// 清理
|
||||
document.addEventListener("astro:before-swap", () => {
|
||||
// 移除可能的全局事件监听器
|
||||
window.removeEventListener("scroll", () => {});
|
||||
});
|
||||
</script>
|
@ -1,421 +0,0 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { SITE_NAME, NAV_LINKS } from '@/consts.ts';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import '@/styles/header.css';
|
||||
|
||||
|
||||
// 文章对象类型定义
|
||||
interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string | Date;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
image?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
// 状态定义
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
const [isArticlesLoaded, setIsArticlesLoaded] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [desktopSearchFocused, setDesktopSearchFocused] = useState(false);
|
||||
|
||||
// 获取当前路径 (使用window.location.pathname)
|
||||
const [pathname, setPathname] = useState('/');
|
||||
useEffect(() => {
|
||||
setPathname(window.location.pathname);
|
||||
}, []);
|
||||
|
||||
// 移除结尾的斜杠以统一路径格式
|
||||
const normalizedPath = pathname?.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
|
||||
// 引用
|
||||
const desktopSearchRef = useRef<HTMLInputElement>(null);
|
||||
const desktopResultsRef = useRef<HTMLDivElement>(null);
|
||||
const mobileSearchRef = useRef<HTMLInputElement>(null);
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 处理滚动效果
|
||||
useEffect(() => {
|
||||
const scrollThreshold = 50;
|
||||
|
||||
function updateHeaderBackground() {
|
||||
if (window.scrollY > scrollThreshold) {
|
||||
setScrolled(true);
|
||||
} else {
|
||||
setScrolled(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始检查
|
||||
updateHeaderBackground();
|
||||
|
||||
// 添加滚动事件监听
|
||||
window.addEventListener('scroll', updateHeaderBackground);
|
||||
|
||||
// 清理
|
||||
return () => window.removeEventListener('scroll', updateHeaderBackground);
|
||||
}, []);
|
||||
|
||||
// 搜索节流函数
|
||||
function debounce<T extends (...args: any[]) => void>(func: T, wait: number): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
return function(this: any, ...args: Parameters<T>): void {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
// 获取文章数据
|
||||
async function fetchArticles() {
|
||||
if (isArticlesLoaded && articles.length > 0) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/search');
|
||||
if (!response.ok) {
|
||||
throw new Error('获取文章数据失败');
|
||||
}
|
||||
const data = await response.json();
|
||||
setArticles(data);
|
||||
setIsArticlesLoaded(true);
|
||||
} catch (error) {
|
||||
console.error('获取文章失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 高亮文本中的匹配部分
|
||||
function highlightText(text: string, query: string): string {
|
||||
if (!text || !query.trim()) return text;
|
||||
|
||||
// 转义正则表达式中的特殊字符
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
||||
|
||||
return text.replace(regex, '<mark class="bg-yellow-100 dark:bg-yellow-900/30 text-gray-900 dark:text-gray-100 px-0.5 rounded">$1</mark>');
|
||||
}
|
||||
|
||||
// 搜索文章逻辑
|
||||
const debouncedSearch = debounce((query: string) => {
|
||||
setSearchQuery(query);
|
||||
}, 300);
|
||||
|
||||
// 点击页面其他区域关闭搜索结果
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
desktopSearchRef.current &&
|
||||
desktopResultsRef.current &&
|
||||
!desktopSearchRef.current.contains(event.target as Node) &&
|
||||
!desktopResultsRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setDesktopSearchFocused(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// 处理ESC键
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
setDesktopSearchFocused(false);
|
||||
setMobileSearchOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// 搜索结果处理
|
||||
const getFilteredArticles = () => {
|
||||
if (!searchQuery.trim()) return [];
|
||||
|
||||
const lowerQuery = searchQuery.toLowerCase();
|
||||
|
||||
return articles
|
||||
.filter((article: Article) => {
|
||||
const title = article.title.toLowerCase();
|
||||
const tags = article.tags ? article.tags.map((tag: string) => tag.toLowerCase()) : [];
|
||||
const summary = article.summary ? article.summary.toLowerCase() : '';
|
||||
const content = article.content ? article.content.toLowerCase() : '';
|
||||
|
||||
return title.includes(lowerQuery) ||
|
||||
tags.some((tag: string) => tag.includes(lowerQuery)) ||
|
||||
summary.includes(lowerQuery) ||
|
||||
content.includes(lowerQuery);
|
||||
})
|
||||
.sort((a: Article, b: Article) => {
|
||||
// 标题匹配优先
|
||||
const aTitle = a.title.toLowerCase();
|
||||
const bTitle = b.title.toLowerCase();
|
||||
|
||||
if (aTitle.includes(lowerQuery) && !bTitle.includes(lowerQuery)) {
|
||||
return -1;
|
||||
}
|
||||
if (!aTitle.includes(lowerQuery) && bTitle.includes(lowerQuery)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 内容匹配次之
|
||||
const aContent = a.content ? a.content.toLowerCase() : '';
|
||||
const bContent = b.content ? b.content.toLowerCase() : '';
|
||||
|
||||
if (aContent.includes(lowerQuery) && !bContent.includes(lowerQuery)) {
|
||||
return -1;
|
||||
}
|
||||
if (!aContent.includes(lowerQuery) && bContent.includes(lowerQuery)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 日期排序
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||
})
|
||||
.slice(0, 10); // 限制结果数量
|
||||
};
|
||||
|
||||
// 生成搜索结果列表项
|
||||
const renderSearchResults = () => {
|
||||
const filteredArticles = getFilteredArticles();
|
||||
if (filteredArticles.length === 0) {
|
||||
return (
|
||||
<div className="py-4 px-4 text-center text-gray-500 dark:text-gray-400">
|
||||
没有找到相关文章
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="py-2">
|
||||
{filteredArticles.map(article => {
|
||||
return (
|
||||
<li key={article.id} className="group">
|
||||
<a href={`/articles/${article.id}`} className="block px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700/70">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 group-hover:text-primary-600 dark:group-hover:text-primary-400"
|
||||
dangerouslySetInnerHTML={{ __html: highlightText(article.title, searchQuery) }}
|
||||
></h4>
|
||||
|
||||
{article.summary && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-1"
|
||||
dangerouslySetInnerHTML={{ __html: highlightText(article.summary, searchQuery) }}
|
||||
></p>
|
||||
)}
|
||||
|
||||
{article.tags && article.tags.length > 0 && (
|
||||
<div className="mt-2 flex items-center flex-wrap gap-1">
|
||||
{article.tags.slice(0, 3).map(tag => (
|
||||
<span key={tag} className="inline-block text-2xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700/70 text-gray-600 dark:text-gray-400 rounded">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{article.tags.length > 3 && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
+{article.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
// 准备样式类
|
||||
const headerBgClasses = `absolute inset-0 bg-gray-50/95 dark:bg-dark-bg/95 ${
|
||||
scrolled ? 'scrolled' : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<header className="fixed w-full top-0 z-50" id="main-header">
|
||||
<div className={headerBgClasses} id="header-bg"></div>
|
||||
<nav className="relative">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
{/* Logo 部分 */}
|
||||
<div className="flex items-center">
|
||||
<a href="/" className="text-xl md:text-2xl font-bold tracking-tight bg-gradient-to-r from-primary-600 to-primary-400 bg-clip-text text-transparent hover:from-primary-500 hover:to-primary-300 dark:from-primary-400 dark:to-primary-200 dark:hover:from-primary-300 dark:hover:to-primary-100">
|
||||
{SITE_NAME}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 导航链接 */}
|
||||
<div className="hidden md:flex md:items-center md:space-x-8">
|
||||
{/* 桌面端搜索框 */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="desktop-search"
|
||||
ref={desktopSearchRef}
|
||||
className="w-48 pl-10 pr-4 py-1.5 rounded-full text-sm text-gray-700 dark:text-gray-200 placeholder-gray-500 dark:placeholder-gray-400 bg-gray-50/80 dark:bg-gray-800/60 border border-gray-200/60 dark:border-gray-700/40 focus:outline-none focus:ring-1 focus:ring-primary-400 dark:focus:ring-primary-500 focus:bg-white dark:focus:bg-gray-800 focus:border-primary-300 dark:focus:border-primary-600"
|
||||
placeholder="搜索文章..."
|
||||
onFocus={() => {
|
||||
setDesktopSearchFocused(true);
|
||||
fetchArticles();
|
||||
}}
|
||||
onChange={(e) => debouncedSearch(e.target.value)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-4 w-4 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果容器 */}
|
||||
<div
|
||||
ref={desktopResultsRef}
|
||||
className={`absolute top-full left-0 right-0 mt-2 max-h-80 overflow-y-auto rounded-lg bg-white/95 dark:bg-gray-800/95 shadow-md border border-gray-200/70 dark:border-gray-700/70 backdrop-blur-sm z-50 ${
|
||||
desktopSearchFocused ? '' : 'hidden'
|
||||
}`}
|
||||
>
|
||||
{renderSearchResults()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导航链接 */}
|
||||
{NAV_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
||||
normalizedPath === (link.href === '/' ? '' : link.href)
|
||||
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-600 dark:border-primary-400'
|
||||
: 'text-secondary-600 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400 hover:border-b-2 hover:border-primary-300 dark:hover:border-primary-700'
|
||||
}`}
|
||||
>
|
||||
{link.text}
|
||||
</a>
|
||||
))}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* 移动端菜单按钮 */}
|
||||
<div className="flex items-center md:hidden">
|
||||
{/* 移动端搜索按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center p-2 rounded-md text-secondary-400 dark:text-secondary-500 hover:text-secondary-500 dark:hover:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-dark-card focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500 mr-2"
|
||||
aria-expanded={mobileSearchOpen}
|
||||
aria-label="搜索"
|
||||
onClick={() => {
|
||||
setMobileSearchOpen(true);
|
||||
setTimeout(() => {
|
||||
mobileSearchRef.current?.focus();
|
||||
}, 100);
|
||||
fetchArticles();
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">搜索</span>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 移动端菜单按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center p-2 rounded-md text-secondary-400 dark:text-secondary-500 hover:text-secondary-500 dark:hover:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-dark-card focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
|
||||
id="mobile-menu-button"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-label="打开菜单"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
<span className="sr-only">打开菜单</span>
|
||||
{mobileMenuOpen ? (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端搜索面板 */}
|
||||
{mobileSearchOpen && (
|
||||
<div className="md:hidden fixed inset-x-0 top-16 p-4 bg-white dark:bg-gray-800 shadow-md z-50 border-t border-gray-200 dark:border-gray-700 show">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
ref={mobileSearchRef}
|
||||
className="w-full pl-10 pr-10 py-2 rounded-full text-sm text-gray-700 dark:text-gray-200 placeholder-gray-500 dark:placeholder-gray-400 bg-gray-50/80 dark:bg-gray-800/60 border border-gray-200/60 dark:border-gray-700/40 focus:outline-none focus:ring-1 focus:ring-primary-400 dark:focus:ring-primary-500 focus:bg-white dark:focus:bg-gray-800 focus:border-primary-300 dark:focus:border-primary-600"
|
||||
placeholder="搜索文章..."
|
||||
onChange={(e) => debouncedSearch(e.target.value)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
aria-label="关闭搜索"
|
||||
onClick={() => setMobileSearchOpen(false)}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 移动端搜索结果 */}
|
||||
<div className={`mt-3 max-h-80 overflow-y-auto rounded-lg bg-white/95 dark:bg-gray-800/95 shadow-md border border-gray-200/70 dark:border-gray-700/70 backdrop-blur-sm ${searchQuery ? '' : 'hidden'}`}>
|
||||
{renderSearchResults()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 移动端菜单 */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden fixed inset-x-0 top-16 z-40" id="mobile-menu">
|
||||
<div className="backdrop-blur-[6px] bg-white/80 dark:bg-gray-800/80 shadow-md">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2">
|
||||
<div className="grid gap-1">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex items-center px-3 py-3 rounded-lg text-base font-medium ${
|
||||
normalizedPath === (link.href === '/' ? '' : link.href)
|
||||
? 'text-white bg-primary-600 dark:bg-primary-500 shadow-sm'
|
||||
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800/70'
|
||||
}`}
|
||||
>
|
||||
{link.text}
|
||||
</a>
|
||||
))}
|
||||
<div
|
||||
className="mt-2 pt-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800/70 rounded-lg px-3 py-2"
|
||||
onClick={(e) => {
|
||||
const themeToggleButton = e.currentTarget.querySelector('[role="button"]');
|
||||
const target = e.target as HTMLElement;
|
||||
if (themeToggleButton instanceof HTMLElement && target !== themeToggleButton && !themeToggleButton.contains(target)) {
|
||||
themeToggleButton.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-600 dark:text-gray-300">切换主题</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
import "@/styles/global.css";
|
||||
import Header from "@/components/Header.tsx";
|
||||
import {Footer} from "@/components/Footer.tsx";
|
||||
import Header from "@/components/Header.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import { ICP, PSB_ICP, PSB_ICP_URL, SITE_NAME, SITE_DESCRIPTION } from "@/consts";
|
||||
|
||||
// 定义Props接口
|
||||
@ -62,22 +62,24 @@ const { title = SITE_NAME, description = SITE_DESCRIPTION, date, author, tags, i
|
||||
// 设置标志,表示初始化已完成
|
||||
window.__themeInitDone = true;
|
||||
|
||||
const theme = (() => {
|
||||
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
||||
return localStorage.getItem('theme');
|
||||
}
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
return 'light';
|
||||
})();
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
const savedTheme = typeof localStorage !== 'undefined' ? localStorage.getItem('theme') : null;
|
||||
const theme = savedTheme || systemTheme;
|
||||
|
||||
// 立即设置文档主题
|
||||
document.documentElement.dataset.theme = theme;
|
||||
|
||||
// 将主题信息存储在全局变量中,以便 React 组件可以立即访问
|
||||
window.__THEME_DATA__ = {
|
||||
currentTheme: theme,
|
||||
systemTheme: systemTheme
|
||||
};
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="m-0 w-full h-full bg-gray-50 dark:bg-dark-bg flex flex-col min-h-screen">
|
||||
<Header client:load/>
|
||||
<Header />
|
||||
<main class="pt-16 flex-grow">
|
||||
<slot />
|
||||
</main>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface MediaGridProps {
|
||||
type: 'movie' | 'book';
|
||||
type: "movie" | "book";
|
||||
title: string;
|
||||
doubanId: string;
|
||||
}
|
||||
@ -12,11 +12,6 @@ interface MediaItem {
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
current: number;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
const [items, setItems] = useState<MediaItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@ -32,15 +27,17 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
isLoading: false,
|
||||
hasMoreContent: true,
|
||||
currentPage: 1,
|
||||
error: null as string | null
|
||||
error: null as string | null,
|
||||
});
|
||||
|
||||
// 封装fetch函数但不使用useCallback以避免依赖循环
|
||||
const fetchMedia = async (page = 1, append = false) => {
|
||||
// 使用ref中的最新状态
|
||||
if (stateRef.current.isLoading ||
|
||||
(!append && !stateRef.current.hasMoreContent) ||
|
||||
(append && !stateRef.current.hasMoreContent)) {
|
||||
if (
|
||||
stateRef.current.isLoading ||
|
||||
(!append && !stateRef.current.hasMoreContent) ||
|
||||
(append && !stateRef.current.hasMoreContent)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -56,10 +53,12 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
|
||||
const start = (page - 1) * itemsPerPage;
|
||||
try {
|
||||
const response = await fetch(`/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`);
|
||||
const response = await fetch(
|
||||
`/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
// 解析响应内容,获取详细错误信息
|
||||
let errorMessage = `获取${type === 'movie' ? '电影' : '图书'}数据失败`;
|
||||
let errorMessage = `获取${type === "movie" ? "电影" : "图书"}数据失败`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData && errorData.error) {
|
||||
@ -111,7 +110,7 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
}
|
||||
} else {
|
||||
if (append) {
|
||||
setItems(prev => {
|
||||
setItems((prev) => {
|
||||
const newItems = [...prev, ...data.items];
|
||||
return newItems;
|
||||
});
|
||||
@ -156,7 +155,11 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
lastScrollTime.current = now;
|
||||
|
||||
// 使用ref中的最新状态来检查
|
||||
if (stateRef.current.isLoading || !stateRef.current.hasMoreContent || stateRef.current.error) {
|
||||
if (
|
||||
stateRef.current.isLoading ||
|
||||
!stateRef.current.hasMoreContent ||
|
||||
stateRef.current.error
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -208,39 +211,44 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
const scrollListener = handleScroll;
|
||||
|
||||
// 移除任何现有监听器
|
||||
window.removeEventListener('scroll', scrollListener);
|
||||
window.removeEventListener("scroll", scrollListener);
|
||||
|
||||
// 添加滚动事件监听器 - 使用passive: true可提高滚动性能
|
||||
window.addEventListener('scroll', scrollListener, { passive: true });
|
||||
window.addEventListener("scroll", scrollListener, { passive: true });
|
||||
|
||||
// 创建一个IntersectionObserver作为备选检测方案
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '300px',
|
||||
threshold: 0.1
|
||||
rootMargin: "300px",
|
||||
threshold: 0.1,
|
||||
};
|
||||
|
||||
const intersectionObserver = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
|
||||
if (entry.isIntersecting &&
|
||||
!stateRef.current.isLoading &&
|
||||
stateRef.current.hasMoreContent &&
|
||||
!stateRef.current.error) {
|
||||
if (
|
||||
entry.isIntersecting &&
|
||||
!stateRef.current.isLoading &&
|
||||
stateRef.current.hasMoreContent &&
|
||||
!stateRef.current.error
|
||||
) {
|
||||
fetchMedia(stateRef.current.currentPage + 1, true);
|
||||
}
|
||||
}, observerOptions);
|
||||
|
||||
// 添加检测底部的元素 - 放在grid容器的后面而不是内部
|
||||
const footer = document.createElement('div');
|
||||
footer.id = 'scroll-detector';
|
||||
footer.style.width = '100%';
|
||||
footer.style.height = '10px';
|
||||
const footer = document.createElement("div");
|
||||
footer.id = "scroll-detector";
|
||||
footer.style.width = "100%";
|
||||
footer.style.height = "10px";
|
||||
|
||||
// 确保mediaListRef有父元素
|
||||
if (mediaListRef.current && mediaListRef.current.parentElement) {
|
||||
// 插入到grid后面而不是内部
|
||||
mediaListRef.current.parentElement.insertBefore(footer, mediaListRef.current.nextSibling);
|
||||
mediaListRef.current.parentElement.insertBefore(
|
||||
footer,
|
||||
mediaListRef.current.nextSibling,
|
||||
);
|
||||
intersectionObserver.observe(footer);
|
||||
}
|
||||
|
||||
@ -254,9 +262,9 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
// 清理函数
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
window.removeEventListener('scroll', scrollListener);
|
||||
window.removeEventListener("scroll", scrollListener);
|
||||
intersectionObserver.disconnect();
|
||||
document.getElementById('scroll-detector')?.remove();
|
||||
document.getElementById("scroll-detector")?.remove();
|
||||
};
|
||||
}, [type, doubanId]); // 只在关键属性变化时执行
|
||||
|
||||
@ -267,8 +275,19 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
return (
|
||||
<div className="col-span-full text-center bg-red-50 p-4 rounded-md">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 text-red-500 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-12 w-12 text-red-500 mb-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-red-800">访问错误</h3>
|
||||
<p className="mt-1 text-sm text-red-700">{error}</p>
|
||||
@ -309,12 +328,18 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">{title}</h1>
|
||||
|
||||
<div ref={mediaListRef} className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<div
|
||||
ref={mediaListRef}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
|
||||
>
|
||||
{error && items.length === 0 ? (
|
||||
<ErrorMessage />
|
||||
) : items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<div key={`${item.title}-${index}`} className="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl">
|
||||
<div
|
||||
key={`${item.title}-${index}`}
|
||||
className="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl"
|
||||
>
|
||||
<div className="relative pb-[150%] overflow-hidden">
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
@ -323,7 +348,12 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent">
|
||||
<h3 className="font-bold text-white text-sm line-clamp-2">
|
||||
<a href={item.link} target="_blank" rel="noopener noreferrer" className="hover:text-blue-300">
|
||||
<a
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-blue-300"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
</h3>
|
||||
@ -332,7 +362,9 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
</div>
|
||||
))
|
||||
) : !isLoading ? (
|
||||
<div className="col-span-full text-center">暂无{type === 'movie' ? '电影' : '图书'}数据</div>
|
||||
<div className="col-span-full text-center">
|
||||
暂无{type === "movie" ? "电影" : "图书"}数据
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -349,9 +381,7 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMoreContent && items.length > 0 && !isLoading && (
|
||||
<EndMessage />
|
||||
)}
|
||||
{!hasMoreContent && items.length > 0 && !isLoading && <EndMessage />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
139
src/components/ThemeToggle.astro
Normal file
139
src/components/ThemeToggle.astro
Normal file
@ -0,0 +1,139 @@
|
||||
---
|
||||
interface Props {
|
||||
height?: number;
|
||||
width?: number;
|
||||
fill?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
height = 16,
|
||||
width = 16,
|
||||
fill = "currentColor",
|
||||
className = ""
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<button
|
||||
id="theme-toggle-button"
|
||||
class={`inline-flex items-center justify-center h-8 w-8 cursor-pointer rounded-md hover:bg-gray-100 dark:hover:bg-gray-700/50 text-secondary-600 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400 ${className}`}
|
||||
aria-label="切换主题"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- 月亮图标 (暗色模式) -->
|
||||
<svg
|
||||
id="dark-icon"
|
||||
style={`height: ${height}px; width: ${width}px;`}
|
||||
fill={fill}
|
||||
viewBox="0 0 16 16"
|
||||
class="hover:scale-110 hidden dark:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
|
||||
</svg>
|
||||
|
||||
<!-- 太阳图标 (亮色模式) -->
|
||||
<svg
|
||||
id="light-icon"
|
||||
style={`height: ${height}px; width: ${width}px;`}
|
||||
fill={fill}
|
||||
viewBox="0 0 16 16"
|
||||
class="hover:scale-110 block dark:hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
function setupThemeToggle() {
|
||||
const themeToggleButton = document.getElementById('theme-toggle-button');
|
||||
|
||||
if (!themeToggleButton) return;
|
||||
|
||||
let transitioning = false;
|
||||
let transitionTimeout: number | null = null;
|
||||
|
||||
// 获取系统首选主题
|
||||
const getSystemTheme = () => {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
// 切换主题
|
||||
const toggleTheme = () => {
|
||||
if (transitioning) return;
|
||||
|
||||
transitioning = true;
|
||||
|
||||
// 获取当前主题
|
||||
const currentTheme = document.documentElement.dataset.theme;
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
|
||||
// 更新 HTML 属性
|
||||
document.documentElement.dataset.theme = newTheme;
|
||||
|
||||
// 更新本地存储
|
||||
const isSystemTheme = newTheme === getSystemTheme();
|
||||
|
||||
if (isSystemTheme) {
|
||||
localStorage.removeItem('theme');
|
||||
} else {
|
||||
localStorage.setItem('theme', newTheme);
|
||||
}
|
||||
|
||||
// 添加防抖
|
||||
if (transitionTimeout) {
|
||||
clearTimeout(transitionTimeout);
|
||||
}
|
||||
|
||||
transitionTimeout = setTimeout(() => {
|
||||
transitioning = false;
|
||||
}, 300) as unknown as number;
|
||||
};
|
||||
|
||||
// 添加点击事件
|
||||
themeToggleButton.addEventListener('click', toggleTheme);
|
||||
|
||||
// 添加键盘事件
|
||||
themeToggleButton.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleTheme();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听系统主题变化
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleMediaChange = (e: MediaQueryListEvent) => {
|
||||
// 只有当主题设置为跟随系统时才更新主题
|
||||
if (!localStorage.getItem('theme')) {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
document.documentElement.dataset.theme = newTheme;
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleMediaChange);
|
||||
|
||||
// 清理
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleMediaChange);
|
||||
themeToggleButton.removeEventListener('click', toggleTheme);
|
||||
if (transitionTimeout) {
|
||||
clearTimeout(transitionTimeout);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 页面加载时初始化
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupThemeToggle);
|
||||
} else {
|
||||
setupThemeToggle();
|
||||
}
|
||||
|
||||
// 支持 Astro 视图转换
|
||||
document.addEventListener('astro:after-swap', setupThemeToggle);
|
||||
document.addEventListener('astro:page-load', setupThemeToggle);
|
||||
</script>
|
@ -1,131 +0,0 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
export function ThemeToggle({ height = 16, width = 16, fill = "currentColor", className = "" }) {
|
||||
// 使用null作为初始状态,表示尚未确定主题
|
||||
const [theme, setTheme] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [transitioning, setTransitioning] = useState(false);
|
||||
const transitionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 获取系统主题
|
||||
const getSystemTheme = useCallback(() => {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}, []);
|
||||
|
||||
// 在客户端挂载后再确定主题
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
// 从 localStorage 或 document.documentElement.dataset.theme 获取主题
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const rootTheme = document.documentElement.dataset.theme;
|
||||
const systemTheme = getSystemTheme();
|
||||
|
||||
// 优先使用已保存的主题,其次是文档根元素的主题,最后是系统主题
|
||||
const initialTheme = savedTheme || rootTheme || systemTheme;
|
||||
setTheme(initialTheme);
|
||||
|
||||
// 确保文档根元素的主题与状态一致
|
||||
document.documentElement.dataset.theme = initialTheme;
|
||||
|
||||
// 监听系统主题变化
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleMediaChange = (e: MediaQueryListEvent) => {
|
||||
// 只有当主题设置为跟随系统时才更新主题
|
||||
if (!localStorage.getItem('theme')) {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
document.documentElement.dataset.theme = newTheme;
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleMediaChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleMediaChange);
|
||||
|
||||
// 清理可能的超时
|
||||
if (transitionTimeoutRef.current) {
|
||||
clearTimeout(transitionTimeoutRef.current);
|
||||
transitionTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [getSystemTheme]);
|
||||
|
||||
// 当主题改变时更新 DOM 和 localStorage
|
||||
useEffect(() => {
|
||||
if (!mounted || theme === null) return;
|
||||
|
||||
document.documentElement.dataset.theme = theme;
|
||||
|
||||
// 检查是否是跟随系统的主题
|
||||
const isSystemTheme = theme === getSystemTheme();
|
||||
|
||||
if (isSystemTheme) {
|
||||
localStorage.removeItem('theme');
|
||||
} else {
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
}, [theme, mounted, getSystemTheme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
if (transitioning) return; // 避免快速连续点击
|
||||
|
||||
setTransitioning(true);
|
||||
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
|
||||
|
||||
// 添加300ms的防抖,避免快速切换
|
||||
transitionTimeoutRef.current = setTimeout(() => {
|
||||
setTransitioning(false);
|
||||
}, 300);
|
||||
}, [transitioning]);
|
||||
|
||||
// 在客户端挂载前,返回一个空的占位符
|
||||
if (!mounted || theme === null) {
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center justify-center h-8 w-8 cursor-pointer rounded-md hover:bg-gray-100 dark:hover:bg-gray-700/50 text-secondary-600 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400 ${className}`}
|
||||
>
|
||||
<span className="sr-only">加载主题切换按钮...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center justify-center h-8 w-8 cursor-pointer rounded-md hover:bg-gray-100 dark:hover:bg-gray-700/50 text-secondary-600 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400 ${transitioning ? 'pointer-events-none opacity-80' : ''} ${className}`}
|
||||
onClick={toggleTheme}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleTheme();
|
||||
}
|
||||
}}
|
||||
aria-label={`切换到${theme === 'dark' ? '浅色' : '深色'}模式`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg
|
||||
style={{ height: `${height}px`, width: `${width}px` }}
|
||||
fill={fill}
|
||||
viewBox="0 0 16 16"
|
||||
className="hover:scale-110"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
style={{ height: `${height}px`, width: `${width}px` }}
|
||||
fill={fill}
|
||||
viewBox="0 0 16 16"
|
||||
className="hover:scale-110"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
|
||||
import worldData from '@/assets/world.zh.json';
|
||||
import chinaData from '@/assets/china.json';
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
||||
import worldData from "@/assets/world.zh.json";
|
||||
import chinaData from "@/assets/china.json";
|
||||
|
||||
interface WorldHeatmapProps {
|
||||
visitedPlaces: string[];
|
||||
@ -12,10 +12,12 @@ interface WorldHeatmapProps {
|
||||
const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [hoveredCountry, setHoveredCountry] = useState<string | null>(null);
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(
|
||||
typeof document !== 'undefined' &&
|
||||
(document.documentElement.classList.contains('dark') || document.documentElement.getAttribute('data-theme') === 'dark')
|
||||
? 'dark' : 'light'
|
||||
const [theme, setTheme] = useState<"light" | "dark">(
|
||||
typeof document !== "undefined" &&
|
||||
(document.documentElement.classList.contains("dark") ||
|
||||
document.documentElement.getAttribute("data-theme") === "dark")
|
||||
? "dark"
|
||||
: "light",
|
||||
);
|
||||
|
||||
const sceneRef = useRef<{
|
||||
@ -43,17 +45,19 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
useEffect(() => {
|
||||
const handleThemeChange = () => {
|
||||
const isDark =
|
||||
document.documentElement.classList.contains('dark') ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
setTheme(isDark ? 'dark' : 'light');
|
||||
document.documentElement.classList.contains("dark") ||
|
||||
document.documentElement.getAttribute("data-theme") === "dark";
|
||||
setTheme(isDark ? "dark" : "light");
|
||||
};
|
||||
|
||||
// 创建 MutationObserver 来监听 class 和 data-theme 属性的变化
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (
|
||||
(mutation.attributeName === 'class' && mutation.target === document.documentElement) ||
|
||||
(mutation.attributeName === 'data-theme' && mutation.target === document.documentElement)
|
||||
(mutation.attributeName === "class" &&
|
||||
mutation.target === document.documentElement) ||
|
||||
(mutation.attributeName === "data-theme" &&
|
||||
mutation.target === document.documentElement)
|
||||
) {
|
||||
handleThemeChange();
|
||||
}
|
||||
@ -63,7 +67,7 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
// 开始观察
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-theme']
|
||||
attributeFilter: ["class", "data-theme"],
|
||||
});
|
||||
|
||||
// 初始检查
|
||||
@ -86,23 +90,24 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
sceneRef.current.renderer.dispose();
|
||||
sceneRef.current.labelRenderer.domElement.remove();
|
||||
sceneRef.current.scene.clear();
|
||||
containerRef.current.innerHTML = '';
|
||||
containerRef.current.innerHTML = "";
|
||||
}
|
||||
|
||||
// 检查当前是否为暗色模式
|
||||
const isDarkMode = document.documentElement.classList.contains('dark') ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
const isDarkMode =
|
||||
document.documentElement.classList.contains("dark") ||
|
||||
document.documentElement.getAttribute("data-theme") === "dark";
|
||||
|
||||
// 根据当前模式设置颜色
|
||||
const getColors = () => {
|
||||
return {
|
||||
earthBase: isDarkMode ? '#111827' : '#f3f4f6', // 深色模式更暗,浅色模式更亮
|
||||
visited: isDarkMode ? '#065f46' : '#34d399', // 访问过的颜色更鲜明
|
||||
border: isDarkMode ? '#6b7280' : '#d1d5db', // 边界颜色更柔和
|
||||
visitedBorder: isDarkMode ? '#10b981' : '#059669', // 访问过的边界颜色更鲜明
|
||||
chinaBorder: isDarkMode ? '#f87171' : '#ef4444', // 中国边界使用红色
|
||||
text: isDarkMode ? '#f9fafb' : '#1f2937', // 文本颜色对比更强
|
||||
highlight: isDarkMode ? '#fbbf24' : '#d97706', // 高亮颜色更适合当前主题
|
||||
earthBase: isDarkMode ? "#111827" : "#f3f4f6", // 深色模式更暗,浅色模式更亮
|
||||
visited: isDarkMode ? "#065f46" : "#34d399", // 访问过的颜色更鲜明
|
||||
border: isDarkMode ? "#6b7280" : "#d1d5db", // 边界颜色更柔和
|
||||
visitedBorder: isDarkMode ? "#10b981" : "#059669", // 访问过的边界颜色更鲜明
|
||||
chinaBorder: isDarkMode ? "#f87171" : "#ef4444", // 中国边界使用红色
|
||||
text: isDarkMode ? "#f9fafb" : "#1f2937", // 文本颜色对比更强
|
||||
highlight: isDarkMode ? "#fbbf24" : "#d97706", // 高亮颜色更适合当前主题
|
||||
};
|
||||
};
|
||||
|
||||
@ -113,36 +118,53 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
scene.background = null;
|
||||
|
||||
// 添加一个动态计算小区域的机制
|
||||
const regionSizeMetrics = new Map<string, {
|
||||
boundingBoxSize?: number,
|
||||
pointCount?: number,
|
||||
importance?: number,
|
||||
isSmallRegion?: boolean,
|
||||
polygonArea?: number
|
||||
}>();
|
||||
const regionSizeMetrics = new Map<
|
||||
string,
|
||||
{
|
||||
boundingBoxSize?: number;
|
||||
pointCount?: number;
|
||||
importance?: number;
|
||||
isSmallRegion?: boolean;
|
||||
polygonArea?: number;
|
||||
}
|
||||
>();
|
||||
|
||||
// 创建材质的辅助函数
|
||||
const createMaterial = (color: string, side: THREE.Side = THREE.FrontSide, opacity: number = 1.0) => {
|
||||
const createMaterial = (
|
||||
color: string,
|
||||
side: THREE.Side = THREE.FrontSide,
|
||||
opacity: number = 1.0,
|
||||
) => {
|
||||
return new THREE.MeshBasicMaterial({
|
||||
color: color,
|
||||
side: side,
|
||||
transparent: true,
|
||||
opacity: opacity
|
||||
opacity: opacity,
|
||||
});
|
||||
};
|
||||
|
||||
// 创建地球几何体
|
||||
const earthGeometry = new THREE.SphereGeometry(2.0, 64, 64);
|
||||
const earthMaterial = createMaterial(colors.earthBase, THREE.FrontSide, isDarkMode ? 0.9 : 0.8);
|
||||
const earthMaterial = createMaterial(
|
||||
colors.earthBase,
|
||||
THREE.FrontSide,
|
||||
isDarkMode ? 0.9 : 0.8,
|
||||
);
|
||||
const earth = new THREE.Mesh(earthGeometry, earthMaterial);
|
||||
earth.renderOrder = 1;
|
||||
scene.add(earth);
|
||||
|
||||
// 添加光源
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, isDarkMode ? 0.7 : 0.8);
|
||||
const ambientLight = new THREE.AmbientLight(
|
||||
0xffffff,
|
||||
isDarkMode ? 0.7 : 0.8,
|
||||
);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(isDarkMode ? 0xeeeeff : 0xffffff, isDarkMode ? 0.6 : 0.5);
|
||||
const directionalLight = new THREE.DirectionalLight(
|
||||
isDarkMode ? 0xeeeeff : 0xffffff,
|
||||
isDarkMode ? 0.6 : 0.5,
|
||||
);
|
||||
directionalLight.position.set(5, 3, 5);
|
||||
scene.add(directionalLight);
|
||||
|
||||
@ -151,7 +173,7 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
45,
|
||||
containerRef.current.clientWidth / containerRef.current.clientHeight,
|
||||
0.1,
|
||||
1000
|
||||
1000,
|
||||
);
|
||||
camera.position.z = 8;
|
||||
|
||||
@ -161,20 +183,26 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
alpha: true,
|
||||
logarithmicDepthBuffer: true,
|
||||
preserveDrawingBuffer: true,
|
||||
precision: "highp"
|
||||
precision: "highp",
|
||||
});
|
||||
renderer.sortObjects = true;
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.setSize(containerRef.current.clientWidth, containerRef.current.clientHeight);
|
||||
renderer.setSize(
|
||||
containerRef.current.clientWidth,
|
||||
containerRef.current.clientHeight,
|
||||
);
|
||||
containerRef.current.appendChild(renderer.domElement);
|
||||
|
||||
// 创建CSS2D渲染器用于标签
|
||||
const labelRenderer = new CSS2DRenderer();
|
||||
labelRenderer.setSize(containerRef.current.clientWidth, containerRef.current.clientHeight);
|
||||
labelRenderer.domElement.style.position = 'absolute';
|
||||
labelRenderer.domElement.style.top = '0';
|
||||
labelRenderer.domElement.style.pointerEvents = 'none';
|
||||
labelRenderer.setSize(
|
||||
containerRef.current.clientWidth,
|
||||
containerRef.current.clientHeight,
|
||||
);
|
||||
labelRenderer.domElement.style.position = "absolute";
|
||||
labelRenderer.domElement.style.top = "0";
|
||||
labelRenderer.domElement.style.pointerEvents = "none";
|
||||
containerRef.current.appendChild(labelRenderer.domElement);
|
||||
|
||||
// 添加控制器
|
||||
@ -190,7 +218,7 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
controls.minPolarAngle = Math.PI * 0.1;
|
||||
controls.maxPolarAngle = Math.PI * 0.9;
|
||||
|
||||
controls.addEventListener('change', () => {
|
||||
controls.addEventListener("change", () => {
|
||||
if (sceneRef.current) {
|
||||
renderer.render(scene, camera);
|
||||
labelRenderer.render(scene, camera);
|
||||
@ -210,13 +238,17 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
const countryBoundingBoxes = new Map<string, THREE.Box3>();
|
||||
|
||||
// 创建一个辅助函数,用于将经纬度转换为三维坐标
|
||||
const latLongToVector3 = (lat: number, lon: number, radius: number): THREE.Vector3 => {
|
||||
const latLongToVector3 = (
|
||||
lat: number,
|
||||
lon: number,
|
||||
radius: number,
|
||||
): THREE.Vector3 => {
|
||||
// 调整经度范围,确保它在[-180, 180]之间
|
||||
while (lon > 180) lon -= 360;
|
||||
while (lon < -180) lon += 360;
|
||||
|
||||
const phi = (90 - lat) * Math.PI / 180;
|
||||
const theta = (lon + 180) * Math.PI / 180;
|
||||
const phi = ((90 - lat) * Math.PI) / 180;
|
||||
const theta = ((lon + 180) * Math.PI) / 180;
|
||||
|
||||
const x = -radius * Math.sin(phi) * Math.cos(theta);
|
||||
const y = radius * Math.cos(phi);
|
||||
@ -233,18 +265,25 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
feature: any,
|
||||
parent: THREE.Group,
|
||||
options: {
|
||||
regionType: 'country' | 'province',
|
||||
parentName?: string,
|
||||
scale?: number,
|
||||
borderColor?: string,
|
||||
visitedBorderColor?: string
|
||||
}
|
||||
regionType: "country" | "province";
|
||||
parentName?: string;
|
||||
scale?: number;
|
||||
borderColor?: string;
|
||||
visitedBorderColor?: string;
|
||||
},
|
||||
) => {
|
||||
const { regionType, parentName, scale = 2.01, borderColor, visitedBorderColor } = options;
|
||||
const {
|
||||
regionType,
|
||||
parentName,
|
||||
scale = 2.01,
|
||||
borderColor,
|
||||
visitedBorderColor,
|
||||
} = options;
|
||||
|
||||
const regionName = regionType === 'province' && parentName
|
||||
? `${parentName}-${feature.properties.name}`
|
||||
: feature.properties.name;
|
||||
const regionName =
|
||||
regionType === "province" && parentName
|
||||
? `${parentName}-${feature.properties.name}`
|
||||
: feature.properties.name;
|
||||
|
||||
const isRegionVisited = visitedPlaces.includes(regionName);
|
||||
|
||||
@ -264,7 +303,11 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
let hasPreDefinedCenter = false;
|
||||
let centerVector;
|
||||
|
||||
if (feature.properties.cp && Array.isArray(feature.properties.cp) && feature.properties.cp.length === 2) {
|
||||
if (
|
||||
feature.properties.cp &&
|
||||
Array.isArray(feature.properties.cp) &&
|
||||
feature.properties.cp.length === 2
|
||||
) {
|
||||
const [cpLon, cpLat] = feature.properties.cp;
|
||||
hasPreDefinedCenter = true;
|
||||
centerVector = latLongToVector3(cpLat, cpLon, scale + 0.005);
|
||||
@ -272,7 +315,7 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
centerLat = cpLat;
|
||||
|
||||
// 保存预定义中心点
|
||||
if (regionType === 'province') {
|
||||
if (regionType === "province") {
|
||||
provinceCenters.set(regionName, centerVector);
|
||||
}
|
||||
}
|
||||
@ -313,10 +356,14 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
const metrics = regionSizeMetrics.get(regionName)!;
|
||||
if (points.length > 2) {
|
||||
// 计算边界框大小
|
||||
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
minZ = Infinity;
|
||||
let maxX = -Infinity,
|
||||
maxY = -Infinity,
|
||||
maxZ = -Infinity;
|
||||
|
||||
points.forEach(point => {
|
||||
points.forEach((point) => {
|
||||
minX = Math.min(minX, point.x);
|
||||
minY = Math.min(minY, point.y);
|
||||
minZ = Math.min(minZ, point.z);
|
||||
@ -328,11 +375,14 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
const sizeX = maxX - minX;
|
||||
const sizeY = maxY - minY;
|
||||
const sizeZ = maxZ - minZ;
|
||||
const boxSize = Math.sqrt(sizeX * sizeX + sizeY * sizeY + sizeZ * sizeZ);
|
||||
const boxSize = Math.sqrt(
|
||||
sizeX * sizeX + sizeY * sizeY + sizeZ * sizeZ,
|
||||
);
|
||||
|
||||
// 更新或初始化指标
|
||||
metrics.boundingBoxSize = metrics.boundingBoxSize ?
|
||||
Math.max(metrics.boundingBoxSize, boxSize) : boxSize;
|
||||
metrics.boundingBoxSize = metrics.boundingBoxSize
|
||||
? Math.max(metrics.boundingBoxSize, boxSize)
|
||||
: boxSize;
|
||||
metrics.pointCount = (metrics.pointCount || 0) + points.length;
|
||||
}
|
||||
|
||||
@ -341,11 +391,11 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const lineMaterial = new THREE.LineBasicMaterial({
|
||||
color: isRegionVisited
|
||||
? (visitedBorderColor || colors.visitedBorder)
|
||||
: (borderColor || colors.border),
|
||||
? visitedBorderColor || colors.visitedBorder
|
||||
: borderColor || colors.border,
|
||||
linewidth: isRegionVisited ? 1.5 : 1,
|
||||
transparent: true,
|
||||
opacity: isRegionVisited ? 0.9 : 0.7
|
||||
opacity: isRegionVisited ? 0.9 : 0.7,
|
||||
});
|
||||
|
||||
const line = new THREE.Line(lineGeometry, lineMaterial);
|
||||
@ -353,9 +403,9 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
name: regionName,
|
||||
isVisited: isRegionVisited,
|
||||
originalColor: isRegionVisited
|
||||
? (visitedBorderColor || colors.visitedBorder)
|
||||
: (borderColor || colors.border),
|
||||
highlightColor: colors.highlight // 使用主题颜色中定义的高亮颜色
|
||||
? visitedBorderColor || colors.visitedBorder
|
||||
: borderColor || colors.border,
|
||||
highlightColor: colors.highlight, // 使用主题颜色中定义的高亮颜色
|
||||
};
|
||||
|
||||
// 设置渲染顺序
|
||||
@ -369,12 +419,16 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
};
|
||||
|
||||
// 处理不同类型的几何体
|
||||
if (feature.geometry && (feature.geometry.type === 'Polygon' || feature.geometry.type === 'MultiPolygon')) {
|
||||
if (feature.geometry.type === 'Polygon') {
|
||||
if (
|
||||
feature.geometry &&
|
||||
(feature.geometry.type === "Polygon" ||
|
||||
feature.geometry.type === "MultiPolygon")
|
||||
) {
|
||||
if (feature.geometry.type === "Polygon") {
|
||||
feature.geometry.coordinates.forEach((ring: any) => {
|
||||
processPolygon(ring);
|
||||
});
|
||||
} else if (feature.geometry.type === 'MultiPolygon') {
|
||||
} else if (feature.geometry.type === "MultiPolygon") {
|
||||
feature.geometry.coordinates.forEach((polygon: any) => {
|
||||
polygon.forEach((ring: any) => {
|
||||
processPolygon(ring);
|
||||
@ -384,14 +438,14 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
|
||||
if (pointCount > 0 && !hasPreDefinedCenter) {
|
||||
// 计算平均中心点
|
||||
centerLon /= pointCount;
|
||||
centerLat /= pointCount;
|
||||
centerLon /= pointCount;
|
||||
centerLat /= pointCount;
|
||||
|
||||
// 将中心点经纬度转换为3D坐标
|
||||
centerVector = latLongToVector3(centerLat, centerLon, scale + 0.005);
|
||||
|
||||
// 保存计算的中心点
|
||||
if (regionType === 'province') {
|
||||
if (regionType === "province") {
|
||||
provinceCenters.set(regionName, centerVector);
|
||||
}
|
||||
}
|
||||
@ -414,45 +468,49 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
const countryName = feature.properties.name;
|
||||
|
||||
// 跳过中国,因为我们将使用更详细的中国地图数据
|
||||
if (countryName === '中国') return;
|
||||
if (countryName === "中国") return;
|
||||
|
||||
processGeoFeature(feature, countryGroup, {
|
||||
regionType: 'country',
|
||||
scale: 2.01
|
||||
regionType: "country",
|
||||
scale: 2.01,
|
||||
});
|
||||
});
|
||||
|
||||
// 处理中国的省份
|
||||
const chinaObject = new THREE.Group();
|
||||
chinaObject.userData = { name: '中国', isVisited: visitedPlaces.includes('中国') };
|
||||
chinaObject.userData = {
|
||||
name: "中国",
|
||||
isVisited: visitedPlaces.includes("中国"),
|
||||
};
|
||||
|
||||
chinaData.features.forEach((feature: any) => {
|
||||
processGeoFeature(feature, chinaObject, {
|
||||
regionType: 'province',
|
||||
parentName: '中国',
|
||||
regionType: "province",
|
||||
parentName: "中国",
|
||||
scale: 2.015,
|
||||
borderColor: colors.chinaBorder,
|
||||
visitedBorderColor: colors.visitedBorder
|
||||
visitedBorderColor: colors.visitedBorder,
|
||||
});
|
||||
});
|
||||
|
||||
// 添加中国对象到国家组
|
||||
countryGroup.add(chinaObject);
|
||||
countries.set('中国', chinaObject);
|
||||
countries.set("中国", chinaObject);
|
||||
|
||||
// 将视图旋转到中国位置
|
||||
const positionCameraToFaceChina = () => {
|
||||
// 检查是否为小屏幕
|
||||
const isSmallScreen = containerRef.current && containerRef.current.clientWidth < 640;
|
||||
const isSmallScreen =
|
||||
containerRef.current && containerRef.current.clientWidth < 640;
|
||||
|
||||
// 根据屏幕大小设置不同的相机初始位置
|
||||
let fixedPosition;
|
||||
if (isSmallScreen) {
|
||||
// 小屏幕显示距离更远,以便看到更多地球
|
||||
fixedPosition = new THREE.Vector3(-2.10, 3.41, -8.0);
|
||||
fixedPosition = new THREE.Vector3(-2.1, 3.41, -8.0);
|
||||
} else {
|
||||
// 大屏幕使用原来的位置
|
||||
fixedPosition = new THREE.Vector3(-2.10, 3.41, -6.5);
|
||||
fixedPosition = new THREE.Vector3(-2.1, 3.41, -6.5);
|
||||
}
|
||||
|
||||
// 应用位置
|
||||
@ -488,12 +546,12 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
let lastFunc: number | null = null;
|
||||
let lastRan: number | null = null;
|
||||
|
||||
return function(this: any, ...args: any[]) {
|
||||
return function (this: any, ...args: any[]) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
lastRan = Date.now();
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
setTimeout(() => (inThrottle = false), limit);
|
||||
} else {
|
||||
// 取消之前的延迟调用
|
||||
if (lastFunc) clearTimeout(lastFunc);
|
||||
@ -582,7 +640,12 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
};
|
||||
|
||||
// 解决射线检测和球面相交的问题
|
||||
const getPointOnSphere = (mouseX: number, mouseY: number, camera: THREE.Camera, radius: number): THREE.Vector3 | null => {
|
||||
const getPointOnSphere = (
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
camera: THREE.Camera,
|
||||
radius: number,
|
||||
): THREE.Vector3 | null => {
|
||||
// 计算鼠标在画布中的归一化坐标
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
const x = ((mouseX - rect.left) / rect.width) * 2 - 1;
|
||||
@ -615,10 +678,15 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
if (!containerRef.current || !sceneRef.current) return;
|
||||
|
||||
// 获取鼠标在球面上的点
|
||||
const spherePoint = getPointOnSphere(event.clientX, event.clientY, camera, 2.01);
|
||||
const spherePoint = getPointOnSphere(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
camera,
|
||||
2.01,
|
||||
);
|
||||
|
||||
// 重置所有线条颜色
|
||||
allLineObjects.forEach(line => {
|
||||
allLineObjects.forEach((line) => {
|
||||
if (line.material instanceof THREE.LineBasicMaterial) {
|
||||
line.material.color.set(line.userData.originalColor);
|
||||
}
|
||||
@ -630,8 +698,11 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
|
||||
if (countryName) {
|
||||
// 高亮显示该国家/地区的线条
|
||||
allLineObjects.forEach(line => {
|
||||
if (lineToCountryMap.get(line) === countryName && line.material instanceof THREE.LineBasicMaterial) {
|
||||
allLineObjects.forEach((line) => {
|
||||
if (
|
||||
lineToCountryMap.get(line) === countryName &&
|
||||
line.material instanceof THREE.LineBasicMaterial
|
||||
) {
|
||||
line.material.color.set(line.userData.highlightColor);
|
||||
}
|
||||
});
|
||||
@ -668,7 +739,7 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
// 清除选择的函数
|
||||
const clearSelection = () => {
|
||||
// 恢复所有线条的原始颜色
|
||||
allLineObjects.forEach(line => {
|
||||
allLineObjects.forEach((line) => {
|
||||
if (line.material instanceof THREE.LineBasicMaterial) {
|
||||
line.material.color.set(line.userData.originalColor);
|
||||
}
|
||||
@ -687,7 +758,12 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
if (!containerRef.current || !sceneRef.current) return;
|
||||
|
||||
// 获取鼠标在球面上的点
|
||||
const spherePoint = getPointOnSphere(event.clientX, event.clientY, camera, 2.01);
|
||||
const spherePoint = getPointOnSphere(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
camera,
|
||||
2.01,
|
||||
);
|
||||
|
||||
// 如果找到点,寻找最近的国家/地区
|
||||
if (spherePoint) {
|
||||
@ -695,15 +771,18 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
|
||||
if (countryName) {
|
||||
// 重置所有线条颜色
|
||||
allLineObjects.forEach(line => {
|
||||
allLineObjects.forEach((line) => {
|
||||
if (line.material instanceof THREE.LineBasicMaterial) {
|
||||
line.material.color.set(line.userData.originalColor);
|
||||
}
|
||||
});
|
||||
|
||||
// 高亮显示该国家/地区的线条
|
||||
allLineObjects.forEach(line => {
|
||||
if (lineToCountryMap.get(line) === countryName && line.material instanceof THREE.LineBasicMaterial) {
|
||||
allLineObjects.forEach((line) => {
|
||||
if (
|
||||
lineToCountryMap.get(line) === countryName &&
|
||||
line.material instanceof THREE.LineBasicMaterial
|
||||
) {
|
||||
line.material.color.set(line.userData.highlightColor);
|
||||
}
|
||||
});
|
||||
@ -735,9 +814,9 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
};
|
||||
|
||||
// 添加事件监听器
|
||||
containerRef.current.addEventListener('mousemove', onMouseMove);
|
||||
containerRef.current.addEventListener('click', onClick);
|
||||
containerRef.current.addEventListener('dblclick', onDoubleClick);
|
||||
containerRef.current.addEventListener("mousemove", onMouseMove);
|
||||
containerRef.current.addEventListener("click", onClick);
|
||||
containerRef.current.addEventListener("dblclick", onDoubleClick);
|
||||
|
||||
// 简化的动画循环函数
|
||||
const animate = () => {
|
||||
@ -773,7 +852,7 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
lastMouseY: null,
|
||||
lastHoverTime: null,
|
||||
regionImportance: undefined,
|
||||
importanceThreshold: undefined
|
||||
importanceThreshold: undefined,
|
||||
};
|
||||
|
||||
// 处理窗口大小变化
|
||||
@ -794,7 +873,7 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
labelRenderer.render(sceneRef.current.scene, camera);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// 开始动画
|
||||
sceneRef.current.animationId = requestAnimationFrame(animate);
|
||||
@ -826,13 +905,13 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
|
||||
// 移除事件监听器
|
||||
if (containerRef.current) {
|
||||
containerRef.current.removeEventListener('mousemove', onMouseMove);
|
||||
containerRef.current.removeEventListener('click', onClick);
|
||||
containerRef.current.removeEventListener('dblclick', onDoubleClick);
|
||||
containerRef.current.removeEventListener("mousemove", onMouseMove);
|
||||
containerRef.current.removeEventListener("click", onClick);
|
||||
containerRef.current.removeEventListener("dblclick", onDoubleClick);
|
||||
}
|
||||
|
||||
// 移除窗口事件监听器
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [visitedPlaces, theme]); // 依赖于visitedPlaces和theme变化
|
||||
|
||||
@ -849,8 +928,16 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
{hoveredCountry}
|
||||
{hoveredCountry && visitedPlaces.includes(hoveredCountry) ? (
|
||||
<span className="inline-flex items-center justify-center bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-400 px-2.5 py-1 rounded-full text-sm ml-1.5 whitespace-nowrap">
|
||||
<svg className="w-4 h-4 mr-1" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
<svg
|
||||
className="w-4 h-4 mr-1"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
已去过
|
||||
</span>
|
||||
|
@ -4,13 +4,13 @@ date: 2025-04-18T22:01:57+08:00
|
||||
tags: []
|
||||
---
|
||||
|
||||
>大多数和别人的对话都是使用谷歌翻译的同声翻译
|
||||
> 大多数和别人的对话都是使用谷歌翻译的同声翻译
|
||||
|
||||
## 睁眼说瞎话
|
||||
|
||||
值机的时候碰到本次旅行第一个交流的外国人,一个会讲中文的马来西亚男人,感觉马来西亚男人说话像乱序中文但是能听懂,马来西亚男人知道了我没有马来西亚货币,提出换一点林吉特给我,马来西亚男人的老婆说人民币拿去没用,我都不抱希望了,但是马来西亚夫妻还是兑换了100人民币给我。
|
||||
值机的时候碰到本次旅行第一个交流的外国人,一个会讲中文的马来西亚男人,感觉马来西亚男人说话像乱序中文但是能听懂,马来西亚男人知道了我没有马来西亚货币,提出换一点林吉特给我,马来西亚男人的老婆说人民币拿去没用,我都不抱希望了,但是马来西亚夫妻还是兑换了 100 人民币给我。
|
||||
|
||||
还没出国门就遇到第一个问题,海关闸机刷了不开门,海关警察来了对我进行盘问,海关的警察问我很多问题,其中就有我父母是否同意,现在遇到突发问题,也是可以面不改色的说假话了,不过好在中午的时候打电话询问了一下我爹的意见,虽然我爹不同意,但是好在留下了通话记录,我将过去的旅游照片给海关看,与海关警察周旋了10多分钟,幸好过海关的时候快到晚上12点了,不好向我父母核实,差点这次旅行计划早夭了。
|
||||
还没出国门就遇到第一个问题,海关闸机刷了不开门,海关警察来了对我进行盘问,海关的警察问我很多问题,其中就有我父母是否同意,现在遇到突发问题,也是可以面不改色的说假话了,不过好在中午的时候打电话询问了一下我爹的意见,虽然我爹不同意,但是好在留下了通话记录,我将过去的旅游照片给海关看,与海关警察周旋了 10 多分钟,幸好过海关的时候快到晚上 12 点了,不好向我父母核实,差点这次旅行计划早夭了。
|
||||
|
||||
第一次做廉航,亚航位置空隙比国内任何大巴空隙都要小,最难受的交通出行方式。
|
||||
|
||||
@ -18,21 +18,21 @@ tags: []
|
||||
|
||||
## 可恶的公交车司机
|
||||
|
||||
马来西亚第一站计划去粉红清真寺,从吉隆坡机场1楼乘坐巴士直接过去,但是购票的时候,公交车售票厅工作人员说没有到粉红清真寺的公交车只能打出租车前往,看了一下打车的价格,决定重新做攻略再挣扎一下,在休息长椅坐了半个小时终于找到新路线`地铁站->布城->公交站->粉红清真寺`,往机场3楼地铁站走的时候发现斜挎包不见了,听说国外酒店工作人员会翻包偷钱,就买了个斜挎包放护照,现金等重要的东西,惊慌了一会,还好头脑风暴了一会想起来了在做攻略的长椅忘拿了。
|
||||
马来西亚第一站计划去粉红清真寺,从吉隆坡机场 1 楼乘坐巴士直接过去,但是购票的时候,公交车售票厅工作人员说没有到粉红清真寺的公交车只能打出租车前往,看了一下打车的价格,决定重新做攻略再挣扎一下,在休息长椅坐了半个小时终于找到新路线`地铁站->布城->公交站->粉红清真寺`,往机场 3 楼地铁站走的时候发现斜挎包不见了,听说国外酒店工作人员会翻包偷钱,就买了个斜挎包放护照,现金等重要的东西,惊慌了一会,还好头脑风暴了一会想起来了在做攻略的长椅忘拿了。
|
||||
|
||||
布城的公交车站是始发站,在最后一个站台才找到`T523`,我想上车但是司机朝我摆手,我就在车子旁边的亭子研究如何打车,研究了一会司机叫了我一声,给我一个招揽的手势,上去了我给司机看我的谷歌地图,用翻译软件软件问司机可以去这里吗,司机用本地话说一大堆,我将谷歌同声翻译打开司机,告诉司机对着这个说我就可以听懂了,但是给司机一个字不说,拿开了手机司机又开始说本地话,僵持了一会司机不耐烦的打手势让我去旁边公交车,我以为上错车了,我将地图给旁边公交车司机看,说要去地图的地方,旁边公交车的司机指着`T523`告诉我那辆车可以去,回到T523后告诉司机就是这个车,车开了一会司机说 three ,我本以为司机完全不会英语呢,途中看到一个清真寺,打开谷歌地图显示现在要去的最后一个站,我指着清真寺问司机"is there?",司机说"yes,down",看着绿色的清真寺我觉得现在照骗太多了,看着别人拿着证件或者是手机给安保人员看了才能进去,我想网上不要预约和门票的说法看来是过时了,不过来都来了我要去试试,到了门口工作人员拦下我,我告诉工作人员我没有预约但是我想进去参观,工作人员反复询问我确定要进去吗,我告诉工作人员我专程过来参观这个清真寺,工作人员的话翻译过来是“这是政府办公的地方不允许外人靠近,但是你是第一次”,我打开地图重新导航显示距离粉红清真寺还有1.2km。
|
||||
布城的公交车站是始发站,在最后一个站台才找到`T523`,我想上车但是司机朝我摆手,我就在车子旁边的亭子研究如何打车,研究了一会司机叫了我一声,给我一个招揽的手势,上去了我给司机看我的谷歌地图,用翻译软件软件问司机可以去这里吗,司机用本地话说一大堆,我将谷歌同声翻译打开司机,告诉司机对着这个说我就可以听懂了,但是给司机一个字不说,拿开了手机司机又开始说本地话,僵持了一会司机不耐烦的打手势让我去旁边公交车,我以为上错车了,我将地图给旁边公交车司机看,说要去地图的地方,旁边公交车的司机指着`T523`告诉我那辆车可以去,回到 T523 后告诉司机就是这个车,车开了一会司机说 three ,我本以为司机完全不会英语呢,途中看到一个清真寺,打开谷歌地图显示现在要去的最后一个站,我指着清真寺问司机"is there?",司机说"yes,down",看着绿色的清真寺我觉得现在照骗太多了,看着别人拿着证件或者是手机给安保人员看了才能进去,我想网上不要预约和门票的说法看来是过时了,不过来都来了我要去试试,到了门口工作人员拦下我,我告诉工作人员我没有预约但是我想进去参观,工作人员反复询问我确定要进去吗,我告诉工作人员我专程过来参观这个清真寺,工作人员的话翻译过来是“这是政府办公的地方不允许外人靠近,但是你是第一次”,我打开地图重新导航显示距离粉红清真寺还有 1.2km。
|
||||
|
||||
粉红清真寺的穹顶真的是粉红色的!穹顶里面看更漂亮,由红色,浅粉色,白色构成的图案
|
||||
|
||||
在粉红清真寺里面有两幅捐款地图,捐款一次可以用针扎自己的家乡,一副世界地图一副中国地图,世界地图上的中国和中国地图都是密密麻麻的针
|
||||
|
||||
国外的公交车不适合i人,我打算从布城从地铁到市中心,需要先坐公交车到布城去,差10秒就赶上了,但是站台没人所以公交车司机没有停,第二次在休息区等了半个小时司机又没停,可能是司机没看到我吧,第三次我站在公交车站台等车的位置等待可是他还是没停,这次可能是没有给司机信号,第四次等公交车快到的时候我死死的看着司机,与他建立心灵链接,但他还是不停,浏览器查询原来要招手,用打车软件看了一下两公里,还是选择打车了
|
||||
国外的公交车不适合 i 人,我打算从布城从地铁到市中心,需要先坐公交车到布城去,差 10 秒就赶上了,但是站台没人所以公交车司机没有停,第二次在休息区等了半个小时司机又没停,可能是司机没看到我吧,第三次我站在公交车站台等车的位置等待可是他还是没停,这次可能是没有给司机信号,第四次等公交车快到的时候我死死的看着司机,与他建立心灵链接,但他还是不停,浏览器查询原来要招手,用打车软件看了一下两公里,还是选择打车了
|
||||
|
||||
在去酒店的路上看到了很多流浪汉,不过感觉他们的穿搭和我没有区别,一个包+拖鞋,历经大雨来到谷歌地图显示的位置,却找不到酒店,找了一个印度男人问路,他也找不到,他给酒店客服打电话后,告诉我不在这个区域,给我指路,往哪走再往哪走到一个塔下快到了问问别人,我一点没记住好在用高德地图重新导航,竟然没问题。
|
||||
在去酒店的路上看到了很多流浪汉,不过感觉他们的穿搭和我没有区别,一个包+拖鞋,历经大雨来到谷歌地图显示的位置,却找不到酒店,找了一个印度男人问路,他也找不到,他给酒店客服打电话后,告诉我不在这个区域,给我指路,往哪走,再往哪走,到一个塔下就快到了,再问问别人,我一点没记住好在用高德地图重新导航,竟然没问题。
|
||||
|
||||
晚餐找了家本地人多的店,点了一个大虾饭,没想到是正宗印度菜,`米饭味道=70%八角+20%洗衣服+10%辣椒`
|
||||
晚餐在酒店旁找了家本地人多的店,点了一个大虾饭,没想到是正宗印度菜,`米饭味道=70%八角+20%洗衣服+10%辣椒`
|
||||
|
||||
凌晨3点被炸街吵醒,没想到精神小伙也是全世界统一
|
||||
凌晨 3 点被炸街吵醒,没想到精神小伙也是全世界统一
|
||||
|
||||
---
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user