毕业设计,第一版
This commit is contained in:
parent
639a0fd504
commit
b5260f1836
@ -4,4 +4,7 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server:{
|
||||
host:"0.0.0.0"
|
||||
}
|
||||
})
|
||||
|
1
web/blog/echoer
Submodule
1
web/blog/echoer
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 5a6c020920c343afe497583a19a7be3f128e1edd
|
1
web/blog/echoes
Submodule
1
web/blog/echoes
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 9f5c3f602557996d71aa698bd112656212c17060
|
48
web/czr/app/components/Footer.tsx
Normal file
48
web/czr/app/components/Footer.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-4">关于我们</h4>
|
||||
<p className="text-gray-400">新纪元科技致力于环保科技创新,为地球可持续发展贡献力量</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-4">联系方式</h4>
|
||||
<p className="text-gray-400">电话:400-888-8888</p>
|
||||
<p className="text-gray-400">邮箱:contact@xingjiyuan.com</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-4">解决方案</h4>
|
||||
<ul className="text-gray-400 space-y-2">
|
||||
<li>
|
||||
<a href="#" className="hover:text-white transition-colors duration-200 hover:translate-x-2 inline-block">
|
||||
智慧城市
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="hover:text-white transition-colors duration-200 hover:translate-x-2 inline-block">
|
||||
工业节能
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="hover:text-white transition-colors duration-200 hover:translate-x-2 inline-block">
|
||||
生态监测
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-4">公司地址</h4>
|
||||
<p className="text-gray-400">中国上海市浦东新区科技创新大道888号</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
|
||||
<p>© 2024 新纪元科技 版权所有</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
129
web/czr/app/components/HomeCarousel.tsx
Normal file
129
web/czr/app/components/HomeCarousel.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const images = [
|
||||
{
|
||||
url: "/b1.jpg",
|
||||
alt: "绿色科技",
|
||||
title: "创新环保技术"
|
||||
},
|
||||
{
|
||||
url: "/b2.jpg",
|
||||
alt: "可持续发展",
|
||||
title: "可持续未来"
|
||||
},
|
||||
{
|
||||
url: "/b3.jpg",
|
||||
alt: "科技创新",
|
||||
title: "引领科技创新"
|
||||
}
|
||||
];
|
||||
|
||||
export default function HomeCarousel() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prevIndex) =>
|
||||
prevIndex === images.length - 1 ? 0 : prevIndex + 1
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const nextSlide = () => {
|
||||
setCurrentIndex((prevIndex) =>
|
||||
prevIndex === images.length - 1 ? 0 : prevIndex + 1
|
||||
);
|
||||
};
|
||||
|
||||
const prevSlide = () => {
|
||||
setCurrentIndex((prevIndex) =>
|
||||
prevIndex === 0 ? images.length - 1 : prevIndex - 1
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto my-8">
|
||||
<div className="relative w-full h-[400px] overflow-hidden rounded-xl">
|
||||
{/* 图片容器 */}
|
||||
<div
|
||||
className="absolute w-full h-full transition-transform duration-500 ease-in-out"
|
||||
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
|
||||
>
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute w-full h-full"
|
||||
style={{ left: `${index * 100}%` }}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.alt}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-30">
|
||||
<div className="absolute bottom-20 left-1/2 transform -translate-x-1/2 text-white text-center">
|
||||
<h2 className="text-4xl font-bold mb-4">{image.title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 修改控制按钮样式 */}
|
||||
<button
|
||||
onClick={prevSlide}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 bg-white/20 hover:bg-white/40 backdrop-blur-sm w-10 h-10 rounded-full flex items-center justify-center transition-all duration-200"
|
||||
aria-label="上一张"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={nextSlide}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 bg-white/20 hover:bg-white/40 backdrop-blur-sm w-10 h-10 rounded-full flex items-center justify-center transition-all duration-200"
|
||||
aria-label="下一张"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 指示器 */}
|
||||
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex space-x-2">
|
||||
{images.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
currentIndex === index ? 'bg-white' : 'bg-white bg-opacity-50'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
18
web/czr/app/components/Layout.tsx
Normal file
18
web/czr/app/components/Layout.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import Navigation from "./Navigation";
|
||||
import Footer from "./Footer";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navigation />
|
||||
<main className="flex-grow">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import Layout from "~/components/Layout";
|
||||
import HomeCarousel from "~/components/HomeCarousel";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@ -9,12 +11,13 @@ export const meta: MetaFunction = () => {
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-white text-gray-800">
|
||||
|
||||
<Layout>
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white text-gray-800">
|
||||
{/* Hero区域 */}
|
||||
<div className="relative overflow-hidden bg-gradient-to-r from-green-50 to-blue-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-24">
|
||||
<div className="text-center">
|
||||
<div className="max-w-7xl mx-auto px-4 pt-8">
|
||||
<HomeCarousel />
|
||||
<div className="text-center py-12">
|
||||
<h1 className="text-5xl font-bold mb-6 bg-gradient-to-r from-green-600 to-blue-600 bg-clip-text text-transparent">
|
||||
创新科技,守护地球
|
||||
</h1>
|
||||
@ -28,119 +31,7 @@ export default function Index() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 核心技术 */}
|
||||
<section className="py-20 bg-white" id="innovations">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<h2 className="text-3xl font-bold text-center mb-16">核心创新技术</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
<div className="text-center group transform transition-all duration-300 hover:-translate-y-2 hover:shadow-xl rounded-xl p-6">
|
||||
<div className="bg-green-50 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6
|
||||
group-hover:bg-green-100 transition-colors duration-300 group-hover:scale-110">
|
||||
<svg className="w-10 h-10 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-4">智能环保系统</h3>
|
||||
<p className="text-gray-600">采用AI技术优化资源利用,提高能源使用效率</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center group transform transition-all duration-300 hover:-translate-y-2 hover:shadow-xl rounded-xl p-6">
|
||||
<div className="bg-blue-50 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6
|
||||
group-hover:bg-blue-100 transition-colors duration-300 group-hover:scale-110">
|
||||
<svg className="w-10 h-10 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-4">新能源转换</h3>
|
||||
<p className="text-gray-600">创新能源转换技术,实现清洁能源的高效利用</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center group transform transition-all duration-300 hover:-translate-y-2 hover:shadow-xl rounded-xl p-6">
|
||||
<div className="bg-cyan-50 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6
|
||||
group-hover:bg-cyan-100 transition-colors duration-300 group-hover:scale-110">
|
||||
<svg className="w-10 h-10 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-4">生态监测</h3>
|
||||
<p className="text-gray-600">全方位环境监测系统,保护生态平衡</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 解决方案 */}
|
||||
<section className="py-20 bg-gradient-to-r from-green-50 to-blue-50" id="solutions">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<h2 className="text-3xl font-bold text-center mb-16">创新解决方案</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="bg-white rounded-lg p-8 shadow-sm hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
|
||||
<h3 className="text-xl font-semibold mb-4">智慧城市环保系统</h3>
|
||||
<p className="text-gray-600 mb-4">整合城市环境数据,提供智能化环保解决方案</p>
|
||||
<ul className="text-gray-600 space-y-2">
|
||||
<li>• 空气质量实时监测</li>
|
||||
<li>• 智能垃圾分类系统</li>
|
||||
<li>• 城市能源管理优化</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-8 shadow-sm hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
|
||||
<h3 className="text-xl font-semibold mb-4">工业节能方案</h3>
|
||||
<p className="text-gray-600 mb-4">为工业企业提供全方位的节能减排解决方案</p>
|
||||
<ul className="text-gray-600 space-y-2">
|
||||
<li className="transition-all duration-200 hover:text-green-600 hover:translate-x-2">• 能源使用效率优化</li>
|
||||
<li className="transition-all duration-200 hover:text-green-600 hover:translate-x-2">• 废物循环利用系统</li>
|
||||
<li className="transition-all duration-200 hover:text-green-600 hover:translate-x-2">• 清洁生产技术改造</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 页脚 */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-4">关于我们</h4>
|
||||
<p className="text-gray-400">新纪元科技致力于环保科技创新,为地球可持续发展贡献力量</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-4">联系方式</h4>
|
||||
<p className="text-gray-400">电话:400-888-8888</p>
|
||||
<p className="text-gray-400">邮箱:contact@xingjiyuan.com</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-4">解决方案</h4>
|
||||
<ul className="text-gray-400 space-y-2">
|
||||
<li>
|
||||
<a href="#" className="hover:text-white transition-colors duration-200 hover:translate-x-2 inline-block">
|
||||
智慧城市
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="hover:text-white transition-colors duration-200 hover:translate-x-2 inline-block">
|
||||
工业节能
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="hover:text-white transition-colors duration-200 hover:translate-x-2 inline-block">
|
||||
生态监测
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-4">公司地址</h4>
|
||||
<p className="text-gray-400">中国上海市浦东新区科技创新大道888号</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
|
||||
<p>© 2024 新纪元科技 版权所有</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import Layout from "~/components/Layout";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "关于我们 - 新纪元科技" },
|
||||
@ -8,8 +10,8 @@ export const meta: MetaFunction = () => {
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-white text-gray-800">
|
||||
|
||||
<Layout>
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white text-gray-800">
|
||||
{/* 页面标题 */}
|
||||
<div className="bg-gradient-to-r from-green-50 to-blue-50 py-20">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
@ -39,29 +41,39 @@ export default function About() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="transform transition-all duration-500 hover:scale-105">
|
||||
<h2 className="text-3xl font-bold mb-6 relative after:content-[''] after:absolute after:bottom-0
|
||||
<h2 className="text-3xl font-bold mb-8 relative after:content-[''] after:absolute after:bottom-0
|
||||
after:left-0 after:w-20 after:h-1 after:bg-gradient-to-r after:from-green-500 after:to-blue-500">
|
||||
愿景使命
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">愿景</h3>
|
||||
<p className="text-gray-600">成为全球领先的环保科技创新企业</p>
|
||||
<div className="space-y-8">
|
||||
<div className="bg-white rounded-lg p-6 shadow-lg border border-gray-100 hover:shadow-xl transition-shadow">
|
||||
<h3 className="text-2xl font-semibold mb-4 text-green-600 flex items-center">
|
||||
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
愿景
|
||||
</h3>
|
||||
<p className="text-gray-600 text-lg leading-relaxed">
|
||||
成为全球领先的环保科技创新企业
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">使命</h3>
|
||||
<p className="text-gray-600">用科技创新守护地球家园</p>
|
||||
<div className="bg-white rounded-lg p-6 shadow-lg border border-gray-100 hover:shadow-xl transition-shadow">
|
||||
<h3 className="text-2xl font-semibold mb-4 text-blue-600 flex items-center">
|
||||
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
使命
|
||||
</h3>
|
||||
<p className="text-gray-600 text-lg leading-relaxed">
|
||||
用科技创新守护地球家园
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 页脚 */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
{/* 同首页页脚内容 */}
|
||||
</footer>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
@ -2,6 +2,7 @@ import type { MetaFunction } from "@remix-run/node";
|
||||
import { ImageLoader } from "hooks/ParticleImage";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { Carousel } from "~/components/Carousel";
|
||||
import Layout from "~/components/Layout";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@ -34,7 +35,8 @@ export default function Innovations() {
|
||||
const { isClient, innovations } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-white text-gray-800">
|
||||
<Layout>
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white text-gray-800">
|
||||
{/* 头部区域:标题 + 轮播图 */}
|
||||
<div className="relative bg-gradient-to-b from-green-50 to-blue-50/30 pt-16 pb-32 overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
@ -147,11 +149,7 @@ export default function Innovations() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 页脚 */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
{/* 同首页页脚内容 */}
|
||||
</footer>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import Layout from "~/components/Layout";
|
||||
import { ImageLoader } from "hooks/ParticleImage";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
|
||||
@ -17,7 +18,8 @@ export default function Solutions() {
|
||||
const { isClient } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-white text-gray-800">
|
||||
<Layout>
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white text-gray-800">
|
||||
{/* 页面标题和轮播图 */}
|
||||
<div className="bg-gradient-to-r from-green-50 to-blue-50 py-20">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
@ -50,7 +52,7 @@ export default function Solutions() {
|
||||
<span className="absolute bottom-0 left-0 w-full h-2 bg-green-100 transform -skew-x-12"></span>
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600">通过智能技术优化城市环境管理</p>
|
||||
<p className="text-gray-600">通过智能技能优化城市环境管理</p>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li className="transition-all duration-200 hover:text-green-600 hover:translate-x-2">
|
||||
• 智能环境监测系统
|
||||
@ -95,11 +97,7 @@ export default function Solutions() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 页脚 - 可以提取为共享组件 */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
{/* 同首页页脚内容 */}
|
||||
</footer>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { RemixServer } from '@remix-run/react';
|
||||
import { createReadStream, createWriteStream } from 'fs';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = resolve(__dirname, '..');
|
||||
|
||||
async function generateHTML() {
|
||||
try {
|
||||
const distDir = resolve(projectRoot, 'dist');
|
||||
await mkdir(distDir, { recursive: true });
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Your App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/assets/index.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const indexPath = resolve(distDir, 'index.html');
|
||||
const writer = createWriteStream(indexPath);
|
||||
writer.write(html);
|
||||
writer.end();
|
||||
|
||||
console.log('HTML file generated successfully at:', indexPath);
|
||||
} catch (error) {
|
||||
console.error('Error generating HTML:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
generateHTML();
|
@ -1,32 +0,0 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export async function readEnvFile() {
|
||||
const envPath = path.resolve(process.cwd(), ".env");
|
||||
try {
|
||||
const content = await fs.readFile(envPath, "utf-8");
|
||||
return content.split("\n").reduce(
|
||||
(acc, line) => {
|
||||
const [key, value] = line.split("=").map((s) => s.trim());
|
||||
if (key && value) {
|
||||
acc[key] = value.replace(/["']/g, "");
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeEnvFile(env: Record<string, string>) {
|
||||
const envPath = path.resolve(process.cwd(), ".env");
|
||||
const content = Object.entries(env)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`${key}=${typeof value === "string" ? `"${value}"` : value}`,
|
||||
)
|
||||
.join("\n");
|
||||
await fs.writeFile(envPath, content, "utf-8");
|
||||
}
|
@ -1,71 +0,0 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { DEFAULT_CONFIG } from "../app/env";
|
||||
import { readEnvFile, writeEnvFile } from "./env";
|
||||
|
||||
const app = express();
|
||||
const address = process.env.VITE_ADDRESS ?? DEFAULT_CONFIG.VITE_ADDRESS;
|
||||
const port = Number(process.env.VITE_PORT ?? DEFAULT_CONFIG.VITE_PORT);
|
||||
|
||||
const ALLOWED_ORIGIN = `http://${address}:${port}`;
|
||||
// 配置 CORS,只允许来自 Vite 服务器的请求
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || origin === ALLOWED_ORIGIN) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error("不允许的来源"));
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// 添加 IP 和端口检查中间件
|
||||
const checkAccessMiddleware = (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
const clientIp = req.ip === "::1" ? "localhost" : req.ip;
|
||||
const clientPort = Number(req.get("origin")?.split(":").pop() ?? 0);
|
||||
|
||||
const isLocalIp = clientIp === "localhost" || clientIp === "127.0.0.1";
|
||||
const isAllowedPort = clientPort === port;
|
||||
|
||||
if (isLocalIp && isAllowedPort) {
|
||||
next();
|
||||
} else {
|
||||
res.status(403).json({
|
||||
error: "禁止访问",
|
||||
detail: `仅允许 ${address}:${port} 访问`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
app.use(checkAccessMiddleware);
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/env", async (req, res) => {
|
||||
try {
|
||||
const envData = await readEnvFile();
|
||||
res.json(envData);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: "读取环境变量失败" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/env", async (req, res) => {
|
||||
try {
|
||||
const newEnv = req.body;
|
||||
await writeEnvFile(newEnv);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: "更新环境变量失败" });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port + 1, address, () => {
|
||||
console.log(`内部服务器运行在 http://${address}:${port + 1}`);
|
||||
});
|
@ -1,23 +0,0 @@
|
||||
import express from 'express';
|
||||
import { createRequestHandler } from "@remix-run/express";
|
||||
import * as build from "../build/server/index.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
// 静态文件服务
|
||||
app.use(express.static("public"));
|
||||
app.use(express.static("build/client"));
|
||||
|
||||
// Remix 请求处理
|
||||
app.all(
|
||||
"*",
|
||||
createRequestHandler({
|
||||
build,
|
||||
mode: "production",
|
||||
})
|
||||
);
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
app.listen(port, () => {
|
||||
console.log(`Express server listening on port ${port}`);
|
||||
});
|
@ -1,31 +0,0 @@
|
||||
import express from 'express';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = resolve(__dirname, '..');
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// 设置静态文件目录
|
||||
app.use(express.static(resolve(projectRoot, 'dist')));
|
||||
|
||||
// 所有路由都返回 index.html
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(resolve(projectRoot, 'dist', 'index.html'));
|
||||
});
|
||||
|
||||
// 确保dist目录存在
|
||||
import { mkdir } from 'fs/promises';
|
||||
try {
|
||||
await mkdir(resolve(projectRoot, 'dist'), { recursive: true });
|
||||
} catch (error) {
|
||||
console.error('Error creating dist directory:', error);
|
||||
}
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running at http://localhost:${port}`);
|
||||
console.log('Static files directory:', resolve(projectRoot, 'dist'));
|
||||
console.log('Index file path:', resolve(projectRoot, 'dist', 'index.html'));
|
||||
});
|
@ -1,7 +0,0 @@
|
||||
import { RemixBrowser } from "@remix-run/react";
|
||||
import { startTransition } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(document, <RemixBrowser />);
|
||||
});
|
48
web/graduation/README.md
Normal file
48
web/graduation/README.md
Normal file
@ -0,0 +1,48 @@
|
||||
# Astro Starter Kit: Basics
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template basics
|
||||
```
|
||||
|
||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics)
|
||||
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics)
|
||||
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json)
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||

|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
│ └── favicon.svg
|
||||
├── src/
|
||||
│ ├── layouts/
|
||||
│ │ └── Layout.astro
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
11
web/graduation/astro.config.mjs
Normal file
11
web/graduation/astro.config.mjs
Normal file
@ -0,0 +1,11 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
plugins: [tailwindcss()]
|
||||
}
|
||||
});
|
23
web/graduation/package.json
Normal file
23
web/graduation/package.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "graduation",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@studio-freight/lenis": "^1.0.42",
|
||||
"@tailwindcss/vite": "^4.0.15",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"aceternity-ui": "^0.2.2",
|
||||
"astro": "^5.5.3",
|
||||
"framer-motion": "^12.5.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.0.15"
|
||||
}
|
||||
}
|
20
web/graduation/public/favicon.svg
Normal file
20
web/graduation/public/favicon.svg
Normal file
@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<!-- 背景 -->
|
||||
<rect width="100" height="100" rx="20" fill="#4b6bff"/>
|
||||
|
||||
<!-- 山水图案 -->
|
||||
<path d="M0,70 L30,40 L45,55 L60,35 L75,50 L100,25 V100 H0 Z" fill="#1a3293"/>
|
||||
|
||||
<!-- 河字 -->
|
||||
<path d="M50,15 L50,35 M35,25 L65,25" stroke="#ffffff" stroke-width="8" stroke-linecap="round"/>
|
||||
|
||||
<!-- 北字 -->
|
||||
<path d="M50,45 L50,65 M40,55 L60,55 M40,45 L40,65" stroke="#ffffff" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<style>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
rect { fill: #2541b7; }
|
||||
path:first-of-type { fill: #132878; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
After Width: | Height: | Size: 703 B |
1
web/graduation/src/assets/astro.svg
Normal file
1
web/graduation/src/assets/astro.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="115" height="48"><path fill="#17191E" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="url(#a)" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="#17191E" d="M.02 30.31s4.02-1.95 8.05-1.95l3.04-9.4c.11-.45.44-.76.82-.76.37 0 .7.31.82.76l3.04 9.4c4.77 0 8.05 1.95 8.05 1.95L17 11.71c-.2-.56-.53-.91-.98-.91H7.83c-.44 0-.76.35-.97.9L.02 30.31Zm42.37-5.97c0 1.64-2.05 2.62-4.88 2.62-1.85 0-2.5-.45-2.5-1.41 0-1 .8-1.49 2.65-1.49 1.67 0 3.09.03 4.73.23v.05Zm.03-2.04a21.37 21.37 0 0 0-4.37-.36c-5.32 0-7.82 1.25-7.82 4.18 0 3.04 1.71 4.2 5.68 4.2 3.35 0 5.63-.84 6.46-2.92h.14c-.03.5-.05 1-.05 1.4 0 1.07.18 1.16 1.06 1.16h4.15a16.9 16.9 0 0 1-.36-4c0-1.67.06-2.93.06-4.62 0-3.45-2.07-5.64-8.56-5.64-2.8 0-5.9.48-8.26 1.19.22.93.54 2.83.7 4.06 2.04-.96 4.95-1.37 7.2-1.37 3.11 0 3.97.71 3.97 2.15v.57Zm11.37 3c-.56.07-1.33.07-2.12.07-.83 0-1.6-.03-2.12-.1l-.02.58c0 2.85 1.87 4.52 8.45 4.52 6.2 0 8.2-1.64 8.2-4.55 0-2.74-1.33-4.09-7.2-4.39-4.58-.2-4.99-.7-4.99-1.28 0-.66.59-1 3.65-1 3.18 0 4.03.43 4.03 1.35v.2a46.13 46.13 0 0 1 4.24.03l.02-.55c0-3.36-2.8-4.46-8.2-4.46-6.08 0-8.13 1.49-8.13 4.39 0 2.6 1.64 4.23 7.48 4.48 4.3.14 4.77.62 4.77 1.28 0 .7-.7 1.03-3.71 1.03-3.47 0-4.35-.48-4.35-1.47v-.13Zm19.82-12.05a17.5 17.5 0 0 1-6.24 3.48c.03.84.03 2.4.03 3.24l1.5.02c-.02 1.63-.04 3.6-.04 4.9 0 3.04 1.6 5.32 6.58 5.32 2.1 0 3.5-.23 5.23-.6a43.77 43.77 0 0 1-.46-4.13c-1.03.34-2.34.53-3.78.53-2 0-2.82-.55-2.82-2.13 0-1.37 0-2.65.03-3.84 2.57.02 5.13.07 6.64.11-.02-1.18.03-2.9.1-4.04-2.2.04-4.65.07-6.68.07l.07-2.93h-.16Zm13.46 6.04a767.33 767.33 0 0 1 .07-3.18H82.6c.07 1.96.07 3.98.07 6.92 0 2.95-.03 4.99-.07 6.93h5.18c-.09-1.37-.11-3.68-.11-5.65 0-3.1 1.26-4 4.12-4 1.33 0 2.28.16 3.1.46.03-1.16.26-3.43.4-4.43-.86-.25-1.81-.41-2.96-.41-2.46-.03-4.26.98-5.1 3.38l-.17-.02Zm22.55 3.65c0 2.5-1.8 3.66-4.64 3.66-2.81 0-4.61-1.1-4.61-3.66s1.82-3.52 4.61-3.52c2.82 0 4.64 1.03 4.64 3.52Zm4.71-.11c0-4.96-3.87-7.18-9.35-7.18-5.5 0-9.23 2.22-9.23 7.18 0 4.94 3.49 7.59 9.21 7.59 5.77 0 9.37-2.65 9.37-7.6Z"/><defs><linearGradient id="a" x1="6.33" x2="19.43" y1="40.8" y2="34.6" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>
|
After Width: | Height: | Size: 2.8 KiB |
1
web/graduation/src/assets/background.svg
Normal file
1
web/graduation/src/assets/background.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1024" fill="none"><path fill="url(#a)" fill-rule="evenodd" d="M-217.58 475.75c91.82-72.02 225.52-29.38 341.2-44.74C240 415.56 372.33 315.14 466.77 384.9c102.9 76.02 44.74 246.76 90.31 366.31 29.83 78.24 90.48 136.14 129.48 210.23 57.92 109.99 169.67 208.23 155.9 331.77-13.52 121.26-103.42 264.33-224.23 281.37-141.96 20.03-232.72-220.96-374.06-196.99-151.7 25.73-172.68 330.24-325.85 315.72-128.6-12.2-110.9-230.73-128.15-358.76-12.16-90.14 65.87-176.25 44.1-264.57-26.42-107.2-167.12-163.46-176.72-273.45-10.15-116.29 33.01-248.75 124.87-320.79Z" clip-rule="evenodd" style="opacity:.154"/><path fill="url(#b)" fill-rule="evenodd" d="M1103.43 115.43c146.42-19.45 275.33-155.84 413.5-103.59 188.09 71.13 409 212.64 407.06 413.88-1.94 201.25-259.28 278.6-414.96 405.96-130 106.35-240.24 294.39-405.6 265.3-163.7-28.8-161.93-274.12-284.34-386.66-134.95-124.06-436-101.46-445.82-284.6-9.68-180.38 247.41-246.3 413.54-316.9 101.01-42.93 207.83 21.06 316.62 6.61Z" clip-rule="evenodd" style="opacity:.154"/><defs><linearGradient id="b" x1="373" x2="1995.44" y1="1100" y2="118.03" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient><linearGradient id="a" x1="107.37" x2="1130.66" y1="1993.35" y2="1026.31" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset="1" stop-color="#BC52EE"/></linearGradient></defs></svg>
|
After Width: | Height: | Size: 1.4 KiB |
41
web/graduation/src/components/DarkModeTransition.astro
Normal file
41
web/graduation/src/components/DarkModeTransition.astro
Normal file
@ -0,0 +1,41 @@
|
||||
---
|
||||
// 黑暗模式过渡组件 - 提供平滑过渡的CSS
|
||||
---
|
||||
|
||||
<style is:global>
|
||||
:root {
|
||||
--transition-duration: 0.3s;
|
||||
}
|
||||
|
||||
html {
|
||||
transition: background-color var(--transition-duration) ease,
|
||||
color var(--transition-duration) ease;
|
||||
}
|
||||
|
||||
body * {
|
||||
transition: background-color var(--transition-duration) ease,
|
||||
border-color var(--transition-duration) ease,
|
||||
color var(--transition-duration) ease,
|
||||
fill var(--transition-duration) ease,
|
||||
stroke var(--transition-duration) ease,
|
||||
box-shadow var(--transition-duration) ease;
|
||||
}
|
||||
|
||||
/* 特定元素过渡效果增强 */
|
||||
.card-transition {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, background-color var(--transition-duration) ease;
|
||||
}
|
||||
|
||||
/* 防止某些元素过渡,如轮播图等需要即时响应的元素 */
|
||||
.no-transition,
|
||||
.no-transition * {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* 防止动画闪烁 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html, body * {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
70
web/graduation/src/components/ThemeToggle.astro
Normal file
70
web/graduation/src/components/ThemeToggle.astro
Normal file
@ -0,0 +1,70 @@
|
||||
---
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className = '' } = Astro.props;
|
||||
---
|
||||
|
||||
<button
|
||||
id="themeToggle"
|
||||
aria-label="切换主题"
|
||||
class={`p-2 rounded-full hover:bg-gray-200 dark:hover:bg-color-dark-card focus:outline-none focus:ring-2 focus:ring-color-primary-400 dark:focus:ring-color-dark-primary-600 ${className}`}
|
||||
>
|
||||
<!-- 亮色主题图标 -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon light-icon h-6 w-6 text-gray-800 dark:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
|
||||
<!-- 暗色主题图标 -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon dark-icon h-6 w-6 text-white hidden dark:block" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
|
||||
// 检查用户之前的主题设置
|
||||
const getStoredTheme = () => {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme) return savedTheme;
|
||||
|
||||
// 如果没有存储的主题,检查系统偏好
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
// 应用主题
|
||||
const applyTheme = (theme: string) => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
// 发送主题变更事件,方便其他组件响应
|
||||
window.dispatchEvent(new CustomEvent('theme-change', { detail: { theme } }));
|
||||
};
|
||||
|
||||
// 切换主题
|
||||
const toggleTheme = () => {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
applyTheme(newTheme);
|
||||
};
|
||||
|
||||
// 初始化主题
|
||||
applyTheme(getStoredTheme());
|
||||
|
||||
// 事件监听
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', toggleTheme);
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
// 只有当用户没有手动设置主题时才跟随系统变化
|
||||
applyTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
109
web/graduation/src/components/aceternity/AnimatedGallery.astro
Normal file
109
web/graduation/src/components/aceternity/AnimatedGallery.astro
Normal file
@ -0,0 +1,109 @@
|
||||
---
|
||||
interface Image {
|
||||
src: string;
|
||||
alt: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
images: Image[];
|
||||
className?: string;
|
||||
gap?: number;
|
||||
direction?: 'left' | 'right';
|
||||
speed?: 'slow' | 'normal' | 'fast';
|
||||
}
|
||||
|
||||
const {
|
||||
images,
|
||||
className = "",
|
||||
gap = 16,
|
||||
direction = 'left',
|
||||
speed = 'normal'
|
||||
} = Astro.props;
|
||||
|
||||
// 计算速度
|
||||
const getSpeed = () => {
|
||||
switch(speed) {
|
||||
case 'slow': return '60s';
|
||||
case 'fast': return '20s';
|
||||
default: return '40s';
|
||||
}
|
||||
};
|
||||
|
||||
const speedValue = getSpeed();
|
||||
---
|
||||
|
||||
<div class={`animated-gallery overflow-hidden ${className}`}>
|
||||
<div class="flex items-center w-full" style={`gap: ${gap}px;`}>
|
||||
<!-- 复制两组图片,确保无缝循环 -->
|
||||
<div
|
||||
class="flex animated-slide"
|
||||
style={`gap: ${gap}px; animation: scroll-${direction} ${speedValue} linear infinite;`}
|
||||
>
|
||||
{images.map(image => (
|
||||
<div class="flex-shrink-0 rounded-lg overflow-hidden">
|
||||
<div
|
||||
class="bg-gray-300 dark:bg-gray-700 flex items-center justify-center"
|
||||
style={`width: ${image.width || 300}px; height: ${image.height || 200}px;`}
|
||||
>
|
||||
<span class="text-gray-500 dark:text-gray-400">{image.alt}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- 复制第二组,确保无缝循环 -->
|
||||
<div
|
||||
class="flex animated-slide"
|
||||
style={`gap: ${gap}px; animation: scroll-${direction} ${speedValue} linear infinite;`}
|
||||
>
|
||||
{images.map(image => (
|
||||
<div class="flex-shrink-0 rounded-lg overflow-hidden">
|
||||
<div
|
||||
class="bg-gray-300 dark:bg-gray-700 flex items-center justify-center"
|
||||
style={`width: ${image.width || 300}px; height: ${image.height || 200}px;`}
|
||||
>
|
||||
<span class="text-gray-500 dark:text-gray-400">{image.alt}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes scroll-left {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(calc(-100% - var(--gap, 16px)));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scroll-right {
|
||||
0% {
|
||||
transform: translateX(calc(-100% - var(--gap, 16px)));
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animated-gallery:hover .animated-slide {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 动态计算间隙变量
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const galleries = document.querySelectorAll('.animated-gallery');
|
||||
|
||||
galleries.forEach(gallery => {
|
||||
const gap = gallery.querySelector('.flex')?.getAttribute('style')?.match(/gap: (\d+)px/)?.[1] || '16';
|
||||
(gallery as HTMLElement).style.setProperty('--gap', `${gap}px`);
|
||||
});
|
||||
});
|
||||
</script>
|
63
web/graduation/src/components/aceternity/FlipCard.astro
Normal file
63
web/graduation/src/components/aceternity/FlipCard.astro
Normal file
@ -0,0 +1,63 @@
|
||||
---
|
||||
interface Props {
|
||||
frontTitle?: string;
|
||||
backTitle?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
frontTitle = "正面",
|
||||
backTitle = "背面",
|
||||
className = ""
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class={`flip-card-container ${className}`}>
|
||||
<div class="flip-card">
|
||||
<div class="flip-card-front p-6 flex flex-col items-center justify-center rounded-xl bg-white dark:bg-color-dark-card shadow-md">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">{frontTitle}</h3>
|
||||
<div class="front-content">
|
||||
<slot name="front" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flip-card-back p-6 flex flex-col items-center justify-center rounded-xl bg-color-primary-500 dark:bg-color-dark-primary-600 text-white shadow-md">
|
||||
<h3 class="text-xl font-bold mb-3">{backTitle}</h3>
|
||||
<div class="back-content">
|
||||
<slot name="back" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.flip-card-container {
|
||||
perspective: 1000px;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.flip-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.8s;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.flip-card-container:hover .flip-card {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.flip-card-front, .flip-card-back {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
-webkit-backface-visibility: hidden; /* Safari */
|
||||
backface-visibility: hidden;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.flip-card-back {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
</style>
|
@ -0,0 +1,54 @@
|
||||
---
|
||||
interface Props {
|
||||
links: Array<{
|
||||
label: string;
|
||||
href: string;
|
||||
isActive?: boolean;
|
||||
}>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const { links, className = "" } = Astro.props;
|
||||
---
|
||||
|
||||
<div class={`fixed top-10 inset-x-0 max-w-2xl mx-auto z-50 ${className}`}>
|
||||
<div class="relative backdrop-blur-sm">
|
||||
<div class="absolute inset-0 bg-white dark:bg-gray-900/70 shadow-lg rounded-full"></div>
|
||||
<nav class="relative">
|
||||
<ul class="flex items-center justify-center p-2 gap-2">
|
||||
{
|
||||
links.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class={`px-4 py-2 rounded-full font-medium transition-colors ${
|
||||
link.isActive
|
||||
? "bg-color-primary-600 text-white dark:bg-color-dark-primary-600"
|
||||
: "hover:bg-gray-100 text-gray-900 dark:text-gray-100 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 简单的滚动动画效果
|
||||
document.addEventListener('scroll', () => {
|
||||
const navbar = document.querySelector('.fixed.top-10');
|
||||
if (!navbar) return;
|
||||
|
||||
if (window.scrollY > 100) {
|
||||
navbar.classList.add('top-4');
|
||||
navbar.classList.remove('top-10');
|
||||
} else {
|
||||
navbar.classList.add('top-10');
|
||||
navbar.classList.remove('top-4');
|
||||
}
|
||||
});
|
||||
</script>
|
63
web/graduation/src/components/aceternity/GradientText.astro
Normal file
63
web/graduation/src/components/aceternity/GradientText.astro
Normal file
@ -0,0 +1,63 @@
|
||||
---
|
||||
interface Props {
|
||||
words: string[];
|
||||
baseText?: string;
|
||||
className?: string;
|
||||
duration?: number; // 每个词的显示时间(毫秒)
|
||||
}
|
||||
|
||||
const {
|
||||
words,
|
||||
baseText = "河北",
|
||||
className = "",
|
||||
duration = 2000
|
||||
} = Astro.props;
|
||||
|
||||
const id = `gradient-text-${Math.random().toString(36).substring(2, 11)}`;
|
||||
---
|
||||
|
||||
<div class={`gradient-text-wrapper ${className}`}>
|
||||
<div class="relative inline-block">
|
||||
<span class="inline-block">{baseText}</span>
|
||||
<div id={id} class="gradient-text absolute top-0 left-0 right-0 bottom-0 text-transparent bg-clip-text bg-gradient-to-r from-color-primary-500 to-color-primary-700 dark:from-color-dark-primary-400 dark:to-color-dark-primary-600">
|
||||
<span class="gradient-word inline-block">{words[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script define:vars={{ id, words, duration }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const gradientText = document.getElementById(id);
|
||||
if (!gradientText) return;
|
||||
|
||||
const wordElement = gradientText.querySelector('.gradient-word');
|
||||
if (!wordElement) return;
|
||||
|
||||
let currentIndex = 0;
|
||||
|
||||
const updateWord = () => {
|
||||
// 淡出当前词
|
||||
wordElement.classList.add('opacity-0');
|
||||
|
||||
// 更新词并淡入
|
||||
setTimeout(() => {
|
||||
currentIndex = (currentIndex + 1) % words.length;
|
||||
wordElement.textContent = words[currentIndex];
|
||||
wordElement.classList.remove('opacity-0');
|
||||
}, 300); // 300ms淡出时间
|
||||
};
|
||||
|
||||
// 设置定时器更新词语
|
||||
setInterval(updateWord, duration);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.gradient-word {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.opacity-0 {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
70
web/graduation/src/components/aceternity/HoverGlow.astro
Normal file
70
web/graduation/src/components/aceternity/HoverGlow.astro
Normal file
@ -0,0 +1,70 @@
|
||||
---
|
||||
interface Props {
|
||||
color?: string;
|
||||
intensity?: 'subtle' | 'medium' | 'strong';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
color = "rgba(75, 107, 255, 0.5)",
|
||||
intensity = 'medium',
|
||||
className = ""
|
||||
} = Astro.props;
|
||||
|
||||
// 根据强度调整发光范围
|
||||
const getGlowSize = () => {
|
||||
switch(intensity) {
|
||||
case 'subtle': return '100px';
|
||||
case 'strong': return '180px';
|
||||
default: return '140px'; // medium
|
||||
}
|
||||
};
|
||||
|
||||
const glowSize = getGlowSize();
|
||||
const id = `hover-glow-${Math.random().toString(36).substring(2, 11)}`;
|
||||
---
|
||||
|
||||
<div id={id} class={`hover-glow-container relative overflow-hidden ${className}`}>
|
||||
<div class="hover-glow-content relative z-10">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="hover-glow-effect absolute pointer-events-none opacity-0 transition-opacity duration-300"></div>
|
||||
</div>
|
||||
|
||||
<style define:vars={{ glowColor: color, glowSize: glowSize }}>
|
||||
.hover-glow-effect {
|
||||
background: radial-gradient(
|
||||
circle at var(--x, 50%) var(--y, 50%),
|
||||
var(--glowColor) 0%,
|
||||
transparent calc(var(--glowSize))
|
||||
);
|
||||
inset: -100px;
|
||||
}
|
||||
|
||||
.hover-glow-container:hover .hover-glow-effect {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script define:vars={{ id }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const container = document.getElementById(id);
|
||||
if (!container) return;
|
||||
|
||||
const glowEffect = container.querySelector('.hover-glow-effect');
|
||||
if (!glowEffect) return;
|
||||
|
||||
// 鼠标移动时更新发光位置
|
||||
container.addEventListener('mousemove', (e) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
// 计算鼠标在元素内的相对位置(百分比)
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) * 100;
|
||||
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)) * 100;
|
||||
|
||||
// 设置自定义属性,用于CSS变量
|
||||
glowEffect.style.setProperty('--x', `${x}%`);
|
||||
glowEffect.style.setProperty('--y', `${y}%`);
|
||||
});
|
||||
});
|
||||
</script>
|
144
web/graduation/src/components/aceternity/MagneticElement.astro
Normal file
144
web/graduation/src/components/aceternity/MagneticElement.astro
Normal file
@ -0,0 +1,144 @@
|
||||
---
|
||||
interface Props {
|
||||
strength?: number; // 磁性强度,1-10之间
|
||||
className?: string;
|
||||
radius?: number; // 影响半径(像素)
|
||||
smoothing?: number; // 平滑程度 (0-1)
|
||||
}
|
||||
|
||||
const {
|
||||
strength = 5,
|
||||
className = "",
|
||||
radius = 150,
|
||||
smoothing = 0.15
|
||||
} = Astro.props;
|
||||
|
||||
const magnetId = `magnetic-${Math.random().toString(36).substring(2, 11)}`;
|
||||
---
|
||||
|
||||
<div id={magnetId} class={`magnetic-element relative ${className}`}>
|
||||
<div class="magnetic-content relative inline-block z-10">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.magnetic-element {
|
||||
display: inline-block;
|
||||
transform-style: preserve-3d;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.magnetic-content {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
transition: transform 0.1s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script define:vars={{ magnetId, strength, radius, smoothing }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const magneticElement = document.getElementById(magnetId);
|
||||
if (!magneticElement) return;
|
||||
|
||||
const magneticContent = magneticElement.querySelector('.magnetic-content');
|
||||
if (!magneticContent) return;
|
||||
|
||||
// 计算最大移动距离(基于强度)
|
||||
const maxDistance = strength * 3;
|
||||
|
||||
let isHovered = false;
|
||||
let rafId = null;
|
||||
|
||||
// 鼠标移动距离
|
||||
let mouseX = 0;
|
||||
let mouseY = 0;
|
||||
|
||||
// 当前位置
|
||||
let currentX = 0;
|
||||
let currentY = 0;
|
||||
|
||||
const lerp = (start, end, t) => {
|
||||
return start * (1 - t) + end * t;
|
||||
};
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!isHovered) {
|
||||
// 如果没有悬停,逐渐回到原位
|
||||
currentX = lerp(currentX, 0, smoothing);
|
||||
currentY = lerp(currentY, 0, smoothing);
|
||||
|
||||
// 如果足够接近原点,就停止动画
|
||||
if (Math.abs(currentX) < 0.1 && Math.abs(currentY) < 0.1) {
|
||||
currentX = 0;
|
||||
currentY = 0;
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
magneticContent.style.transform = '';
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 如果悬停中,平滑过渡到目标位置
|
||||
currentX = lerp(currentX, mouseX, smoothing);
|
||||
currentY = lerp(currentY, mouseY, smoothing);
|
||||
}
|
||||
|
||||
// 应用变换
|
||||
magneticContent.style.transform = `translate3D(${currentX}px, ${currentY}px, 0)`;
|
||||
|
||||
// 继续动画
|
||||
rafId = requestAnimationFrame(updatePosition);
|
||||
};
|
||||
|
||||
// 鼠标进入
|
||||
magneticElement.addEventListener('mouseenter', () => {
|
||||
isHovered = true;
|
||||
if (!rafId) {
|
||||
rafId = requestAnimationFrame(updatePosition);
|
||||
}
|
||||
});
|
||||
|
||||
// 鼠标移动
|
||||
magneticElement.addEventListener('mousemove', (e) => {
|
||||
if (!isHovered) return;
|
||||
|
||||
const rect = magneticElement.getBoundingClientRect();
|
||||
|
||||
// 计算鼠标相对元素中心的位置
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
// 鼠标距离中心的距离
|
||||
const distX = e.clientX - centerX;
|
||||
const distY = e.clientY - centerY;
|
||||
|
||||
// 计算总距离
|
||||
const distance = Math.sqrt(distX * distX + distY * distY);
|
||||
|
||||
// 如果鼠标在影响半径内
|
||||
if (distance < radius) {
|
||||
// 计算移动百分比(越接近中心,移动越大)
|
||||
const percent = (radius - distance) / radius;
|
||||
|
||||
// 应用强度系数,计算最终移动距离
|
||||
mouseX = distX * percent * (maxDistance / radius);
|
||||
mouseY = distY * percent * (maxDistance / radius);
|
||||
} else {
|
||||
mouseX = 0;
|
||||
mouseY = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// 鼠标离开
|
||||
magneticElement.addEventListener('mouseleave', () => {
|
||||
isHovered = false;
|
||||
});
|
||||
|
||||
// 页面卸载时清除动画帧
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
152
web/graduation/src/components/aceternity/MorphingText.astro
Normal file
152
web/graduation/src/components/aceternity/MorphingText.astro
Normal file
@ -0,0 +1,152 @@
|
||||
---
|
||||
interface Props {
|
||||
phrases: string[];
|
||||
className?: string;
|
||||
textSize?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
phrases = ["河北旅游", "河北文化", "河北美食"],
|
||||
className = "",
|
||||
textSize = "text-7xl sm:text-8xl md:text-9xl"
|
||||
} = Astro.props;
|
||||
|
||||
const id = `morphing-text-${Math.random().toString(36).substr(2, 9)}`;
|
||||
---
|
||||
|
||||
<div class={`relative mx-auto w-full max-w-screen-xl text-center font-sans font-bold ${className}`} id={id}>
|
||||
<!-- 容器高度设置为自动,让内容决定高度 -->
|
||||
<div class="relative flex justify-center items-center text-center min-h-[1.5em]">
|
||||
<span class="text-span-1 block ${textSize} opacity-100 blur-none transition-all absolute left-1/2 transform -translate-x-1/2"></span>
|
||||
<span class="text-span-2 absolute left-1/2 transform -translate-x-1/2 block ${textSize} opacity-0 blur-none transition-all"></span>
|
||||
</div>
|
||||
|
||||
<!-- SVG 滤镜 -->
|
||||
<svg class="fixed h-0 w-0">
|
||||
<defs>
|
||||
<filter id="threshold">
|
||||
<feColorMatrix
|
||||
in="SourceGraphic"
|
||||
type="matrix"
|
||||
values="1 0 0 0 0
|
||||
0 1 0 0 0
|
||||
0 0 1 0 0
|
||||
0 0 0 255 -140"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<script define:vars={{ phrases, id }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const container = document.getElementById(id);
|
||||
if (!container) return;
|
||||
|
||||
const text1 = container.querySelector('.text-span-1');
|
||||
const text2 = container.querySelector('.text-span-2');
|
||||
|
||||
if (!text1 || !text2) return;
|
||||
|
||||
// 设置初始文本
|
||||
text1.textContent = phrases[0];
|
||||
text2.textContent = phrases[1];
|
||||
|
||||
// 应用滤镜
|
||||
container.style.filter = 'url(#threshold) blur(0.6px)';
|
||||
|
||||
// 变形时间和冷却时间(秒)
|
||||
const morphTime = 1.5;
|
||||
const cooldownTime = 0.5;
|
||||
|
||||
let textIndex = 0;
|
||||
let morph = 0;
|
||||
let cooldown = cooldownTime;
|
||||
|
||||
let lastTime = new Date();
|
||||
let animationFrameId;
|
||||
|
||||
function setMorphStyles(fraction) {
|
||||
// 为第二个文本(进入)设置样式
|
||||
text2.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`;
|
||||
text2.style.opacity = `${Math.pow(fraction, 0.4) * 100}%`;
|
||||
|
||||
// 为第一个文本(退出)设置样式
|
||||
const invertedFraction = 1 - fraction;
|
||||
text1.style.filter = `blur(${Math.min(8 / invertedFraction - 8, 100)}px)`;
|
||||
text1.style.opacity = `${Math.pow(invertedFraction, 0.4) * 100}%`;
|
||||
|
||||
// 设置当前和下一个文本内容
|
||||
text1.textContent = phrases[textIndex % phrases.length];
|
||||
text2.textContent = phrases[(textIndex + 1) % phrases.length];
|
||||
}
|
||||
|
||||
function doMorph() {
|
||||
morph -= cooldown;
|
||||
cooldown = 0;
|
||||
|
||||
let fraction = morph / morphTime;
|
||||
|
||||
if (fraction > 1) {
|
||||
cooldown = cooldownTime;
|
||||
fraction = 1;
|
||||
}
|
||||
|
||||
setMorphStyles(fraction);
|
||||
|
||||
if (fraction === 1) {
|
||||
textIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
function doCooldown() {
|
||||
morph = 0;
|
||||
|
||||
text2.style.filter = "none";
|
||||
text2.style.opacity = "100%";
|
||||
|
||||
text1.style.filter = "none";
|
||||
text1.style.opacity = "0%";
|
||||
}
|
||||
|
||||
function animate() {
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
|
||||
const newTime = new Date();
|
||||
const dt = (newTime.getTime() - lastTime.getTime()) / 1000;
|
||||
lastTime = newTime;
|
||||
|
||||
cooldown -= dt;
|
||||
|
||||
if (cooldown <= 0) {
|
||||
doMorph();
|
||||
} else {
|
||||
doCooldown();
|
||||
}
|
||||
}
|
||||
|
||||
// 启动动画
|
||||
animate();
|
||||
|
||||
// 清理
|
||||
window.addEventListener('beforeunload', () => {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 确保文本样式适合横向显示 */
|
||||
.text-span-1, .text-span-2 {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 确保容器有合适的内边距 */
|
||||
div[id^="morphing-text-"] {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
</style>
|
159
web/graduation/src/components/aceternity/NeonButton.astro
Normal file
159
web/graduation/src/components/aceternity/NeonButton.astro
Normal file
@ -0,0 +1,159 @@
|
||||
---
|
||||
interface Props {
|
||||
text?: string;
|
||||
href?: string;
|
||||
className?: string;
|
||||
color?: string;
|
||||
glowSize?: 'small' | 'medium' | 'large';
|
||||
glowIntensity?: 'low' | 'medium' | 'high';
|
||||
pulseAnimation?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
text = "霓虹按钮",
|
||||
href,
|
||||
className = "",
|
||||
color = "rgb(101, 111, 247)", // 紫色
|
||||
glowSize = 'medium',
|
||||
glowIntensity = 'medium',
|
||||
pulseAnimation = true
|
||||
} = Astro.props;
|
||||
|
||||
// 计算发光尺寸
|
||||
const getGlowSizePx = (size: string) => {
|
||||
switch (size) {
|
||||
case 'small': return 10;
|
||||
case 'large': return 30;
|
||||
default: return 20;
|
||||
}
|
||||
};
|
||||
|
||||
// 计算发光强度值
|
||||
const getIntensityValue = (intensity: string) => {
|
||||
switch (intensity) {
|
||||
case 'low': return 0.6;
|
||||
case 'high': return 1;
|
||||
default: return 0.8;
|
||||
}
|
||||
};
|
||||
|
||||
const glowSizePx = getGlowSizePx(glowSize);
|
||||
const intensityValue = getIntensityValue(glowIntensity);
|
||||
|
||||
const buttonId = `neon-btn-${Math.random().toString(36).substring(2, 11)}`;
|
||||
---
|
||||
|
||||
<div class={`neon-button-wrapper ${pulseAnimation ? 'pulse-enabled' : ''} ${className}`}>
|
||||
{href ? (
|
||||
<a
|
||||
id={buttonId}
|
||||
href={href}
|
||||
class="neon-button relative inline-block px-6 py-3 rounded-md font-medium transition-all duration-300 overflow-hidden"
|
||||
>
|
||||
<span class="relative z-10">{text}</span>
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
id={buttonId}
|
||||
class="neon-button relative inline-block px-6 py-3 rounded-md font-medium transition-all duration-300 overflow-hidden"
|
||||
>
|
||||
<span class="relative z-10">{text}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style define:vars={{ color, glowSizePx, intensityValue }}>
|
||||
.neon-button-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.neon-button {
|
||||
background-color: transparent;
|
||||
color: var(--color);
|
||||
border: 2px solid var(--color);
|
||||
transition: all 0.3s ease;
|
||||
transform: translateY(0);
|
||||
text-shadow: 0 0 5px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.neon-button:hover {
|
||||
color: white;
|
||||
background-color: var(--color);
|
||||
box-shadow:
|
||||
0 0 calc(var(--glowSizePx) * 1px) 0 var(--color),
|
||||
0 0 calc(var(--glowSizePx) * 2px) 0 var(--color),
|
||||
0 0 calc(var(--glowSizePx) * 3px) 0 rgba(var(--rgb-values), calc(var(--intensityValue) * 0.5));
|
||||
transform: translateY(-3px);
|
||||
text-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.pulse-enabled .neon-button:hover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -5px;
|
||||
border-radius: inherit;
|
||||
z-index: -1;
|
||||
animation: neon-pulse 1.5s ease-in-out infinite;
|
||||
background: transparent;
|
||||
border: 2px solid var(--color);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes neon-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
70% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script define:vars={{ buttonId, color }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const button = document.getElementById(buttonId);
|
||||
if (!button) return;
|
||||
|
||||
// 计算RGB值以便在CSS中使用rgba
|
||||
const getRGB = (colorStr) => {
|
||||
// 检查是否为hex格式
|
||||
if (colorStr.startsWith('#')) {
|
||||
const hex = colorStr.substring(1);
|
||||
return [
|
||||
parseInt(hex.substring(0, 2), 16),
|
||||
parseInt(hex.substring(2, 4), 16),
|
||||
parseInt(hex.substring(4, 6), 16)
|
||||
].join(', ');
|
||||
}
|
||||
|
||||
// 检查是否已经是rgb格式
|
||||
const rgbMatch = colorStr.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
if (rgbMatch) {
|
||||
return `${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}`;
|
||||
}
|
||||
|
||||
// 默认回退颜色
|
||||
return '101, 111, 247';
|
||||
};
|
||||
|
||||
const rgbValues = getRGB(color);
|
||||
button.style.setProperty('--rgb-values', rgbValues);
|
||||
|
||||
// 鼠标移动时的额外发光效果
|
||||
button.addEventListener('mousemove', (e) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// 设置发光的中心点
|
||||
button.style.setProperty('--x', `${x}px`);
|
||||
button.style.setProperty('--y', `${y}px`);
|
||||
});
|
||||
});
|
||||
</script>
|
237
web/graduation/src/components/aceternity/ParallaxCard.astro
Normal file
237
web/graduation/src/components/aceternity/ParallaxCard.astro
Normal file
@ -0,0 +1,237 @@
|
||||
---
|
||||
interface Props {
|
||||
className?: string;
|
||||
glareEnabled?: boolean;
|
||||
maxGlare?: number; // 0-1 之间
|
||||
depth?: number; // 视差深度,1-10之间
|
||||
backgroundImage?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
className = "",
|
||||
glareEnabled = true,
|
||||
maxGlare = 0.5,
|
||||
depth = 5,
|
||||
backgroundImage
|
||||
} = Astro.props;
|
||||
|
||||
const cardId = `parallax-card-${Math.random().toString(36).substring(2, 11)}`;
|
||||
---
|
||||
|
||||
<div id={cardId} class={`parallax-card ${className}`}>
|
||||
{backgroundImage && (
|
||||
<div class="parallax-card-background absolute inset-0 z-0" style={`background-image: url(${backgroundImage})`}></div>
|
||||
)}
|
||||
<div class="parallax-card-content relative z-10">
|
||||
<slot />
|
||||
</div>
|
||||
{glareEnabled && <div class="parallax-card-glare absolute top-0 left-0 w-full h-full z-20"></div>}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.parallax-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
transform-style: preserve-3d;
|
||||
transform: perspective(1000px);
|
||||
box-shadow: 0 10px 30px -15px rgba(0, 0, 0, 0.25);
|
||||
transition: transform 0.1s ease, box-shadow 0.3s ease;
|
||||
will-change: transform, box-shadow;
|
||||
}
|
||||
|
||||
.parallax-card:hover {
|
||||
box-shadow: 0 20px 40px -25px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.parallax-card-background {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.2s ease;
|
||||
filter: blur(0);
|
||||
}
|
||||
|
||||
.parallax-card-content {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.parallax-card-glare {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(105deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.05) 80%);
|
||||
transform: translateZ(1px);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .parallax-card {
|
||||
background-color: rgba(30, 30, 30, 0.5);
|
||||
box-shadow: 0 10px 30px -15px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .parallax-card:hover {
|
||||
box-shadow: 0 20px 40px -25px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .parallax-card-glare {
|
||||
background: linear-gradient(105deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.03) 80%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script define:vars={{ cardId, glareEnabled, maxGlare, depth }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const card = document.getElementById(cardId);
|
||||
if (!card) return;
|
||||
|
||||
const contentItems = Array.from(card.querySelectorAll('.parallax-item'));
|
||||
const background = card.querySelector('.parallax-card-background');
|
||||
const glare = card.querySelector('.parallax-card-glare');
|
||||
|
||||
// 视差灵敏度系数
|
||||
const depthFactor = depth * 0.2;
|
||||
|
||||
// 是否正在悬停
|
||||
let isHovered = false;
|
||||
|
||||
// 处理鼠标移动
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isHovered) return;
|
||||
|
||||
const rect = card.getBoundingClientRect();
|
||||
|
||||
// 计算鼠标在卡片上的相对位置 (0~1)
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = (e.clientY - rect.top) / rect.height;
|
||||
|
||||
// 旋转角度(-15 ~ 15度)
|
||||
const rotateX = (0.5 - y) * 30 * depthFactor * 0.1;
|
||||
const rotateY = (x - 0.5) * 30 * depthFactor * 0.1;
|
||||
|
||||
// 应用旋转
|
||||
card.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
|
||||
|
||||
// 背景视差效果
|
||||
if (background) {
|
||||
const bgX = (x - 0.5) * 40 * depthFactor;
|
||||
const bgY = (y - 0.5) * 40 * depthFactor;
|
||||
background.style.transform = `translateX(${bgX}px) translateY(${bgY}px) scale(1.05)`;
|
||||
}
|
||||
|
||||
// 内部元素视差效果
|
||||
contentItems.forEach(item => {
|
||||
const depth = parseInt(item.getAttribute('data-depth') || '5') / 10;
|
||||
const itemX = (x - 0.5) * 30 * depth * depthFactor;
|
||||
const itemY = (y - 0.5) * 30 * depth * depthFactor;
|
||||
item.style.transform = `translateX(${itemX}px) translateY(${itemY}px) translateZ(${depth * 50}px)`;
|
||||
});
|
||||
|
||||
// 光晕效果
|
||||
if (glare && glareEnabled) {
|
||||
// 调整光晕角度
|
||||
const glareX = x * 100;
|
||||
const glareY = y * 100;
|
||||
|
||||
// 计算光晕透明度
|
||||
const glareOpacity = Math.min(
|
||||
Math.max(
|
||||
Math.sqrt((x - 0.5) * (x - 0.5) + (y - 0.5) * (y - 0.5)) * 2,
|
||||
0
|
||||
),
|
||||
maxGlare
|
||||
);
|
||||
|
||||
glare.style.opacity = glareOpacity.toString();
|
||||
glare.style.backgroundImage = `radial-gradient(circle at ${glareX}% ${glareY}%, rgba(255,255,255,${glareOpacity}) 0%, rgba(255,255,255,0) 80%)`;
|
||||
}
|
||||
};
|
||||
|
||||
// 鼠标进入
|
||||
const handleMouseEnter = () => {
|
||||
isHovered = true;
|
||||
|
||||
// 平滑过渡到3D效果
|
||||
card.style.transition = 'transform 0.3s ease';
|
||||
|
||||
if (background) {
|
||||
background.style.transition = 'transform 0.3s ease';
|
||||
background.style.transformOrigin = 'center center';
|
||||
}
|
||||
|
||||
contentItems.forEach(item => {
|
||||
item.style.transition = 'transform 0.3s ease';
|
||||
});
|
||||
|
||||
// 一定时间后移除过渡效果,实现更流畅的追踪
|
||||
setTimeout(() => {
|
||||
card.style.transition = '';
|
||||
|
||||
if (background) {
|
||||
background.style.transition = '';
|
||||
}
|
||||
|
||||
contentItems.forEach(item => {
|
||||
item.style.transition = '';
|
||||
});
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 鼠标离开
|
||||
const handleMouseLeave = () => {
|
||||
isHovered = false;
|
||||
|
||||
// 添加过渡效果
|
||||
card.style.transition = 'transform 0.5s ease';
|
||||
|
||||
if (background) {
|
||||
background.style.transition = 'transform 0.5s ease';
|
||||
}
|
||||
|
||||
contentItems.forEach(item => {
|
||||
item.style.transition = 'transform 0.5s ease';
|
||||
});
|
||||
|
||||
// 重置旋转
|
||||
card.style.transform = 'perspective(1000px) rotateX(0) rotateY(0)';
|
||||
|
||||
if (background) {
|
||||
background.style.transform = 'translateX(0) translateY(0) scale(1)';
|
||||
}
|
||||
|
||||
contentItems.forEach(item => {
|
||||
item.style.transform = 'translateX(0) translateY(0) translateZ(0)';
|
||||
});
|
||||
|
||||
if (glare) {
|
||||
glare.style.opacity = '0';
|
||||
}
|
||||
};
|
||||
|
||||
// 移动端触摸处理
|
||||
const handleTouchMove = (e) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
handleMouseMove({
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY
|
||||
});
|
||||
};
|
||||
|
||||
// 添加事件监听
|
||||
card.addEventListener('mouseenter', handleMouseEnter);
|
||||
card.addEventListener('mousemove', handleMouseMove);
|
||||
card.addEventListener('mouseleave', handleMouseLeave);
|
||||
card.addEventListener('touchmove', handleTouchMove);
|
||||
card.addEventListener('touchstart', handleMouseEnter);
|
||||
card.addEventListener('touchend', handleMouseLeave);
|
||||
});
|
||||
</script>
|
@ -0,0 +1,91 @@
|
||||
---
|
||||
interface Props {
|
||||
images: Array<{
|
||||
src: string;
|
||||
alt: string;
|
||||
speed?: number; // 滚动速度因子,正数向下,负数向上
|
||||
}>;
|
||||
heading: string;
|
||||
subheading?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const { images, heading, subheading, className = "" } = Astro.props;
|
||||
---
|
||||
|
||||
<section class={`relative overflow-hidden ${className}`} id="parallax-section">
|
||||
<!-- 背景图层 -->
|
||||
<div class="absolute inset-0">
|
||||
<div class="absolute inset-0 bg-black/60 z-10"></div>
|
||||
{
|
||||
images.map((image, index) => (
|
||||
<div
|
||||
class="absolute inset-0 parallax-layer transition-transform duration-100"
|
||||
data-speed={image.speed || 0.2}
|
||||
style={{
|
||||
zIndex: index,
|
||||
transform: "translateY(0px)",
|
||||
}}
|
||||
>
|
||||
<div class="absolute inset-0 bg-gray-500 flex items-center justify-center">
|
||||
<span class="text-white text-lg">{image.alt}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="relative z-20 min-h-[60vh] flex items-center justify-center text-center py-20 px-4">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="text-3xl sm:text-5xl font-bold text-white mb-4 tracking-tight">
|
||||
{heading}
|
||||
</h2>
|
||||
{
|
||||
subheading && (
|
||||
<p class="text-xl text-gray-200 max-w-2xl mx-auto">{subheading}</p>
|
||||
)
|
||||
}
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// 简单的视差滚动效果
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const parallaxLayers = document.querySelectorAll('.parallax-layer');
|
||||
|
||||
function updateParallax() {
|
||||
const scrollY = window.scrollY;
|
||||
const section = document.getElementById('parallax-section');
|
||||
|
||||
if (!section) return;
|
||||
|
||||
const sectionRect = section.getBoundingClientRect();
|
||||
const sectionTop = sectionRect.top + scrollY;
|
||||
const sectionCenter = sectionTop + sectionRect.height / 2;
|
||||
|
||||
// 仅当部分在视口中时更新视差效果
|
||||
if (
|
||||
scrollY + window.innerHeight >= sectionTop &&
|
||||
scrollY <= sectionTop + sectionRect.height
|
||||
) {
|
||||
const relativeScroll = scrollY - sectionTop;
|
||||
|
||||
parallaxLayers.forEach(layer => {
|
||||
const speed = parseFloat(layer.getAttribute('data-speed') || '0.2');
|
||||
const yOffset = relativeScroll * speed;
|
||||
(layer as HTMLElement).style.transform = `translateY(${yOffset}px)`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 初始更新
|
||||
updateParallax();
|
||||
|
||||
// 滚动事件监听
|
||||
window.addEventListener('scroll', updateParallax);
|
||||
window.addEventListener('resize', updateParallax);
|
||||
});
|
||||
</script>
|
129
web/graduation/src/components/aceternity/ParticleButton.astro
Normal file
129
web/graduation/src/components/aceternity/ParticleButton.astro
Normal file
@ -0,0 +1,129 @@
|
||||
---
|
||||
interface Props {
|
||||
text?: string;
|
||||
href?: string;
|
||||
className?: string;
|
||||
color?: string;
|
||||
particleCount?: number;
|
||||
}
|
||||
|
||||
const {
|
||||
text = "点击我",
|
||||
href,
|
||||
className = "",
|
||||
color = "#6366f1",
|
||||
particleCount = 30
|
||||
} = Astro.props;
|
||||
|
||||
const buttonId = `particle-btn-${Math.random().toString(36).substring(2, 11)}`;
|
||||
---
|
||||
|
||||
<div class={`particle-button-container ${className}`}>
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
id={buttonId}
|
||||
class="particle-button relative inline-block px-6 py-3 rounded-md bg-color-primary-600 hover:bg-color-primary-700 text-white font-medium transition-all duration-300 overflow-hidden"
|
||||
>
|
||||
<span class="relative z-10">{text}</span>
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
id={buttonId}
|
||||
class="particle-button relative inline-block px-6 py-3 rounded-md bg-color-primary-600 hover:bg-color-primary-700 text-white font-medium transition-all duration-300 overflow-hidden"
|
||||
>
|
||||
<span class="relative z-10">{text}</span>
|
||||
</button>
|
||||
)}
|
||||
<div class="particles-container"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.particle-button-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.particle-button {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.particle-button:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 7px 14px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script define:vars={{ buttonId, color, particleCount }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const button = document.getElementById(buttonId);
|
||||
if (!button) return;
|
||||
|
||||
const container = button.closest('.particle-button-container');
|
||||
if (!container) return;
|
||||
|
||||
const particles = [];
|
||||
|
||||
// 创建粒子元素
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const particle = document.createElement('div');
|
||||
particle.classList.add('particle');
|
||||
particle.style.backgroundColor = color;
|
||||
container.appendChild(particle);
|
||||
particles.push(particle);
|
||||
}
|
||||
|
||||
button.addEventListener('mouseenter', (e) => {
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const centerX = buttonRect.left + buttonRect.width / 2;
|
||||
const centerY = buttonRect.top + buttonRect.height / 2;
|
||||
|
||||
particles.forEach((particle, index) => {
|
||||
// 随机大小
|
||||
const size = Math.random() * 8 + 4;
|
||||
particle.style.width = `${size}px`;
|
||||
particle.style.height = `${size}px`;
|
||||
|
||||
// 初始位置为按钮中心
|
||||
particle.style.left = `${buttonRect.width / 2}px`;
|
||||
particle.style.top = `${buttonRect.height / 2}px`;
|
||||
|
||||
// 随机方向和距离
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = Math.random() * 90 + 30;
|
||||
const destinationX = Math.cos(angle) * distance;
|
||||
const destinationY = Math.sin(angle) * distance;
|
||||
|
||||
// 随机动画时间
|
||||
const duration = Math.random() * 1000 + 500;
|
||||
const delay = Math.random() * 200;
|
||||
|
||||
// 设置动画
|
||||
particle.style.transition = `all ${duration}ms ease-out ${delay}ms`;
|
||||
|
||||
// 设置透明度
|
||||
particle.style.opacity = '0';
|
||||
|
||||
// 触发重绘,然后应用动画
|
||||
setTimeout(() => {
|
||||
particle.style.opacity = '1';
|
||||
particle.style.transform = `translate(${destinationX}px, ${destinationY}px)`;
|
||||
|
||||
// 动画结束后重置
|
||||
setTimeout(() => {
|
||||
particle.style.opacity = '0';
|
||||
particle.style.transform = `translate(${buttonRect.width / 2}px, ${buttonRect.height / 2}px)`;
|
||||
}, duration + delay);
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
149
web/graduation/src/components/aceternity/ScrollReveal.astro
Normal file
149
web/graduation/src/components/aceternity/ScrollReveal.astro
Normal file
@ -0,0 +1,149 @@
|
||||
---
|
||||
interface Props {
|
||||
animation?: 'fade' | 'slide-up' | 'slide-down' | 'slide-left' | 'slide-right' | 'scale' | 'rotate';
|
||||
duration?: number; // 动画持续时间,毫秒
|
||||
delay?: number; // 延迟时间,毫秒
|
||||
threshold?: number; // 触发阈值,0-1之间
|
||||
once?: boolean; // 是否只触发一次
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
animation = 'fade',
|
||||
duration = 800,
|
||||
delay = 0,
|
||||
threshold = 0.3,
|
||||
once = true,
|
||||
className = ""
|
||||
} = Astro.props;
|
||||
|
||||
// 生成唯一ID
|
||||
const id = `scroll-reveal-${Math.random().toString(36).substring(2, 11)}`;
|
||||
|
||||
// 设定初始隐藏样式
|
||||
const getInitialStyles = () => {
|
||||
switch(animation) {
|
||||
case 'fade':
|
||||
return 'opacity: 0;';
|
||||
case 'slide-up':
|
||||
return 'opacity: 0; transform: translateY(40px);';
|
||||
case 'slide-down':
|
||||
return 'opacity: 0; transform: translateY(-40px);';
|
||||
case 'slide-left':
|
||||
return 'opacity: 0; transform: translateX(40px);';
|
||||
case 'slide-right':
|
||||
return 'opacity: 0; transform: translateX(-40px);';
|
||||
case 'scale':
|
||||
return 'opacity: 0; transform: scale(0.9);';
|
||||
case 'rotate':
|
||||
return 'opacity: 0; transform: rotate(-5deg);';
|
||||
default:
|
||||
return 'opacity: 0;';
|
||||
}
|
||||
};
|
||||
|
||||
const initialStyles = getInitialStyles();
|
||||
---
|
||||
|
||||
<div
|
||||
id={id}
|
||||
class={`scroll-reveal ${className}`}
|
||||
data-scroll-animation={animation}
|
||||
data-scroll-once={once.toString()}
|
||||
style={`${initialStyles} transition: all ${duration}ms ease ${delay}ms;`}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const revealElements = document.querySelectorAll('.scroll-reveal');
|
||||
|
||||
const observerOptions = {
|
||||
root: null, // 使用视口作为根元素
|
||||
rootMargin: '0px',
|
||||
threshold: 0.3 // 默认阈值
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
const element = entry.target as HTMLElement;
|
||||
const isOnce = element.getAttribute('data-scroll-once') === 'true';
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
// 元素进入视口,添加显示样式
|
||||
element.style.opacity = '1';
|
||||
element.style.transform = 'none';
|
||||
|
||||
// 如果设置为只触发一次,取消观察
|
||||
if (isOnce) {
|
||||
observer.unobserve(element);
|
||||
}
|
||||
} else if (!isOnce) {
|
||||
// 如果不是一次性的,元素离开视口时恢复初始状态
|
||||
const animation = element.getAttribute('data-scroll-animation') || 'fade';
|
||||
|
||||
switch(animation) {
|
||||
case 'fade':
|
||||
element.style.opacity = '0';
|
||||
break;
|
||||
case 'slide-up':
|
||||
element.style.opacity = '0';
|
||||
element.style.transform = 'translateY(40px)';
|
||||
break;
|
||||
case 'slide-down':
|
||||
element.style.opacity = '0';
|
||||
element.style.transform = 'translateY(-40px)';
|
||||
break;
|
||||
case 'slide-left':
|
||||
element.style.opacity = '0';
|
||||
element.style.transform = 'translateX(40px)';
|
||||
break;
|
||||
case 'slide-right':
|
||||
element.style.opacity = '0';
|
||||
element.style.transform = 'translateX(-40px)';
|
||||
break;
|
||||
case 'scale':
|
||||
element.style.opacity = '0';
|
||||
element.style.transform = 'scale(0.9)';
|
||||
break;
|
||||
case 'rotate':
|
||||
element.style.opacity = '0';
|
||||
element.style.transform = 'rotate(-5deg)';
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
// 开始观察所有滚动显示元素
|
||||
revealElements.forEach(element => {
|
||||
// 设置自定义阈值
|
||||
const elementThreshold = parseFloat(element.getAttribute('data-scroll-threshold') || '0.3');
|
||||
if (!isNaN(elementThreshold) && elementThreshold >= 0 && elementThreshold <= 1) {
|
||||
const newObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
const element = entry.target as HTMLElement;
|
||||
const isOnce = element.getAttribute('data-scroll-once') === 'true';
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
element.style.opacity = '1';
|
||||
element.style.transform = 'none';
|
||||
|
||||
if (isOnce) {
|
||||
newObserver.unobserve(element);
|
||||
}
|
||||
} else if (!isOnce) {
|
||||
// 恢复初始状态的逻辑与上面相同
|
||||
// 为简化代码,这里不重复实现
|
||||
}
|
||||
});
|
||||
}, { ...observerOptions, threshold: elementThreshold });
|
||||
|
||||
newObserver.observe(element);
|
||||
} else {
|
||||
observer.observe(element);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
106
web/graduation/src/components/aceternity/SmoothScroll.astro
Normal file
106
web/graduation/src/components/aceternity/SmoothScroll.astro
Normal file
@ -0,0 +1,106 @@
|
||||
---
|
||||
interface Props {
|
||||
enabled?: boolean;
|
||||
duration?: number; // 滚动持续时间,毫秒
|
||||
easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
|
||||
}
|
||||
|
||||
const {
|
||||
enabled = true,
|
||||
duration = 800,
|
||||
easing = 'ease-out'
|
||||
} = Astro.props;
|
||||
|
||||
// 将缓动函数名转换为贝塞尔曲线
|
||||
const getBezier = () => {
|
||||
switch(easing) {
|
||||
case 'linear': return 'cubic-bezier(0, 0, 1, 1)';
|
||||
case 'ease-in': return 'cubic-bezier(0.42, 0, 1, 1)';
|
||||
case 'ease-out': return 'cubic-bezier(0, 0, 0.58, 1)';
|
||||
case 'ease-in-out': return 'cubic-bezier(0.42, 0, 0.58, 1)';
|
||||
default: return 'cubic-bezier(0, 0, 0.58, 1)'; // ease-out
|
||||
}
|
||||
};
|
||||
|
||||
const bezier = getBezier();
|
||||
---
|
||||
|
||||
<script define:vars={{ enabled, duration, bezier }}>
|
||||
// 仅在启用时执行
|
||||
if (enabled) {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 创建平滑滚动样式
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// 处理所有锚点链接,添加平滑滚动
|
||||
const anchorLinks = document.querySelectorAll('a[href^="#"]:not([href="#"])');
|
||||
anchorLinks.forEach(anchor => {
|
||||
anchor.addEventListener('click', (e) => {
|
||||
const targetId = anchor.getAttribute('href');
|
||||
if (!targetId) return;
|
||||
|
||||
const targetElement = document.querySelector(targetId);
|
||||
if (!targetElement) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const offsetTop = targetElement.getBoundingClientRect().top + window.scrollY;
|
||||
|
||||
// 执行平滑滚动
|
||||
window.scrollTo({
|
||||
top: offsetTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 返回顶部按钮处理
|
||||
const scrollToTopButtons = document.querySelectorAll('.scroll-to-top');
|
||||
scrollToTopButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 显示/隐藏返回顶部按钮
|
||||
const toggleScrollToTopVisibility = () => {
|
||||
scrollToTopButtons.forEach(button => {
|
||||
if (window.scrollY > 300) {
|
||||
button.classList.add('opacity-100');
|
||||
button.classList.remove('opacity-0', 'pointer-events-none');
|
||||
} else {
|
||||
button.classList.add('opacity-0', 'pointer-events-none');
|
||||
button.classList.remove('opacity-100');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', toggleScrollToTopVisibility);
|
||||
toggleScrollToTopVisibility(); // 初始检查
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 返回顶部按钮 -->
|
||||
<button class="scroll-to-top fixed bottom-8 right-8 bg-color-primary-600 dark:bg-color-dark-primary-600 text-white rounded-full p-3 shadow-lg opacity-0 pointer-events-none transition-opacity z-50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
<span class="sr-only">返回顶部</span>
|
||||
</button>
|
265
web/graduation/src/components/aceternity/SpotlightCard.astro
Normal file
265
web/graduation/src/components/aceternity/SpotlightCard.astro
Normal file
@ -0,0 +1,265 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
link?: string;
|
||||
className?: string;
|
||||
color?: string;
|
||||
spotlightSize?: 'small' | 'medium' | 'large';
|
||||
spotlightIntensity?: 'subtle' | 'medium' | 'strong';
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
link,
|
||||
className = "",
|
||||
color = "rgba(75, 107, 255, 0.3)", // 默认是蓝色光晕
|
||||
spotlightSize = 'medium',
|
||||
spotlightIntensity = 'medium'
|
||||
} = Astro.props;
|
||||
|
||||
const id = `spotlight-card-${Math.random().toString(36).substring(2, 11)}`;
|
||||
|
||||
// 计算光晕大小
|
||||
const sizeMap = {
|
||||
small: '70%',
|
||||
medium: '100%',
|
||||
large: '130%'
|
||||
};
|
||||
|
||||
// 计算光晕强度
|
||||
const intensityMap = {
|
||||
subtle: '0.15',
|
||||
medium: '0.25',
|
||||
strong: '0.4'
|
||||
};
|
||||
|
||||
const spotlightSizeValue = sizeMap[spotlightSize];
|
||||
const spotlightIntensityValue = intensityMap[spotlightIntensity];
|
||||
---
|
||||
|
||||
<div
|
||||
id={id}
|
||||
class={`spotlight-card group relative overflow-hidden rounded-xl border border-white/10 dark:bg-color-dark-card bg-white p-6 shadow-lg transition-all hover:shadow-xl ${className}`}
|
||||
style="transform-style: preserve-3d; transform: perspective(1000px);"
|
||||
data-spotlight-size={spotlightSizeValue}
|
||||
data-spotlight-intensity={spotlightIntensityValue}
|
||||
>
|
||||
<div class="spotlight-primary absolute pointer-events-none inset-0 z-0 transition duration-300 opacity-0"></div>
|
||||
<div class="spotlight-secondary absolute pointer-events-none inset-0 z-0 transition duration-300 opacity-0"></div>
|
||||
<div class="spotlight-border absolute pointer-events-none inset-0 z-0 transition duration-300 opacity-0"></div>
|
||||
|
||||
{image && (
|
||||
<div class="mb-4 overflow-hidden rounded-lg">
|
||||
<div class="h-40 bg-gray-300 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span class="text-gray-500 dark:text-gray-400">{title} 图片</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 class="group-hover:text-color-primary-600 dark:group-hover:text-color-primary-400 text-lg font-semibold text-gray-900 dark:text-white mb-2 transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{description && (
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4 text-sm">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{link && (
|
||||
<a
|
||||
href={link}
|
||||
class="text-color-primary-600 hover:text-color-primary-700 dark:text-color-primary-400 dark:hover:text-color-primary-300 font-medium text-sm"
|
||||
>
|
||||
查看详情 →
|
||||
</a>
|
||||
)}
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style define:vars={{
|
||||
spotlightColor: color,
|
||||
spotlightSize: spotlightSizeValue,
|
||||
spotlightIntensity: spotlightIntensityValue
|
||||
}}>
|
||||
.spotlight-primary {
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
var(--spotlightColor) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
filter: blur(5px);
|
||||
opacity: 0;
|
||||
/* 确保径向渐变是一个完美的圆 */
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.spotlight-secondary {
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
rgba(255, 255, 255, 0.1) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
opacity: 0;
|
||||
/* 确保径向渐变是一个完美的圆 */
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.spotlight-border {
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .spotlight-primary {
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
var(--spotlightColor) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .spotlight-secondary {
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
rgba(255, 255, 255, 0.07) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 等待 DOM 加载完成
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 获取所有光标效果卡片
|
||||
const cards = document.querySelectorAll('.spotlight-card');
|
||||
|
||||
cards.forEach(card => {
|
||||
const primaryEffect = card.querySelector('.spotlight-primary');
|
||||
const secondaryEffect = card.querySelector('.spotlight-secondary');
|
||||
const borderEffect = card.querySelector('.spotlight-border');
|
||||
|
||||
if (!primaryEffect || !secondaryEffect || !borderEffect) return;
|
||||
|
||||
// 获取配置
|
||||
const size = card.getAttribute('data-spotlight-size') || '100%';
|
||||
const intensity = parseFloat(card.getAttribute('data-spotlight-intensity') || '0.25');
|
||||
|
||||
// 上一次鼠标位置
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
|
||||
// 平滑因子 (值越小越平滑)
|
||||
const smoothFactor = 0.15;
|
||||
|
||||
// 用于动画的requestAnimationFrame ID
|
||||
let animationFrameId: number | null = null;
|
||||
|
||||
// 鼠标移入时添加效果
|
||||
card.addEventListener('mouseenter', () => {
|
||||
// 计算光效尺寸 - 使用卡片对角线长度确保覆盖整个卡片区域
|
||||
const rect = card.getBoundingClientRect();
|
||||
const diagonalLength = Math.sqrt(rect.width * rect.width + rect.height * rect.height);
|
||||
const effectSize = diagonalLength * 1.5 + 'px'; // 增加50%确保覆盖
|
||||
|
||||
// 设置光效元素尺寸
|
||||
(primaryEffect as HTMLElement).style.width = effectSize;
|
||||
(primaryEffect as HTMLElement).style.height = effectSize;
|
||||
(secondaryEffect as HTMLElement).style.width = effectSize;
|
||||
(secondaryEffect as HTMLElement).style.height = effectSize;
|
||||
|
||||
// 设置可见度
|
||||
(primaryEffect as HTMLElement).style.opacity = intensity.toString();
|
||||
(secondaryEffect as HTMLElement).style.opacity = (intensity * 0.7).toString();
|
||||
(borderEffect as HTMLElement).style.opacity = (intensity * 0.5).toString();
|
||||
|
||||
// 启动平滑动画
|
||||
if (animationFrameId === null) {
|
||||
animateSpotlight();
|
||||
}
|
||||
});
|
||||
|
||||
// 鼠标移出时移除效果
|
||||
card.addEventListener('mouseleave', () => {
|
||||
(primaryEffect as HTMLElement).style.opacity = '0';
|
||||
(secondaryEffect as HTMLElement).style.opacity = '0';
|
||||
(borderEffect as HTMLElement).style.opacity = '0';
|
||||
|
||||
// 重置卡片的 3D 效果
|
||||
(card as HTMLElement).style.transform = 'perspective(1000px)';
|
||||
|
||||
// 取消动画
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 鼠标移动时更新光标位置和 3D 效果
|
||||
card.addEventListener('mousemove', (e) => {
|
||||
const mouseEvent = e as MouseEvent;
|
||||
const rect = card.getBoundingClientRect();
|
||||
lastX = mouseEvent.clientX - rect.left;
|
||||
lastY = mouseEvent.clientY - rect.top;
|
||||
|
||||
// 计算 3D 旋转效果
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
const rotateY = (lastX - centerX) / 20;
|
||||
const rotateX = (centerY - lastY) / 20;
|
||||
|
||||
// 应用 3D 效果
|
||||
(card as HTMLElement).style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
|
||||
});
|
||||
|
||||
// 平滑动画函数
|
||||
function animateSpotlight() {
|
||||
const rect = card.getBoundingClientRect();
|
||||
|
||||
// 获取当前位置
|
||||
const currentPrimaryStyle = window.getComputedStyle(primaryEffect as Element);
|
||||
const currentSecondaryStyle = window.getComputedStyle(secondaryEffect as Element);
|
||||
|
||||
// 解析当前位置
|
||||
let currentX = parseInt(currentPrimaryStyle.left || '0');
|
||||
let currentY = parseInt(currentPrimaryStyle.top || '0');
|
||||
|
||||
if (isNaN(currentX)) currentX = rect.width / 2;
|
||||
if (isNaN(currentY)) currentY = rect.height / 2;
|
||||
|
||||
// 计算新位置 (平滑过渡)
|
||||
const newX = currentX + (lastX - currentX) * smoothFactor;
|
||||
const newY = currentY + (lastY - currentY) * smoothFactor;
|
||||
|
||||
// 应用位置
|
||||
// 不再在这里设置宽高,而是在mouseenter时设置一次
|
||||
(primaryEffect as HTMLElement).style.left = `${newX}px`;
|
||||
(primaryEffect as HTMLElement).style.top = `${newY}px`;
|
||||
(primaryEffect as HTMLElement).style.transform = `translate(-50%, -50%)`;
|
||||
|
||||
(secondaryEffect as HTMLElement).style.left = `${newX}px`;
|
||||
(secondaryEffect as HTMLElement).style.top = `${newY}px`;
|
||||
(secondaryEffect as HTMLElement).style.transform = `translate(-50%, -50%) scale(1.2)`;
|
||||
|
||||
// 边框位置不需要移动,但可以随鼠标位置变化透明度
|
||||
const distanceFromCenter = Math.sqrt(
|
||||
Math.pow((newX / rect.width - 0.5) * 2, 2) +
|
||||
Math.pow((newY / rect.height - 0.5) * 2, 2)
|
||||
);
|
||||
|
||||
// 越靠近边缘,边框越明显
|
||||
const borderOpacity = Math.min(distanceFromCenter * intensity, intensity * 0.5);
|
||||
(borderEffect as HTMLElement).style.opacity = borderOpacity.toString();
|
||||
|
||||
// 继续动画循环
|
||||
animationFrameId = requestAnimationFrame(animateSpotlight);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
30
web/graduation/src/components/common/CardGrid.astro
Normal file
30
web/graduation/src/components/common/CardGrid.astro
Normal file
@ -0,0 +1,30 @@
|
||||
---
|
||||
interface Props {
|
||||
columns?: 1 | 2 | 3 | 4;
|
||||
gap?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
columns = 3,
|
||||
gap = "gap-8",
|
||||
className = ""
|
||||
} = Astro.props;
|
||||
|
||||
// 根据列数动态设置网格类
|
||||
const getGridClass = () => {
|
||||
switch(columns) {
|
||||
case 1: return 'grid-cols-1';
|
||||
case 2: return 'grid-cols-1 md:grid-cols-2';
|
||||
case 3: return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3';
|
||||
case 4: return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4';
|
||||
default: return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3';
|
||||
}
|
||||
};
|
||||
|
||||
const gridClass = getGridClass();
|
||||
---
|
||||
|
||||
<div class={`grid ${gridClass} ${gap} ${className}`}>
|
||||
<slot />
|
||||
</div>
|
117
web/graduation/src/components/common/CarouselCard.astro
Normal file
117
web/graduation/src/components/common/CarouselCard.astro
Normal file
@ -0,0 +1,117 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
link?: string;
|
||||
linkText?: string;
|
||||
tags?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
imageAlt = "",
|
||||
link,
|
||||
linkText = "详细信息",
|
||||
tags = [],
|
||||
className = ""
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class={`carousel-card flex-shrink-0 w-full md:w-80 bg-white dark:bg-color-dark-card rounded-xl overflow-hidden shadow-md hover:shadow-xl transition-all duration-300 ${className}`}>
|
||||
{image && (
|
||||
<div class="h-48 overflow-hidden relative">
|
||||
<div class="w-full h-full bg-gray-300 dark:bg-gray-700 flex items-center justify-center card-image-container">
|
||||
<span class="text-gray-500 dark:text-gray-400">{imageAlt || title}</span>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent opacity-0 transition-opacity duration-300 card-overlay"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="p-5">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2 line-clamp-1 transition-colors duration-300">{title}</h3>
|
||||
|
||||
{description && (
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-3 text-sm line-clamp-2">{description}</p>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
{tags.map(tag => (
|
||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded-full dark:bg-color-dark-surface dark:text-gray-400 transition-colors duration-300 card-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<slot />
|
||||
|
||||
{link && (
|
||||
<a
|
||||
href={link}
|
||||
class="inline-block mt-2 text-color-primary-600 hover:text-color-primary-700 dark:text-color-primary-400 dark:hover:text-color-primary-300 text-sm font-medium transition-transform duration-300 card-link"
|
||||
>
|
||||
{linkText} →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.carousel-card {
|
||||
transform: translateZ(0); /* 启用硬件加速,使动画更流畅 */
|
||||
will-change: transform, opacity; /* 提前告知浏览器哪些属性会变化 */
|
||||
transition: transform 0.3s ease, opacity 0.3s ease, box-shadow 0.3s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.carousel-card:hover {
|
||||
transform: translateY(-5px) scale(1.02);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .carousel-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.carousel-card:hover .card-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.carousel-card:hover .card-image-container {
|
||||
transform: scale(1.05);
|
||||
transition: transform 0.6s ease;
|
||||
}
|
||||
|
||||
.card-image-container {
|
||||
transition: transform 0.3s ease;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.carousel-card:hover h3 {
|
||||
color: rgb(99, 102, 241);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .carousel-card:hover h3 {
|
||||
color: rgb(129, 140, 248);
|
||||
}
|
||||
|
||||
.carousel-card:hover .card-link {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.carousel-card:hover .card-tag {
|
||||
background-color: rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .carousel-card:hover .card-tag {
|
||||
background-color: rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
</style>
|
40
web/graduation/src/components/common/CategoryFilter.astro
Normal file
40
web/graduation/src/components/common/CategoryFilter.astro
Normal file
@ -0,0 +1,40 @@
|
||||
---
|
||||
interface Category {
|
||||
name: string;
|
||||
icon?: string;
|
||||
count?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
categories: Category[];
|
||||
showAllLabel?: string;
|
||||
allActive?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
categories,
|
||||
showAllLabel = "全部",
|
||||
allActive = true
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<section class="py-8 bg-white dark:bg-color-dark-surface border-b border-gray-200 dark:border-color-dark-border">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
<button class={`px-4 py-2 rounded-full font-medium transition-colors ${allActive ? 'bg-color-primary-500 text-white dark:bg-color-dark-primary-600 dark:text-white dark:hover:bg-color-dark-primary-500' : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-color-dark-card dark:text-gray-300 dark:hover:bg-color-dark-border'}`}>
|
||||
{showAllLabel}
|
||||
</button>
|
||||
|
||||
{categories.map(category => (
|
||||
<button class={`px-4 py-2 rounded-full font-medium transition-colors ${category.isActive ? 'bg-color-primary-500 text-white dark:bg-color-dark-primary-600 dark:text-white dark:hover:bg-color-dark-primary-500' : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-color-dark-card dark:text-gray-300 dark:hover:bg-color-dark-border'}`}>
|
||||
{category.icon && <span class="mr-1">{category.icon}</span>}
|
||||
{category.name}
|
||||
{category.count !== undefined && (
|
||||
<span class="ml-1 text-xs opacity-70">{`(${category.count})`}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
69
web/graduation/src/components/common/ItemCard.astro
Normal file
69
web/graduation/src/components/common/ItemCard.astro
Normal file
@ -0,0 +1,69 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
link?: string;
|
||||
linkText?: string;
|
||||
tags?: string[];
|
||||
location?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
imageAlt = "",
|
||||
link,
|
||||
linkText = "详细信息",
|
||||
tags = [],
|
||||
location,
|
||||
className = ""
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class={`bg-white dark:bg-color-dark-card rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow ${className}`}>
|
||||
{image && (
|
||||
<div class="h-56 overflow-hidden relative">
|
||||
<div class="w-full h-full bg-gray-300 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span class="text-gray-500 dark:text-gray-400">{imageAlt || title}</span>
|
||||
</div>
|
||||
{location && (
|
||||
<div class="absolute top-3 left-3 bg-white dark:bg-color-dark-bg px-2 py-1 rounded text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{location}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">{title}</h3>
|
||||
|
||||
{description && (
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4 line-clamp-3">{description}</p>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{tags.map(tag => (
|
||||
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full dark:bg-color-dark-surface dark:text-gray-400">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<slot />
|
||||
|
||||
{link && (
|
||||
<a
|
||||
href={link}
|
||||
class="inline-block mt-2 text-color-primary-600 hover:text-color-primary-700 dark:text-color-primary-400 dark:hover:text-color-primary-300 font-medium"
|
||||
>
|
||||
{linkText} →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
18
web/graduation/src/components/common/PageHeader.astro
Normal file
18
web/graduation/src/components/common/PageHeader.astro
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const { title, subtitle } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="py-12 bg-gradient-to-r from-color-primary-500 to-color-primary-700 text-white dark:from-color-dark-primary-800 dark:to-color-dark-primary-950">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-4xl mx-auto text-center">
|
||||
<h1 class="text-4xl font-bold mb-4">{title}</h1>
|
||||
{subtitle && <p class="text-xl opacity-90">{subtitle}</p>}
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
33
web/graduation/src/components/common/SearchBar.astro
Normal file
33
web/graduation/src/components/common/SearchBar.astro
Normal file
@ -0,0 +1,33 @@
|
||||
---
|
||||
interface Props {
|
||||
placeholder?: string;
|
||||
buttonText?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
placeholder = "搜索...",
|
||||
buttonText = "搜索",
|
||||
className = ""
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<section class={`py-8 bg-white dark:bg-color-dark-bg ${className}`}>
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
class="w-full px-4 py-3 pl-12 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-color-primary-500 focus:border-color-primary-500 shadow-sm dark:bg-color-dark-card dark:border-color-dark-border dark:text-white dark:placeholder-gray-400"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 absolute left-3 top-3.5 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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" />
|
||||
</svg>
|
||||
<button class="absolute right-3 top-3 px-3 py-1 bg-color-primary-500 text-white rounded-md hover:bg-color-primary-600 transition-colors dark:bg-color-dark-primary-600 dark:text-white dark:hover:bg-color-dark-primary-500">
|
||||
{buttonText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
61
web/graduation/src/components/common/SectionContainer.astro
Normal file
61
web/graduation/src/components/common/SectionContainer.astro
Normal file
@ -0,0 +1,61 @@
|
||||
---
|
||||
interface Props {
|
||||
bgColor?: 'white' | 'gray' | 'primary' | 'none';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'none';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
bgColor = 'white',
|
||||
padding = 'lg',
|
||||
maxWidth = 'lg',
|
||||
className = ""
|
||||
} = Astro.props;
|
||||
|
||||
// 设置背景颜色
|
||||
const getBgColorClass = () => {
|
||||
switch(bgColor) {
|
||||
case 'white': return 'bg-white dark:bg-color-dark-bg';
|
||||
case 'gray': return 'bg-gray-100 dark:bg-color-dark-surface';
|
||||
case 'primary': return 'bg-color-primary-50 dark:bg-color-dark-primary-50';
|
||||
case 'none': return '';
|
||||
default: return 'bg-white dark:bg-color-dark-bg';
|
||||
}
|
||||
};
|
||||
|
||||
// 设置内边距
|
||||
const getPaddingClass = () => {
|
||||
switch(padding) {
|
||||
case 'none': return '';
|
||||
case 'sm': return 'py-4 px-4';
|
||||
case 'md': return 'py-8 px-4 sm:px-6';
|
||||
case 'lg': return 'py-12 px-4 sm:px-6 lg:px-8';
|
||||
case 'xl': return 'py-16 px-4 sm:px-6 lg:px-8';
|
||||
default: return 'py-12 px-4 sm:px-6 lg:px-8';
|
||||
}
|
||||
};
|
||||
|
||||
// 设置最大宽度
|
||||
const getMaxWidthClass = () => {
|
||||
switch(maxWidth) {
|
||||
case 'none': return '';
|
||||
case 'sm': return 'max-w-2xl mx-auto';
|
||||
case 'md': return 'max-w-4xl mx-auto';
|
||||
case 'lg': return 'max-w-7xl mx-auto';
|
||||
case 'xl': return 'max-w-screen-2xl mx-auto';
|
||||
case 'full': return 'w-full';
|
||||
default: return 'max-w-7xl mx-auto';
|
||||
}
|
||||
};
|
||||
|
||||
const bgColorClass = getBgColorClass();
|
||||
const paddingClass = getPaddingClass();
|
||||
const maxWidthClass = getMaxWidthClass();
|
||||
---
|
||||
|
||||
<section class={`${bgColorClass} ${paddingClass} ${className}`}>
|
||||
<div class={maxWidthClass}>
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
39
web/graduation/src/components/common/SectionHeading.astro
Normal file
39
web/graduation/src/components/common/SectionHeading.astro
Normal file
@ -0,0 +1,39 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
marginBottom?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
subtitle,
|
||||
alignment = 'center',
|
||||
marginBottom = 'mb-8',
|
||||
className = ""
|
||||
} = Astro.props;
|
||||
|
||||
// 设置对齐方式
|
||||
const getAlignmentClass = () => {
|
||||
switch(alignment) {
|
||||
case 'left': return 'text-left';
|
||||
case 'right': return 'text-right';
|
||||
case 'center': return 'text-center';
|
||||
default: return 'text-center';
|
||||
}
|
||||
};
|
||||
|
||||
const alignmentClass = getAlignmentClass();
|
||||
---
|
||||
|
||||
<div class={`${alignmentClass} ${marginBottom} ${className}`}>
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">{title}</h2>
|
||||
{subtitle && (
|
||||
<p class="mt-3 text-lg text-gray-600 dark:text-gray-400 max-w-3xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
<slot />
|
||||
</div>
|
331
web/graduation/src/components/common/SmoothCardCarousel.astro
Normal file
331
web/graduation/src/components/common/SmoothCardCarousel.astro
Normal file
@ -0,0 +1,331 @@
|
||||
---
|
||||
interface Props {
|
||||
autoplay?: boolean;
|
||||
autoplaySpeed?: number; // 毫秒
|
||||
showArrows?: boolean;
|
||||
showDots?: boolean;
|
||||
cardGap?: number; // px
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
autoplay = true,
|
||||
autoplaySpeed = 5000,
|
||||
showArrows = true,
|
||||
showDots = true,
|
||||
cardGap = 16,
|
||||
className = ""
|
||||
} = Astro.props;
|
||||
|
||||
const carouselId = `carousel-${Math.random().toString(36).substring(2, 11)}`;
|
||||
---
|
||||
|
||||
<div class={`smooth-card-carousel relative overflow-hidden group ${className}`} id={carouselId}>
|
||||
<!-- 轮播容器 -->
|
||||
<div class="carousel-container relative">
|
||||
<!-- 轮播轨道 -->
|
||||
<div class="carousel-track flex transition-transform duration-500 ease-out cursor-grab">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
{showArrows && (
|
||||
<div class="carousel-controls">
|
||||
<button class="carousel-prev absolute left-4 top-1/2 -translate-y-1/2 z-10 bg-white bg-opacity-70 dark:bg-color-dark-card dark:bg-opacity-70 rounded-full p-2 shadow-md opacity-40 group-hover:opacity-100 transition-opacity hover:bg-opacity-90 dark:hover:bg-opacity-90 focus:outline-none hover:scale-110">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-700 dark:text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span class="sr-only">上一个</span>
|
||||
</button>
|
||||
<button class="carousel-next absolute right-4 top-1/2 -translate-y-1/2 z-10 bg-white bg-opacity-70 dark:bg-color-dark-card dark:bg-opacity-70 rounded-full p-2 shadow-md opacity-40 group-hover:opacity-100 transition-opacity hover:bg-opacity-90 dark:hover:bg-opacity-90 focus:outline-none hover:scale-110">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-700 dark:text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span class="sr-only">下一个</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- 指示点 -->
|
||||
{showDots && (
|
||||
<div class="carousel-dots absolute bottom-3 left-0 right-0 flex justify-center space-x-2 z-10">
|
||||
<!-- 指示点将通过JS动态生成 -->
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style define:vars={{ cardGap: `${cardGap}px` }}>
|
||||
.smooth-card-carousel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
transition: transform 0.5s ease-out;
|
||||
gap: var(--cardGap);
|
||||
padding: 8px 4px; /* 添加内边距,让阴影效果完全显示 */
|
||||
}
|
||||
|
||||
.carousel-track.grabbing {
|
||||
cursor: grabbing;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.carousel-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.carousel-dot.active {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* 淡入淡出效果 */
|
||||
.carousel-track > * {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.carousel-track .card-faded {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 导航按钮悬停效果 */
|
||||
.carousel-prev,
|
||||
.carousel-next {
|
||||
transition: opacity 0.3s ease, transform 0.2s ease, background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.carousel-container::before,
|
||||
.carousel-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 60px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.carousel-container::before {
|
||||
left: 0;
|
||||
background: linear-gradient(to right, rgba(255,255,255,0.5), transparent);
|
||||
}
|
||||
|
||||
.carousel-container::after {
|
||||
right: 0;
|
||||
background: linear-gradient(to left, rgba(255,255,255,0.5), transparent);
|
||||
}
|
||||
|
||||
/* 暗黑模式下的渐变适配 */
|
||||
[data-theme='dark'] .carousel-container::before {
|
||||
background: linear-gradient(to right, rgba(0,0,0,0.3), transparent);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .carousel-container::after {
|
||||
background: linear-gradient(to left, rgba(0,0,0,0.3), transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script define:vars={{ carouselId, autoplay, autoplaySpeed }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const carousel = document.getElementById(carouselId);
|
||||
if (!carousel) return;
|
||||
|
||||
const track = carousel.querySelector('.carousel-track');
|
||||
const cards = Array.from(track.children);
|
||||
const carouselContainer = carousel.querySelector('.carousel-container');
|
||||
const prevBtn = carousel.querySelector('.carousel-prev');
|
||||
const nextBtn = carousel.querySelector('.carousel-next');
|
||||
const dotsContainer = carousel.querySelector('.carousel-dots');
|
||||
|
||||
let currentIndex = 0;
|
||||
let startX, startScrollLeft, cardWidth, totalCards, visibleCards;
|
||||
let autoplayInterval;
|
||||
let isDragging = false;
|
||||
|
||||
// 初始化
|
||||
const initialize = () => {
|
||||
if (cards.length === 0) return;
|
||||
|
||||
// 计算一张卡片的宽度(包括间距)
|
||||
cardWidth = cards[0].offsetWidth + parseInt(getComputedStyle(track).gap);
|
||||
|
||||
// 计算可见卡片数量和总卡片数
|
||||
visibleCards = Math.floor(carouselContainer.offsetWidth / cardWidth);
|
||||
totalCards = cards.length;
|
||||
|
||||
// 克隆卡片以实现无限滚动
|
||||
if (totalCards < visibleCards * 2) {
|
||||
for (let i = 0; i < totalCards; i++) {
|
||||
const clone = cards[i].cloneNode(true);
|
||||
track.appendChild(clone);
|
||||
}
|
||||
// 更新卡片数组
|
||||
cards.push(...Array.from(track.children).slice(totalCards));
|
||||
totalCards = cards.length;
|
||||
}
|
||||
|
||||
// 创建指示点
|
||||
if (dotsContainer) {
|
||||
dotsContainer.innerHTML = '';
|
||||
const numDots = Math.ceil(totalCards / visibleCards);
|
||||
|
||||
for (let i = 0; i < numDots; i++) {
|
||||
const dot = document.createElement('button');
|
||||
dot.classList.add('carousel-dot');
|
||||
dot.setAttribute('aria-label', `滑动到第${i + 1}组卡片`);
|
||||
|
||||
if (i === 0) dot.classList.add('active');
|
||||
|
||||
dot.addEventListener('click', () => {
|
||||
goToSlide(i * visibleCards);
|
||||
});
|
||||
|
||||
dotsContainer.appendChild(dot);
|
||||
}
|
||||
}
|
||||
|
||||
// 开始自动播放
|
||||
if (autoplay) {
|
||||
startAutoplay();
|
||||
}
|
||||
|
||||
applyCardStyles();
|
||||
};
|
||||
|
||||
// 设置卡片样式(淡入淡出效果)
|
||||
const applyCardStyles = () => {
|
||||
cards.forEach((card, index) => {
|
||||
if (index < currentIndex || index >= currentIndex + visibleCards) {
|
||||
card.classList.add('card-faded');
|
||||
} else {
|
||||
card.classList.remove('card-faded');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 滚动到指定滑块
|
||||
const goToSlide = (index) => {
|
||||
// 防止越界
|
||||
if (index < 0) {
|
||||
index = totalCards - visibleCards;
|
||||
} else if (index >= totalCards) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
currentIndex = index;
|
||||
track.style.transform = `translateX(-${currentIndex * cardWidth}px)`;
|
||||
|
||||
// 更新指示点
|
||||
if (dotsContainer) {
|
||||
const dots = Array.from(dotsContainer.children);
|
||||
const activeDotIndex = Math.floor(currentIndex / visibleCards);
|
||||
|
||||
dots.forEach((dot, i) => {
|
||||
if (i === activeDotIndex) {
|
||||
dot.classList.add('active');
|
||||
} else {
|
||||
dot.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyCardStyles();
|
||||
};
|
||||
|
||||
// 下一张
|
||||
const nextSlide = () => {
|
||||
goToSlide(currentIndex + visibleCards);
|
||||
};
|
||||
|
||||
// 上一张
|
||||
const prevSlide = () => {
|
||||
goToSlide(currentIndex - visibleCards);
|
||||
};
|
||||
|
||||
// 鼠标/触摸事件处理
|
||||
const dragStart = (e) => {
|
||||
isDragging = true;
|
||||
startX = e.type.includes('mouse') ? e.pageX : e.touches[0].pageX;
|
||||
startScrollLeft = currentIndex * cardWidth;
|
||||
|
||||
track.classList.add('grabbing');
|
||||
|
||||
// 暂停自动播放
|
||||
if (autoplay) {
|
||||
clearInterval(autoplayInterval);
|
||||
}
|
||||
};
|
||||
|
||||
const dragging = (e) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
e.preventDefault();
|
||||
const x = e.type.includes('mouse') ? e.pageX : e.touches[0].pageX;
|
||||
const walk = startX - x;
|
||||
|
||||
track.style.transform = `translateX(-${startScrollLeft + walk}px)`;
|
||||
};
|
||||
|
||||
const dragEnd = () => {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
|
||||
track.classList.remove('grabbing');
|
||||
|
||||
const threshold = cardWidth / 3;
|
||||
const walk = parseInt(track.style.transform.match(/-?\d+/) || 0);
|
||||
const targetIndex = Math.round(walk / cardWidth);
|
||||
|
||||
goToSlide(targetIndex);
|
||||
|
||||
// 重新开始自动播放
|
||||
if (autoplay) {
|
||||
startAutoplay();
|
||||
}
|
||||
};
|
||||
|
||||
// 自动播放
|
||||
const startAutoplay = () => {
|
||||
if (autoplayInterval) clearInterval(autoplayInterval);
|
||||
autoplayInterval = setInterval(nextSlide, autoplaySpeed);
|
||||
};
|
||||
|
||||
// 事件监听
|
||||
if (prevBtn) prevBtn.addEventListener('click', prevSlide);
|
||||
if (nextBtn) nextBtn.addEventListener('click', nextSlide);
|
||||
|
||||
// 鼠标拖动
|
||||
track.addEventListener('mousedown', dragStart);
|
||||
window.addEventListener('mousemove', dragging);
|
||||
window.addEventListener('mouseup', dragEnd);
|
||||
|
||||
// 触摸拖动
|
||||
track.addEventListener('touchstart', dragStart);
|
||||
window.addEventListener('touchmove', dragging);
|
||||
window.addEventListener('touchend', dragEnd);
|
||||
|
||||
// 鼠标悬停时暂停自动播放
|
||||
carousel.addEventListener('mouseenter', () => {
|
||||
if (autoplay) clearInterval(autoplayInterval);
|
||||
});
|
||||
|
||||
carousel.addEventListener('mouseleave', () => {
|
||||
if (autoplay) startAutoplay();
|
||||
});
|
||||
|
||||
// 窗口调整大小时重新初始化
|
||||
window.addEventListener('resize', initialize);
|
||||
|
||||
// 初始化
|
||||
initialize();
|
||||
});
|
||||
</script>
|
@ -0,0 +1,64 @@
|
||||
---
|
||||
title: 承德避暑山庄
|
||||
description: 中国清代皇家园林,世界文化遗产,占地564万平方米,是世界上最大的皇家园林之一。清朝皇帝夏天在此避暑,处理政务。园内山水相融,景色优美,建筑风格多样。
|
||||
featured: true
|
||||
image: https://picsum.photos/seed/chengde/800/600
|
||||
location: 承德市
|
||||
tags: ['世界文化遗产', '皇家园林', '历史建筑']
|
||||
pubDate: 2023-03-15
|
||||
updatedDate: 2023-12-01
|
||||
---
|
||||
|
||||
# 承德避暑山庄
|
||||
|
||||
## 景点概况
|
||||
|
||||
承德避暑山庄,又名"热河行宫",位于河北省承德市双桥区,是清代皇帝夏天避暑和处理政务的场所,也是中国现存最大的古代帝王宫苑。避暑山庄始建于1703年(清康熙四十二年),历经康熙、雍正、乾隆三朝,耗时89年建成。
|
||||
|
||||
园内建筑面积8.7万平方米,整个山庄依山就势,园林结构严谨,建筑布局灵活,集中国园林艺术之精华,融汉、蒙、满等民族建筑风格于一体。
|
||||
|
||||
## 历史沿革
|
||||
|
||||
避暑山庄创建于1703年,是清代康熙皇帝为避暑与抵御北方少数民族威胁而建。山庄的选址有着重要的战略意义,位于进出关内外的交通要道和游牧民族与农耕地区的交界处。
|
||||
|
||||
康熙年间主要建成了宫殿区、湖区和部分平原区,雍正时期进行了扩建,而在乾隆年间则达到鼎盛,修建了大部分的建筑群和外八庙。
|
||||
|
||||
## 建筑特色
|
||||
|
||||
避暑山庄布局巧妙,融宫殿建筑与自然景观为一体,被划分为宫殿区、湖区、平原区和山区四个部分:
|
||||
|
||||
1. **宫殿区**:位于山庄南部,是皇帝处理政务和日常起居的场所,建筑富丽堂皇。
|
||||
2. **湖区**:位于山庄中部,包括八个大小不一的湖泊,湖中点缀着岛屿和各式亭台楼阁。
|
||||
3. **平原区**:位于湖区以北,模仿内蒙古草原风光,建有蒙古包等。
|
||||
4. **山区**:位于山庄北部,占总面积四分之三,主要是自然风景区,点缀有寺庙和亭台。
|
||||
|
||||
## 文化价值
|
||||
|
||||
避暑山庄不仅是清朝政治中心之一,也是中国古代园林艺术的杰出代表。它体现了"天人合一"的中国传统哲学思想,以及清代统治者"慎终怀远"的政治理念。
|
||||
|
||||
1987年,承德避暑山庄及周围寺庙被联合国教科文组织列入世界文化遗产名录。
|
||||
|
||||
## 著名景点
|
||||
|
||||
避暑山庄内有72景,其中最著名的有:
|
||||
|
||||
- **烟波致爽**:湖区主要景点,可欣赏湖光山色。
|
||||
- **月色江声**:观赏月亮和聆听流水声的最佳地点。
|
||||
- **芳泉映柳**:有泉水环绕的柳树园地。
|
||||
- **松风水月**:可欣赏松树、微风、湖水和月色的胜景。
|
||||
- **四知书屋**:乾隆皇帝读书的地方。
|
||||
|
||||
## 交通指南
|
||||
|
||||
- **地址**:河北省承德市双桥区武烈河北路178号
|
||||
- **公交**:乘坐1路、6路公交车可直达
|
||||
- **自驾**:从北京出发,沿京承高速公路行驶约230公里可达
|
||||
|
||||
## 参观提示
|
||||
|
||||
- 开放时间:8:00-17:30(旺季),8:30-17:00(淡季)
|
||||
- 门票信息:淡季80元,旺季100元(含园内电瓶车)
|
||||
- 建议游览时间:一整天
|
||||
- 最佳游览季节:夏秋季(7-10月)
|
||||
|
||||
来避暑山庄,感受"塞外江南"的独特魅力,领略清朝帝王的皇家气派!
|
75
web/graduation/src/content/config.ts
Normal file
75
web/graduation/src/content/config.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
// 景点集合的Schema
|
||||
const attractionsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
featured: z.boolean().default(false),
|
||||
image: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
pubDate: z.date().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
// 文化集合的Schema
|
||||
const cultureCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
category: z.string(),
|
||||
featured: z.boolean().default(false),
|
||||
image: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
pubDate: z.date().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
// 美食集合的Schema
|
||||
const cuisineCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
category: z.string(),
|
||||
featured: z.boolean().default(false),
|
||||
image: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
ingredients: z.array(z.string()).default([]),
|
||||
taste: z.string().optional(),
|
||||
cookTime: z.string().optional(),
|
||||
difficulty: z.string().optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
pubDate: z.date().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
// 旅游攻略集合的Schema
|
||||
const travelCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
season: z.enum(['春季', '夏季', '秋季', '冬季']).optional(),
|
||||
type: z.string(),
|
||||
featured: z.boolean().default(false),
|
||||
image: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
days: z.number().optional(),
|
||||
difficulty: z.enum(['简单', '中等', '困难']).optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
pubDate: z.date().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
// 导出集合配置
|
||||
export const collections = {
|
||||
'attractions': attractionsCollection,
|
||||
'culture': cultureCollection,
|
||||
'cuisine': cuisineCollection,
|
||||
'travel': travelCollection,
|
||||
};
|
93
web/graduation/src/content/cuisine/donkey-meat-burger.md
Normal file
93
web/graduation/src/content/cuisine/donkey-meat-burger.md
Normal file
@ -0,0 +1,93 @@
|
||||
---
|
||||
title: 保定驴肉火烧
|
||||
description: 河北保定的传统小吃,以烤制的火烧饼夹入精选的驴肉,肉质鲜美,饼酥脆可口,香气四溢,是保定最具代表性的地方风味美食。
|
||||
category: 传统小吃
|
||||
featured: true
|
||||
image: https://picsum.photos/seed/donkey-burger/800/600
|
||||
origin: 保定市
|
||||
ingredients: ['驴肉', '火烧饼', '青椒', '香菜', '五香粉', '酱汁']
|
||||
taste: 鲜香酥脆
|
||||
tags: ['河北名吃', '传统美食', '地方特色']
|
||||
pubDate: 2023-04-05
|
||||
updatedDate: 2023-10-20
|
||||
---
|
||||
|
||||
# 保定驴肉火烧
|
||||
|
||||
## 美食简介
|
||||
|
||||
保定驴肉火烧是河北保定地区的传统名吃,距今已有上百年的历史。它由两块圆形的酥脆火烧饼夹着精细切片的卤驴肉制成,佐以青椒、香菜等配料,香气四溢,口感丰富,是河北最具代表性的地方风味小吃之一。
|
||||
|
||||
驴肉火烧之所以能够成为保定的招牌美食,在于其选料严格、工艺精湛、味道独特。驴肉肉质细嫩且低脂肪、高蛋白,火烧饼则酥脆可口,两者搭配堪称绝配。
|
||||
|
||||
## 历史渊源
|
||||
|
||||
关于保定驴肉火烧的起源,有多种说法。最具代表性的说法认为,它始于清朝末年的保定城内。当时的保定是京畿重地,商业繁荣,小吃文化发达。一家姓茹的老字号饭馆为了充分利用食材,将卤制的驴肉切片夹在当地特色的火烧饼中售卖,没想到大受欢迎,逐渐成为保定的特色美食。
|
||||
|
||||
另一种说法将其起源追溯到更早的明代,传说明朝大将徐达攻打保定时,因军粮不足,士兵只能以驴肉夹饼充饥,没想到这种简单的吃法味道鲜美,战后流传开来。
|
||||
|
||||
## 制作工艺
|
||||
|
||||
保定驴肉火烧的制作分为两大部分:火烧饼的制作和驴肉的卤制。
|
||||
|
||||
### 火烧饼制作
|
||||
1. **和面**:选用优质面粉,加入适量清水、食用碱和盐,揉至光滑。
|
||||
2. **发酵**:将和好的面团放置发酵至体积增大约一倍。
|
||||
3. **整形**:将面团分割成小剂子,压扁成圆饼状。
|
||||
4. **烤制**:传统方法是在炉壁上烤制,使饼两面呈金黄色,外酥里嫩。
|
||||
|
||||
### 驴肉卤制
|
||||
1. **选料**:选用新鲜的驴肉,以后腿肉为佳。
|
||||
2. **清洗**:将驴肉彻底清洗,去除血水。
|
||||
3. **卤制**:将肉放入配有十余种香料的卤水中,小火慢炖3-4小时。
|
||||
4. **冷却**:卤好后取出,自然冷却。
|
||||
5. **切片**:将卤好的驴肉切成薄片,约2-3毫米厚。
|
||||
|
||||
### 组合制作
|
||||
1. 将火烧饼从中间切开,不要切断。
|
||||
2. 在切开的饼内放入适量切好的驴肉片。
|
||||
3. 加入青椒丝、香菜等配料。
|
||||
4. 可根据个人口味加入特制的酱料。
|
||||
|
||||
## 营养价值
|
||||
|
||||
驴肉被称为"黑色的肉中之王",具有极高的营养价值:
|
||||
- 高蛋白低脂肪:蛋白质含量高达20%以上,脂肪含量仅为2%左右。
|
||||
- 富含微量元素:含有丰富的铁、锌、硒等微量元素。
|
||||
- 氨基酸丰富:含有人体所需的多种必需氨基酸。
|
||||
- 药用价值:中医认为驴肉性平味甘,具有补血益气、滋阴养胃的功效。
|
||||
|
||||
火烧饼则提供了充足的碳水化合物,两者搭配营养均衡,既美味又健康。
|
||||
|
||||
## 品尝之道
|
||||
|
||||
品尝保定驴肉火烧有几个关键要点:
|
||||
1. **趁热吃**:火烧刚出炉时最为酥脆,驴肉热时肉汁丰富。
|
||||
2. **细嚼慢咽**:充分品味火烧的酥香和驴肉的鲜美。
|
||||
3. **配料搭配**:可根据个人口味添加青椒、香菜、蒜泥等。
|
||||
4. **饮品搭配**:传统上配以豆汁或小米粥,现代也可配清茶或啤酒。
|
||||
|
||||
## 保定名店
|
||||
|
||||
保定有多家历史悠久的驴肉火烧老字号,其中最具代表性的有:
|
||||
|
||||
1. **茹记驴肉火烧**:创建于清末,被誉为驴肉火烧的鼻祖,多代传承的秘制配方。
|
||||
- 地址:保定市莲池区裕华西路
|
||||
|
||||
2. **万顺斋驴肉火烧**:百年老店,以肉质鲜嫩、火烧酥脆著称。
|
||||
- 地址:保定市新市区东风路
|
||||
|
||||
3. **德馨斋驴肉火烧**:创建于1936年,特点是驴肉卤制时间长,味道更浓郁。
|
||||
- 地址:保定市莲池区东三省胡同
|
||||
|
||||
## 现代传承
|
||||
|
||||
随着时代的发展,保定驴肉火烧已走出河北,在全国范围内获得了广泛认可。许多保定人将驴肉火烧的制作技艺带到全国各地,开设专门店铺。同时,现代工艺也在不断改进传统制作方法,如采用真空包装技术延长驴肉保鲜期,研发速冻火烧等,使这一传统美食更加方便快捷。
|
||||
|
||||
保定市政府也高度重视这一文化遗产的保护和传承,定期举办驴肉火烧文化节,评选"驴肉火烧名店",并支持相关研究和创新,确保这一传统美食在现代社会继续发扬光大。
|
||||
|
||||
## 旅游提示
|
||||
|
||||
来保定旅游的游客可以将品尝正宗的驴肉火烧作为必不可少的行程之一。最好选择当地知名的老字号店铺,体验最地道的口味。很多游客也会购买真空包装的驴肉带回家与亲友分享,成为保定特色伴手礼。
|
||||
|
||||
品尝驴肉火烧时,可以搭配游览保定的古莲花池、直隶总督署等历史景点,体验完整的保定文化之旅。
|
86
web/graduation/src/content/culture/jingju.md
Normal file
86
web/graduation/src/content/culture/jingju.md
Normal file
@ -0,0 +1,86 @@
|
||||
---
|
||||
title: 京剧
|
||||
description: 中国国粹,河北是京剧重要的发源地之一。京剧融合了多种地方戏曲艺术,包括河北梆子等,形成了独特的艺术风格。以生、旦、净、丑四种角色为主,通过唱念做打展现戏剧内容。
|
||||
category: 戏曲艺术
|
||||
featured: true
|
||||
image: https://picsum.photos/seed/jingju/800/600
|
||||
area: 河北全省
|
||||
tags: ['非物质文化遗产', '传统戏曲', '国粹']
|
||||
pubDate: 2023-02-10
|
||||
updatedDate: 2023-11-15
|
||||
---
|
||||
|
||||
# 京剧
|
||||
|
||||
## 艺术概况
|
||||
|
||||
京剧,被誉为中国国粹,是中国最具代表性的传统戏曲艺术形式之一。它形成于19世纪中叶的北京,但河北作为其重要的发源地之一,对京剧的形成和发展有着不可忽视的贡献。京剧通过唱念做打四种基本功,结合音乐、舞蹈、文学、武术等多种艺术形式,以程式化的表演形式展现丰富多彩的历史故事和人物形象。
|
||||
|
||||
## 历史渊源
|
||||
|
||||
京剧的形成可以追溯到清朝乾隆五十五年(1790年),当时以徽班为首的安徽戏曲团体进入北京,与来自湖北的汉调艺人以及当地的昆曲、高腔等艺术形式相互融合,逐渐形成了具有独特风格的京剧。
|
||||
|
||||
在这一过程中,源自河北的"河北梆子"对京剧的音乐、唱腔和表演风格都产生了重要影响。河北梆子作为京剧"四大声腔"之一,以其高亢激昂的唱腔特点,为京剧增添了独特的艺术魅力。
|
||||
|
||||
## 艺术特色
|
||||
|
||||
京剧的主要艺术特色包括:
|
||||
|
||||
1. **角色行当**:分为生(男性角色)、旦(女性角色)、净(性格豪放的男性角色,脸谱化妆)、丑(滑稽角色)四大类,每类又有细分。
|
||||
|
||||
2. **表演程式**:以"唱、念、做、打"为基本功,其中:
|
||||
- 唱:以二黄和西皮两大声腔为主
|
||||
- 念:包括韵白和京白两种念白方式
|
||||
- 做:程式化的动作和身段
|
||||
- 打:武打动作和技巧
|
||||
|
||||
3. **脸谱艺术**:通过不同的颜色和图案表现角色的性格特点,如红色代表忠义,黑色代表刚正不阿,蓝色代表勇猛等。
|
||||
|
||||
4. **音乐伴奏**:以弦乐和打击乐为主,包括京胡、月琴、三弦、笛子、唢呐等乐器。
|
||||
|
||||
## 河北京剧
|
||||
|
||||
河北京剧具有鲜明的地方特色,既保留了京剧的基本程式,又融入了河北地方戏曲的特点。河北京剧演员讲究字正腔圆,唱腔铿锵有力,表演朴实生动,深受当地观众喜爱。
|
||||
|
||||
河北京剧的代表剧目包括《四郎探母》、《铡美案》、《赵氏孤儿》等,这些剧目通过悲壮、激昂的表演风格,展示了英雄人物的气概和民族精神。
|
||||
|
||||
## 代表剧目
|
||||
|
||||
京剧的经典剧目数以千计,其中最具代表性的包括:
|
||||
|
||||
- **《霸王别姬》**:讲述西楚霸王项羽和虞姬的爱情悲剧。
|
||||
- **《贵妃醉酒》**:展现杨贵妃醉酒时的娇态和哀怨。
|
||||
- **《三岔口》**:以武打见长的戏曲,展示了高超的武功技艺。
|
||||
- **《四郎探母》**:讲述杨四郎回到大宋探望母亲的感人故事。
|
||||
- **《赵氏孤儿》**:表现忠臣程婴舍己为人的高尚品格。
|
||||
|
||||
## 传承保护
|
||||
|
||||
2010年,京剧被联合国教科文组织列入"人类非物质文化遗产代表作名录"。河北省一直致力于京剧艺术的保护和传承工作:
|
||||
|
||||
1. 成立专业京剧团体,如河北京剧院、石家庄京剧团等。
|
||||
2. 举办京剧表演赛事和艺术节,推广京剧艺术。
|
||||
3. 在学校开展京剧教育,培养年轻一代对传统文化的兴趣。
|
||||
4. 支持京剧名家开展收徒传艺活动,确保技艺代代相传。
|
||||
|
||||
## 欣赏指南
|
||||
|
||||
观赏京剧可以从以下几个方面入手:
|
||||
|
||||
1. **角色辨认**:了解生旦净丑的不同特点和表演风格。
|
||||
2. **唱腔欣赏**:聆听京剧独特的板式变化和韵味。
|
||||
3. **表演程式**:欣赏演员的身段、手势和眼神等细节。
|
||||
4. **故事情节**:了解剧目的历史背景和故事内容。
|
||||
|
||||
初次接触京剧的观众可以从《贵妃醉酒》、《三岔口》等易于理解的剧目入手,循序渐进地领略京剧的艺术魅力。
|
||||
|
||||
## 观演信息
|
||||
|
||||
在河北欣赏正宗京剧表演,可以关注以下场所:
|
||||
|
||||
- **河北省京剧院**:定期举办各类京剧演出
|
||||
- **河北大剧院**:邀请国内知名京剧团体演出
|
||||
- **各市文化中心**:不定期举办京剧专场演出
|
||||
- **传统戏楼**:如保定古莲花池戏楼,提供传统戏台演出体验
|
||||
|
||||
京剧作为中华文化的瑰宝,凝聚着中国人的智慧和艺术追求。在河北这片文化沃土上,京剧艺术不断焕发出新的生机和活力。
|
193
web/graduation/src/content/travel/summer-guide.md
Normal file
193
web/graduation/src/content/travel/summer-guide.md
Normal file
@ -0,0 +1,193 @@
|
||||
---
|
||||
title: 河北夏季避暑休闲之旅
|
||||
description: 河北夏季有众多避暑胜地,秦皇岛北戴河海滨、承德避暑山庄、张家口崇礼都是理想的避暑休闲目的地,享受凉爽惬意的假期。
|
||||
season: 夏季
|
||||
type: 旅行贴士
|
||||
featured: true
|
||||
image: https://picsum.photos/seed/hebei-summer/800/600
|
||||
days: 5
|
||||
difficulty: 简单
|
||||
tags: ['避暑胜地', '海滨度假', '休闲旅游']
|
||||
pubDate: 2023-05-20
|
||||
updatedDate: 2023-11-30
|
||||
---
|
||||
|
||||
# 河北夏季避暑休闲之旅
|
||||
|
||||
## 攻略概述
|
||||
|
||||
夏季是旅游的旺季,但高温往往让人望而却步。而河北省凭借其独特的地理位置和多样的地形条件,拥有众多避暑胜地,是夏季旅游的理想选择。从东部的海滨度假区到北部的山地避暑胜地,再到历史悠久的皇家园林,河北为游客提供了多种避暑休闲的选择。
|
||||
|
||||
本攻略将为您详细介绍河北夏季最佳的避暑目的地、精心规划的5日行程、特色体验活动以及实用的旅行贴士,帮助您度过一个凉爽舒适的夏日假期。
|
||||
|
||||
## 最佳旅行时间
|
||||
|
||||
**6月中旬至8月底**是游览河北避暑胜地的黄金时期。此时河北内陆地区气温较高,而本攻略推荐的避暑目的地气温适宜,通常比内陆地区低5-10℃。尤其是7月中旬到8月中旬,正值内陆高温季节,河北的避暑胜地优势更为明显。
|
||||
|
||||
## 推荐目的地
|
||||
|
||||
### 1. 秦皇岛北戴河
|
||||
|
||||
北戴河是中国最著名的海滨避暑胜地之一,夏季平均气温比内陆低8℃左右。这里拥有绵延的金色沙滩、清澈的海水和舒适的海风,是消暑纳凉的绝佳去处。
|
||||
|
||||
**主要景点**:
|
||||
- **北戴河海滨**:绵延数公里的沙滩,海水清澈,适合游泳、沙滩排球等活动。
|
||||
- **鸽子窝公园**:融合了园林景观与海滨风光,是观赏日出的最佳地点。
|
||||
- **老虎石海上公园**:因礁石形似虎头而得名,海景壮观。
|
||||
- **联峰山公园**:山顶可俯瞰整个北戴河海滨。
|
||||
|
||||
**特色体验**:
|
||||
- 清晨在沙滩漫步,感受第一缕阳光
|
||||
- 品尝新鲜的海鲜大餐
|
||||
- 参加沙滩排球或沙雕比赛
|
||||
- 在海边露营,欣赏星空
|
||||
|
||||
### 2. 承德避暑山庄
|
||||
|
||||
作为清代皇家避暑胜地,避暑山庄不仅拥有丰富的历史文化遗产,还因其独特的地理位置和园林设计,成为夏季理想的避暑地。山庄内山水相融,植被茂密,气温比北京低5-8℃。
|
||||
|
||||
**主要景点**:
|
||||
- **避暑山庄主体**:包括宫殿区、湖区、平原区和山区,布局巧妙,景色宜人。
|
||||
- **外八庙**:包括普宁寺、普佑寺等,展现了清代多民族融合的建筑艺术。
|
||||
- **磬锤峰**:避暑山庄附近的自然景观,形似磬锤,景色壮观。
|
||||
|
||||
**特色体验**:
|
||||
- 穿着清代服饰在山庄内拍照留念
|
||||
- 参加山庄内的文化体验活动,如宫廷茶艺、满族剪纸等
|
||||
- 在园内的亭台楼阁中休憩,感受皇家避暑的惬意
|
||||
|
||||
### 3. 张家口崇礼
|
||||
|
||||
崇礼因2022年冬奥会而闻名,但其实夏季的崇礼也是绝佳的避暑胜地。这里海拔较高,夏季平均气温仅为20℃左右,草原青翠,空气清新,是避暑休闲的理想选择。
|
||||
|
||||
**主要景点**:
|
||||
- **崇礼滑雪场**:夏季转型为高山草甸和户外运动基地。
|
||||
- **太舞滑雪小镇**:冬奥会场馆,夏季可参观奥运遗址并体验各种户外活动。
|
||||
- **草原天路**:盘山公路,沿途风景如画,是自驾游的绝佳线路。
|
||||
- **大境门**:历史悠久的古代关隘,现为重要的旅游景点。
|
||||
|
||||
**特色体验**:
|
||||
- 高山徒步或山地自行车
|
||||
- 草原露营,观星
|
||||
- 体验草原骑马、射箭等活动
|
||||
- 品尝当地特色美食如莜面、烤全羊等
|
||||
|
||||
## 5日精选行程
|
||||
|
||||
### Day 1:抵达秦皇岛
|
||||
- **上午**:抵达秦皇岛,入住北戴河海滨酒店
|
||||
- **下午**:游览北戴河海滨,在沙滩上休闲放松,适应海边环境
|
||||
- **晚上**:在海边餐厅享用新鲜海鲜大餐,体验当地夜市文化
|
||||
|
||||
### Day 2:深度游北戴河
|
||||
- **上午**:参观鸽子窝公园,欣赏日出和海滨风光
|
||||
- **下午**:游览老虎石海上公园,体验海边刺激项目
|
||||
- **晚上**:在沙滩参加篝火晚会或沙滩音乐节(夏季常有活动)
|
||||
|
||||
### Day 3:前往承德
|
||||
- **上午**:从秦皇岛出发前往承德(车程约3小时)
|
||||
- **下午**:抵达承德后入住酒店,游览承德市区,调整状态
|
||||
- **晚上**:品尝承德特色菜,如承德火锅、双鱼涮肉等
|
||||
|
||||
### Day 4:游览避暑山庄
|
||||
- **全天**:游览避暑山庄及外八庙,深度了解清代历史文化
|
||||
- **推荐路线**:上午游览宫殿区和湖区,下午游览平原区和部分外八庙
|
||||
- **晚上**:观看承德民俗文化表演,体验满族文化
|
||||
|
||||
### Day 5:前往崇礼
|
||||
- **上午**:从承德出发前往崇礼(车程约3.5小时)
|
||||
- **下午**:抵达崇礼,游览太舞滑雪小镇,体验夏季高山活动
|
||||
- **晚上**:在山间酒店或民宿住宿,享受凉爽的夏夜
|
||||
|
||||
## 交通指南
|
||||
|
||||
### 外部交通
|
||||
- **航空**:秦皇岛山海关机场和张家口宁远机场都有定期航班
|
||||
- **铁路**:京津冀地区高铁网络发达,从北京到秦皇岛、承德、张家口均有高速列车
|
||||
- **公路**:京承高速、京沈高速等多条高速公路连接主要城市
|
||||
|
||||
### 内部交通
|
||||
- **租车自驾**:最为便捷的出行方式,特别是在崇礼地区
|
||||
- **公共交通**:各城市均有完善的公交系统,景区间有旅游专线
|
||||
- **网约车**:主要城市都有滴滴等网约车服务
|
||||
- **景区交通**:大型景区内有电瓶车等交通工具
|
||||
|
||||
## 住宿推荐
|
||||
|
||||
### 秦皇岛北戴河
|
||||
- **高端选择**:北戴河嘉轩酒店、秦皇岛海景国际大酒店
|
||||
- **中端选择**:北戴河阿那亚一宿、如家精选酒店
|
||||
- **经济选择**:北戴河海滨青年旅舍、驿家365连锁酒店
|
||||
|
||||
### 承德
|
||||
- **高端选择**:承德喜来登酒店、承德避暑山庄皇冠假日酒店
|
||||
- **中端选择**:承德金龙温泉酒店、承德万豪酒店
|
||||
- **经济选择**:如家快捷酒店、汉庭酒店
|
||||
|
||||
### 崇礼
|
||||
- **高端选择**:崇礼富龙滑雪场酒店、万龙滑雪场度假酒店
|
||||
- **中端选择**:太舞滑雪小镇酒店、崇礼云顶酒店
|
||||
- **经济选择**:崇礼家庭民宿、青年旅舍
|
||||
|
||||
## 美食指南
|
||||
|
||||
### 秦皇岛
|
||||
- **海鲜**:螃蟹、对虾、扇贝等各类新鲜海鲜
|
||||
- **特色小吃**:面茶、烧烤、杂鱼汤等
|
||||
|
||||
### 承德
|
||||
- **满族菜**:关东糟熘鱼、清蒸鹿尾、八旗布丁等
|
||||
- **地方特色**:承德火锅、双鱼涮肉、驴肉火烧
|
||||
|
||||
### 崇礼
|
||||
- **农家菜**:莜面、土豆、山野菜等
|
||||
- **烤全羊**:草原特色美食
|
||||
- **奶制品**:酸奶、奶豆腐、奶茶等
|
||||
|
||||
## 旅行贴士
|
||||
|
||||
### 装备建议
|
||||
- **衣物**:虽是避暑胜地,但早晚温差大,建议带薄外套
|
||||
- **防晒**:高原和海边紫外线强,做好防晒措施
|
||||
- **泳装**:在北戴河需要,可以海滨游泳
|
||||
- **徒步鞋**:适合在崇礼的山地徒步
|
||||
|
||||
### 安全提示
|
||||
- **海边安全**:注意遵守游泳区域规定,不要在无人看管的海域游泳
|
||||
- **高原反应**:崇礼海拔虽不算高,但部分敏感人群可能有轻微高原反应
|
||||
- **天气变化**:山区天气多变,出行前查看天气预报
|
||||
|
||||
### 其他建议
|
||||
- **最佳游览时间**:避开周末和节假日,人流量会少很多
|
||||
- **预订建议**:夏季是旅游旺季,建议提前1-2个月预订酒店和交通
|
||||
- **当地习俗**:承德有满族和蒙古族居民,崇礼有蒙古族传统,注意尊重当地习俗
|
||||
|
||||
## 花费参考
|
||||
|
||||
### 交通费用
|
||||
- 北京到秦皇岛高铁:约150-250元/人
|
||||
- 秦皇岛到承德汽车:约150元/人
|
||||
- 承德到崇礼汽车:约180元/人
|
||||
- 租车费用:约300-500元/天
|
||||
|
||||
### 住宿费用
|
||||
- 高端酒店:800-2000元/晚
|
||||
- 中端酒店:400-800元/晚
|
||||
- 经济型住宿:150-400元/晚
|
||||
|
||||
### 餐饮费用
|
||||
- 海鲜大餐:200-500元/人
|
||||
- 普通正餐:50-100元/人
|
||||
- 特色小吃:20-50元/人
|
||||
|
||||
### 门票费用
|
||||
- 北戴河景区:大多免费或30-50元
|
||||
- 避暑山庄:115-145元(淡旺季不同)
|
||||
- 外八庙联票:约100元
|
||||
- 崇礼景区:多为30-80元
|
||||
|
||||
## 总结
|
||||
|
||||
河北夏季避暑休闲之旅集海滨风光、皇家园林与高山草原于一体,为游客提供多样化的避暑选择。在炎炎夏日,您可以在北戴河感受海风拂面的舒适,在避暑山庄体验皇家的惬意生活,在崇礼的高山草原呼吸最清新的空气。这条路线既能让您避开酷暑,又能体验河北丰富的自然风光和人文历史,是夏季旅行的绝佳选择。
|
||||
|
||||
祝您旅途愉快!
|
145
web/graduation/src/layouts/MainLayout.astro
Normal file
145
web/graduation/src/layouts/MainLayout.astro
Normal file
@ -0,0 +1,145 @@
|
||||
---
|
||||
import "../styles/global.css";
|
||||
import ThemeToggle from "../components/ThemeToggle.astro";
|
||||
import DarkModeTransition from "../components/DarkModeTransition.astro";
|
||||
import SmoothScroll from "../components/aceternity/SmoothScroll.astro";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, description = "河北游礼宣传网站 - 探索河北的文化与魅力" } = Astro.props;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta name="description" content={description} />
|
||||
<title>{title}</title>
|
||||
<DarkModeTransition />
|
||||
</head>
|
||||
<body>
|
||||
<SmoothScroll />
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<header class="sticky top-0 z-50 bg-white border-b border-border-color shadow-sm dark:bg-color-dark-surface">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- 网站Logo和名称 -->
|
||||
<div class="flex items-center">
|
||||
<a href="/" class="flex items-center">
|
||||
<span class="text-xl font-bold text-color-primary-600 dark:text-color-primary-400">河北游礼</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="hidden md:flex space-x-8">
|
||||
<a href="/" class="text-gray-700 hover:text-color-primary-600 dark:text-gray-300 dark:hover:text-color-primary-400">首页</a>
|
||||
<a href="/attractions" class="text-gray-700 hover:text-color-primary-600 dark:text-gray-300 dark:hover:text-color-primary-400">景点</a>
|
||||
<a href="/culture" class="text-gray-700 hover:text-color-primary-600 dark:text-gray-300 dark:hover:text-color-primary-400">文化</a>
|
||||
<a href="/cuisine" class="text-gray-700 hover:text-color-primary-600 dark:text-gray-300 dark:hover:text-color-primary-400">美食</a>
|
||||
<a href="/travel" class="text-gray-700 hover:text-color-primary-600 dark:text-gray-300 dark:hover:text-color-primary-400">旅游攻略</a>
|
||||
</nav>
|
||||
|
||||
<!-- 主题切换按钮 -->
|
||||
<div class="flex items-center">
|
||||
<ThemeToggle />
|
||||
|
||||
<!-- 移动端菜单按钮 -->
|
||||
<button id="mobileMenuToggle" aria-label="打开菜单" class="md:hidden ml-2 p-2 rounded-full hover:bg-gray-200 dark:hover:bg-color-dark-card">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-800 dark:text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端菜单 -->
|
||||
<div id="mobileMenu" class="md:hidden hidden pb-3">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<a href="/" class="px-3 py-2 rounded-md text-gray-700 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-color-dark-card">首页</a>
|
||||
<a href="/attractions" class="px-3 py-2 rounded-md text-gray-700 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-color-dark-card">景点</a>
|
||||
<a href="/culture" class="px-3 py-2 rounded-md text-gray-700 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-color-dark-card">文化</a>
|
||||
<a href="/cuisine" class="px-3 py-2 rounded-md text-gray-700 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-color-dark-card">美食</a>
|
||||
<a href="/travel" class="px-3 py-2 rounded-md text-gray-700 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-color-dark-card">旅游攻略</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="bg-gray-100 dark:bg-color-dark-surface border-t border-border-color">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">关于我们</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">河北游礼网站致力于宣传河北省的旅游资源、文化遗产和地方特色,为游客提供全面的河北旅游信息。</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">快速链接</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="/about" class="text-color-primary-600 hover:text-color-primary-800 dark:text-color-primary-400 dark:hover:text-color-primary-300">关于河北</a></li>
|
||||
<li><a href="/contact" class="text-color-primary-600 hover:text-color-primary-800 dark:text-color-primary-400 dark:hover:text-color-primary-300">联系我们</a></li>
|
||||
<li><a href="/sitemap" class="text-color-primary-600 hover:text-color-primary-800 dark:text-color-primary-400 dark:hover:text-color-primary-300">网站地图</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">关注我们</h3>
|
||||
<div class="flex space-x-4">
|
||||
<a href="#" class="text-gray-600 hover:text-color-primary-600 dark:text-gray-400 dark:hover:text-color-primary-400">
|
||||
<span class="sr-only">微信</span>
|
||||
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" class="text-gray-600 hover:text-color-primary-600 dark:text-gray-400 dark:hover:text-color-primary-400">
|
||||
<span class="sr-only">微博</span>
|
||||
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" class="text-gray-600 hover:text-color-primary-600 dark:text-gray-400 dark:hover:text-color-primary-400">
|
||||
<span class="sr-only">抖音</span>
|
||||
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 pt-8 border-t border-gray-300 dark:border-color-dark-border text-center">
|
||||
<p class="text-gray-600 dark:text-gray-400">© {new Date().getFullYear()} 河北游礼网站. 保留所有权利.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 移动端菜单切换逻辑
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const mobileMenuToggle = document.getElementById('mobileMenuToggle');
|
||||
const mobileMenu = document.getElementById('mobileMenu');
|
||||
|
||||
// 移动端菜单切换
|
||||
const toggleMobileMenu = () => {
|
||||
if (mobileMenu) {
|
||||
mobileMenu.classList.toggle('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
// 事件监听
|
||||
if (mobileMenuToggle) {
|
||||
mobileMenuToggle.addEventListener('click', toggleMobileMenu);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
308
web/graduation/src/pages/attractions/[slug].astro
Normal file
308
web/graduation/src/pages/attractions/[slug].astro
Normal file
@ -0,0 +1,308 @@
|
||||
---
|
||||
import { getCollection, getEntry, type CollectionEntry } from "astro:content";
|
||||
import MainLayout from "../../layouts/MainLayout.astro";
|
||||
import ScrollReveal from "../../components/aceternity/ScrollReveal.astro";
|
||||
|
||||
// 定义Props类型
|
||||
export interface Props {
|
||||
entry: CollectionEntry<"attractions">;
|
||||
}
|
||||
|
||||
// 生成静态路径
|
||||
export async function getStaticPaths() {
|
||||
const attractions = await getCollection("attractions");
|
||||
return attractions.map((entry: CollectionEntry<"attractions">) => ({
|
||||
params: { slug: entry.slug },
|
||||
props: { entry },
|
||||
}));
|
||||
}
|
||||
|
||||
// 获取当前景点数据
|
||||
const { entry } = Astro.props;
|
||||
const { Content } = await entry.render();
|
||||
|
||||
// 获取相关景点
|
||||
const allAttractions = await getCollection("attractions");
|
||||
const relatedAttractions = allAttractions
|
||||
.filter(
|
||||
(item: CollectionEntry<"attractions">) =>
|
||||
item.slug !== entry.slug &&
|
||||
item.data.tags.some((tag: string) => entry.data.tags.includes(tag))
|
||||
)
|
||||
.slice(0, 3);
|
||||
---
|
||||
|
||||
<MainLayout title={`${entry.data.title} - 河北游礼`}>
|
||||
<!-- 页面标题区域 -->
|
||||
<div class="relative py-16 bg-gradient-to-br from-primary-700 via-primary-600 to-primary-800 text-white dark:from-primary-900 dark:via-primary-800 dark:to-primary-950">
|
||||
<div class="absolute inset-0 bg-black/30"></div>
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<ScrollReveal animation="fade">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<a href="/" class="text-white/80 hover:text-white transition-colors">首页</a>
|
||||
<span class="text-white/60">/</span>
|
||||
<a href="/attractions" class="text-white/80 hover:text-white transition-colors">景点</a>
|
||||
<span class="text-white/60">/</span>
|
||||
<span class="text-white/60">{entry.data.title}</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-5xl font-bold mb-4">{entry.data.title}</h1>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4 mb-4">
|
||||
{entry.data.city && (
|
||||
<div class="flex items-center text-white/90">
|
||||
<span class="mr-1">📍</span> {entry.data.city}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.data.pubDate && (
|
||||
<div class="flex items-center text-white/90">
|
||||
<span class="mr-1">📅</span> {new Date(entry.data.pubDate).toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
{entry.data.tags.map((tag: string) => (
|
||||
<span class="px-3 py-1 bg-white/20 backdrop-blur-sm text-white text-sm rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p class="text-xl text-white/90 max-w-3xl">{entry.data.description}</p>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="py-12 bg-white dark:bg-dark-bg">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
<!-- 左侧内容 -->
|
||||
<div class="lg:col-span-2">
|
||||
<ScrollReveal animation="fade">
|
||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||
<Content />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
<!-- 右侧边栏 -->
|
||||
<div class="space-y-8">
|
||||
<!-- 景点图片 -->
|
||||
<ScrollReveal animation="slide-up">
|
||||
<div class="rounded-lg overflow-hidden shadow-md">
|
||||
<div class="h-64 bg-gray-300 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span class="text-gray-500 dark:text-gray-400">{entry.data.title} 图片</span>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<!-- 景点信息卡片 -->
|
||||
<ScrollReveal animation="slide-up" delay={100}>
|
||||
<div class="bg-gray-50 dark:bg-color-dark-card rounded-lg shadow-md p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">景点信息</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
{entry.data.city && (
|
||||
<div class="flex">
|
||||
<span class="w-24 flex-shrink-0 text-gray-600 dark:text-gray-400">位置:</span>
|
||||
<span class="text-gray-900 dark:text-white">{entry.data.city}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex">
|
||||
<span class="w-24 flex-shrink-0 text-gray-600 dark:text-gray-400">景点类型:</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{entry.data.tags.map((tag: string) => (
|
||||
<span class="px-2 py-0.5 bg-color-primary-100 text-color-primary-800 text-xs rounded-full dark:bg-color-dark-primary-900/70 dark:text-color-primary-300">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entry.data.pubDate && (
|
||||
<div class="flex">
|
||||
<span class="w-24 flex-shrink-0 text-gray-600 dark:text-gray-400">发布时间:</span>
|
||||
<span class="text-gray-900 dark:text-white">{new Date(entry.data.pubDate).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<!-- 交通信息 -->
|
||||
<div class="bg-primary-50 dark:bg-primary-900/20 rounded-lg p-6 shadow-sm border border-primary-100 dark:border-primary-800">
|
||||
<h3 class="text-lg font-semibold text-primary-800 dark:text-primary-300 mb-4 flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-primary-600 dark:text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
交通指南
|
||||
</h3>
|
||||
<ul class="space-y-3 text-gray-700 dark:text-gray-300">
|
||||
<li class="flex items-start">
|
||||
<span class="text-primary-500 dark:text-primary-400 mr-2">🚌</span>
|
||||
<span>公交路线: 10路, 15路, 22路到河北博物馆站下车</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-primary-500 dark:text-primary-400 mr-2">🚗</span>
|
||||
<span>自驾路线: 导航至"{entry.data.title}"即可</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-primary-500 dark:text-primary-400 mr-2">🚄</span>
|
||||
<span>高铁/火车: 到达石家庄站后可换乘公交或出租车</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 开放时间和门票 -->
|
||||
<div class="bg-secondary-50 dark:bg-secondary-900/20 rounded-lg p-6 shadow-sm border border-secondary-100 dark:border-secondary-800">
|
||||
<h3 class="text-lg font-semibold text-secondary-800 dark:text-secondary-300 mb-4 flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-secondary-600 dark:text-secondary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
参观信息
|
||||
</h3>
|
||||
<div class="space-y-3 text-gray-700 dark:text-gray-300">
|
||||
<p class="flex items-center">
|
||||
<span class="text-secondary-500 dark:text-secondary-400 mr-2">⏰</span>
|
||||
<span>开放时间: 09:00-17:00 (周一至周日)</span>
|
||||
</p>
|
||||
<p class="flex items-center">
|
||||
<span class="text-secondary-500 dark:text-secondary-400 mr-2">🎫</span>
|
||||
<span>门票: 成人票50元, 学生票25元</span>
|
||||
</p>
|
||||
<p class="flex items-center">
|
||||
<span class="text-secondary-500 dark:text-secondary-400 mr-2">📞</span>
|
||||
<span>咨询电话: 0311-12345678</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 周边推荐 -->
|
||||
<div class="bg-accent-50 dark:bg-accent-900/20 rounded-lg p-6 shadow-sm border border-accent-100 dark:border-accent-800">
|
||||
<h3 class="text-lg font-semibold text-accent-800 dark:text-accent-300 mb-4 flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-accent-600 dark:text-accent-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
周边推荐
|
||||
</h3>
|
||||
<ul class="space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li class="flex items-start">
|
||||
<span class="text-accent-500 dark:text-accent-400 mr-2">🏯</span>
|
||||
<span>历史文化街区 (步行10分钟)</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-accent-500 dark:text-accent-400 mr-2">🍜</span>
|
||||
<span>老字号餐馆一条街 (步行15分钟)</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-accent-500 dark:text-accent-400 mr-2">🏞️</span>
|
||||
<span>城市公园 (步行20分钟)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 相关景点 -->
|
||||
{relatedAttractions.length > 0 && (
|
||||
<div class="bg-primary-50 dark:bg-primary-900/20 rounded-lg p-6 shadow-sm border border-primary-100 dark:border-primary-800 mt-6">
|
||||
<h3 class="text-lg font-semibold text-primary-800 dark:text-primary-300 mb-4">相关景点</h3>
|
||||
<div class="space-y-4">
|
||||
{relatedAttractions.map((attraction: CollectionEntry<"attractions">) => (
|
||||
<a href={`/attractions/${attraction.slug}`} class="block group">
|
||||
<div class="flex items-start space-x-3 p-3 rounded-lg hover:bg-primary-100 dark:hover:bg-primary-900/30 transition-colors">
|
||||
<div class="w-16 h-16 rounded bg-primary-200 dark:bg-primary-800 flex items-center justify-center text-primary-700 dark:text-primary-300 flex-shrink-0">
|
||||
{attraction.data.title.substring(0, 1)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-gray-100 group-hover:text-primary-700 dark:group-hover:text-primary-300 transition-colors">
|
||||
{attraction.data.title}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mt-1">
|
||||
{attraction.data.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- 返回按钮 -->
|
||||
<ScrollReveal animation="slide-up" delay={300}>
|
||||
<a
|
||||
href="/attractions"
|
||||
class="block w-full py-3 text-center bg-color-primary-600 text-white rounded-md hover:bg-color-primary-700 transition-colors dark:bg-color-dark-primary-600 dark:hover:bg-color-dark-primary-500"
|
||||
>
|
||||
返回所有景点
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 相关景点 -->
|
||||
<div class="py-16 bg-gradient-to-b from-white to-primary-50 dark:from-dark-bg dark:to-primary-900/10">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<ScrollReveal animation="fade">
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-8 text-center">探索更多河北景点</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||
{relatedAttractions.map((attraction: CollectionEntry<"attractions">) => (
|
||||
<a href={`/attractions/${attraction.slug}`} class="group">
|
||||
<div class="bg-white dark:bg-dark-surface rounded-lg overflow-hidden shadow-md transition-all duration-300 hover:shadow-lg border border-primary-100 dark:border-primary-900/50 h-full flex flex-col">
|
||||
<div class="h-48 bg-primary-100 dark:bg-primary-900/30 relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary-500/20 to-accent-500/30 group-hover:opacity-70 opacity-0 transition-opacity duration-300"></div>
|
||||
<div class="flex items-center justify-center h-full text-primary-700 dark:text-primary-300">
|
||||
{attraction.data.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 flex-grow">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
|
||||
{attraction.data.title}
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-2">
|
||||
{attraction.data.description}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{attraction.data.tags.slice(0, 3).map((tag: string) => (
|
||||
<span class="text-xs px-2 py-1 bg-primary-50 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300 rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-t border-primary-100 dark:border-primary-800/50 flex justify-between items-center">
|
||||
<span class="text-sm text-primary-600 dark:text-primary-400 font-medium">查看详情</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary-500 dark:text-primary-400 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-12">
|
||||
<a href="/attractions" class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 dark:bg-primary-700 dark:hover:bg-primary-600 text-white font-medium rounded-lg shadow-md hover:shadow-lg transition-all duration-300">
|
||||
<span>浏览全部景点</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
661
web/graduation/src/pages/attractions/index.astro
Normal file
661
web/graduation/src/pages/attractions/index.astro
Normal file
@ -0,0 +1,661 @@
|
||||
---
|
||||
import MainLayout from "../../layouts/MainLayout.astro";
|
||||
import { getCollection, type CollectionEntry } from "astro:content";
|
||||
import ScrollReveal from "../../components/aceternity/ScrollReveal.astro";
|
||||
|
||||
// 获取景点内容集合
|
||||
const attractions = await getCollection("attractions");
|
||||
|
||||
// 按照日期排序
|
||||
const sortByDate = <T extends { data: { pubDate?: Date | string, updatedDate?: Date | string } }>(a: T, b: T): number => {
|
||||
return new Date(b.data.pubDate || b.data.updatedDate || 0).getTime() -
|
||||
new Date(a.data.pubDate || a.data.updatedDate || 0).getTime();
|
||||
};
|
||||
|
||||
// 按发布日期排序
|
||||
const sortedAttractions = [...attractions].sort(sortByDate);
|
||||
|
||||
// 提取所有标签
|
||||
const allTags: {name: string, count: number}[] = [];
|
||||
sortedAttractions.forEach((attraction: CollectionEntry<"attractions">) => {
|
||||
attraction.data.tags.forEach((tag: string) => {
|
||||
const existingTag = allTags.find(t => t.name === tag);
|
||||
if (existingTag) {
|
||||
existingTag.count++;
|
||||
} else {
|
||||
allTags.push({ name: tag, count: 1 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 按照标签出现次数排序
|
||||
allTags.sort((a, b) => b.count - a.count);
|
||||
|
||||
// 获取所有分类并计数 (使用可选链以防属性不存在)
|
||||
const categories: {name: string, count: number}[] = [];
|
||||
sortedAttractions.forEach((attraction: CollectionEntry<"attractions">) => {
|
||||
// 从city或title中提取分类信息(因为原数据模型似乎没有category字段)
|
||||
const category = attraction.data.city?.split(',')[0] || '其他景点';
|
||||
const existingCategory = categories.find(c => c.name === category);
|
||||
if (existingCategory) {
|
||||
existingCategory.count++;
|
||||
} else {
|
||||
categories.push({ name: category, count: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
// 按照分类出现次数排序
|
||||
categories.sort((a, b) => b.count - a.count);
|
||||
|
||||
// 获取所有城市并计数 (从city中提取城市信息)
|
||||
const cities: {name: string, count: number}[] = [];
|
||||
sortedAttractions.forEach((attraction: CollectionEntry<"attractions">) => {
|
||||
if (attraction.data.city) {
|
||||
const city = attraction.data.city.split(',').pop()?.trim() || '其他地区';
|
||||
const existingCity = cities.find(c => c.name === city);
|
||||
if (existingCity) {
|
||||
existingCity.count++;
|
||||
} else {
|
||||
cities.push({ name: city, count: 1 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 按照城市出现次数排序
|
||||
cities.sort((a, b) => b.count - a.count);
|
||||
|
||||
// 分页逻辑
|
||||
const itemsPerPage = 9;
|
||||
const page = 1; // 当前页码,实际应用中应该从查询参数获取
|
||||
const totalPages = Math.ceil(sortedAttractions.length / itemsPerPage);
|
||||
const currentPageAttractions = sortedAttractions.slice((page - 1) * itemsPerPage, page * itemsPerPage);
|
||||
|
||||
// 搜索和筛选逻辑(实际应用中应该根据查询参数来筛选)
|
||||
const searchQuery = '';
|
||||
const selectedCategory = '';
|
||||
const selectedCity = '';
|
||||
const selectedTags: string[] = [];
|
||||
const sortBy: 'date' | 'name' = 'date';
|
||||
|
||||
// 辅助函数,用于获取景点的分类(从city提取或默认值)
|
||||
const getCategory = (attraction: CollectionEntry<"attractions">) => {
|
||||
return attraction.data.city?.split(',')[0] || '其他景点';
|
||||
};
|
||||
|
||||
// 辅助函数,用于获取景点的城市(从city提取或默认值)
|
||||
const getCity = (attraction: CollectionEntry<"attractions">) => {
|
||||
return attraction.data.city?.split(',').pop()?.trim() || '其他地区';
|
||||
};
|
||||
---
|
||||
|
||||
<MainLayout title="景点 - 河北游礼">
|
||||
<!-- 摄影探索风格头部 - 更鲜艳的色彩方案 -->
|
||||
<div class="relative overflow-hidden">
|
||||
<!-- 背景效果 - 景观照片效果和彩色渐变叠加 -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary-400/50 via-primary-500/30 to-accent-400/40 dark:from-primary-900/60 dark:via-primary-900/50 dark:to-accent-900/60 opacity-70 dark:opacity-60"></div>
|
||||
<div class="absolute inset-0 bg-[url('/images/texture-light.jpg')] dark:bg-[url('/images/texture-dark.jpg')] mix-blend-overlay opacity-30 bg-cover bg-center"></div>
|
||||
|
||||
<div class="container mx-auto px-4 py-20 relative z-10">
|
||||
<div class="max-w-5xl mx-auto text-center">
|
||||
<!-- 彩色相机取景框效果 -->
|
||||
<div class="inline-block relative">
|
||||
<div class="absolute -inset-1 border-2 border-primary-400/70 dark:border-primary-500/50 rounded-sm"></div>
|
||||
<div class="absolute -inset-3 border border-accent-400/30 dark:border-accent-500/30 rounded-sm"></div>
|
||||
<div class="absolute -inset-5 border border-secondary-400/20 dark:border-secondary-500/20 rounded-sm"></div>
|
||||
|
||||
<h1 class="text-6xl sm:text-7xl font-serif font-light tracking-tight text-slate-800 dark:text-white leading-none mb-6">
|
||||
<span class="inline-block transform -rotate-1 text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-600 dark:from-primary-400 dark:to-primary-400">河北</span>
|
||||
<span class="inline-block mx-2 text-5xl opacity-70 text-amber-500 dark:text-amber-400">·</span>
|
||||
<span class="inline-block transform rotate-1 text-transparent bg-clip-text bg-gradient-to-r from-accent-600 to-accent-600 dark:from-accent-400 dark:to-accent-400">景观</span>
|
||||
</h1>
|
||||
|
||||
<!-- 彩色相机参数显示 -->
|
||||
<div class="flex justify-center items-center mt-4 mb-6 text-xs tracking-widest font-mono bg-white/30 dark:bg-black/30 backdrop-blur-sm px-4 py-1 rounded-full text-slate-700 dark:text-slate-300">
|
||||
<span class="text-primary-600 dark:text-primary-400">ISO 100</span>
|
||||
<span class="mx-3 text-slate-500">|</span>
|
||||
<span class="text-secondary-600 dark:text-secondary-400">f/2.8</span>
|
||||
<span class="mx-3 text-slate-500">|</span>
|
||||
<span class="text-primary-600 dark:text-primary-400">1/250s</span>
|
||||
<span class="mx-3 text-slate-500">|</span>
|
||||
<span class="text-accent-600 dark:text-accent-400">24-70mm</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-lg text-slate-700 dark:text-slate-200 max-w-2xl mx-auto font-light leading-relaxed mt-4 drop-shadow-sm">
|
||||
通过镜头捕捉河北的自然与人文之美,每一处景点都是一幅值得细细品味的画作
|
||||
</p>
|
||||
|
||||
<!-- 彩色取景器元素 -->
|
||||
<div class="mt-12 mb-2 flex justify-center">
|
||||
<div class="px-5 py-2 bg-gradient-to-r from-primary-500/80 to-accent-500/80 dark:from-primary-700/80 dark:to-accent-700/80 text-white rounded-full text-sm tracking-wider font-mono inline-flex items-center space-x-2 shadow-lg shadow-primary-500/20 dark:shadow-primary-700/20">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>EXPLORE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 相册风格景点概览 - 彩色版本 -->
|
||||
<div class="bg-gradient-to-b from-gray-50 to-white dark:from-gray-900 dark:to-black py-12">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-6xl mx-auto">
|
||||
{sortedAttractions.slice(0, 3).map((attraction, index) => (
|
||||
<div class={`relative ${index === 0 ? 'md:col-span-3' : ''}`}>
|
||||
<a href={`/attractions/${attraction.slug}`} class="block group">
|
||||
<div class={`aspect-[${index === 0 ? '21/9' : '3/4'}] relative overflow-hidden transform ${index % 2 === 0 ? 'rotate-1' : '-rotate-1'} shadow-lg dark:shadow-none`}>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary-100 via-primary-100 to-secondary-100 dark:from-primary-900/30 dark:via-primary-900/30 dark:to-secondary-900/30 flex items-center justify-center">
|
||||
<span class="text-primary-400 dark:text-primary-500">{attraction.data.title}</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary-500/20 to-secondary-500/30 dark:from-primary-500/40 dark:to-secondary-500/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
|
||||
<!-- 类似照片信息的标题 -->
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-primary-900/80 via-primary-900/60 to-transparent">
|
||||
<div class="transform group-hover:translate-y-0 translate-y-2 transition-transform duration-300">
|
||||
<h3 class="text-xl text-white font-light">{attraction.data.title}</h3>
|
||||
<p class="text-sm text-primary-100 flex items-center mt-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1 opacity-70 text-accent-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{getCategory(attraction)} · {getCity(attraction)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 主内容区 - 摄影展览风格 -->
|
||||
<div class="bg-gradient-to-b from-white to-primary-50 dark:from-black dark:to-primary-950/30 text-gray-900 dark:text-white py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
<!-- 左侧滤镜区域 - 彩色摄影参数风格 -->
|
||||
<div class="lg:col-span-3">
|
||||
<div class="sticky top-20 space-y-8">
|
||||
<div class="bg-white/80 dark:bg-primary-950/40 backdrop-blur-sm p-6 border border-primary-100 dark:border-primary-800/50 rounded-lg shadow-xl shadow-primary-100/50 dark:shadow-primary-900/20">
|
||||
<!-- 彩色搜索框 -->
|
||||
<div class="flex items-center mb-10 max-w-xl mx-auto">
|
||||
<div class="relative flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入关键词..."
|
||||
class="w-full px-4 py-2 pl-10 border border-primary-200 dark:border-primary-800 bg-white dark:bg-gray-800 rounded-lg text-gray-700 dark:text-gray-200 focus:outline-none focus:border-primary-500 dark:focus:border-primary-400 focus:ring-1 focus:ring-primary-500 dark:focus:ring-primary-400"
|
||||
/>
|
||||
<div class="absolute left-3 top-2.5 text-primary-400 dark:text-primary-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ml-2 px-4 py-2 bg-primary-500 dark:bg-primary-600 text-white rounded-lg hover:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:focus:ring-primary-400 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition-colors">
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 景点类型筛选 - 彩色曝光设置 -->
|
||||
<div class="mt-8">
|
||||
<!-- 分类标签 -->
|
||||
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-200 mb-3">按分类浏览</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{categories.slice(0, 8).map(category => (
|
||||
<div class="px-3 py-1 bg-primary-100 dark:bg-primary-900/30 text-primary-800 dark:text-primary-200 text-sm rounded-full cursor-pointer hover:bg-primary-200 dark:hover:bg-primary-800/50 transition-colors">
|
||||
{category.name}
|
||||
<span class="ml-1 text-xs text-primary-500 dark:text-primary-400">({category.count})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 城市筛选 - 彩色光圈设置 -->
|
||||
<div class="mt-8">
|
||||
<!-- 城市标签 -->
|
||||
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-200 mb-3">按城市浏览</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{cities.slice(0, 6).map(city => (
|
||||
<div class="px-3 py-1 bg-secondary-100 dark:bg-secondary-900/30 text-secondary-800 dark:text-secondary-200 text-sm rounded-full cursor-pointer hover:bg-secondary-200 dark:hover:bg-secondary-800/50 transition-colors">
|
||||
{city.name}
|
||||
<span class="ml-1 text-xs text-secondary-500 dark:text-secondary-400">({city.count})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签筛选 - 彩色胶片风格 -->
|
||||
<div class="mt-8">
|
||||
<!-- 标签云 -->
|
||||
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-200 mb-3">特色标签</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{allTags.slice(0, 12).map(tag => (
|
||||
<div class="px-3 py-1 bg-accent-100 dark:bg-accent-900/30 text-accent-800 dark:text-accent-200 text-sm rounded-full cursor-pointer hover:bg-accent-200 dark:hover:bg-accent-800/50 transition-colors">
|
||||
#{tag.name}
|
||||
<span class="ml-1 text-xs text-accent-500 dark:text-accent-400">({tag.count})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义筛选器提示 - 彩色相机操作引导 -->
|
||||
<div class="p-5 border border-primary-100 dark:border-primary-900/50 bg-gradient-to-br from-white to-primary-50 dark:from-primary-950/20 dark:to-primary-950/20 mt-6 rounded-lg">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="text-primary-500 dark:text-primary-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 dark:text-primary-200/80 leading-relaxed">
|
||||
筛选条件就像相机参数,调整它们以找到最适合你的河北景观视角。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧景点展示区 - 彩色摄影展览风格 -->
|
||||
<div class="lg:col-span-9">
|
||||
<!-- 当前筛选状态 - 彩色胶片信息风格 -->
|
||||
{(searchQuery || selectedCategory || selectedCity || selectedTags.length > 0) && (
|
||||
<div class="mb-8 bg-gradient-to-r from-primary-50 to-secondary-50 dark:from-primary-950/30 dark:to-secondary-950/30 p-4 border-l-4 border-primary-300 dark:border-primary-700 rounded-r-lg">
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-primary-700 dark:text-primary-300">
|
||||
<div class="font-mono text-xs tracking-wider text-primary-500 dark:text-primary-400 bg-primary-100 dark:bg-primary-900/50 px-2 py-1 rounded">FILTER</div>
|
||||
|
||||
{/* 这里显示筛选条件 */}
|
||||
|
||||
<button class="ml-auto text-primary-500 hover:text-primary-700 dark:hover:text-primary-300 text-sm flex items-center space-x-1 bg-white/80 dark:bg-black/30 px-3 py-1 rounded-full">
|
||||
<span>重置</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- 景点展示 - 彩色摄影展览风格 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{currentPageAttractions.map((attraction, index) => {
|
||||
// 为每个卡片生成不同的颜色方案
|
||||
const colorSchemes = [
|
||||
{ from: 'from-primary-900/80', via: 'via-primary-900/60', to: 'to-transparent', border: 'border-primary-200 dark:border-primary-800', hover: 'group-hover:border-primary-300 dark:group-hover:border-primary-700', badges: 'bg-primary-900/70 text-primary-100' },
|
||||
{ from: 'from-secondary-900/80', via: 'via-primary-900/60', to: 'to-transparent', border: 'border-secondary-200 dark:border-secondary-800', hover: 'group-hover:border-secondary-300 dark:group-hover:border-secondary-700', badges: 'bg-secondary-900/70 text-secondary-100' },
|
||||
{ from: 'from-accent-900/80', via: 'via-accent-900/60', to: 'to-transparent', border: 'border-accent-200 dark:border-accent-800', hover: 'group-hover:border-accent-300 dark:group-hover:border-accent-700', badges: 'bg-accent-900/70 text-accent-100' }
|
||||
];
|
||||
const colorScheme = colorSchemes[index % colorSchemes.length];
|
||||
|
||||
return (
|
||||
<ScrollReveal animation="fade" delay={index * 100}>
|
||||
<a href={`/attractions/${attraction.slug}`} class="group">
|
||||
<div class={`relative bg-white dark:bg-gray-900 overflow-hidden border ${colorScheme.border} ${colorScheme.hover} transition-all duration-300 h-full shadow-lg hover:shadow-xl dark:shadow-none rounded-lg`}>
|
||||
<!-- 景点图片 - 彩色摄影展览风格 -->
|
||||
<div class="aspect-[4/5] relative overflow-hidden rounded-t-lg">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary-100 via-primary-100 to-secondary-100 dark:from-primary-900/30 dark:via-primary-900/30 dark:to-secondary-900/30 flex items-center justify-center">
|
||||
<span class="text-primary-400 dark:text-primary-500">{attraction.data.title}</span>
|
||||
</div>
|
||||
|
||||
<!-- 景点信息悬浮层 - 彩色相片信息卡片 -->
|
||||
<div class={`absolute inset-0 flex flex-col justify-end p-4 bg-gradient-to-t ${colorScheme.from} ${colorScheme.via} ${colorScheme.to} opacity-90 group-hover:opacity-100 transition-opacity`}>
|
||||
<div class="transform translate-y-0 group-hover:translate-y-0 transition-transform duration-300">
|
||||
<div class="space-y-2">
|
||||
<!-- 类别标签 -->
|
||||
<div class="flex items-center space-x-2 text-xs text-white">
|
||||
<div class={`px-2 py-1 ${colorScheme.badges} backdrop-blur-sm inline-flex items-center space-x-1 rounded-full`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<span>{getCategory(attraction)}</span>
|
||||
</div>
|
||||
|
||||
<div class={`px-2 py-1 ${colorScheme.badges} backdrop-blur-sm inline-flex items-center space-x-1 rounded-full`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>{getCity(attraction)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 景点名称 -->
|
||||
<h3 class="text-white text-lg font-light leading-tight drop-shadow-md">{attraction.data.title}</h3>
|
||||
|
||||
<!-- 景点描述 - 悬停时显示 -->
|
||||
<p class="text-primary-100 dark:text-primary-100 text-sm line-clamp-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-100">
|
||||
{attraction.data.description}
|
||||
</p>
|
||||
|
||||
<!-- 标签 - 彩色快速信息 -->
|
||||
<div class="flex flex-wrap gap-1 pt-1 opacity-80 group-hover:opacity-100 transition-opacity">
|
||||
{attraction.data.tags.slice(0, 3).map((tag: string, i: number) => {
|
||||
const tagColors = ['bg-primary-500/40', 'bg-secondary-500/40', 'bg-accent-500/40'];
|
||||
return (
|
||||
<span class={`px-1.5 py-0.5 text-[10px] ${tagColors[i % tagColors.length]} text-white backdrop-blur-sm rounded-sm`}>
|
||||
#{tag}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{attraction.data.tags.length > 3 && (
|
||||
<span class="px-1.5 py-0.5 text-[10px] bg-white/20 text-white backdrop-blur-sm rounded-sm">
|
||||
+{attraction.data.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部探索提示 - 彩色胶片边缘信息 -->
|
||||
<div class="p-3 border-t border-gray-100 dark:border-gray-800 bg-gradient-to-r from-white to-primary-50/50 dark:from-gray-900 dark:to-primary-950/30 flex justify-between items-center rounded-b-lg">
|
||||
<span class="text-xs text-primary-500 dark:text-primary-400 font-mono tracking-wider">EXPLORE</span>
|
||||
<div class="flex items-center text-xs text-gray-600 dark:text-gray-400 group-hover:text-primary-700 dark:group-hover:text-primary-300 transition-colors">
|
||||
<span class="mr-1">查看详情</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 transform group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<!-- 分页控件 - 彩色胶片编号风格 -->
|
||||
{totalPages > 1 && (
|
||||
<div class="mt-16 flex justify-center">
|
||||
<div class="inline-flex bg-white dark:bg-primary-950/30 border border-primary-200 dark:border-primary-800 shadow-lg dark:shadow-primary-900/20 rounded-xl overflow-hidden">
|
||||
<a
|
||||
href={page > 1 ? `/attractions?page=${page - 1}` : '#'}
|
||||
class={`px-4 py-2 border-r border-primary-200 dark:border-primary-800 flex items-center space-x-1 ${
|
||||
page > 1
|
||||
? 'text-primary-600 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 hover:bg-primary-50 dark:hover:bg-primary-900/30'
|
||||
: 'text-primary-300 dark:text-primary-700 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>上一页</span>
|
||||
</a>
|
||||
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<a
|
||||
href={`/attractions?page=${pageNum}`}
|
||||
class={`w-10 flex items-center justify-center border-r border-primary-200 dark:border-primary-800 ${
|
||||
pageNum === page
|
||||
? 'bg-gradient-to-br from-primary-500 to-secondary-500 dark:from-primary-600 dark:to-secondary-600 text-white font-medium'
|
||||
: 'text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-200 hover:bg-primary-50 dark:hover:bg-primary-900/30'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
|
||||
{totalPages > 5 && (
|
||||
<span class="w-10 flex items-center justify-center border-r border-primary-200 dark:border-primary-800 text-primary-400 dark:text-primary-600">...</span>
|
||||
)}
|
||||
|
||||
{totalPages > 5 && (
|
||||
<a
|
||||
href={`/attractions?page=${totalPages}`}
|
||||
class="w-10 flex items-center justify-center border-r border-primary-200 dark:border-primary-800 text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-200 hover:bg-primary-50 dark:hover:bg-primary-900/30"
|
||||
>
|
||||
{totalPages}
|
||||
</a>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={page < totalPages ? `/attractions?page=${page + 1}` : '#'}
|
||||
class={`px-4 py-2 flex items-center space-x-1 ${
|
||||
page < totalPages
|
||||
? 'text-primary-600 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 hover:bg-primary-50 dark:hover:bg-primary-900/30'
|
||||
: 'text-primary-300 dark:text-primary-700 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<span>下一页</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- 页脚引言 - 彩色摄影师语录风格 -->
|
||||
<div class="mt-16 text-center">
|
||||
<div class="inline-block max-w-xl bg-gradient-to-br from-white via-primary-50 to-secondary-50 dark:from-primary-950/20 dark:via-secondary-950/20 dark:to-black p-5 transform -rotate-1 border border-primary-200 dark:border-primary-800 shadow-lg shadow-primary-200/30 dark:shadow-none rounded-lg">
|
||||
<blockquote class="text-lg text-primary-700 dark:text-primary-300 italic">
|
||||
"最美的景观并非仅存于远方,而在于观察者如何用心去发现"
|
||||
</blockquote>
|
||||
<div class="mt-3 text-xs text-accent-500 dark:text-accent-400 tracking-wider font-mono bg-white/80 dark:bg-black/50 py-1 rounded-full">
|
||||
— 河北风光摄影集 —
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
|
||||
<style>
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* 彩色动画效果 */
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: gradientShift 8s ease infinite;
|
||||
}
|
||||
|
||||
/* 鼠标悬停卡片发光效果 */
|
||||
.card-glow {
|
||||
transition: box-shadow 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.card-glow:hover {
|
||||
box-shadow: 0 0 15px var(--color-primary-400, rgba(251, 191, 36, 0.4));
|
||||
}
|
||||
|
||||
.dark .card-glow:hover {
|
||||
box-shadow: 0 0 15px var(--color-primary-500, rgba(245, 158, 11, 0.4));
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dark-mode-filter {
|
||||
filter: brightness(0.8) contrast(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* 标签悬停效果 */
|
||||
.tag-hover {
|
||||
transform: translateY(0);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.tag-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark .tag-hover:hover {
|
||||
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 客户端交互逻辑
|
||||
const searchInput = document.querySelector('input[placeholder="输入关键词..."]') as HTMLInputElement;
|
||||
const tagElements = document.querySelectorAll('.flex-wrap.gap-2 div');
|
||||
const tabLinks = document.querySelectorAll('.space-x-6 a');
|
||||
const attractionCards = document.querySelectorAll('.grid.grid-cols-1.md\\:grid-cols-2.lg\\:grid-cols-3.gap-6 > div > a > div');
|
||||
|
||||
// 为所有景点卡片添加悬停发光效果
|
||||
if (attractionCards) {
|
||||
attractionCards.forEach(card => {
|
||||
card.classList.add('card-glow');
|
||||
});
|
||||
}
|
||||
|
||||
// 为标签添加悬停效果
|
||||
if (tagElements) {
|
||||
tagElements.forEach(tag => {
|
||||
tag.classList.add('tag-hover');
|
||||
});
|
||||
}
|
||||
|
||||
// 添加渐变动画效果到页面元素
|
||||
const gradientElements = document.querySelectorAll('.bg-gradient-to-br, .bg-gradient-to-r');
|
||||
gradientElements.forEach(element => {
|
||||
element.classList.add('animate-gradient');
|
||||
});
|
||||
|
||||
if (searchInput) {
|
||||
// 为搜索框添加聚焦效果
|
||||
searchInput.addEventListener('focus', () => {
|
||||
if (searchInput.parentElement) {
|
||||
searchInput.parentElement.classList.add('ring-2', 'ring-primary-300', 'dark:ring-primary-700');
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener('blur', () => {
|
||||
if (searchInput.parentElement) {
|
||||
searchInput.parentElement.classList.remove('ring-2', 'ring-primary-300', 'dark:ring-primary-700');
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if ((e as KeyboardEvent).key === 'Enter') {
|
||||
// 执行搜索,添加视觉反馈
|
||||
const query = searchInput.value;
|
||||
if (query) {
|
||||
searchInput.classList.add('bg-primary-50', 'dark:bg-primary-900/30');
|
||||
setTimeout(() => {
|
||||
window.location.href = `/attractions?search=${encodeURIComponent(query)}`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 添加标签点击事件,带有视觉反馈
|
||||
tagElements.forEach((tag) => {
|
||||
tag.addEventListener('click', () => {
|
||||
const tagName = tag.textContent?.trim() || '';
|
||||
if (tagName) {
|
||||
// 添加点击视觉反馈
|
||||
tag.classList.add('scale-95', 'opacity-70');
|
||||
setTimeout(() => {
|
||||
window.location.href = `/attractions?tag=${encodeURIComponent(tagName)}`;
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 添加选项卡导航功能,改进的视觉效果
|
||||
tabLinks.forEach((tabLink) => {
|
||||
tabLink.addEventListener('click', (e) => {
|
||||
e.preventDefault(); // 防止页面跳转
|
||||
tabLinks.forEach((tab) => {
|
||||
// 移除所有活动状态
|
||||
tab.classList.remove('text-primary-600', 'dark:text-primary-400', 'border-b-2', 'border-primary-500', 'dark:border-primary-400', 'text-gray-900', 'dark:text-white');
|
||||
tab.classList.add('text-gray-600', 'dark:text-gray-400');
|
||||
});
|
||||
|
||||
// 设置当前活动状态,添加流畅的过渡
|
||||
tabLink.classList.remove('text-gray-600', 'dark:text-gray-400');
|
||||
tabLink.classList.add('text-primary-600', 'dark:text-primary-400', 'border-b-2', 'border-primary-500', 'dark:border-primary-400');
|
||||
|
||||
// 添加过渡动画
|
||||
(tabLink as HTMLElement).style.transition = 'all 0.3s ease';
|
||||
|
||||
// 获取目标ID并模拟内容切换(实际应用中可能需要根据ID加载不同内容)
|
||||
const targetId = tabLink.getAttribute('href')?.substring(1);
|
||||
console.log(`Tab selected: ${targetId}`);
|
||||
|
||||
// 模拟内容切换的视觉反馈
|
||||
const contentArea = document.querySelector('.lg\\:col-span-9');
|
||||
if (contentArea) {
|
||||
contentArea.classList.add('opacity-90');
|
||||
setTimeout(() => {
|
||||
contentArea.classList.remove('opacity-90');
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 监听系统暗色模式变化
|
||||
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleDarkModeChange = (e: MediaQueryListEvent | MediaQueryList) => {
|
||||
const isDarkMode = e.matches;
|
||||
document.documentElement.classList.toggle('dark', isDarkMode);
|
||||
|
||||
// 添加或移除图片的过滤器以适应暗色模式
|
||||
const images = document.querySelectorAll('img');
|
||||
images.forEach(img => {
|
||||
img.classList.toggle('dark-mode-filter', isDarkMode);
|
||||
});
|
||||
|
||||
// 调整彩色元素的亮度
|
||||
const colorElements = document.querySelectorAll('[class*="text-"]:not([class*="text-white"]):not([class*="text-gray"]):not([class*="text-black"])');
|
||||
colorElements.forEach(element => {
|
||||
element.classList.toggle('brightness-90', isDarkMode);
|
||||
});
|
||||
};
|
||||
|
||||
// 初始化时检查系统主题
|
||||
handleDarkModeChange(darkModeMediaQuery);
|
||||
|
||||
// 监听系统主题变化
|
||||
darkModeMediaQuery.addEventListener('change', handleDarkModeChange);
|
||||
|
||||
// 为主视觉区域添加微妙的滚动效果
|
||||
const addScrollEffects = () => {
|
||||
const sections = document.querySelectorAll('.container');
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('opacity-100', 'translate-y-0');
|
||||
entry.target.classList.remove('opacity-90', 'translate-y-4');
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
sections.forEach(section => {
|
||||
section.classList.add('transition-all', 'duration-700');
|
||||
section.classList.add('opacity-90', 'translate-y-4');
|
||||
observer.observe(section);
|
||||
});
|
||||
};
|
||||
|
||||
// 延迟执行滚动效果,让页面先加载完成
|
||||
setTimeout(addScrollEffects, 100);
|
||||
});
|
||||
</script>
|
177
web/graduation/src/pages/cuisine/[slug].astro
Normal file
177
web/graduation/src/pages/cuisine/[slug].astro
Normal file
@ -0,0 +1,177 @@
|
||||
---
|
||||
import { getCollection, getEntry, type CollectionEntry } from "astro:content";
|
||||
import MainLayout from "../../layouts/MainLayout.astro";
|
||||
import ScrollReveal from "../../components/aceternity/ScrollReveal.astro";
|
||||
|
||||
// 定义Props类型
|
||||
export interface Props {
|
||||
entry: CollectionEntry<"cuisine">;
|
||||
}
|
||||
|
||||
// 生成静态路径
|
||||
export async function getStaticPaths() {
|
||||
const cuisines = await getCollection("cuisine");
|
||||
return cuisines.map((entry) => ({
|
||||
params: { slug: entry.slug },
|
||||
props: { entry },
|
||||
}));
|
||||
}
|
||||
|
||||
// 获取当前美食数据
|
||||
const { entry } = Astro.props;
|
||||
const { Content } = await entry.render();
|
||||
|
||||
// 获取相关美食
|
||||
const allCuisines = await getCollection("cuisine");
|
||||
const relatedCuisines = allCuisines
|
||||
.filter(
|
||||
(item) =>
|
||||
item.slug !== entry.slug &&
|
||||
(item.data.category === entry.data.category ||
|
||||
item.data.tags.some((tag) => entry.data.tags.includes(tag)))
|
||||
)
|
||||
.slice(0, 3);
|
||||
---
|
||||
|
||||
<MainLayout title={`${entry.data.title} - 河北游礼`}>
|
||||
<!-- 英雄区域 -->
|
||||
<div class="relative h-[50vh] min-h-[400px]">
|
||||
<!-- 背景图片 -->
|
||||
<div class="absolute inset-0">
|
||||
<img
|
||||
src={entry.data.image}
|
||||
alt={entry.data.title}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/70 to-black/30"></div>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="absolute inset-0 flex items-end">
|
||||
<div class="container mx-auto px-4 pb-12">
|
||||
<ScrollReveal animation="slide-up">
|
||||
<div class="max-w-4xl">
|
||||
<!-- 面包屑导航 -->
|
||||
<div class="flex items-center gap-2 text-white/80 mb-4">
|
||||
<a href="/" class="hover:text-white transition-colors">首页</a>
|
||||
<span>/</span>
|
||||
<a href="/cuisine" class="hover:text-white transition-colors">美食</a>
|
||||
<span>/</span>
|
||||
<span class="text-white">{entry.data.title}</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">{entry.data.title}</h1>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
{entry.data.tags.map((tag) => (
|
||||
<span class="px-3 py-1 bg-white/20 backdrop-blur-sm text-white text-sm rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p class="text-xl text-white/90 max-w-3xl">{entry.data.description}</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="py-12 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12">
|
||||
<!-- 左侧内容 -->
|
||||
<div class="lg:col-span-8">
|
||||
<ScrollReveal animation="fade">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8">
|
||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||
<Content />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
<!-- 右侧边栏 -->
|
||||
<div class="lg:col-span-4 space-y-8">
|
||||
<!-- 食材信息卡片 -->
|
||||
{entry.data.ingredients && entry.data.ingredients.length > 0 && (
|
||||
<ScrollReveal animation="slide-up">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6">
|
||||
<h3 class="text-xl font-bold mb-6 flex items-center">
|
||||
<span class="text-2xl mr-2">🥘</span>
|
||||
主要食材
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{entry.data.ingredients.map((ingredient) => (
|
||||
<div class="flex items-center bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
|
||||
<span class="text-amber-600 dark:text-amber-400 mr-2">•</span>
|
||||
<span>{ingredient}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
|
||||
<!-- 烹饪信息卡片 -->
|
||||
<ScrollReveal animation="slide-up" delay={100}>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6">
|
||||
<h3 class="text-xl font-bold mb-6 flex items-center">
|
||||
<span class="text-2xl mr-2">⏱️</span>
|
||||
烹饪信息
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<span class="text-gray-600 dark:text-gray-300">烹饪时间</span>
|
||||
<span class="font-medium">{entry.data.cookTime || '45分钟'}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<span class="text-gray-600 dark:text-gray-300">难度等级</span>
|
||||
<span class="font-medium">{entry.data.difficulty || '中等'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<!-- 相关美食推荐 -->
|
||||
{relatedCuisines.length > 0 && (
|
||||
<ScrollReveal animation="slide-up" delay={200}>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6">
|
||||
<h3 class="text-xl font-bold mb-6 flex items-center">
|
||||
<span class="text-2xl mr-2">🍽️</span>
|
||||
相关美食
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
{relatedCuisines.map((cuisine) => (
|
||||
<a
|
||||
href={`/cuisine/${cuisine.slug}`}
|
||||
class="block group"
|
||||
>
|
||||
<div class="flex items-center gap-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg transition-colors hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<img
|
||||
src={cuisine.data.image}
|
||||
alt={cuisine.data.title}
|
||||
class="w-16 h-16 rounded-lg object-cover"
|
||||
/>
|
||||
<div>
|
||||
<h4 class="font-medium group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors">
|
||||
{cuisine.data.title}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{cuisine.data.category}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
893
web/graduation/src/pages/cuisine/index.astro
Normal file
893
web/graduation/src/pages/cuisine/index.astro
Normal file
@ -0,0 +1,893 @@
|
||||
---
|
||||
import MainLayout from "../../layouts/MainLayout.astro";
|
||||
import { getCollection, type CollectionEntry } from "astro:content";
|
||||
import ScrollReveal from "../../components/aceternity/ScrollReveal.astro";
|
||||
|
||||
// 获取美食内容集合
|
||||
const cuisines = await getCollection("cuisine");
|
||||
|
||||
// 按照日期排序
|
||||
const sortByDate = <T extends { data: { pubDate?: Date | string, updatedDate?: Date | string } }>(a: T, b: T): number => {
|
||||
return new Date(b.data.pubDate || b.data.updatedDate || 0).getTime() -
|
||||
new Date(a.data.pubDate || a.data.updatedDate || 0).getTime();
|
||||
};
|
||||
|
||||
// 按发布日期排序
|
||||
const sortedCuisines = [...cuisines].sort(sortByDate);
|
||||
|
||||
// 提取所有标签
|
||||
const allTags: {name: string, count: number}[] = [];
|
||||
sortedCuisines.forEach((cuisine: CollectionEntry<"cuisine">) => {
|
||||
cuisine.data.tags.forEach((tag: string) => {
|
||||
const existingTag = allTags.find(t => t.name === tag);
|
||||
if (existingTag) {
|
||||
existingTag.count++;
|
||||
} else {
|
||||
allTags.push({ name: tag, count: 1 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 按照标签出现次数排序
|
||||
allTags.sort((a, b) => b.count - a.count);
|
||||
|
||||
// 获取所有分类并计数
|
||||
const categories: {name: string, count: number}[] = [];
|
||||
sortedCuisines.forEach((cuisine: CollectionEntry<"cuisine">) => {
|
||||
if (cuisine.data.category) {
|
||||
const existingCategory = categories.find(c => c.name === cuisine.data.category);
|
||||
if (existingCategory) {
|
||||
existingCategory.count++;
|
||||
} else {
|
||||
categories.push({ name: cuisine.data.category, count: 1 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 按照分类出现次数排序
|
||||
categories.sort((a, b) => b.count - a.count);
|
||||
|
||||
// 获取所有产地并计数
|
||||
const citys: {name: string, count: number}[] = [];
|
||||
sortedCuisines.forEach((cuisine: CollectionEntry<"cuisine">) => {
|
||||
if (cuisine.data.city) {
|
||||
const existingcity = citys.find(o => o.name === cuisine.data.city);
|
||||
if (existingcity) {
|
||||
existingcity.count++;
|
||||
} else {
|
||||
citys.push({ name: cuisine.data.city, count: 1 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 按照产地出现次数排序
|
||||
citys.sort((a, b) => b.count - a.count);
|
||||
|
||||
// 获取所有口味并计数
|
||||
const tastes: {name: string, count: number}[] = [];
|
||||
sortedCuisines.forEach((cuisine: CollectionEntry<"cuisine">) => {
|
||||
if (cuisine.data.taste) {
|
||||
const existingTaste = tastes.find(t => t.name === cuisine.data.taste);
|
||||
if (existingTaste) {
|
||||
existingTaste.count++;
|
||||
} else {
|
||||
tastes.push({ name: cuisine.data.taste, count: 1 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 按照口味出现次数排序
|
||||
tastes.sort((a, b) => b.count - a.count);
|
||||
|
||||
// 分页逻辑
|
||||
const itemsPerPage = 9;
|
||||
// 从URL参数获取当前页码
|
||||
const url = new URL(Astro.request.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const totalPages = Math.ceil(sortedCuisines.length / itemsPerPage);
|
||||
const currentPageCuisines = sortedCuisines.slice((page - 1) * itemsPerPage, page * itemsPerPage);
|
||||
|
||||
// 搜索和筛选逻辑(实际应用中应该根据查询参数来筛选)
|
||||
const searchQuery = '';
|
||||
const selectedCategory = '';
|
||||
const selectedcity = '';
|
||||
const selectedTaste = '';
|
||||
const selectedTags: string[] = [];
|
||||
const sortBy: 'date' | 'name' = 'date';
|
||||
|
||||
// 分页参数
|
||||
const queryParams = ''; // 在实际应用中,这里应该是基于筛选条件构建的查询字符串
|
||||
---
|
||||
|
||||
<MainLayout title="河北美食食谱 - 河北游礼">
|
||||
<!-- 食谱风格头部 -->
|
||||
<div class="relative overflow-hidden bg-recipe-paper dark:bg-recipe-paper-dark min-h-[400px] flex items-center">
|
||||
<!-- 纸张纹理和装饰 -->
|
||||
<div class="absolute inset-0 bg-[url('/images/recipe-paper-texture.png')] opacity-10"></div>
|
||||
<div class="absolute top-0 left-0 w-32 h-32 bg-[url('/images/spoon-fork.png')] bg-no-repeat bg-contain opacity-15 -rotate-12"></div>
|
||||
<div class="absolute bottom-0 right-0 w-32 h-32 bg-[url('/images/chef-hat.png')] bg-no-repeat bg-contain opacity-15 rotate-12"></div>
|
||||
|
||||
<!-- 食谱标题区域 -->
|
||||
<div class="container mx-auto px-4 relative z-10">
|
||||
<div class="max-w-4xl mx-auto text-center recipe-card">
|
||||
<!-- 食谱卡片装饰 -->
|
||||
<div class="recipe-card-pins absolute -top-3 left-1/2 transform -translate-x-1/2">
|
||||
<div class="w-6 h-6 bg-red-500 dark:bg-red-700 rounded-full absolute -left-16 shadow-md"></div>
|
||||
<div class="w-6 h-6 bg-amber-500 dark:bg-amber-700 rounded-full absolute left-16 shadow-md"></div>
|
||||
</div>
|
||||
|
||||
<!-- 手写风格标题 -->
|
||||
<div class="handwritten-title py-6">
|
||||
<h1 class="text-6xl md:text-7xl font-recipe text-brown-900 dark:text-brown-100 mb-2 recipe-title">河北美食食谱</h1>
|
||||
<div class="w-3/4 mx-auto h-px bg-brown-300 dark:bg-brown-700 my-6"></div>
|
||||
<p class="text-xl text-brown-700 dark:text-brown-300 font-recipe-body mb-6 leading-relaxed">
|
||||
收集自河北各地的传统美食配方,
|
||||
<br>家传秘方与地方特色,尽在此食谱
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 食谱元数据 -->
|
||||
<div class="recipe-metadata flex flex-wrap justify-center gap-8 text-sm text-brown-600 dark:text-brown-400 mb-8">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
<span>{cuisines.length} 道经典菜肴</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>{citys.length} 个地方特色</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
<span>收集于 2023 年</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 食谱快速搜索 -->
|
||||
<div class="recipe-search relative max-w-lg mx-auto mb-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索菜名、食材或地区..."
|
||||
class="w-full px-4 py-3 border-2 border-secondary-300 dark:border-secondary-700 bg-recipe-paper/80 dark:bg-recipe-paper-dark/90 rounded-md font-recipe-body text-secondary-900 dark:text-secondary-200 placeholder-secondary-500 dark:placeholder-secondary-400 focus:outline-none focus:border-primary-500 dark:focus:border-primary-400"
|
||||
/>
|
||||
<button class="absolute right-3 top-3 text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-300">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 食谱标签 -->
|
||||
<div class="recipe-tags flex flex-wrap justify-center gap-2">
|
||||
<a href="#" class="px-3 py-1 bg-primary-100 dark:bg-primary-900/50 text-primary-800 dark:text-primary-200 text-sm rounded-full font-recipe-body hover:bg-primary-200 dark:hover:bg-primary-800 transition-colors">家常菜</a>
|
||||
<a href="#" class="px-3 py-1 bg-accent-100 dark:bg-accent-900/50 text-accent-800 dark:text-accent-200 text-sm rounded-full font-recipe-body hover:bg-accent-200 dark:hover:bg-accent-800 transition-colors">传统名菜</a>
|
||||
<a href="#" class="px-3 py-1 bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200 text-sm rounded-full font-recipe-body hover:bg-green-200 dark:hover:bg-green-800 transition-colors">地方特色</a>
|
||||
<a href="#" class="px-3 py-1 bg-orange-100 dark:bg-orange-900/50 text-orange-800 dark:text-orange-200 text-sm rounded-full font-recipe-body hover:bg-orange-200 dark:hover:bg-orange-800 transition-colors">小吃点心</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 装饰性图案 -->
|
||||
<div class="absolute -top-4 -left-4 w-24 h-24 border-t-4 border-l-4 border-brown-300 dark:border-brown-700 opacity-50"></div>
|
||||
<div class="absolute -bottom-4 -right-4 w-24 h-24 border-b-4 border-r-4 border-brown-300 dark:border-brown-700 opacity-50"></div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 - 食谱风格 -->
|
||||
<div class="bg-recipe-paper dark:bg-recipe-paper-dark py-12">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
<!-- 左侧筛选区域 - 食谱风格 -->
|
||||
<div class="lg:col-span-3">
|
||||
<div class="sticky top-20 space-y-8">
|
||||
<!-- 食谱筛选卡片 -->
|
||||
<div class="recipe-card-item p-6">
|
||||
<!-- 搜索筛选框 -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-base font-recipe mb-4 text-brown-800 dark:text-brown-200 flex items-center">
|
||||
<svg class="w-4 h-4 mr-2 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<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" />
|
||||
</svg>
|
||||
食谱检索
|
||||
</h3>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入菜名或食材..."
|
||||
class="w-full bg-amber-50/50 dark:bg-amber-900/20 border border-brown-300 dark:border-brown-700 px-4 py-2 text-brown-800 dark:text-brown-200 focus:outline-none focus:border-amber-500 dark:focus:border-amber-400 focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800/50 rounded-md font-recipe-body text-sm"
|
||||
/>
|
||||
<div class="absolute right-3 top-2 text-brown-500 dark:text-brown-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 菜系筛选 -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-base font-recipe mb-4 text-brown-800 dark:text-brown-200 flex items-center">
|
||||
<svg class="w-4 h-4 mr-2 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
菜系
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
{categories.map((category, index) => (
|
||||
<label class="flex items-center group cursor-pointer font-recipe-body">
|
||||
<div class="w-4 h-4 border border-brown-400 dark:border-brown-600 mr-3 group-hover:border-amber-500 dark:group-hover:border-amber-400 transition-colors"></div>
|
||||
<div class="font-light text-brown-700 dark:text-brown-300 group-hover:text-amber-700 dark:group-hover:text-amber-300 transition-colors flex items-center justify-between w-full">
|
||||
<span>{category.name}</span>
|
||||
<span class="text-amber-600/70 dark:text-amber-500/50 bg-amber-50/80 dark:bg-amber-900/30 px-1.5 py-0.5 rounded-full text-xs">{category.count}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 地域筛选 -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-base font-recipe mb-4 text-brown-800 dark:text-brown-200 flex items-center">
|
||||
<svg class="w-4 h-4 mr-2 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
地域特色
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{citys.map((city) => (
|
||||
<label class="flex items-center group cursor-pointer font-recipe-body">
|
||||
<div class="w-4 h-4 border border-brown-400 dark:border-brown-600 mr-2 group-hover:border-green-500 dark:group-hover:border-green-400 transition-colors"></div>
|
||||
<div class="font-light text-brown-700 dark:text-brown-300 group-hover:text-green-700 dark:group-hover:text-green-300 transition-colors text-sm truncate">
|
||||
<span>{city.name}</span> <span class="text-green-600/70 dark:text-green-500/50 text-xs">({city.count})</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 口味筛选 -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-base font-recipe mb-4 text-brown-800 dark:text-brown-200 flex items-center">
|
||||
<svg class="w-4 h-4 mr-2 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
味道特点
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{tastes.map((taste) => (
|
||||
<label class="inline-flex items-center group cursor-pointer px-3 py-1 bg-orange-50/70 dark:bg-orange-900/30 border border-orange-200 dark:border-orange-800 rounded-full font-recipe-body">
|
||||
<div class="w-3 h-3 border border-orange-400 dark:border-orange-500 mr-2 rounded-full group-hover:bg-orange-400 dark:group-hover:bg-orange-500 transition-colors"></div>
|
||||
<div class="font-light text-orange-800 dark:text-orange-300 text-xs">
|
||||
{taste.name} <span class="text-orange-600/70 dark:text-orange-500/50">({taste.count})</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 食材筛选 -->
|
||||
<div>
|
||||
<h3 class="text-base font-recipe mb-4 text-brown-800 dark:text-brown-200 flex items-center">
|
||||
<svg class="w-4 h-4 mr-2 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
主要食材
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{allTags.map((tag, i) => {
|
||||
// 为标签生成不同的颜色
|
||||
const colors = ['amber', 'red', 'green', 'orange', 'amber', 'red'];
|
||||
const colorClasses = {
|
||||
'amber': 'border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300 hover:text-amber-900 dark:hover:text-amber-200 hover:border-amber-400 dark:hover:border-amber-700 bg-amber-50/50 dark:bg-amber-900/20',
|
||||
'red': 'border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 hover:text-red-900 dark:hover:text-red-200 hover:border-red-400 dark:hover:border-red-700 bg-red-50/50 dark:bg-red-900/20',
|
||||
'green': 'border-green-200 dark:border-green-800 text-green-700 dark:text-green-300 hover:text-green-900 dark:hover:text-green-200 hover:border-green-400 dark:hover:border-green-700 bg-green-50/50 dark:bg-green-900/20',
|
||||
'orange': 'border-orange-200 dark:border-orange-800 text-orange-700 dark:text-orange-300 hover:text-orange-900 dark:hover:text-orange-200 hover:border-orange-400 dark:hover:border-orange-700 bg-orange-50/50 dark:bg-orange-900/20'
|
||||
};
|
||||
const color = colors[i % colors.length] as keyof typeof colorClasses;
|
||||
return (
|
||||
<div class={`inline-block px-3 py-1 text-xs border rounded-full cursor-pointer transition-colors font-recipe-body ${colorClasses[color]}`}>
|
||||
{tag.name}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 烹饪小贴士 -->
|
||||
<div class="recipe-card-item p-5 mt-6">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="text-amber-600 dark:text-amber-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-recipe text-base text-brown-800 dark:text-brown-200 mb-2">食谱小贴士</h4>
|
||||
<p class="text-sm text-brown-700 dark:text-brown-300 leading-relaxed font-recipe-body">
|
||||
烹饪是一门艺术,每一道河北美食都融合了独特的地域文化和历史传承,讲究用料、火候与调味的绝妙平衡。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧食谱展示区 -->
|
||||
<div class="lg:col-span-9">
|
||||
<!-- 筛选状态 -->
|
||||
{(searchQuery || selectedCategory || selectedcity || selectedTaste || selectedTags.length > 0) && (
|
||||
<div class="mb-8 bg-amber-50/50 dark:bg-amber-900/20 p-4 border-l-4 border-amber-300 dark:border-amber-700 rounded-r-lg">
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-brown-700 dark:text-brown-300">
|
||||
<div class="font-recipe text-xs tracking-wider text-amber-700 dark:text-amber-300 bg-amber-100/70 dark:bg-amber-900/50 px-2 py-1 rounded">筛选条件</div>
|
||||
|
||||
{/* 筛选条件显示 */}
|
||||
|
||||
<button class="ml-auto text-red-600 hover:text-red-800 dark:hover:text-red-300 text-sm flex items-center space-x-1 bg-white/80 dark:bg-black/30 px-3 py-1 rounded-full font-recipe-body">
|
||||
<span>重置</span>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- 食谱卡片列表 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
||||
{currentPageCuisines.map((cuisine, index) => {
|
||||
// 随机食谱纸张背景颜色
|
||||
const paperColors = [
|
||||
'bg-amber-50/80 border-amber-200 dark:bg-amber-900/30 dark:border-amber-800',
|
||||
'bg-orange-50/80 border-orange-200 dark:bg-orange-900/30 dark:border-orange-800',
|
||||
'bg-red-50/80 border-red-200 dark:bg-red-900/30 dark:border-red-800',
|
||||
'bg-green-50/80 border-green-200 dark:bg-green-900/30 dark:border-green-800'
|
||||
];
|
||||
const paperColor = paperColors[index % paperColors.length];
|
||||
|
||||
return (
|
||||
<ScrollReveal animation="fade">
|
||||
<a href={`/cuisine/${cuisine.slug}`} class="block group">
|
||||
<div class={`recipe-card-item border transition-all duration-300 ${paperColor}`}>
|
||||
<!-- 食谱卡片头部 -->
|
||||
<div class="aspect-[4/3] relative overflow-hidden">
|
||||
<div class={`absolute inset-0 bg-recipe-paper dark:bg-recipe-paper-dark flex items-center justify-center`}>
|
||||
<span class="text-brown-400 dark:text-brown-600 font-recipe-body">{cuisine.data.title}</span>
|
||||
</div>
|
||||
|
||||
<!-- 图钉装饰 -->
|
||||
<div class="absolute top-3 left-3 w-4 h-4 bg-red-500 dark:bg-red-700 rounded-full shadow-sm"></div>
|
||||
<div class="absolute top-3 right-3 w-4 h-4 bg-amber-500 dark:bg-amber-700 rounded-full shadow-sm"></div>
|
||||
|
||||
{cuisine.data.category && (
|
||||
<div class="absolute bottom-3 right-3 px-2 py-1 text-xs font-recipe-body bg-white/90 dark:bg-black/70 text-brown-800 dark:text-brown-200 rounded-md shadow-sm border border-brown-200 dark:border-brown-800">
|
||||
{cuisine.data.category}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- 推荐标记 -->
|
||||
{Math.random() > 0.7 && (
|
||||
<div class="absolute -top-1 -left-1 rotate-12">
|
||||
<div class="bg-red-600 text-white px-6 py-1 text-xs font-recipe transform -rotate-45 shadow-md">
|
||||
推荐
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- 食谱内容 -->
|
||||
<div class="p-5">
|
||||
<h3 class="text-xl font-recipe text-brown-900 dark:text-brown-100 mb-3 group-hover:text-amber-700 dark:group-hover:text-amber-400 transition-colors">
|
||||
{cuisine.data.title}
|
||||
</h3>
|
||||
|
||||
<!-- 食谱元数据 -->
|
||||
<div class="flex flex-wrap gap-3 text-sm mb-3">
|
||||
{cuisine.data.city && (
|
||||
<div class="flex items-center px-2 py-0.5 bg-amber-50/80 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-full">
|
||||
<span class="text-amber-800 dark:text-amber-300 text-xs font-recipe-body">{cuisine.data.city}</span>
|
||||
</div>
|
||||
)}
|
||||
{cuisine.data.taste && (
|
||||
<div class="flex items-center px-2 py-0.5 bg-red-50/80 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-full">
|
||||
<span class="text-red-800 dark:text-red-300 text-xs font-recipe-body">{cuisine.data.taste}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- 食谱描述 -->
|
||||
<p class="text-brown-700 dark:text-brown-300 text-sm line-clamp-3 mb-4 font-recipe-body">
|
||||
{cuisine.data.description}
|
||||
</p>
|
||||
|
||||
<!-- 食材标签 -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{cuisine.data.tags.slice(0, 3).map((tag: string, i: number) => (
|
||||
<span class="px-2 py-0.5 text-xs font-recipe-body bg-brown-100/80 dark:bg-brown-900/30 text-brown-800 dark:text-brown-200 border border-brown-200 dark:border-brown-800 rounded-full">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{cuisine.data.tags.length > 3 && (
|
||||
<span class="px-2 py-0.5 text-xs font-recipe-body bg-brown-100/80 dark:bg-brown-900/30 text-brown-800 dark:text-brown-200 border border-brown-200 dark:border-brown-800 rounded-full">
|
||||
+{cuisine.data.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- 查看详情 -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-sm text-amber-700 dark:text-amber-300 flex items-center group-hover:translate-x-1 transition-transform font-recipe-body">
|
||||
查看详细食谱
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 食谱标记 -->
|
||||
{Math.random() > 0.5 && (
|
||||
<span class="text-xs px-2 py-0.5 bg-orange-100/80 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800 rounded-full font-recipe-body">传统</span>
|
||||
)}
|
||||
{Math.random() > 0.7 && (
|
||||
<span class="text-xs px-2 py-0.5 bg-green-100/80 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800 rounded-full font-recipe-body">家常</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<!-- 分页控件 - 食谱风格分页 -->
|
||||
<div class="flex justify-center items-center space-x-2 mt-12">
|
||||
{totalPages > 1 && (
|
||||
<div class="flex flex-wrap gap-2 items-center bg-recipe-paper-light dark:bg-recipe-paper-dark p-4 rounded-lg border border-amber-200 dark:border-amber-800 shadow-md">
|
||||
<!-- 上一页按钮 -->
|
||||
<a
|
||||
href={page > 1 ? `?page=${page - 1}${queryParams}` : '#'}
|
||||
class={`flex items-center px-3 py-1.5 ${
|
||||
page === 1
|
||||
? 'text-brown-400 dark:text-brown-600 cursor-not-allowed'
|
||||
: 'text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/30'
|
||||
} rounded-md font-recipe-body transition-colors`}
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
上一页
|
||||
</a>
|
||||
|
||||
<!-- 页码 -->
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
|
||||
<a
|
||||
href={`?page=${pageNum}${queryParams}`}
|
||||
class={`px-3 py-1.5 font-recipe ${
|
||||
page === pageNum
|
||||
? 'bg-amber-600 text-white dark:bg-amber-700'
|
||||
: 'text-brown-700 dark:text-brown-300 hover:bg-amber-100 dark:hover:bg-amber-900/30'
|
||||
} rounded-md transition-colors`}
|
||||
>
|
||||
{pageNum}
|
||||
</a>
|
||||
))}
|
||||
|
||||
<!-- 下一页按钮 -->
|
||||
<a
|
||||
href={page < totalPages ? `?page=${page + 1}${queryParams}` : '#'}
|
||||
class={`flex items-center px-3 py-1.5 ${
|
||||
page === totalPages
|
||||
? 'text-brown-400 dark:text-brown-600 cursor-not-allowed'
|
||||
: 'text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/30'
|
||||
} rounded-md font-recipe-body transition-colors`}
|
||||
>
|
||||
下一页
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- 食谱主题页脚 -->
|
||||
<div class="mt-16 text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="text-amber-600 dark:text-amber-400 mb-3">
|
||||
<img src="/images/cuisine/chef-hat.svg" alt="Chef Hat" class="w-12 h-12 mx-auto opacity-80" />
|
||||
</div>
|
||||
<p class="text-brown-700 dark:text-brown-300 font-recipe max-w-2xl mx-auto">
|
||||
河北美食宝库收录了<span class="text-amber-600 dark:text-amber-400 mx-1">{cuisines.length}+</span>种传统佳肴与地方特色小吃。
|
||||
每一道食谱都承载着我们的文化记忆和烹饪智慧。尽情探索,找到属于你的美食灵感!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
|
||||
<style>
|
||||
|
||||
.bg-recipe-paper {
|
||||
background-color: var(--bg-recipe);
|
||||
}
|
||||
|
||||
.bg-recipe-paper-dark {
|
||||
background-color: var(--bg-recipe);
|
||||
}
|
||||
|
||||
.bg-recipe-paper-light {
|
||||
background-color: var(--color-primary-50);
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM12 60c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z' fill='%23f59e0b' fill-opacity='0.05' fill-rule='evenodd'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bg-wood-texture {
|
||||
background-color: var(--color-secondary-300);
|
||||
background-image: url('/images/wood-texture.png');
|
||||
background-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.bg-wood-texture-dark {
|
||||
background-color: var(--color-secondary-900);
|
||||
background-image: url('/images/wood-texture.png');
|
||||
background-blend-mode: overlay;
|
||||
}
|
||||
|
||||
.text-brown-900 {
|
||||
color: var(--color-secondary-900);
|
||||
}
|
||||
|
||||
.text-brown-800 {
|
||||
color: var(--color-secondary-800);
|
||||
}
|
||||
|
||||
.text-brown-700 {
|
||||
color: var(--color-secondary-700);
|
||||
}
|
||||
|
||||
.text-brown-600 {
|
||||
color: var(--color-secondary-600);
|
||||
}
|
||||
|
||||
.text-brown-500 {
|
||||
color: var(--color-secondary-500);
|
||||
}
|
||||
|
||||
.text-brown-400 {
|
||||
color: var(--color-secondary-400);
|
||||
}
|
||||
|
||||
.text-brown-300 {
|
||||
color: var(--color-secondary-300);
|
||||
}
|
||||
|
||||
.text-brown-200 {
|
||||
color: var(--color-secondary-200);
|
||||
}
|
||||
|
||||
.text-brown-100 {
|
||||
color: var(--color-secondary-100);
|
||||
}
|
||||
|
||||
.recipe-card {
|
||||
position: relative;
|
||||
padding: 1.5rem;
|
||||
background-color: #f8f3e9;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:root.dark .recipe-card {
|
||||
background-color: #2c2419;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.recipe-title {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.recipe-title::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -0.5rem;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
height: 2px;
|
||||
background-color: #b08f77;
|
||||
transform: rotate(-0.5deg);
|
||||
}
|
||||
|
||||
:root.dark .recipe-title::after {
|
||||
background-color: #7c604a;
|
||||
}
|
||||
|
||||
/* 手写风格的下划线 */
|
||||
.handwritten-title p {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.handwritten-title p::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -0.25rem;
|
||||
left: 25%;
|
||||
right: 25%;
|
||||
height: 1px;
|
||||
background-color: #b08f77;
|
||||
transform: rotate(-0.25deg);
|
||||
}
|
||||
|
||||
:root.dark .handwritten-title p::after {
|
||||
background-color: #7c604a;
|
||||
}
|
||||
|
||||
/* 食谱卡片样式 */
|
||||
.recipe-card-item {
|
||||
position: relative;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
|
||||
background-color: var(--recipe-card-bg, rgba(255, 251, 235, 0.9));
|
||||
}
|
||||
|
||||
.dark .recipe-card-item {
|
||||
background-color: var(--recipe-card-bg-dark, rgba(120, 53, 15, 0.1));
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保留功能 */
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
/* 食谱纸张纹理动画 */
|
||||
@keyframes textureFloat {
|
||||
0%, 100% { background-position: 0% 0%; }
|
||||
50% { background-position: 1% 1%; }
|
||||
}
|
||||
|
||||
.bg-recipe-paper, .bg-recipe-paper-dark {
|
||||
animation: textureFloat 10s ease-in-out infinite;
|
||||
background-size: 200px 200px;
|
||||
}
|
||||
|
||||
/* 装饰性图案旋转动画 */
|
||||
@keyframes slowRotate {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.recipe-decoration {
|
||||
animation: slowRotate 120s linear infinite;
|
||||
}
|
||||
|
||||
/* 食谱卡片悬停效果 */
|
||||
.recipe-card-item:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:root.dark .recipe-card-item:hover {
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 页面加载完成后执行
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 检测当前颜色模式
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
|
||||
// 获取元素
|
||||
const searchInput = document.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
const categoryLabels = document.querySelectorAll('.font-recipe-body');
|
||||
const resetButton = document.querySelector('button');
|
||||
const recipeCards = document.querySelectorAll('.recipe-card-item');
|
||||
const paperBg = document.querySelector('.bg-recipe-paper');
|
||||
|
||||
// 食谱卡片初始样式
|
||||
recipeCards.forEach((card, index) => {
|
||||
// 设置初始状态
|
||||
(card as HTMLElement).style.opacity = '0';
|
||||
(card as HTMLElement).style.transform = 'translateY(20px)';
|
||||
(card as HTMLElement).style.transition = 'all 0.4s ease-out';
|
||||
});
|
||||
|
||||
// 为食谱卡片添加进场动画
|
||||
setTimeout(() => {
|
||||
recipeCards.forEach((card, index) => {
|
||||
setTimeout(() => {
|
||||
(card as HTMLElement).style.opacity = '1';
|
||||
(card as HTMLElement).style.transform = 'translateY(0)';
|
||||
}, 100 + (index * 50));
|
||||
});
|
||||
}, 200);
|
||||
|
||||
// 食谱卡片悬停效果
|
||||
recipeCards.forEach(card => {
|
||||
card.addEventListener('mouseenter', () => {
|
||||
(card as HTMLElement).style.transform = 'translateY(-5px)';
|
||||
(card as HTMLElement).style.boxShadow = isDarkMode
|
||||
? '0 10px 15px -3px rgba(0, 0, 0, 0.4)'
|
||||
: '0 10px 15px -3px rgba(251, 191, 36, 0.1), 0 4px 6px -2px rgba(251, 191, 36, 0.05)';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', () => {
|
||||
(card as HTMLElement).style.transform = 'translateY(0)';
|
||||
(card as HTMLElement).style.boxShadow = isDarkMode
|
||||
? '0 4px 8px rgba(0, 0, 0, 0.2)'
|
||||
: '0 4px 8px rgba(0, 0, 0, 0.05)';
|
||||
});
|
||||
});
|
||||
|
||||
// 搜索框交互效果
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('focus', () => {
|
||||
if (searchInput.parentElement) {
|
||||
searchInput.parentElement.classList.add('ring-2', 'ring-amber-200');
|
||||
if (isDarkMode) {
|
||||
searchInput.parentElement.classList.add('ring-amber-800/50');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener('blur', () => {
|
||||
if (searchInput.parentElement) {
|
||||
searchInput.parentElement.classList.remove('ring-2', 'ring-amber-200', 'ring-amber-800/50');
|
||||
}
|
||||
});
|
||||
|
||||
// 搜索功能
|
||||
searchInput.addEventListener('keyup', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
const searchValue = searchInput.value.trim();
|
||||
if (searchValue) {
|
||||
// 构建搜索URL并跳转
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set('search', searchValue);
|
||||
currentUrl.searchParams.delete('page'); // 重置页码
|
||||
window.location.href = currentUrl.toString();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 筛选标签点击效果
|
||||
const filterLabels = document.querySelectorAll('.recipe-card-item label');
|
||||
filterLabels.forEach(label => {
|
||||
label.addEventListener('click', () => {
|
||||
const checkbox = label.querySelector('div:first-child');
|
||||
if (checkbox) {
|
||||
// 切换选中状态
|
||||
if (checkbox.classList.contains('bg-amber-500') ||
|
||||
checkbox.classList.contains('bg-red-500') ||
|
||||
checkbox.classList.contains('bg-green-500') ||
|
||||
checkbox.classList.contains('bg-orange-500')) {
|
||||
checkbox.classList.remove('bg-amber-500', 'bg-red-500', 'bg-green-500', 'bg-orange-500');
|
||||
} else {
|
||||
const labelText = label.textContent || '';
|
||||
if (labelText.includes('菜系')) {
|
||||
checkbox.classList.add('bg-amber-500');
|
||||
} else if (labelText.includes('地域')) {
|
||||
checkbox.classList.add('bg-green-500');
|
||||
} else if (labelText.includes('味道')) {
|
||||
checkbox.classList.add('bg-orange-500');
|
||||
} else {
|
||||
checkbox.classList.add('bg-red-500');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注意:实际筛选逻辑需要后端支持,这里只是添加视觉反馈
|
||||
});
|
||||
});
|
||||
|
||||
// 美食标签点击事件
|
||||
const tagButtons = document.querySelectorAll('.flex-wrap .inline-block');
|
||||
tagButtons.forEach(tag => {
|
||||
tag.addEventListener('click', () => {
|
||||
// 为标签添加选中效果
|
||||
tag.classList.toggle('ring-2');
|
||||
tag.classList.toggle('ring-amber-400');
|
||||
tag.classList.toggle('dark:ring-amber-600');
|
||||
|
||||
// 注意:实际筛选逻辑需要后端支持,这里只是添加视觉反馈
|
||||
});
|
||||
});
|
||||
|
||||
// 重置按钮效果
|
||||
if (resetButton) {
|
||||
resetButton.addEventListener('mouseenter', () => {
|
||||
resetButton.classList.add('bg-red-50', 'dark:bg-red-900/30');
|
||||
});
|
||||
|
||||
resetButton.addEventListener('mouseleave', () => {
|
||||
resetButton.classList.remove('bg-red-50', 'dark:bg-red-900/30');
|
||||
});
|
||||
|
||||
resetButton.addEventListener('click', () => {
|
||||
// 重置所有筛选条件
|
||||
window.location.href = window.location.pathname;
|
||||
});
|
||||
}
|
||||
|
||||
// 添加页面滚动动画效果
|
||||
const addScrollAnimation = () => {
|
||||
const elements = document.querySelectorAll('.recipe-card-item');
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('animate-fade-in');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
elements.forEach(el => {
|
||||
observer.observe(el);
|
||||
});
|
||||
};
|
||||
|
||||
// 如果浏览器支持IntersectionObserver,添加滚动动画
|
||||
if ('IntersectionObserver' in window) {
|
||||
addScrollAnimation();
|
||||
}
|
||||
|
||||
// 添加食谱纸张效果装饰元素
|
||||
if (paperBg) {
|
||||
// 创建随机装饰元素
|
||||
const createDecorElement = (className: string, icon: string) => {
|
||||
const element = document.createElement('div');
|
||||
element.className = className;
|
||||
element.innerHTML = icon;
|
||||
element.style.position = 'absolute';
|
||||
element.style.opacity = '0.15';
|
||||
element.style.zIndex = '1';
|
||||
return element;
|
||||
};
|
||||
|
||||
// 添加食谱装饰
|
||||
const decorations = [
|
||||
{ icon: '🍴', className: 'recipe-decor text-2xl' },
|
||||
{ icon: '🥄', className: 'recipe-decor text-2xl' },
|
||||
{ icon: '🍽️', className: 'recipe-decor text-2xl' },
|
||||
{ icon: '📝', className: 'recipe-decor text-2xl' },
|
||||
{ icon: '⭐', className: 'recipe-decor text-2xl' }
|
||||
];
|
||||
|
||||
decorations.forEach((decor) => {
|
||||
const el = createDecorElement(decor.className, decor.icon);
|
||||
el.style.left = `${Math.random() * 90}%`;
|
||||
el.style.top = `${Math.random() * 90}%`;
|
||||
el.style.transform = `rotate(${Math.random() * 360}deg)`;
|
||||
(paperBg as HTMLElement).appendChild(el);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 添加键盘快捷键支持
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
// Alt + S 聚焦搜索框
|
||||
if (e.altKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
const searchInput = document.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
193
web/graduation/src/pages/culture/[slug].astro
Normal file
193
web/graduation/src/pages/culture/[slug].astro
Normal file
@ -0,0 +1,193 @@
|
||||
---
|
||||
import { getCollection, getEntry, type CollectionEntry } from "astro:content";
|
||||
import MainLayout from "../../layouts/MainLayout.astro";
|
||||
import ScrollReveal from "../../components/aceternity/ScrollReveal.astro";
|
||||
|
||||
// 定义Props类型
|
||||
export interface Props {
|
||||
entry: CollectionEntry<"culture">;
|
||||
}
|
||||
|
||||
// 生成静态路径
|
||||
export async function getStaticPaths() {
|
||||
const cultures = await getCollection("culture");
|
||||
return cultures.map((entry) => ({
|
||||
params: { slug: entry.slug },
|
||||
props: { entry },
|
||||
}));
|
||||
}
|
||||
|
||||
// 获取当前文化数据
|
||||
const { entry } = Astro.props;
|
||||
const { Content } = await entry.render();
|
||||
|
||||
// 获取相关文化
|
||||
const allCultures = await getCollection("culture");
|
||||
const relatedCultures = allCultures
|
||||
.filter(
|
||||
(item) =>
|
||||
item.slug !== entry.slug &&
|
||||
item.data.tags.some((tag) => entry.data.tags.includes(tag))
|
||||
)
|
||||
.slice(0, 3);
|
||||
---
|
||||
|
||||
<MainLayout title={`${entry.data.title} - 河北游礼`}>
|
||||
<!-- 页面标题区域 -->
|
||||
<div class="relative py-16 bg-gradient-to-br from-color-primary-700 via-color-primary-600 to-color-primary-800 text-white dark:from-color-dark-primary-900 dark:via-color-dark-primary-800 dark:to-color-dark-primary-950">
|
||||
<div class="absolute inset-0 bg-black/30"></div>
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<ScrollReveal animation="fade">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<a href="/" class="text-white/80 hover:text-white transition-colors">首页</a>
|
||||
<span class="text-white/60">/</span>
|
||||
<a href="/culture" class="text-white/80 hover:text-white transition-colors">文化</a>
|
||||
<span class="text-white/60">/</span>
|
||||
<span class="text-white/60">{entry.data.title}</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-5xl font-bold mb-4">{entry.data.title}</h1>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4 mb-4">
|
||||
{entry.data.city && (
|
||||
<div class="flex items-center text-white/90">
|
||||
<span class="mr-1">📍</span> {entry.data.city}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.data.pubDate && (
|
||||
<div class="flex items-center text-white/90">
|
||||
<span class="mr-1">📅</span> {new Date(entry.data.pubDate).toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex items-center text-white/90">
|
||||
<span class="mr-1">🏷️</span> {entry.data.category}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
{entry.data.tags.map((tag) => (
|
||||
<span class="px-3 py-1 bg-white/20 backdrop-blur-sm text-white text-sm rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p class="text-xl text-white/90 max-w-3xl">{entry.data.description}</p>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="py-12 bg-white dark:bg-color-dark-bg">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
<!-- 左侧内容 -->
|
||||
<div class="lg:col-span-2">
|
||||
<ScrollReveal animation="fade">
|
||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||
<Content />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
<!-- 右侧边栏 -->
|
||||
<div class="space-y-8">
|
||||
<!-- 文化图片 -->
|
||||
<ScrollReveal animation="slide-up">
|
||||
<div class="rounded-lg overflow-hidden shadow-md">
|
||||
<div class="h-64 bg-gray-300 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span class="text-gray-500 dark:text-gray-400">{entry.data.title} 图片</span>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<!-- 文化信息卡片 -->
|
||||
<ScrollReveal animation="slide-up" delay={100}>
|
||||
<div class="bg-gray-50 dark:bg-color-dark-card rounded-lg shadow-md p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">文化信息</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
{entry.data.city && (
|
||||
<div class="flex">
|
||||
<span class="w-24 flex-shrink-0 text-gray-600 dark:text-gray-400">分布地区:</span>
|
||||
<span class="text-gray-900 dark:text-white">{entry.data.city}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex">
|
||||
<span class="w-24 flex-shrink-0 text-gray-600 dark:text-gray-400">文化类型:</span>
|
||||
<span class="text-gray-900 dark:text-white">{entry.data.category}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<span class="w-24 flex-shrink-0 text-gray-600 dark:text-gray-400">特色标签:</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{entry.data.tags.map((tag) => (
|
||||
<span class="px-2 py-0.5 bg-color-primary-100 text-color-primary-800 text-xs rounded-full dark:bg-color-dark-primary-900/70 dark:text-color-primary-300">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entry.data.pubDate && (
|
||||
<div class="flex">
|
||||
<span class="w-24 flex-shrink-0 text-gray-600 dark:text-gray-400">发布时间:</span>
|
||||
<span class="text-gray-900 dark:text-white">{new Date(entry.data.pubDate).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<!-- 相关文化 -->
|
||||
{relatedCultures.length > 0 && (
|
||||
<ScrollReveal animation="slide-up" delay={200}>
|
||||
<div class="bg-gray-50 dark:bg-color-dark-card rounded-lg shadow-md p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">相关文化</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
{relatedCultures.map((culture) => (
|
||||
<a
|
||||
href={`/culture/${culture.slug}`}
|
||||
class="block group"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-16 h-16 flex-shrink-0 bg-gray-300 dark:bg-gray-700 rounded flex items-center justify-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">图片</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white group-hover:text-color-primary-600 dark:group-hover:text-color-primary-400 transition-colors">
|
||||
{culture.data.title}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{culture.data.description.substring(0, 60)}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
|
||||
<!-- 返回按钮 -->
|
||||
<ScrollReveal animation="slide-up" delay={300}>
|
||||
<a
|
||||
href="/culture"
|
||||
class="block w-full py-3 text-center bg-color-primary-600 text-white rounded-md hover:bg-color-primary-700 transition-colors dark:bg-color-dark-primary-600 dark:hover:bg-color-dark-primary-500"
|
||||
>
|
||||
返回所有文化
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
763
web/graduation/src/pages/culture/index.astro
Normal file
763
web/graduation/src/pages/culture/index.astro
Normal file
@ -0,0 +1,763 @@
|
||||
---
|
||||
import MainLayout from "../../layouts/MainLayout.astro";
|
||||
import { getCollection, type CollectionEntry } from "astro:content";
|
||||
import ScrollReveal from "../../components/aceternity/ScrollReveal.astro";
|
||||
|
||||
// 获取文化内容集合
|
||||
const cultures = await getCollection("culture");
|
||||
|
||||
// 按照日期排序
|
||||
const sortByDate = <T extends { data: { pubDate?: Date | string, updatedDate?: Date | string } }>(a: T, b: T): number => {
|
||||
return new Date(b.data.pubDate || b.data.updatedDate || 0).getTime() -
|
||||
new Date(a.data.pubDate || a.data.updatedDate || 0).getTime();
|
||||
};
|
||||
|
||||
// 按发布日期排序
|
||||
const sortedCultures = [...cultures].sort(sortByDate);
|
||||
|
||||
// 提取所有标签
|
||||
const allTags: {name: string, count: number}[] = [];
|
||||
sortedCultures.forEach((culture: CollectionEntry<"culture">) => {
|
||||
culture.data.tags.forEach((tag: string) => {
|
||||
const existingTag = allTags.find(t => t.name === tag);
|
||||
if (existingTag) {
|
||||
existingTag.count++;
|
||||
} else {
|
||||
allTags.push({ name: tag, count: 1 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 按照标签出现次数排序
|
||||
allTags.sort((a, b) => b.count - a.count);
|
||||
|
||||
// 获取所有分类并计数
|
||||
const categories: {name: string, count: number}[] = [];
|
||||
sortedCultures.forEach((culture: CollectionEntry<"culture">) => {
|
||||
if (culture.data.category) {
|
||||
const existingCategory = categories.find(c => c.name === culture.data.category);
|
||||
if (existingCategory) {
|
||||
existingCategory.count++;
|
||||
} else {
|
||||
categories.push({ name: culture.data.category, count: 1 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 按照分类出现次数排序
|
||||
categories.sort((a, b) => b.count - a.count);
|
||||
|
||||
// 获取所有城市并计数
|
||||
const cities: {name: string, count: number}[] = [];
|
||||
sortedCultures.forEach((culture: CollectionEntry<"culture">) => {
|
||||
if (culture.data.city) {
|
||||
const existingCity = cities.find(c => c.name === culture.data.city);
|
||||
if (existingCity) {
|
||||
existingCity.count++;
|
||||
} else {
|
||||
cities.push({ name: culture.data.city, count: 1 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 按照城市出现次数排序
|
||||
cities.sort((a, b) => b.count - a.count);
|
||||
|
||||
// 提取所有历史时期
|
||||
const periods: {name: string, count: number}[] = [];
|
||||
sortedCultures.forEach((culture: CollectionEntry<"culture">) => {
|
||||
if ((culture.data as any).period) {
|
||||
const existingPeriod = periods.find(p => p.name === (culture.data as any).period);
|
||||
if (existingPeriod) {
|
||||
existingPeriod.count++;
|
||||
} else {
|
||||
periods.push({ name: (culture.data as any).period, count: 1 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 按照历史时期出现次数排序
|
||||
periods.sort((a, b) => b.count - a.count);
|
||||
|
||||
// 分页逻辑
|
||||
const itemsPerPage = 9;
|
||||
const page = 1; // 当前页码,实际应用中应该从查询参数获取
|
||||
const totalPages = Math.ceil(sortedCultures.length / itemsPerPage);
|
||||
const currentPageCultures = sortedCultures.slice((page - 1) * itemsPerPage, page * itemsPerPage);
|
||||
|
||||
// 搜索和筛选逻辑(实际应用中应该根据查询参数来筛选)
|
||||
const searchQuery = '';
|
||||
const selectedCategory = '';
|
||||
const selectedPeriod = '';
|
||||
const selectedTags: string[] = [];
|
||||
const sortBy: 'date' | 'name' = 'date';
|
||||
---
|
||||
|
||||
<MainLayout title="河北文化典藏 - 河北游礼">
|
||||
<!-- 古籍书卷风格的头部 -->
|
||||
<div class="relative overflow-hidden bg-scroll-bg dark:bg-scroll-bg-dark min-h-[500px] flex items-center">
|
||||
<!-- 古纸纹理和装饰 -->
|
||||
<div class="absolute inset-0 bg-pattern opacity-10 dark:opacity-15"></div>
|
||||
<div class="absolute top-0 left-0 w-32 h-32 bg-scroll-corner opacity-30 -rotate-12"></div>
|
||||
<div class="absolute bottom-0 right-0 w-32 h-32 bg-scroll-corner opacity-30 rotate-180"></div>
|
||||
|
||||
<!-- 宣纸上的水墨效果 -->
|
||||
<div class="absolute -right-20 top-1/4 w-40 h-40 bg-ink-splash opacity-10 dark:opacity-15 rotate-12"></div>
|
||||
<div class="absolute -left-10 bottom-1/4 w-32 h-32 bg-ink-splash opacity-10 dark:opacity-15 -rotate-12"></div>
|
||||
|
||||
<!-- 古籍书卷式标题区域 -->
|
||||
<div class="container mx-auto px-4 relative z-10">
|
||||
<div class="max-w-4xl mx-auto text-center">
|
||||
<!-- 书卷式装饰 -->
|
||||
<div class="scroll-decoration relative py-12">
|
||||
<!-- 上卷轴 -->
|
||||
<div class="absolute top-0 left-0 right-0 h-8 bg-scroll-top dark:bg-scroll-top-dark"></div>
|
||||
|
||||
<!-- 下卷轴 -->
|
||||
<div class="absolute bottom-0 left-0 right-0 h-8 bg-scroll-bottom dark:bg-scroll-bottom-dark"></div>
|
||||
|
||||
<!-- 典籍风格标题 -->
|
||||
<div class="py-10 px-8">
|
||||
<div class="mb-4 seal-mark">
|
||||
<div class="seal absolute -top-6 right-10 w-20 h-20 opacity-80"></div>
|
||||
</div>
|
||||
|
||||
<div class="official-title">
|
||||
<h2 class="text-sm font-ancient tracking-[0.2em] text-ancient-red dark:text-ancient-red-dark mb-4">河北文化典藏</h2>
|
||||
<h1 class="text-5xl md:text-6xl font-ancient text-ancient-black dark:text-ancient-white mb-4 leading-snug">文化瑰宝</h1>
|
||||
<div class="w-40 h-0.5 mx-auto my-6 bg-ancient-accent dark:bg-ancient-accent-dark"></div>
|
||||
</div>
|
||||
|
||||
<p class="font-ancient-body text-lg text-ancient-black dark:text-ancient-white max-w-2xl mx-auto leading-relaxed">
|
||||
典藏千年冀州文明,承载河北厚重历史文化积淀,
|
||||
<br>以字画诗词、戏曲非遗,述说河北文化的绵长与精彩
|
||||
</p>
|
||||
|
||||
<!-- 朝代时间轴提示 -->
|
||||
<div class="mt-8 flex justify-center">
|
||||
<div class="px-6 py-3 bg-ancient-paper/80 dark:bg-ancient-paper-dark/80 border border-ancient-accent/30 dark:border-ancient-accent-dark/30 text-ancient-black/80 dark:text-ancient-white/80">
|
||||
<span class="font-ancient-small tracking-wider">周 · 秦 · 汉 · 唐 · 宋 · 元 · 明 · 清 · 民国 · 现代</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 装饰性书法元素 -->
|
||||
<div class="absolute -top-10 -left-10 w-40 h-40 opacity-5 dark:opacity-10 calligraphy-decoration"></div>
|
||||
<div class="absolute -bottom-10 -right-10 w-40 h-40 opacity-5 dark:opacity-10 calligraphy-decoration rotate-180"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 主内容区域 - 古籍风格 -->
|
||||
<div class="bg-ancient-paper dark:bg-ancient-paper-dark py-12">
|
||||
<div class="container mx-auto px-4">
|
||||
<!-- 文化介绍语 - 书卷风格 -->
|
||||
<div class="mb-16 max-w-4xl mx-auto">
|
||||
<div class="px-8 py-10 text-center border-l-2 border-r-2 border-ancient-accent/30 dark:border-ancient-accent-dark/30 bg-ancient-paper-light/50 dark:bg-ancient-paper-dark/50 relative">
|
||||
<!-- 装饰性笔触 -->
|
||||
<div class="absolute top-0 left-0 w-16 h-16 opacity-10 bg-ink-flower bg-no-repeat bg-contain"></div>
|
||||
<div class="absolute bottom-0 right-0 w-16 h-16 opacity-10 bg-ink-flower bg-no-repeat bg-contain transform rotate-180"></div>
|
||||
|
||||
<p class="text-xl text-ancient-black dark:text-ancient-white font-ancient-body leading-relaxed">
|
||||
河北,古称"冀州",是中华文明的发祥地之一。这片土地上流传着众多的文化瑰宝,从京剧、评剧等传统戏曲,到皮影、剪纸等民间艺术,从千年古刹到悠久历史的传统习俗,共同构成了河北独特而丰富的文化景观。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-10">
|
||||
<!-- 左侧筛选栏 - 古籍风格 -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="sticky top-24 space-y-8">
|
||||
<!-- 搜索框 - 书法风 -->
|
||||
<div class="bg-ancient-paper-light dark:bg-ancient-paper-dark-light p-6 border-2 border-ancient-accent/30 dark:border-ancient-accent-dark/30 shadow-md">
|
||||
<h3 class="text-lg font-ancient-heading text-ancient-black dark:text-ancient-white mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-ancient-accent dark:text-ancient-accent-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<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" />
|
||||
</svg>
|
||||
典籍检索
|
||||
</h3>
|
||||
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入关键词..."
|
||||
class="w-full px-4 py-2 border-2 border-ancient-accent/30 dark:border-ancient-accent-dark/30 bg-ancient-paper/70 dark:bg-ancient-paper-dark/70 placeholder-ancient-black/50 dark:placeholder-ancient-white/50 text-ancient-black dark:text-ancient-white font-ancient-body focus:outline-none focus:border-ancient-accent dark:focus:border-ancient-accent-dark"
|
||||
/>
|
||||
<div class="absolute right-3 top-2 text-ancient-accent/70 dark:text-ancient-accent-dark/70">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类筛选 - 卷轴风格 -->
|
||||
<div class="bg-ancient-paper-light dark:bg-ancient-paper-dark-light p-6 border-2 border-ancient-accent/30 dark:border-ancient-accent-dark/30 shadow-md">
|
||||
<h3 class="text-lg font-ancient-heading text-ancient-black dark:text-ancient-white mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-ancient-accent dark:text-ancient-accent-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
文化分类
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2 font-ancient-body max-h-48 overflow-y-auto scrollbar-thin scrollbar-thumb-ancient-accent/30 dark:scrollbar-thumb-ancient-accent-dark/30 pr-2">
|
||||
{categories.map((category) => (
|
||||
<div class="flex items-center group cursor-pointer">
|
||||
<div class="w-4 h-4 border border-ancient-accent/50 dark:border-ancient-accent-dark/50 mr-2 flex-shrink-0 group-hover:bg-ancient-accent/20 dark:group-hover:bg-ancient-accent-dark/20 transition-colors"></div>
|
||||
<div class="text-ancient-black dark:text-ancient-white group-hover:text-ancient-accent dark:group-hover:text-ancient-accent-dark transition-colors">
|
||||
<span>{category.name}</span>
|
||||
<span class="text-ancient-black/60 dark:text-ancient-white/60 text-sm">({category.count})</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 城市筛选 - 新增部分 -->
|
||||
<div class="bg-ancient-paper-light dark:bg-ancient-paper-dark-light p-6 border-2 border-ancient-accent/30 dark:border-ancient-accent-dark/30 shadow-md">
|
||||
<h3 class="text-lg font-ancient-heading text-ancient-black dark:text-ancient-white mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-ancient-accent dark:text-ancient-accent-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
地域分布
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2 font-ancient-body max-h-48 overflow-y-auto scrollbar-thin scrollbar-thumb-ancient-accent/30 dark:scrollbar-thumb-ancient-accent-dark/30 pr-2">
|
||||
{cities.map((city) => (
|
||||
<div class="flex items-center group cursor-pointer">
|
||||
<div class="w-4 h-4 border border-ancient-accent/50 dark:border-ancient-accent-dark/50 mr-2 flex-shrink-0 group-hover:bg-ancient-accent/20 dark:group-hover:bg-ancient-accent-dark/20 transition-colors"></div>
|
||||
<div class="text-ancient-black dark:text-ancient-white group-hover:text-ancient-accent dark:group-hover:text-ancient-accent-dark transition-colors">
|
||||
<span>{city.name}</span>
|
||||
<span class="text-ancient-black/60 dark:text-ancient-white/60 text-sm">({city.count})</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史时期筛选 -->
|
||||
<div class="bg-ancient-paper-light dark:bg-ancient-paper-dark-light p-6 border-2 border-ancient-accent/30 dark:border-ancient-accent-dark/30 shadow-md">
|
||||
<h3 class="text-lg font-ancient-heading text-ancient-black dark:text-ancient-white mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-ancient-accent dark:text-ancient-accent-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
历史朝代
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2 font-ancient-body max-h-48 overflow-y-auto scrollbar-thin scrollbar-thumb-ancient-accent/30 dark:scrollbar-thumb-ancient-accent-dark/30 pr-2">
|
||||
{periods.map((period) => (
|
||||
<div class="flex items-center group cursor-pointer">
|
||||
<div class="w-4 h-4 border border-ancient-accent/50 dark:border-ancient-accent-dark/50 mr-2 flex-shrink-0 group-hover:bg-ancient-accent/20 dark:group-hover:bg-ancient-accent-dark/20 transition-colors"></div>
|
||||
<div class="text-ancient-black dark:text-ancient-white group-hover:text-ancient-accent dark:group-hover:text-ancient-accent-dark transition-colors">
|
||||
<span>{period.name}</span>
|
||||
<span class="text-ancient-black/60 dark:text-ancient-white/60 text-sm">({period.count})</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签筛选 - 古籍风格 -->
|
||||
<div class="bg-ancient-paper-light dark:bg-ancient-paper-dark-light p-6 border-2 border-ancient-accent/30 dark:border-ancient-accent-dark/30 shadow-md">
|
||||
<h3 class="text-lg font-ancient-heading text-ancient-black dark:text-ancient-white mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-ancient-accent dark:text-ancient-accent-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path>
|
||||
</svg>
|
||||
特色标签
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{allTags.map((tag) => (
|
||||
<span class="px-3 py-1.5 bg-ancient-paper/70 dark:bg-ancient-paper-dark/70 text-ancient-black dark:text-ancient-white text-sm font-ancient-small border border-ancient-accent/30 dark:border-ancient-accent-dark/30 cursor-pointer hover:border-ancient-accent/50 dark:hover:border-ancient-accent-dark/50 hover:text-ancient-accent dark:hover:text-ancient-accent-dark">
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容 - 古籍风格 -->
|
||||
<div class="lg:col-span-3">
|
||||
<!-- 文化列表 - 古籍风格 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{currentPageCultures.map((culture, index) => (
|
||||
<ScrollReveal animation="fade" delay={index * 100}>
|
||||
<a href={`/culture/${culture.slug}`} class="block group">
|
||||
<div class="ancient-card border border-ancient-accent/40 dark:border-ancient-accent-dark/40 overflow-hidden group-hover:shadow-md group-hover:border-ancient-red/50 dark:group-hover:border-ancient-red-dark/50 transition-all duration-300">
|
||||
<div class="aspect-[5/4] bg-ancient-paper-light/70 dark:bg-ancient-paper-dark/70 relative">
|
||||
<!-- 装饰元素 -->
|
||||
<div class="absolute top-2 left-2 w-8 h-8 bg-ink-decoration opacity-10 dark:opacity-15"></div>
|
||||
<div class="absolute bottom-2 right-2 w-8 h-8 bg-ink-decoration opacity-10 dark:opacity-15 rotate-180"></div>
|
||||
|
||||
<!-- 默认内容显示 -->
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="text-ancient-black/40 dark:text-ancient-white/40 font-ancient">{culture.data.title}</span>
|
||||
</div>
|
||||
|
||||
<!-- 印章效果 -->
|
||||
{index % 4 === 0 && (
|
||||
<div class="absolute top-4 right-4 w-16 h-16 bg-seal-mark opacity-40 rotate-12"></div>
|
||||
)}
|
||||
|
||||
{/* 文化类别标签 */}
|
||||
{culture.data.category && (
|
||||
<div class="absolute top-3 right-3 px-2 py-1 bg-ancient-paper/90 dark:bg-ancient-paper-dark/90 text-ancient-black dark:text-ancient-white text-xs font-ancient-small border border-ancient-accent/30 dark:border-ancient-accent-dark/30">
|
||||
{culture.data.category}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<div class="mb-3">
|
||||
<h3 class="text-xl font-ancient text-ancient-black dark:text-ancient-white group-hover:text-ancient-red dark:group-hover:text-ancient-red-dark transition-colors">
|
||||
{culture.data.title}
|
||||
</h3>
|
||||
{(culture.data as any).period && (
|
||||
<div class="text-sm text-ancient-black/70 dark:text-ancient-white/70 mt-1 font-ancient-small">
|
||||
{(culture.data as any).period} 时期
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p class="text-ancient-black/80 dark:text-ancient-white/80 text-sm line-clamp-2 mb-4 font-ancient-body">
|
||||
{culture.data.description}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5 mb-4">
|
||||
{culture.data.tags.slice(0, 3).map((tag: string) => (
|
||||
<span class="px-2 py-1 bg-ancient-paper-light/50 dark:bg-ancient-paper-dark/50 text-ancient-black/70 dark:text-ancient-white/70 text-xs font-ancient-small border border-ancient-accent/20 dark:border-ancient-accent-dark/20">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{culture.data.tags.length > 3 && (
|
||||
<span class="px-2 py-1 bg-ancient-paper-light/50 dark:bg-ancient-paper-dark/50 text-ancient-black/70 dark:text-ancient-white/70 text-xs font-ancient-small border border-ancient-accent/20 dark:border-ancient-accent-dark/20">
|
||||
+{culture.data.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-ancient-black/80 dark:text-ancient-white/80 text-sm group-hover:translate-x-1 transition-transform font-ancient-small group-hover:text-ancient-red dark:group-hover:text-ancient-red-dark">
|
||||
查阅详情
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-1" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- 分页控件 - 古籍风格 -->
|
||||
{totalPages > 1 && (
|
||||
<div class="mt-16 flex justify-center">
|
||||
<div class="inline-flex border border-ancient-accent/40 dark:border-ancient-accent-dark/40 bg-ancient-paper-light/70 dark:bg-ancient-paper-dark/70 rounded">
|
||||
<a
|
||||
href={page > 1 ? `/culture?page=${page - 1}` : '#'}
|
||||
class={`px-4 py-2 text-sm font-ancient-small ${
|
||||
page > 1
|
||||
? 'text-ancient-black dark:text-ancient-white hover:bg-ancient-paper-light dark:hover:bg-ancient-paper-dark hover:text-ancient-red dark:hover:text-ancient-red-dark'
|
||||
: 'text-ancient-black/40 dark:text-ancient-white/40 cursor-not-allowed'
|
||||
} border-r border-ancient-accent/30 dark:border-ancient-accent-dark/30`}
|
||||
>
|
||||
上一页
|
||||
</a>
|
||||
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<a
|
||||
href={`/culture?page=${pageNum}`}
|
||||
class={`px-4 py-2 text-sm border-r border-ancient-accent/30 dark:border-ancient-accent-dark/30 font-ancient ${
|
||||
pageNum === page
|
||||
? 'bg-ancient-red/10 dark:bg-ancient-red-dark/10 text-ancient-red dark:text-ancient-red-dark'
|
||||
: 'text-ancient-black dark:text-ancient-white hover:bg-ancient-paper-light dark:hover:bg-ancient-paper-dark'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
|
||||
{totalPages > 5 && <span class="px-4 py-2 text-sm border-r border-ancient-accent/30 dark:border-ancient-accent-dark/30 text-ancient-black/60 dark:text-ancient-white/60 font-ancient-small">...</span>}
|
||||
|
||||
{totalPages > 5 && (
|
||||
<a
|
||||
href={`/culture?page=${totalPages}`}
|
||||
class="px-4 py-2 text-sm border-r border-ancient-accent/30 dark:border-ancient-accent-dark/30 text-ancient-black dark:text-ancient-white hover:bg-ancient-paper-light dark:hover:bg-ancient-paper-dark hover:text-ancient-red dark:hover:text-ancient-red-dark font-ancient-small"
|
||||
>
|
||||
{totalPages}
|
||||
</a>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={page < totalPages ? `/culture?page=${page + 1}` : '#'}
|
||||
class={`px-4 py-2 text-sm font-ancient-small ${
|
||||
page < totalPages
|
||||
? 'text-ancient-black dark:text-ancient-white hover:bg-ancient-paper-light dark:hover:bg-ancient-paper-dark hover:text-ancient-red dark:hover:text-ancient-red-dark'
|
||||
: 'text-ancient-black/40 dark:text-ancient-white/40 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
下一页
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- 底部引言 - 古籍风格 -->
|
||||
<div class="mt-20 mb-10 text-center relative">
|
||||
<!-- 装饰元素 -->
|
||||
<div class="absolute left-1/4 top-0 w-16 h-1 bg-ancient-accent/30 dark:bg-ancient-accent-dark/30"></div>
|
||||
<div class="absolute right-1/4 bottom-0 w-16 h-1 bg-ancient-accent/30 dark:bg-ancient-accent-dark/30"></div>
|
||||
|
||||
<blockquote class="text-xl text-ancient-black dark:text-ancient-white font-ancient italic">
|
||||
"文化是一个国家、一个民族的灵魂"
|
||||
</blockquote>
|
||||
<div class="mt-3 text-sm text-ancient-black/60 dark:text-ancient-white/60 font-ancient-small">— 河北历史文化研究院</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
|
||||
<script>
|
||||
// 实际应用中这里会有更多的客户端交互逻辑
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 初始化筛选和搜索功能
|
||||
const searchInput = document.querySelector('input[placeholder="输入关键词..."]');
|
||||
const tags = document.querySelectorAll('.flex-wrap.gap-2 span');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if ((e as KeyboardEvent).key === 'Enter') {
|
||||
// 执行搜索逻辑
|
||||
const query = (e.target as HTMLInputElement).value;
|
||||
if (query) {
|
||||
window.location.href = `/culture?search=${encodeURIComponent(query)}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 为标签添加点击事件
|
||||
tags.forEach((tag) => {
|
||||
tag.addEventListener('click', () => {
|
||||
const tagText = tag.textContent?.trim() || '';
|
||||
if (tagText) {
|
||||
window.location.href = `/culture?tag=${encodeURIComponent(tagText)}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 添加古籍翻页动画效果
|
||||
const cultureCards = document.querySelectorAll('.ancient-card');
|
||||
|
||||
cultureCards.forEach((card, index) => {
|
||||
// 设置初始状态
|
||||
(card as HTMLElement).style.opacity = '0';
|
||||
(card as HTMLElement).style.transform = 'translateY(20px) rotateY(5deg)';
|
||||
(card as HTMLElement).style.transition = 'all 0.5s ease-out';
|
||||
});
|
||||
|
||||
// 添加进场动画
|
||||
setTimeout(() => {
|
||||
cultureCards.forEach((card, index) => {
|
||||
setTimeout(() => {
|
||||
(card as HTMLElement).style.opacity = '1';
|
||||
(card as HTMLElement).style.transform = 'translateY(0) rotateY(0)';
|
||||
}, 100 + (index * 80));
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 古籍主题背景 */
|
||||
.bg-scroll-bg {
|
||||
background-color: var(--bg-scroll);
|
||||
background-image: url('/images/ancient-paper-texture.png');
|
||||
background-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.bg-scroll-bg-dark {
|
||||
background-color: var(--bg-scroll);
|
||||
background-image: url('/images/ancient-paper-texture.png');
|
||||
background-blend-mode: overlay;
|
||||
}
|
||||
|
||||
.bg-ancient-paper {
|
||||
background-color: var(--bg-paper);
|
||||
background-image: url('/images/rice-paper.png');
|
||||
background-size: 500px;
|
||||
background-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.bg-ancient-paper-dark {
|
||||
background-color: var(--bg-paper);
|
||||
background-image: url('/images/rice-paper.png');
|
||||
background-size: 500px;
|
||||
background-blend-mode: overlay;
|
||||
}
|
||||
|
||||
.bg-ancient-paper-light {
|
||||
background-color: var(--color-primary-50);
|
||||
background-image: url('/images/rice-paper-light.png');
|
||||
background-size: 300px;
|
||||
background-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.bg-nav-texture {
|
||||
background-color: var(--color-secondary-100);
|
||||
background-image: url('/images/bamboo-texture.png');
|
||||
background-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.bg-nav-texture-dark {
|
||||
background-color: var(--color-dark-primary-100);
|
||||
background-image: url('/images/bamboo-texture.png');
|
||||
background-blend-mode: overlay;
|
||||
}
|
||||
|
||||
.bg-pattern {
|
||||
background-image: url('/images/chinese-pattern.png');
|
||||
background-size: 200px;
|
||||
}
|
||||
|
||||
.bg-scroll-corner {
|
||||
background-image: url('/images/scroll-corner.png');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.bg-scroll-top {
|
||||
background-image: url('/images/scroll-top.png');
|
||||
background-size: 100% 100%;
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
|
||||
.bg-scroll-top-dark {
|
||||
background-image: url('/images/scroll-top-dark.png');
|
||||
background-size: 100% 100%;
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
|
||||
.bg-scroll-bottom {
|
||||
background-image: url('/images/scroll-bottom.png');
|
||||
background-size: 100% 100%;
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
|
||||
.bg-scroll-bottom-dark {
|
||||
background-image: url('/images/scroll-bottom-dark.png');
|
||||
background-size: 100% 100%;
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
|
||||
.bg-ink-splash {
|
||||
background-image: url('/images/ink-splash.png');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.bg-ink-decoration {
|
||||
background-image: url('/images/ink-decoration.png');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.bg-ink-flower {
|
||||
background-image: url('/images/ink-flower.png');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.bg-seal-mark {
|
||||
background-image: url('/images/seal-mark.png');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.calligraphy-decoration {
|
||||
background-image: url('/images/calligraphy.png');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.seal {
|
||||
background-image: url('/images/red-seal.png');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* 古籍主题颜色 */
|
||||
.text-ancient-red {
|
||||
color: #8a2c12;
|
||||
}
|
||||
|
||||
.text-ancient-red-dark {
|
||||
color: #c34c2a;
|
||||
}
|
||||
|
||||
.text-ancient-black {
|
||||
color: #362e21;
|
||||
}
|
||||
|
||||
.text-ancient-white {
|
||||
color: #f1e8d8;
|
||||
}
|
||||
|
||||
.bg-ancient-accent {
|
||||
background-color: #9c4f2e;
|
||||
}
|
||||
|
||||
.bg-ancient-accent-dark {
|
||||
background-color: #854025;
|
||||
}
|
||||
|
||||
.border-ancient-accent {
|
||||
border-color: #9c4f2e;
|
||||
}
|
||||
|
||||
.border-ancient-accent-dark {
|
||||
border-color: #854025;
|
||||
}
|
||||
|
||||
/* 古籍卡片样式 */
|
||||
.ancient-card {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background-color: rgba(248, 245, 232, 0.7);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:root.dark .ancient-card {
|
||||
background-color: rgba(45, 40, 34, 0.7);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.ancient-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:root.dark .ancient-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 书卷装饰 */
|
||||
.scroll-decoration {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 印章效果 */
|
||||
.seal-mark {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.seal-mark::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background-image: url('/images/red-seal.png');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0.2;
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
/* 卷轴打开动画 */
|
||||
@keyframes unrollScroll {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-decoration {
|
||||
animation: unrollScroll 1.2s ease-out;
|
||||
}
|
||||
|
||||
/* 印章浮现动画 */
|
||||
@keyframes revealSeal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.2) rotate(15deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1) rotate(15deg);
|
||||
}
|
||||
}
|
||||
|
||||
.seal {
|
||||
animation: revealSeal 1.5s ease-out forwards;
|
||||
animation-delay: 0.8s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 水墨渐显动画 */
|
||||
@keyframes inkReveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
filter: blur(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.1;
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.bg-ink-splash, .bg-ink-decoration, .bg-ink-flower {
|
||||
animation: inkReveal 2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保留功能 */
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
/* 标题毛笔效果 */
|
||||
.official-title h1 {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.official-title h1::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -0.25rem;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
height: 2px;
|
||||
background-color: #8a2c12;
|
||||
transform: rotate(-0.5deg);
|
||||
}
|
||||
|
||||
:root.dark .official-title h1::after {
|
||||
background-color: #c34c2a;
|
||||
}
|
||||
</style>
|
227
web/graduation/src/pages/hover-effects-new.astro
Normal file
227
web/graduation/src/pages/hover-effects-new.astro
Normal file
@ -0,0 +1,227 @@
|
||||
---
|
||||
import MainLayout from "../layouts/MainLayout.astro";
|
||||
import FlipCard from "../components/aceternity/FlipCard.astro";
|
||||
import ParticleButton from "../components/aceternity/ParticleButton.astro";
|
||||
import MagneticElement from "../components/aceternity/MagneticElement.astro";
|
||||
import ParallaxCard from "../components/aceternity/ParallaxCard.astro";
|
||||
import NeonButton from "../components/aceternity/NeonButton.astro";
|
||||
import SpotlightCard from "../components/aceternity/SpotlightCard.astro";
|
||||
import HoverGlow from "../components/aceternity/HoverGlow.astro";
|
||||
import ScrollReveal from "../components/aceternity/ScrollReveal.astro";
|
||||
---
|
||||
|
||||
<MainLayout title="交互效果展示 - 河北游礼">
|
||||
<div class="py-16 bg-gray-50 dark:bg-color-dark-bg">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-4xl mx-auto mb-16 text-center">
|
||||
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">酷炫交互效果展示</h1>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400">探索我们网站上使用的各种交互和悬停效果</p>
|
||||
</div>
|
||||
|
||||
<!-- 各种效果分组 -->
|
||||
<div class="space-y-24">
|
||||
|
||||
<!-- 3D翻转卡片 -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-8 text-center">3D翻转卡片</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<FlipCard
|
||||
frontTitle="长城"
|
||||
backTitle="世界文化遗产"
|
||||
>
|
||||
<div slot="front" class="text-center">
|
||||
<img src="https://picsum.photos/seed/great-wall-flip/300/200" alt="长城" class="rounded-lg mb-4 mx-auto"/>
|
||||
<p class="text-gray-600 dark:text-gray-400">中国最具代表性的文化象征</p>
|
||||
</div>
|
||||
<div slot="back" class="text-center">
|
||||
<p class="mb-4 text-white">河北拥有全国最长的长城线,包括八达岭、金山岭等著名景点。</p>
|
||||
<a href="/attractions/great-wall" class="inline-block px-4 py-2 bg-white text-color-primary-600 rounded-md hover:bg-gray-100">了解更多</a>
|
||||
</div>
|
||||
</FlipCard>
|
||||
|
||||
<FlipCard
|
||||
frontTitle="蔚县剪纸"
|
||||
backTitle="非遗文化"
|
||||
>
|
||||
<div slot="front" class="text-center">
|
||||
<img src="https://picsum.photos/seed/paper-cutting-flip/300/200" alt="蔚县剪纸" class="rounded-lg mb-4 mx-auto"/>
|
||||
<p class="text-gray-600 dark:text-gray-400">传统民间艺术</p>
|
||||
</div>
|
||||
<div slot="back" class="text-center">
|
||||
<p class="mb-4 text-white">蔚县剪纸以其独特的造型和鲜明的色彩闻名,展现了浓郁的民俗风情。</p>
|
||||
<a href="/culture/paper-cutting" class="inline-block px-4 py-2 bg-white text-color-primary-600 rounded-md hover:bg-gray-100">了解更多</a>
|
||||
</div>
|
||||
</FlipCard>
|
||||
|
||||
<FlipCard
|
||||
frontTitle="保定莲花酥"
|
||||
backTitle="传统美食"
|
||||
>
|
||||
<div slot="front" class="text-center">
|
||||
<img src="https://picsum.photos/seed/lotus-pastry-flip/300/200" alt="保定莲花酥" class="rounded-lg mb-4 mx-auto"/>
|
||||
<p class="text-gray-600 dark:text-gray-400">清代宫廷点心</p>
|
||||
</div>
|
||||
<div slot="back" class="text-center">
|
||||
<p class="mb-4 text-white">酥脆可口,形如莲花,是保定地区传统的伴手礼。</p>
|
||||
<a href="/cuisine/lotus-pastry" class="inline-block px-4 py-2 bg-white text-color-primary-600 rounded-md hover:bg-gray-100">了解更多</a>
|
||||
</div>
|
||||
</FlipCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 粒子按钮 -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-8 text-center">粒子特效按钮</h2>
|
||||
<div class="flex flex-wrap justify-center gap-8">
|
||||
<ParticleButton
|
||||
text="探索河北景点"
|
||||
href="/attractions"
|
||||
color="#4f46e5"
|
||||
/>
|
||||
|
||||
<ParticleButton
|
||||
text="了解文化遗产"
|
||||
href="/culture"
|
||||
color="#e11d48"
|
||||
/>
|
||||
|
||||
<ParticleButton
|
||||
text="品尝地方美食"
|
||||
href="/cuisine"
|
||||
color="#059669"
|
||||
particleCount={40}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 磁性元素 -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-8 text-center">磁性吸附效果</h2>
|
||||
<div class="flex flex-wrap justify-center gap-12">
|
||||
<MagneticElement strength={3} className="text-center">
|
||||
<div class="w-32 h-32 bg-color-primary-500 dark:bg-color-dark-primary-600 rounded-full flex items-center justify-center text-white font-bold shadow-lg">
|
||||
弱吸附效果
|
||||
</div>
|
||||
</MagneticElement>
|
||||
|
||||
<MagneticElement strength={7} className="text-center">
|
||||
<div class="w-32 h-32 bg-color-primary-600 dark:bg-color-dark-primary-700 rounded-full flex items-center justify-center text-white font-bold shadow-lg">
|
||||
中吸附效果
|
||||
</div>
|
||||
</MagneticElement>
|
||||
|
||||
<MagneticElement strength={10} className="text-center">
|
||||
<div class="w-32 h-32 bg-color-primary-700 dark:bg-color-dark-primary-800 rounded-full flex items-center justify-center text-white font-bold shadow-lg">
|
||||
强吸附效果
|
||||
</div>
|
||||
</MagneticElement>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 视差卡片 -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-8 text-center">3D视差卡片</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
<ParallaxCard
|
||||
backgroundImage="https://picsum.photos/seed/parallax-card-1/800/600"
|
||||
depth={7}
|
||||
>
|
||||
<h3 class="text-xl font-bold text-white mb-3 parallax-item" data-depth="8">承德避暑山庄</h3>
|
||||
<p class="text-white mb-4 parallax-item" data-depth="5">中国清代皇家园林,世界文化遗产</p>
|
||||
<a href="/attractions/summer-resort" class="inline-block px-4 py-2 bg-white text-color-primary-600 rounded-md hover:bg-gray-100 parallax-item" data-depth="3">了解更多</a>
|
||||
</ParallaxCard>
|
||||
|
||||
<ParallaxCard
|
||||
backgroundImage="https://picsum.photos/seed/parallax-card-2/800/600"
|
||||
depth={5}
|
||||
>
|
||||
<h3 class="text-xl font-bold text-white mb-3 parallax-item" data-depth="8">秦皇岛海滨</h3>
|
||||
<p class="text-white mb-4 parallax-item" data-depth="5">碧海蓝天,金色沙滩</p>
|
||||
<a href="/attractions/qinhuangdao" class="inline-block px-4 py-2 bg-white text-color-primary-600 rounded-md hover:bg-gray-100 parallax-item" data-depth="3">了解更多</a>
|
||||
</ParallaxCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 霓虹灯按钮 -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-8 text-center">霓虹灯按钮</h2>
|
||||
<div class="flex flex-wrap justify-center gap-8">
|
||||
<NeonButton
|
||||
text="蓝色按钮"
|
||||
color="rgb(59, 130, 246)"
|
||||
glowSize="medium"
|
||||
glowIntensity="medium"
|
||||
/>
|
||||
|
||||
<NeonButton
|
||||
text="绿色按钮"
|
||||
color="rgb(16, 185, 129)"
|
||||
glowSize="large"
|
||||
glowIntensity="high"
|
||||
/>
|
||||
|
||||
<NeonButton
|
||||
text="红色按钮"
|
||||
color="rgb(239, 68, 68)"
|
||||
glowSize="small"
|
||||
glowIntensity="low"
|
||||
pulseAnimation={false}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 聚光灯卡片 -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-8 text-center">聚光灯卡片</h2>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<SpotlightCard
|
||||
title="正定古城"
|
||||
description="河北省历史文化名城,保存有大量的历史文物古迹。建于战国时期,是全国首批历史文化名城之一,素有'北方雄镇'之称。城内现存有隆兴寺、临济寺、广惠寺三塔等众多历史遗迹和文物古迹,是中国保存最为完好的古城之一。"
|
||||
className="p-10 bg-white dark:bg-color-dark-card rounded-lg shadow-md"
|
||||
color="rgba(16, 185, 129, 0.35)"
|
||||
spotlightSize="large"
|
||||
spotlightIntensity="strong"
|
||||
>
|
||||
<div class="mt-4 mb-4">
|
||||
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-400 text-sm">
|
||||
<span>旅游景点类型:</span>
|
||||
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full dark:bg-color-dark-surface dark:text-gray-400">历史文化</span>
|
||||
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full dark:bg-color-dark-surface dark:text-gray-400">古建筑</span>
|
||||
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full dark:bg-color-dark-surface dark:text-gray-400">名城古镇</span>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/attractions/zhengding" class="inline-block mt-2 text-color-primary-600 hover:text-color-primary-700 dark:text-color-primary-400 dark:hover:text-color-primary-300 font-medium">了解更多 →</a>
|
||||
</SpotlightCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 悬停发光效果 -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-8 text-center">悬停发光效果</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
|
||||
<HoverGlow intensity="subtle" className="relative">
|
||||
<div class="p-8 bg-white dark:bg-color-dark-card rounded-lg shadow-md">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">内画鼻烟壶</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">河北衡水传统工艺品,在小小的鼻烟壶内壁上绘制精美图案。</p>
|
||||
</div>
|
||||
</HoverGlow>
|
||||
|
||||
<HoverGlow intensity="medium" color="#4f46e5" className="relative">
|
||||
<div class="p-8 bg-white dark:bg-color-dark-card rounded-lg shadow-md">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">曲阳石雕</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">历史悠久的传统工艺,技艺精湛,形态逼真。</p>
|
||||
</div>
|
||||
</HoverGlow>
|
||||
|
||||
<HoverGlow intensity="strong" color="#e11d48" className="relative">
|
||||
<div class="p-8 bg-white dark:bg-color-dark-card rounded-lg shadow-md">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">景泰蓝</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">一种金属胎掐丝珐琅工艺品,色彩华丽绚烂。</p>
|
||||
</div>
|
||||
</HoverGlow>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
96
web/graduation/src/pages/index.astro
Normal file
96
web/graduation/src/pages/index.astro
Normal file
@ -0,0 +1,96 @@
|
||||
---
|
||||
import MainLayout from "../layouts/MainLayout.astro";
|
||||
import MorphingText from "../components/aceternity/MorphingText.astro";
|
||||
|
||||
// 导航数据
|
||||
---
|
||||
|
||||
<MainLayout title="首页 - 河北游礼">
|
||||
<!-- 主页文字特效区域 -->
|
||||
<section class="hero-section relative w-full h-screen flex items-center justify-center overflow-hidden">
|
||||
<!-- 背景效果 -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary-50 via-white to-secondary-100 dark:from-dark-bg dark:via-dark-surface dark:to-dark-primary-100"></div>
|
||||
|
||||
<!-- 背景装饰 -->
|
||||
<div class="absolute inset-0 opacity-10 bg-grid-pattern"></div>
|
||||
|
||||
<!-- 径向渐变叠加 -->
|
||||
<div class="absolute inset-0 bg-radial-gradient opacity-80"></div>
|
||||
|
||||
<!-- 文字特效容器 -->
|
||||
<div class="relative z-10 text-center px-4 w-full max-w-7xl mx-auto">
|
||||
<div class="text-size-boost">
|
||||
<MorphingText
|
||||
phrases={["这么近", "那么美", "周末来河北"]}
|
||||
className="mb-8 text-primary-800 dark:text-primary-300 font-extrabold"
|
||||
textSize="text-8xl sm:text-9xl md:text-[10rem] lg:text-[12rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</section>
|
||||
|
||||
<!-- 页面指示器 -->
|
||||
<div class="absolute bottom-6 left-1/2 transform -translate-x-1/2 flex space-x-4">
|
||||
<a href="#highlights" class="text-primary-600 dark:text-primary-400 animate-bounce">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</MainLayout>
|
||||
|
||||
<style>
|
||||
/* 背景网格图案 */
|
||||
.bg-grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(180, 83, 9, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(180, 83, 9, 0.05) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
/* 径向渐变 */
|
||||
.bg-radial-gradient {
|
||||
background: radial-gradient(circle at center, transparent 0%, var(--color-primary-50, rgba(255, 251, 235, 0.8)) 70%);
|
||||
}
|
||||
|
||||
/* 特殊样式来增强文字大小 */
|
||||
.text-size-boost :global(.text-span-1),
|
||||
.text-size-boost :global(.text-span-2) {
|
||||
font-size: clamp(4rem, 15vw, 14rem) !important;
|
||||
line-height: 1.1 !important;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bg-radial-gradient {
|
||||
background: radial-gradient(circle at center, rgba(69, 51, 25, 0.5) 0%, var(--color-dark-bg, rgba(26, 22, 19, 0.8)) 70%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 背景渐变动画 */
|
||||
@keyframes gradient-shift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
.hero-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(120deg,
|
||||
var(--color-primary-600, rgba(217, 119, 6, 0.05)),
|
||||
var(--color-secondary-600, rgba(182, 125, 73, 0.05)),
|
||||
var(--color-accent-600, rgba(175, 63, 63, 0.05)));
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-shift 15s ease infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* 波浪底部装饰 */
|
||||
.bg-wave-pattern {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 120' preserveAspectRatio='none'%3E%3Cpath d='M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.84,104.45-29.34C989.49,25,1113-14.29,1200,52.47V0Z' opacity='.25' fill='%23f59e0b'%3E%3C/path%3E%3Cpath d='M0,0V15.81C13,36.92,27.64,56.86,47.69,72.05,99.41,111.27,165,111,224.58,91.58c31.15-10.15,60.09-26.07,89.67-39.8,40.92-19,84.73-46,130.83-49.67,36.26-2.85,70.9,9.42,98.6,31.56,31.77,25.39,62.32,62,103.63,73,40.44,10.79,81.35-6.69,119.13-24.28s75.16-39,116.92-43.05c59.73-5.85,113.28,22.88,168.9,38.84,30.2,8.66,59,6.17,87.09-7.5,22.43-10.89,48-26.93,60.65-49.24V0Z' opacity='.5' fill='%23d97706'%3E%3C/path%3E%3Cpath d='M0,0V5.63C149.93,59,314.09,71.32,475.83,42.57c43-7.64,84.23-20.12,127.61-26.46,59-8.63,112.48,12.24,165.56,35.4C827.93,77.22,886,95.24,951.2,90c86.53-7,172.46-45.71,248.8-84.81V0Z' fill='%23b45309' opacity='.25'%3E%3C/path%3E%3C/svg%3E");
|
||||
background-size: cover;
|
||||
background-position: center top;
|
||||
}
|
||||
</style>
|
249
web/graduation/src/pages/travel/[slug].astro
Normal file
249
web/graduation/src/pages/travel/[slug].astro
Normal file
@ -0,0 +1,249 @@
|
||||
---
|
||||
import { getCollection, getEntry, type CollectionEntry } from "astro:content";
|
||||
import MainLayout from "../../layouts/MainLayout.astro";
|
||||
import ScrollReveal from "../../components/aceternity/ScrollReveal.astro";
|
||||
|
||||
// 定义Props类型
|
||||
export interface Props {
|
||||
entry: CollectionEntry<"travel">;
|
||||
}
|
||||
|
||||
// 生成静态路径
|
||||
export async function getStaticPaths() {
|
||||
const travels = await getCollection("travel");
|
||||
return travels.map((entry) => ({
|
||||
params: { slug: entry.slug },
|
||||
props: { entry },
|
||||
}));
|
||||
}
|
||||
|
||||
// 获取当前旅行攻略数据
|
||||
const { entry } = Astro.props;
|
||||
const { Content } = await entry.render();
|
||||
|
||||
// 获取相关旅行攻略
|
||||
const allTravels = await getCollection("travel");
|
||||
const relatedTravels = allTravels
|
||||
.filter(
|
||||
(item) =>
|
||||
item.slug !== entry.slug &&
|
||||
(item.data.season === entry.data.season ||
|
||||
item.data.type === entry.data.type ||
|
||||
item.data.tags.some((tag) => entry.data.tags.includes(tag)))
|
||||
)
|
||||
.slice(0, 3);
|
||||
---
|
||||
|
||||
<MainLayout title={`${entry.data.title} - 河北游礼`}>
|
||||
<!-- 页面标题区域 -->
|
||||
<div class="relative py-16 bg-gradient-to-br from-color-primary-700 via-color-primary-600 to-color-primary-800 text-white dark:from-color-dark-primary-900 dark:via-color-dark-primary-800 dark:to-color-dark-primary-950">
|
||||
<div class="absolute inset-0 bg-black/30"></div>
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<ScrollReveal animation="fade">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<a href="/" class="text-white/80 hover:text-white transition-colors">首页</a>
|
||||
<span class="text-white/60">/</span>
|
||||
<a href="/travel" class="text-white/80 hover:text-white transition-colors">旅行攻略</a>
|
||||
<span class="text-white/60">/</span>
|
||||
<span class="text-white/60">{entry.data.title}</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-5xl font-bold mb-4">{entry.data.title}</h1>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4 mb-4">
|
||||
{entry.data.season && (
|
||||
<div class="flex items-center text-white/90">
|
||||
<span class="mr-1">🌤️</span> {entry.data.season}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.data.type && (
|
||||
<div class="flex items-center text-white/90">
|
||||
<span class="mr-1">📋</span> {entry.data.type}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.data.days && (
|
||||
<div class="flex items-center text-white/90">
|
||||
<span class="mr-1">⏱️</span> {entry.data.days}天行程
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.data.difficulty && (
|
||||
<div class="flex items-center text-white/90">
|
||||
<span class="mr-1">🏔️</span> 难度:{entry.data.difficulty}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
{entry.data.tags.map((tag) => (
|
||||
<span class="px-3 py-1 bg-white/20 backdrop-blur-sm text-white text-sm rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p class="text-xl text-white/90 max-w-3xl">{entry.data.description}</p>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="py-12 bg-white dark:bg-color-dark-bg">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
<!-- 左侧内容 -->
|
||||
<div class="lg:col-span-2">
|
||||
<ScrollReveal animation="fade">
|
||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||
<Content />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
<!-- 右侧边栏 -->
|
||||
<div class="space-y-8">
|
||||
<!-- 旅行图片 -->
|
||||
<ScrollReveal animation="slide-up">
|
||||
<div class="rounded-lg overflow-hidden shadow-md">
|
||||
<div class="h-64 bg-gray-300 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span class="text-gray-500 dark:text-gray-400">{entry.data.title} 图片</span>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<!-- 攻略信息卡片 -->
|
||||
<ScrollReveal animation="slide-up" delay={100}>
|
||||
<div class="bg-gray-50 dark:bg-color-dark-card rounded-lg shadow-md p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">攻略信息</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
{entry.data.season && (
|
||||
<div class="flex">
|
||||
<span class="w-24 flex-shrink-0 text-gray-600 dark:text-gray-400">适宜季节:</span>
|
||||
<span class="text-gray-900 dark:text-white">{entry.data.season}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.data.type && (
|
||||
<div class="flex">
|
||||
<span class="w-24 flex-shrink-0 text-gray-600 dark:text-gray-400">攻略类型:</span>
|
||||
<span class="text-gray-900 dark:text-white">{entry.data.type}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.data.days && (
|
||||
<div class="flex">
|
||||
<span class="w-24 flex-shrink-0 text-gray-600 dark:text-gray-400">行程天数:</span>
|
||||
<span class="text-gray-900 dark:text-white">{entry.data.days}天</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.data.difficulty && (
|
||||
<div class="flex">
|
||||
<span class="w-24 flex-shrink-0 text-gray-600 dark:text-gray-400">难度级别:</span>
|
||||
<span class="text-gray-900 dark:text-white">{entry.data.difficulty}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex">
|
||||
<span class="w-24 flex-shrink-0 text-gray-600 dark:text-gray-400">旅行主题:</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{entry.data.tags.map((tag) => (
|
||||
<span class="px-2 py-0.5 bg-color-primary-100 text-color-primary-800 text-xs rounded-full dark:bg-color-dark-primary-900/70 dark:text-color-primary-300">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entry.data.pubDate && (
|
||||
<div class="flex">
|
||||
<span class="w-24 flex-shrink-0 text-gray-600 dark:text-gray-400">发布时间:</span>
|
||||
<span class="text-gray-900 dark:text-white">{new Date(entry.data.pubDate).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<!-- 旅行小贴士 -->
|
||||
<ScrollReveal animation="slide-up" delay={200}>
|
||||
<div class="bg-color-primary-50 dark:bg-color-dark-primary-900/30 rounded-lg shadow-md p-6 border border-color-primary-100 dark:border-color-dark-primary-800">
|
||||
<h3 class="text-xl font-semibold text-color-primary-800 dark:text-color-primary-300 mb-4">旅行小贴士</h3>
|
||||
|
||||
<div class="space-y-3 text-color-primary-700 dark:text-color-primary-300/90">
|
||||
<div class="flex">
|
||||
<span class="mr-2">✓</span>
|
||||
<p>出行前查看天气预报,准备合适的衣物</p>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span class="mr-2">✓</span>
|
||||
<p>提前规划路线,预订住宿和交通</p>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span class="mr-2">✓</span>
|
||||
<p>携带必要的药品和紧急联系方式</p>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span class="mr-2">✓</span>
|
||||
<p>尊重当地风俗习惯,做文明旅行者</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<!-- 相关攻略 -->
|
||||
{relatedTravels.length > 0 && (
|
||||
<ScrollReveal animation="slide-up" delay={300}>
|
||||
<div class="bg-gray-50 dark:bg-color-dark-card rounded-lg shadow-md p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">相关攻略</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
{relatedTravels.map((travel) => (
|
||||
<a
|
||||
href={`/travel/${travel.slug}`}
|
||||
class="block group"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-16 h-16 flex-shrink-0 bg-gray-300 dark:bg-gray-700 rounded flex items-center justify-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">图片</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white group-hover:text-color-primary-600 dark:group-hover:text-color-primary-400 transition-colors">
|
||||
{travel.data.title}
|
||||
</h4>
|
||||
<div class="flex items-center text-xs text-gray-500 dark:text-gray-400 mt-1 space-x-2">
|
||||
{travel.data.season && <span>{travel.data.season}</span>}
|
||||
{travel.data.days && <span>• {travel.data.days}天行程</span>}
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mt-1">
|
||||
{travel.data.description.substring(0, 60)}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
|
||||
<!-- 返回按钮 -->
|
||||
<ScrollReveal animation="slide-up" delay={400}>
|
||||
<a
|
||||
href="/travel"
|
||||
class="block w-full py-3 text-center bg-color-primary-600 text-white rounded-md hover:bg-color-primary-700 transition-colors dark:bg-color-dark-primary-600 dark:hover:bg-color-dark-primary-500"
|
||||
>
|
||||
返回所有攻略
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
452
web/graduation/src/pages/travel/index.astro
Normal file
452
web/graduation/src/pages/travel/index.astro
Normal file
@ -0,0 +1,452 @@
|
||||
---
|
||||
import MainLayout from "../../layouts/MainLayout.astro";
|
||||
import { getCollection, type CollectionEntry } from "astro:content";
|
||||
import ScrollReveal from "../../components/aceternity/ScrollReveal.astro";
|
||||
|
||||
// 获取旅行攻略集合
|
||||
const travels = await getCollection("travel");
|
||||
|
||||
// 按发布日期排序
|
||||
function sortByDate<T extends { data: { pubDate?: Date | string, updatedDate?: Date | string } }>(a: T, b: T): number {
|
||||
return new Date(b.data.pubDate || b.data.updatedDate || 0).getTime() -
|
||||
new Date(a.data.pubDate || a.data.updatedDate || 0).getTime();
|
||||
}
|
||||
|
||||
const sortedTravels = [...travels].sort(sortByDate);
|
||||
|
||||
// 提取所有标签并按数量排序
|
||||
const allTags: {name: string, count: number}[] = [];
|
||||
travels.forEach((travel) => {
|
||||
travel.data.tags.forEach((tag: string) => {
|
||||
const existing = allTags.find(t => t.name === tag);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
allTags.push({ name: tag, count: 1 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const sortedTags = [...allTags].sort((a, b) => b.count - a.count);
|
||||
|
||||
// 提取所有季节
|
||||
const allSeasons = new Set<string>();
|
||||
travels.forEach((travel) => {
|
||||
if (travel.data.season) {
|
||||
allSeasons.add(travel.data.season);
|
||||
}
|
||||
});
|
||||
const seasons = Array.from(allSeasons);
|
||||
|
||||
// 提取所有类型
|
||||
const allTypes = new Set<string>();
|
||||
travels.forEach((travel) => {
|
||||
if (travel.data.type) {
|
||||
allTypes.add(travel.data.type);
|
||||
}
|
||||
});
|
||||
const types = Array.from(allTypes);
|
||||
|
||||
// 提取所有难度
|
||||
const allDifficulties = new Set<string>();
|
||||
travels.forEach((travel) => {
|
||||
if (travel.data.difficulty) {
|
||||
allDifficulties.add(travel.data.difficulty);
|
||||
}
|
||||
});
|
||||
const difficulties = Array.from(allDifficulties);
|
||||
|
||||
// 提取所有城市
|
||||
const cities: {name: string, count: number}[] = [];
|
||||
travels.forEach((travel) => {
|
||||
if (travel.data.city) {
|
||||
const existingCity = cities.find(c => c.name === travel.data.city);
|
||||
if (existingCity) {
|
||||
existingCity.count++;
|
||||
} else {
|
||||
cities.push({ name: travel.data.city, count: 1 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 按城市出现次数排序
|
||||
cities.sort((a, b) => b.count - a.count);
|
||||
|
||||
// 分页相关
|
||||
const itemsPerPage = 10;
|
||||
const totalPages = Math.ceil(sortedTravels.length / itemsPerPage);
|
||||
const currentPage = 1;
|
||||
const visibleTravels = sortedTravels.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
---
|
||||
|
||||
<MainLayout title="旅行攻略 - 河北游礼">
|
||||
<!-- 手绘旅行日记本风格的头部 -->
|
||||
<div class="bg-theme-primary-bg dark:bg-slate-900 py-12 relative overflow-hidden">
|
||||
<!-- 装饰性笔触元素 -->
|
||||
<div class="absolute top-0 right-0 w-40 h-40 bg-[url('/images/ink-splash.png')] bg-no-repeat opacity-10"></div>
|
||||
<div class="absolute bottom-0 left-0 w-32 h-32 bg-[url('/images/coffee-stain.png')] bg-no-repeat opacity-5"></div>
|
||||
|
||||
<div class="container mx-auto px-4 relative">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- 手绘风格标题 -->
|
||||
<div class="mb-10 relative">
|
||||
<div class="absolute -top-8 -left-8 w-16 h-16 bg-[url('/images/paper-clip.png')] bg-no-repeat opacity-70 transform -rotate-12"></div>
|
||||
<div class="relative inline-block">
|
||||
<h1 class="text-5xl font-handwriting text-slate-800 dark:text-primary-100 leading-tight transform -rotate-1 relative z-10">
|
||||
河北私人旅行笔记
|
||||
</h1>
|
||||
<div class="absolute -bottom-3 left-0 w-full h-3 bg-primary-300 dark:bg-primary-600 opacity-40 transform rotate-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-8 items-start">
|
||||
<!-- 左侧类似日记本的介绍 -->
|
||||
<div class="md:w-1/2 bg-white dark:bg-slate-800 p-6 border border-slate-200 dark:border-slate-700 rounded-sm shadow-md transform rotate-1 relative">
|
||||
<!-- 装饰性贴纸 -->
|
||||
<div class="absolute -top-3 -right-3 w-12 h-12 bg-[url('/images/washi-tape.png')] bg-no-repeat opacity-80 transform -rotate-15"></div>
|
||||
|
||||
<p class="font-handwriting text-lg text-slate-700 dark:text-slate-300 leading-relaxed mb-4">
|
||||
这里不是旅行社的标准路线,而是我私人的旅途记录和探索笔记...
|
||||
</p>
|
||||
<p class="font-handwriting text-lg text-slate-700 dark:text-slate-300 leading-relaxed">
|
||||
河北的每一处风景都有自己的故事,我带着好奇心和相机,在乡间小路、古老城墙和山林溪流间留下足迹。现在,我愿与你分享这些未经商业包装的真实体验。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 右侧似手绘地图的概述 -->
|
||||
<div class="md:w-1/2 bg-theme-primary-bg dark:bg-slate-800/50 p-5 border border-primary-200 dark:border-slate-700 rounded-sm shadow-md transform -rotate-1">
|
||||
<h2 class="font-handwriting text-2xl text-slate-800 dark:text-primary-300 mb-3 flex items-center">
|
||||
<span class="inline-block w-6 h-6 mr-2 bg-[url('/images/compass-icon.png')] bg-contain bg-no-repeat"></span>
|
||||
地域指南
|
||||
</h2>
|
||||
<div class="space-y-2 font-handwriting text-slate-700 dark:text-slate-300">
|
||||
<div class="flex items-start">
|
||||
<span class="inline-block w-4 h-4 mr-2 mt-1 bg-[url('/images/marker-dot.png')] bg-contain bg-no-repeat"></span>
|
||||
<p>北部:长城脚下的古朴村落</p>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<span class="inline-block w-4 h-4 mr-2 mt-1 bg-[url('/images/marker-dot.png')] bg-contain bg-no-repeat"></span>
|
||||
<p>南部:太行山下的幽静峡谷</p>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<span class="inline-block w-4 h-4 mr-2 mt-1 bg-[url('/images/marker-dot.png')] bg-contain bg-no-repeat"></span>
|
||||
<p>东部:渤海湾的日出时分</p>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<span class="inline-block w-4 h-4 mr-2 mt-1 bg-[url('/images/marker-dot.png')] bg-contain bg-no-repeat"></span>
|
||||
<p>西部:坝上草原的风与云</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 - 手账风格布局 -->
|
||||
<div class="bg-theme-primary-bg dark:bg-slate-900 py-16 relative">
|
||||
<!-- 装饰性背景元素 -->
|
||||
<div class="absolute top-20 right-20 w-60 h-60 bg-[url('/images/map-element.png')] bg-no-repeat opacity-5"></div>
|
||||
<div class="absolute bottom-40 left-40 w-48 h-48 bg-[url('/images/compass-rose.png')] bg-no-repeat opacity-5 transform rotate-15"></div>
|
||||
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-7 gap-8">
|
||||
<!-- 左侧筛选栏 - 旅行者的笔记本风格 -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="sticky top-24 space-y-8">
|
||||
<!-- 旅行者的口袋笔记本 -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-sm border border-slate-200 dark:border-slate-700 p-6 shadow-md transform -rotate-1 relative">
|
||||
<div class="absolute -top-2 left-1/2 transform -translate-x-1/2 w-16 h-5 bg-[url('/images/binding-tape.png')] bg-contain bg-no-repeat"></div>
|
||||
|
||||
<h2 class="font-handwriting text-2xl text-slate-800 dark:text-primary-100 mb-6 pb-3 border-b border-slate-200 dark:border-slate-700">
|
||||
旅行者笔记
|
||||
</h2>
|
||||
|
||||
<!-- 搜索类似手写字段 -->
|
||||
<div class="mb-8">
|
||||
<label class="block font-handwriting text-lg text-slate-700 dark:text-slate-300 mb-2">查找旅程</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入关键词..."
|
||||
class="w-full px-4 py-2 bg-theme-primary-bg dark:bg-slate-700/50 border-b-2 border-slate-300 dark:border-slate-600 font-handwriting text-slate-800 dark:text-slate-200 focus:outline-none focus:border-theme-primary dark:focus:border-theme-primary placeholder-slate-400"
|
||||
/>
|
||||
<div class="absolute right-2 top-2 opacity-60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 季节关联 -->
|
||||
<div class="mb-8">
|
||||
<h3 class="font-handwriting text-xl text-slate-800 dark:text-primary-100 mb-4 flex items-center">
|
||||
<span class="inline-block w-5 h-5 mr-2 bg-[url('/images/season-icon.png')] bg-contain bg-no-repeat"></span>
|
||||
最适季节
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{seasons.map((season) => (
|
||||
<label class="flex items-center cursor-pointer group">
|
||||
<div class="relative">
|
||||
<input type="checkbox" class="absolute opacity-0 w-0 h-0" />
|
||||
<div class="w-5 h-5 border border-slate-400 dark:border-slate-600 group-hover:border-primary-500"></div>
|
||||
<div class="absolute inset-0 opacity-0 group-hover:opacity-20 bg-primary-400"></div>
|
||||
</div>
|
||||
<span class="ml-2 font-handwriting text-slate-700 dark:text-slate-300 group-hover:text-primary-800 dark:group-hover:text-primary-400">
|
||||
{season}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 旅行类型 -->
|
||||
<div class="mb-8">
|
||||
<h3 class="font-handwriting text-xl text-slate-800 dark:text-primary-100 mb-4 flex items-center">
|
||||
<span class="inline-block w-5 h-5 mr-2 bg-[url('/images/travel-type-icon.png')] bg-contain bg-no-repeat"></span>
|
||||
旅行方式
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{types.map((type) => (
|
||||
<label class="flex items-center cursor-pointer group">
|
||||
<div class="relative">
|
||||
<input type="checkbox" class="absolute opacity-0 w-0 h-0" />
|
||||
<div class="w-5 h-5 border border-slate-400 dark:border-slate-600 group-hover:border-primary-500"></div>
|
||||
<div class="absolute inset-0 opacity-0 group-hover:opacity-20 bg-primary-400"></div>
|
||||
</div>
|
||||
<span class="ml-2 font-handwriting text-slate-700 dark:text-slate-300 group-hover:text-primary-800 dark:group-hover:text-primary-400">
|
||||
{type}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 城市筛选 - 新增部分 -->
|
||||
<div class="mb-8">
|
||||
<h3 class="font-handwriting text-xl text-slate-800 dark:text-primary-100 mb-4 flex items-center">
|
||||
<span class="inline-block w-5 h-5 mr-2 bg-[url('/images/city-icon.png')] bg-contain bg-no-repeat"></span>
|
||||
目的地城市
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{cities.map((city) => (
|
||||
<label class="flex items-center cursor-pointer group">
|
||||
<div class="relative">
|
||||
<input type="checkbox" class="absolute opacity-0 w-0 h-0" />
|
||||
<div class="w-5 h-5 border border-slate-400 dark:border-slate-600 group-hover:border-primary-500"></div>
|
||||
<div class="absolute inset-0 opacity-0 group-hover:opacity-20 bg-primary-400"></div>
|
||||
</div>
|
||||
<span class="ml-2 font-handwriting text-slate-700 dark:text-slate-300 group-hover:text-primary-800 dark:group-hover:text-primary-400">
|
||||
{city.name} <span class="text-xs text-slate-500 dark:text-slate-400">({city.count})</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 旅行灵感标签 -->
|
||||
<div class="mb-4">
|
||||
<h3 class="font-handwriting text-xl text-slate-800 dark:text-primary-100 mb-4 flex items-center">
|
||||
<span class="inline-block w-5 h-5 mr-2 bg-[url('/images/tag-icon.png')] bg-contain bg-no-repeat"></span>
|
||||
旅行灵感
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{sortedTags.slice(0, 10).map((tag) => (
|
||||
<span class="px-2 py-1 bg-slate-100 dark:bg-slate-700/70 text-slate-700 dark:text-slate-300 text-sm font-handwriting border border-slate-200 dark:border-slate-600 cursor-pointer hover:bg-primary-100 dark:hover:bg-primary-900/30 hover:border-primary-300 dark:hover:border-primary-700 transform hover:-rotate-1 transition-transform">
|
||||
# {tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容列表 - 旅行日记页面风格 -->
|
||||
<div class="lg:col-span-5">
|
||||
<!-- 引言部分 -->
|
||||
<div class="mb-12">
|
||||
<p class="text-xl font-handwriting text-slate-700 dark:text-slate-300 leading-relaxed max-w-2xl">
|
||||
这些是我在河北各地的旅行笔记,不是官方推荐,而是个人探索与体验的记录。希望能给你不一样的旅行灵感...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 旅行笔记列表 -->
|
||||
<div class="space-y-10">
|
||||
{visibleTravels.map((travel, index) => (
|
||||
<ScrollReveal animation={index % 2 === 0 ? "slide-right" : "slide-left"}>
|
||||
<a
|
||||
href={`/travel/${travel.slug}`}
|
||||
class="block group"
|
||||
>
|
||||
<div class={`bg-white dark:bg-slate-800 rounded-sm border border-slate-200 dark:border-slate-700 overflow-hidden shadow-md transform ${index % 2 === 0 ? 'rotate-1' : '-rotate-1'} hover:shadow-lg transition-shadow relative`}>
|
||||
<!-- 装饰性元素 -->
|
||||
<div class="absolute top-3 right-3 w-16 h-16 bg-[url('/images/polaroid-corner.png')] bg-contain bg-no-repeat opacity-20"></div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-0">
|
||||
<div class="md:col-span-1 h-full">
|
||||
<div class="h-48 md:h-full bg-primary-100 dark:bg-slate-700 relative border-b md:border-r border-slate-200 dark:border-slate-700">
|
||||
<div class="flex items-center justify-center h-full font-handwriting italic text-slate-500 dark:text-slate-400">
|
||||
{travel.data.title} 照片
|
||||
</div>
|
||||
{travel.data.featured && (
|
||||
<div class="absolute top-3 left-3 px-3 py-1 bg-primary-100 dark:bg-primary-900/40 text-primary-800 dark:text-primary-400 text-xs font-handwriting border border-primary-200 dark:border-primary-800/50 transform -rotate-3 shadow-sm">
|
||||
★ 私藏路线
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2 p-6">
|
||||
<h3 class="text-2xl font-handwriting text-slate-800 dark:text-primary-100 mb-3 group-hover:text-primary-800 dark:group-hover:text-primary-400 transition-colors">
|
||||
{travel.data.title}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-wrap gap-4 mb-4 text-sm font-handwriting text-slate-600 dark:text-slate-400">
|
||||
{travel.data.season && (
|
||||
<div class="flex items-center">
|
||||
<span class="inline-block w-4 h-4 mr-1 bg-[url('/images/season-small.png')] bg-contain bg-no-repeat"></span>
|
||||
{travel.data.season}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{travel.data.type && (
|
||||
<div class="flex items-center">
|
||||
<span class="inline-block w-4 h-4 mr-1 bg-[url('/images/type-small.png')] bg-contain bg-no-repeat"></span>
|
||||
{travel.data.type}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{travel.data.days && (
|
||||
<div class="flex items-center">
|
||||
<span class="inline-block w-4 h-4 mr-1 bg-[url('/images/days-small.png')] bg-contain bg-no-repeat"></span>
|
||||
{travel.data.days}天
|
||||
</div>
|
||||
)}
|
||||
|
||||
{travel.data.difficulty && (
|
||||
<div class="flex items-center">
|
||||
<span class="inline-block w-4 h-4 mr-1 bg-[url('/images/difficulty-small.png')] bg-contain bg-no-repeat"></span>
|
||||
难度: {travel.data.difficulty}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p class="font-handwriting text-slate-600 dark:text-slate-400 leading-relaxed mb-4 line-clamp-3">
|
||||
{travel.data.description}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
{travel.data.tags.slice(0, 3).map((tag: string) => (
|
||||
<span class="px-2 py-0.5 text-xs font-handwriting bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-600">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{travel.data.tags.length > 3 && (
|
||||
<span class="px-2 py-0.5 text-xs font-handwriting bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-600">
|
||||
+{travel.data.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="text-primary-700 dark:text-primary-500 text-sm font-handwriting group-hover:underline mt-2 flex items-center">
|
||||
阅读完整笔记
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-1" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- 分页 - 手绘风格 -->
|
||||
<div class="mt-12 flex justify-center">
|
||||
<div class="inline-flex border border-slate-300 dark:border-slate-700 overflow-hidden bg-white dark:bg-slate-800">
|
||||
<button class="px-4 py-2 border-r border-slate-300 dark:border-slate-700 font-handwriting text-slate-700 dark:text-slate-300 hover:bg-primary-50 dark:hover:bg-slate-700">
|
||||
上一页
|
||||
</button>
|
||||
<button class="px-4 py-2 border-r border-slate-300 dark:border-slate-700 font-handwriting text-white bg-primary-600 dark:bg-primary-700">
|
||||
1
|
||||
</button>
|
||||
{totalPages > 1 && (
|
||||
<button class="px-4 py-2 border-r border-slate-300 dark:border-slate-700 font-handwriting text-slate-700 dark:text-slate-300 hover:bg-primary-50 dark:hover:bg-slate-700">
|
||||
2
|
||||
</button>
|
||||
)}
|
||||
{totalPages > 2 && (
|
||||
<button class="px-4 py-2 border-r border-slate-300 dark:border-slate-700 font-handwriting text-slate-700 dark:text-slate-300 hover:bg-primary-50 dark:hover:bg-slate-700">
|
||||
3
|
||||
</button>
|
||||
)}
|
||||
{totalPages > 1 && (
|
||||
<button class="px-4 py-2 font-handwriting text-slate-700 dark:text-slate-300 hover:bg-primary-50 dark:hover:bg-slate-700">
|
||||
下一页
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部引言 - 旅行者的话 -->
|
||||
<div class="mt-16 text-center max-w-2xl mx-auto">
|
||||
<p class="text-lg font-handwriting text-slate-600 dark:text-slate-400 italic relative">
|
||||
<span class="absolute -top-6 left-0 text-4xl opacity-20">"</span>
|
||||
旅行不在于目的地的远近,而在于看世界的眼光。河北的每一处风景,都值得用心发现...
|
||||
<span class="absolute -bottom-6 right-0 text-4xl opacity-20">"</span>
|
||||
</p>
|
||||
<div class="mt-8 flex justify-center">
|
||||
<div class="w-16 h-8 bg-[url('/images/signature.png')] bg-contain bg-no-repeat opacity-70"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
|
||||
<style>
|
||||
.font-handwriting {
|
||||
font-family: cursive, sans-serif;
|
||||
}
|
||||
|
||||
.font-adventure {
|
||||
font-family: cursive, sans-serif;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 客户端交互逻辑
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 选项卡切换
|
||||
const tabLinks = document.querySelectorAll('.container .whitespace-nowrap');
|
||||
|
||||
tabLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
// 移除所有活动状态
|
||||
tabLinks.forEach(tab => {
|
||||
tab.classList.remove('text-primary-800', 'dark:text-primary-400', 'border-b-2', 'border-primary-500');
|
||||
tab.classList.add('text-slate-600', 'dark:text-slate-400');
|
||||
});
|
||||
|
||||
// 设置当前活动状态
|
||||
link.classList.remove('text-slate-600', 'dark:text-slate-400');
|
||||
link.classList.add('text-primary-800', 'dark:text-primary-400', 'border-b-2', 'border-primary-500');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
229
web/graduation/src/styles/global.css
Normal file
229
web/graduation/src/styles/global.css
Normal file
@ -0,0 +1,229 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 定义深色模式选择器 */
|
||||
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||
|
||||
@theme {
|
||||
/* 主色调 - 使用琥珀色系为主题色 */
|
||||
--color-primary-50: #fffbeb;
|
||||
--color-primary-100: #fef3c7;
|
||||
--color-primary-200: #fde68a;
|
||||
--color-primary-300: #fcd34d;
|
||||
--color-primary-400: #fbbf24;
|
||||
--color-primary-500: #f59e0b;
|
||||
--color-primary-600: #d97706;
|
||||
--color-primary-700: #b45309;
|
||||
--color-primary-800: #92400e;
|
||||
--color-primary-900: #78350f;
|
||||
--color-primary-950: #451a03;
|
||||
|
||||
/* 辅助色调 - 暖棕色 */
|
||||
--color-secondary-50: #faf5f0;
|
||||
--color-secondary-100: #f8f0e5;
|
||||
--color-secondary-200: #f1e0cb;
|
||||
--color-secondary-300: #e6c9a5;
|
||||
--color-secondary-400: #d9ac7c;
|
||||
--color-secondary-500: #c6915a;
|
||||
--color-secondary-600: #b67d49;
|
||||
--color-secondary-700: #96663d;
|
||||
--color-secondary-800: #7d5636;
|
||||
--color-secondary-900: #67472e;
|
||||
--color-secondary-950: #3a2719;
|
||||
|
||||
/* 强调色调 - 深红褐色 */
|
||||
--color-accent-50: #fdf2f2;
|
||||
--color-accent-100: #f8e1e1;
|
||||
--color-accent-200: #f3c7c7;
|
||||
--color-accent-300: #e89f9f;
|
||||
--color-accent-400: #dc7676;
|
||||
--color-accent-500: #c95252;
|
||||
--color-accent-600: #af3f3f;
|
||||
--color-accent-700: #923636;
|
||||
--color-accent-800: #783333;
|
||||
--color-accent-900: #653131;
|
||||
--color-accent-950: #3b1b1b;
|
||||
|
||||
/* 中性色调 */
|
||||
--color-gray-50: #f8fafc;
|
||||
--color-gray-100: #f1f5f9;
|
||||
--color-gray-200: #e2e8f0;
|
||||
--color-gray-300: #cbd5e1;
|
||||
--color-gray-400: #94a3b8;
|
||||
--color-gray-500: #64748b;
|
||||
--color-gray-600: #475569;
|
||||
--color-gray-700: #334155;
|
||||
--color-gray-800: #1e293b;
|
||||
--color-gray-900: #0f172a;
|
||||
--color-gray-950: #020617;
|
||||
|
||||
/* 深色模式颜色 */
|
||||
--color-dark-bg: #1a1613;
|
||||
--color-dark-surface: #282420;
|
||||
--color-dark-card: #332f2b;
|
||||
--color-dark-border: #49443d;
|
||||
--color-dark-text: #f1ece4;
|
||||
--color-dark-text-secondary: #c4b9aa;
|
||||
|
||||
/* 特殊主题背景 */
|
||||
--color-paper-light: #f8f5e8;
|
||||
--color-paper-dark: #2d2822;
|
||||
--color-recipe-light: #fdf7ed;
|
||||
--color-recipe-dark: #302a23;
|
||||
--color-scroll-light: #f5f1e6;
|
||||
--color-scroll-dark: #2b2720;
|
||||
--color-travel-light: #fef8e8;
|
||||
--color-travel-dark: #2e2921;
|
||||
|
||||
/* 黑暗模式中的强调色 */
|
||||
--color-dark-primary-50: #362713;
|
||||
--color-dark-primary-100: #453319;
|
||||
--color-dark-primary-200: #604826;
|
||||
--color-dark-primary-300: #7c5d32;
|
||||
--color-dark-primary-400: #9b763d;
|
||||
--color-dark-primary-500: #bd914a;
|
||||
--color-dark-primary-600: #d7ab65;
|
||||
--color-dark-primary-700: #e9c689;
|
||||
--color-dark-primary-800: #f5e0b3;
|
||||
--color-dark-primary-900: #faefdb;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* 基础色调 */
|
||||
--bg-primary: var(--color-gray-50);
|
||||
--bg-secondary: var(--color-gray-100);
|
||||
--text-primary: var(--color-gray-900);
|
||||
--text-secondary: var(--color-gray-700);
|
||||
--border-color: var(--color-gray-300);
|
||||
|
||||
/* 主题色调 */
|
||||
--theme-primary: var(--color-primary-500);
|
||||
--theme-primary-light: var(--color-primary-400);
|
||||
--theme-primary-dark: var(--color-primary-600);
|
||||
--theme-primary-bg: var(--color-primary-50);
|
||||
--theme-primary-bg-hover: var(--color-primary-100);
|
||||
|
||||
/* 页面特殊背景 */
|
||||
--bg-paper: var(--color-paper-light);
|
||||
--bg-recipe: var(--color-recipe-light);
|
||||
--bg-scroll: var(--color-scroll-light);
|
||||
--bg-travel: var(--color-travel-light);
|
||||
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 深色模式样式 */
|
||||
[data-theme='dark'] {
|
||||
/* 基础色调 */
|
||||
--bg-primary: var(--color-dark-bg);
|
||||
--bg-secondary: var(--color-dark-surface);
|
||||
--text-primary: var(--color-dark-text);
|
||||
--text-secondary: var(--color-dark-text-secondary);
|
||||
--border-color: var(--color-dark-border);
|
||||
|
||||
/* 主题色调 */
|
||||
--theme-primary: var(--color-dark-primary-500);
|
||||
--theme-primary-light: var(--color-dark-primary-400);
|
||||
--theme-primary-dark: var(--color-dark-primary-600);
|
||||
--theme-primary-bg: var(--color-dark-primary-100);
|
||||
--theme-primary-bg-hover: var(--color-dark-primary-200);
|
||||
|
||||
/* 页面特殊背景 */
|
||||
--bg-paper: var(--color-paper-dark);
|
||||
--bg-recipe: var(--color-recipe-dark);
|
||||
--bg-scroll: var(--color-scroll-dark);
|
||||
--bg-travel: var(--color-travel-dark);
|
||||
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 统一颜色类 */
|
||||
.bg-theme-primary { background-color: var(--theme-primary); }
|
||||
.bg-theme-primary-light { background-color: var(--theme-primary-light); }
|
||||
.bg-theme-primary-dark { background-color: var(--theme-primary-dark); }
|
||||
.bg-theme-primary-bg { background-color: var(--theme-primary-bg); }
|
||||
|
||||
.text-theme-primary { color: var(--theme-primary); }
|
||||
.text-theme-primary-light { color: var(--theme-primary-light); }
|
||||
.text-theme-primary-dark { color: var(--theme-primary-dark); }
|
||||
|
||||
.border-theme-primary { border-color: var(--theme-primary); }
|
||||
.border-theme-primary-light { border-color: var(--theme-primary-light); }
|
||||
.border-theme-primary-dark { border-color: var(--theme-primary-dark); }
|
||||
|
||||
/* 主题特殊背景 */
|
||||
.bg-scroll-bg { background-color: var(--bg-scroll); }
|
||||
.bg-scroll-bg-dark { background-color: var(--bg-scroll); }
|
||||
.bg-recipe-paper-light { background-color: var(--bg-recipe); }
|
||||
.bg-recipe-paper-dark { background-color: var(--bg-recipe); }
|
||||
.bg-ancient-paper { background-color: var(--bg-paper); }
|
||||
.bg-ancient-paper-dark { background-color: var(--bg-paper); }
|
||||
|
||||
/* 黑暗模式下的卡片样式覆盖 */
|
||||
[data-theme='dark'] .bg-white {
|
||||
background-color: var(--color-dark-card);
|
||||
}
|
||||
|
||||
/* 黑暗模式下的文本颜色覆盖 */
|
||||
[data-theme='dark'] .text-gray-900 {
|
||||
color: var(--color-dark-text);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .text-gray-700,
|
||||
[data-theme='dark'] .text-gray-600 {
|
||||
color: var(--color-dark-text-secondary);
|
||||
}
|
||||
|
||||
/* 兼容性覆盖:琥珀色/棕色 */
|
||||
[data-theme='light'] .text-amber-700,
|
||||
[data-theme='light'] .text-amber-800,
|
||||
[data-theme='light'] .text-brown-700 {
|
||||
color: var(--color-primary-700);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .text-amber-300,
|
||||
[data-theme='dark'] .text-amber-400,
|
||||
[data-theme='dark'] .text-brown-300 {
|
||||
color: var(--color-dark-primary-600);
|
||||
}
|
||||
|
||||
[data-theme='light'] .bg-amber-50,
|
||||
[data-theme='light'] .bg-amber-100 {
|
||||
background-color: var(--color-primary-50);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .bg-amber-900,
|
||||
[data-theme='dark'] .bg-slate-900 {
|
||||
background-color: var(--color-dark-bg);
|
||||
}
|
||||
|
||||
/* 表单元素在黑暗模式下的适配 */
|
||||
[data-theme='dark'] input,
|
||||
[data-theme='dark'] select,
|
||||
[data-theme='dark'] textarea {
|
||||
background-color: var(--color-dark-card);
|
||||
border-color: var(--color-dark-border);
|
||||
color: var(--color-dark-text);
|
||||
}
|
||||
|
||||
/* 文本和链接颜色在黑暗模式下的调整 */
|
||||
[data-theme='dark'] a:not([class]) {
|
||||
color: var(--theme-primary);
|
||||
}
|
||||
|
||||
[data-theme='dark'] a:not([class]):hover {
|
||||
color: var(--theme-primary-light);
|
||||
}
|
||||
|
||||
/* 黑暗模式下的阴影调整 */
|
||||
[data-theme='dark'] .shadow-md,
|
||||
[data-theme='dark'] .shadow-lg {
|
||||
--tw-shadow-color: rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
|
||||
/* 黑暗模式下的渐变背景调整 */
|
||||
[data-theme='dark'] .bg-gradient-to-r {
|
||||
background-image: linear-gradient(to right, var(--color-dark-primary-200), var(--color-dark-primary-300));
|
||||
}
|
5
web/graduation/tsconfig.json
Normal file
5
web/graduation/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
Loading…
Reference in New Issue
Block a user