From 2aaffb9e2bdba79ee2d9e5b96a2eaacd0f2f24ee Mon Sep 17 00:00:00 2001 From: lsy Date: Sat, 7 Dec 2024 02:25:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E7=B2=92=E5=AD=90=E5=9B=BE=E5=83=8F=E7=BB=84=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=96=87=E7=AB=A0=E6=95=B0=E6=8D=AE=E7=BB=93?= =?UTF-8?q?=E6=9E=84=EF=BC=8C=E6=94=B9=E8=BF=9B=E4=B8=BB=E9=A2=98=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=92=8C=E6=A0=B7=E5=BC=8F=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=A4=9A=E4=B8=AA=E5=B0=8F=E9=97=AE=E9=A2=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/hooks/ParticleImage.tsx | 476 +++++++++++++++----- frontend/hooks/themeMode.tsx | 63 ++- frontend/interface/fields.ts | 83 ++++ frontend/interface/post.ts | 15 - frontend/themes/echoes/about.tsx | 2 +- frontend/themes/echoes/article.tsx | 402 ++++++++++++----- frontend/themes/echoes/layout.tsx | 342 ++++++++------ frontend/themes/echoes/post.tsx | 132 +++++- frontend/themes/echoes/styles/layouts.css | 80 +++- frontend/themes/echoes/utils/colorScheme.ts | 28 ++ 10 files changed, 1178 insertions(+), 445 deletions(-) create mode 100644 frontend/interface/fields.ts delete mode 100644 frontend/interface/post.ts create mode 100644 frontend/themes/echoes/utils/colorScheme.ts diff --git a/frontend/hooks/ParticleImage.tsx b/frontend/hooks/ParticleImage.tsx index 47cf3fd..83e8d3f 100644 --- a/frontend/hooks/ParticleImage.tsx +++ b/frontend/hooks/ParticleImage.tsx @@ -13,6 +13,11 @@ interface Particle { delay: number; } +const particleLoadQueue = new Set(); +let lastAnimationTime = 0; +const ANIMATION_THRESHOLD = 300; // 300ms的阈值 +const MIN_DELAY = 100; // 最小延迟时间 + const createErrorParticles = (width: number, height: number) => { const particles: Particle[] = []; const positionArray: number[] = []; @@ -20,7 +25,7 @@ const createErrorParticles = (width: number, height: number) => { const errorColor = new THREE.Color(0.8, 0, 0); const size = Math.min(width, height); - const scaleFactor = size * 0.3; + const scaleFactor = size * 0.35; const particlesPerLine = 50; // X 形状的两条线 @@ -82,7 +87,7 @@ const createSmileParticles = (width: number, height: number) => { const size = Math.min(width, height); const scale = size / 200; - const radius = size * 0.35; + const radius = size * 0.4; const particleSize = Math.max(1.2, scale * 1.2); const particleColor = new THREE.Color(0.8, 0.6, 0); @@ -91,23 +96,26 @@ const createSmileParticles = (width: number, height: number) => { // 计算脸部轮廓的点 const outlinePoints = Math.floor(60 * scale); + const offsetX = radius * 0.05; // 添加水平偏移 + const offsetY = radius * 0.05; // 添加垂直偏移 + for (let i = 0; i < outlinePoints; i++) { const angle = (i / outlinePoints) * Math.PI * 2; allPoints.push({ - x: Math.cos(angle) * radius, - y: Math.sin(angle) * radius + x: Math.cos(angle) * radius + offsetX, + y: Math.sin(angle) * radius + offsetY }); } - // 修改眼睛的生成方式 + // 改眼睛的生成方式 const eyeOffset = radius * 0.3; - const eyeY = radius * 0.15; + const eyeY = radius * 0.15 + offsetY; const eyeSize = radius * 0.1; // 稍微减小眼睛尺寸 const eyePoints = Math.floor(20 * scale); [-1, 1].forEach(side => { // 使用同心圆的方式生成眼睛 - const eyeCenterX = side * eyeOffset; + const eyeCenterX = side * eyeOffset + offsetX; const rings = 3; // 同心圆的数量 for (let ring = 0; ring < rings; ring++) { @@ -132,13 +140,13 @@ const createSmileParticles = (width: number, height: number) => { // 计算嘴巴的点 const smileWidth = radius * 0.6; - const smileY = -radius * 0.35; + const smileY = -radius * 0.35 + offsetY; const smilePoints = Math.floor(25 * scale); for (let i = 0; i < smilePoints; i++) { const t = i / (smilePoints - 1); - const x = (t * 2 - 1) * smileWidth; - const y = smileY + Math.pow(x / smileWidth, 2) * radius * 0.2; + const x = (t * 2 - 1) * smileWidth + offsetX; + const y = smileY + Math.pow(x / smileWidth, 2) * radius * 0.2 + offsetY; allPoints.push({ x, y }); } @@ -210,6 +218,64 @@ const BG_CONFIG = { className: 'bg-gradient-to-br from-[rgb(248,250,252)] via-[rgb(241,245,249)] to-[rgb(236,241,247)] dark:from-[rgb(10,37,77)] dark:via-[rgb(8,27,57)] dark:to-[rgb(2,8,23)]' }; +// 修改采样率和粒子大小计算函数 +const getOptimalImageParams = (width: number, height: number) => { + const totalPixels = width * height; + const pixelRatio = window.devicePixelRatio || 1; + const isMobile = window.innerWidth <= 768; + + // 移动端使用更大的采样间隔来减少���数量 + let samplingGap = isMobile + ? Math.ceil(Math.max(width, height) / 60) // 移动端降低采样密度 + : Math.ceil(Math.max(width, height) / 120); // 桌面端保持较高采密度 + + // 限制最小采样间隔,避免粒子过多 + samplingGap = Math.max(samplingGap, isMobile ? 4 : 2); + + // 计算粒子大小 + const size = Math.min(width, height); + const scale = size / 200; + // 移动端适当增大粒子以保持视觉效果 + const particleSize = isMobile + ? Math.max(2, scale * 2.2) + : Math.max(1.5, scale * (1.8 / pixelRatio)); + + return { + samplingGap, + particleSize, + // 移动端使用较短的动画时间减少性能开销 + animationDuration: isMobile ? 0.6 : 0.8, + // 移动端减少延迟时间,使动画更快完成 + delayMultiplier: isMobile ? 0.3 : 0.6 + }; +}; + +// 添加资源清理函数 +const cleanupResources = (scene: THREE.Scene) => { + scene.traverse((object) => { + if (object instanceof THREE.Points) { + const geometry = object.geometry; + const material = object.material as THREE.PointsMaterial; + + // 清空几何体缓冲区 + if (geometry.attributes.position) { + geometry.attributes.position.array = new Float32Array(0); + } + if (geometry.attributes.color) { + geometry.attributes.color.array = new Float32Array(0); + } + + geometry.dispose(); + material.dispose(); + + // 移除所有属性 + geometry.deleteAttribute('position'); + geometry.deleteAttribute('color'); + } + }); + scene.clear(); +}; + export const ParticleImage = ({ src, status, @@ -221,10 +287,182 @@ export const ParticleImage = ({ const cameraRef = useRef(); const rendererRef = useRef(); const animationFrameRef = useRef(); + const animationTimeoutRef = useRef(); + const isAnimatingRef = useRef(false); // 添加动画状态控制 + + // 添加一个 ref 来追踪组件的挂载状态 + const isMountedRef = useRef(true); + + // 修改动画控制函数 + const startAnimation = useCallback(( + positionAttribute: THREE.BufferAttribute, + particles: Particle[], + width: number, + height: number + ) => { + if (isAnimatingRef.current) return; + isAnimatingRef.current = true; + + const { animationDuration, delayMultiplier } = getOptimalImageParams(width, height); + const animations: gsap.core.Tween[] = []; + + // 获取当前场景中的 Points 对象 + const points = sceneRef.current?.children[0] as THREE.Points; + if (!points) return; + + const material = points.material as THREE.PointsMaterial; + + // 添加材质透明度动画 + const materialTween = gsap.to(material, { + opacity: 1, + duration: 0.3, + ease: "power2.out" + }); + animations.push(materialTween); + + particles.forEach((particle, i) => { + const i3 = i * 3; + const distanceToCenter = Math.sqrt( + Math.pow(particle.originalX, 2) + + Math.pow(particle.originalY, 2) + ); + const maxDistance = Math.sqrt(Math.pow(width/2, 2) + Math.pow(height/2, 2)); + const normalizedDistance = distanceToCenter / maxDistance; + + const tween = gsap.to(positionAttribute.array, { + duration: animationDuration, + delay: normalizedDistance * delayMultiplier, + [i3]: particle.originalX, + [i3 + 1]: particle.originalY, + [i3 + 2]: 0, + ease: "power2.inOut", + onUpdate: () => { + positionAttribute.needsUpdate = true; + } + }); + animations.push(tween); + }); + + if (animationTimeoutRef.current) { + clearTimeout(animationTimeoutRef.current); + } + + const maxDelay = Math.max(...animations.map(tween => tween.delay() || 0)); + + // 在动画即将结束时开始淡出粒子 + const fadeOutDelay = (maxDelay + animationDuration) * 1000 - 200; + + setTimeout(() => { + gsap.to(material, { + opacity: 0, + duration: 0.3, + ease: "power2.in" + }); + }, fadeOutDelay); + + // 确保在粒子完全消失后才触发完成回调 + animationTimeoutRef.current = setTimeout(() => { + requestAnimationFrame(() => { + isAnimatingRef.current = false; + if (onAnimationComplete) { + onAnimationComplete(); + } + }); + }, fadeOutDelay + 300); + }, [onAnimationComplete]); + + // 修改清理函数 + const cleanup = useCallback(() => { + if (!isMountedRef.current) return; + + // 清理动画状态 + isAnimatingRef.current = false; + + // 清理超时 + if (animationTimeoutRef.current) { + clearTimeout(animationTimeoutRef.current); + } + + // 取消动画帧 + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = undefined; + } + + // 清理 GSAP 动画 + gsap.killTweensOf('*'); + + // 清理场景资源 + if (sceneRef.current) { + cleanupResources(sceneRef.current); + } + + // 修改渲染器清理逻辑 + if (rendererRef.current) { + const renderer = rendererRef.current; + const domElement = renderer.domElement; + + // 使用 requestAnimationFrame 确保在一帧进�� DOM 操作 + requestAnimationFrame(() => { + if (isMountedRef.current && containerRef.current?.contains(domElement)) { + try { + containerRef.current.removeChild(domElement); + } catch (e) { + console.warn('清理渲染器DOM元素失败:', e); + } + } + + renderer.dispose(); + renderer.forceContextLoss(); + }); + + rendererRef.current = undefined; + } + }, []); + + // 修改 useEffect 的清理 + useEffect(() => { + return () => { + cleanup(); + }; + }, [cleanup]); + + // 修改 updateParticles 函数 + const updateParticles = useCallback((width: number, height: number) => { + if (!sceneRef.current || isAnimatingRef.current || !isMountedRef.current) return; + + cleanup(); + + if (!isMountedRef.current) return; + + const { particles, positionArray, colorArray, particleSize } = createSmileParticles(width, height); + + const geometry = new THREE.BufferGeometry(); + const material = new THREE.PointsMaterial({ + size: particleSize, + vertexColors: true, + transparent: true, + opacity: 1, + sizeAttenuation: true, + blending: THREE.AdditiveBlending, + depthWrite: false, + depthTest: false + }); + + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positionArray, 3)); + geometry.setAttribute('color', new THREE.Float32BufferAttribute(colorArray, 3)); + + const points = new THREE.Points(geometry, material); + sceneRef.current.add(points); + + const positionAttribute = geometry.attributes.position as THREE.BufferAttribute; + startAnimation(positionAttribute, particles, width, height); + }, [cleanup, startAnimation]); // 将 resize 处理逻辑移到组件顶层 const handleResize = useCallback(() => { - if (!containerRef.current || !cameraRef.current || !rendererRef.current || !sceneRef.current) return; + if (!containerRef.current || !cameraRef.current || !rendererRef.current || + !sceneRef.current || !isMountedRef.current) return; const width = containerRef.current.offsetWidth; const height = containerRef.current.offsetHeight; @@ -240,71 +478,24 @@ export const ParticleImage = ({ // 更新渲染器大小 rendererRef.current.setSize(width, height); - // 只有当尺寸变化超阈值时才重生成粒子 + // 只在尺寸显著变化时重新生成粒子 const currentSize = Math.min(width, height); const previousSize = sceneRef.current.userData.previousSize || currentSize; const sizeChange = Math.abs(currentSize - previousSize) / previousSize; if (sizeChange > 0.2 && src === '') { + if (sceneRef.current) { + cleanupResources(sceneRef.current); + } sceneRef.current.userData.previousSize = currentSize; updateParticles(width, height); } - }, [src]); - - // 将粒子更新逻辑抽取为单独的函数 - const updateParticles = useCallback((width: number, height: number) => { - if (!sceneRef.current) return; - - gsap.killTweensOf('*'); - - const { particles, positionArray, colorArray, particleSize } = createSmileParticles(width, height); - - const material = new THREE.PointsMaterial({ - size: particleSize, - vertexColors: true, - transparent: true, - opacity: 1, - sizeAttenuation: true, - blending: THREE.AdditiveBlending, - depthWrite: false, - depthTest: false - }); - - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute('position', new THREE.Float32BufferAttribute(positionArray, 3)); - geometry.setAttribute('color', new THREE.Float32BufferAttribute(colorArray, 3)); - - sceneRef.current.clear(); - const points = new THREE.Points(geometry, material); - sceneRef.current.add(points); - - const positionAttribute = geometry.attributes.position; - - particles.forEach((particle, i) => { - const i3 = i * 3; - const distanceToCenter = Math.sqrt( - Math.pow(particle.originalX, 2) + - Math.pow(particle.originalY, 2) - ); - const maxDistance = Math.sqrt(Math.pow(width/2, 2) + Math.pow(height/2, 2)); - const normalizedDistance = distanceToCenter / maxDistance; - - gsap.to(positionAttribute.array, { - duration: 0.8, - delay: normalizedDistance * 0.6, - [i3]: particle.originalX, - [i3 + 1]: particle.originalY, - [i3 + 2]: 0, - ease: "sine.inOut", - onUpdate: () => { - positionAttribute.needsUpdate = true; - } - }); - }); - }, []); + }, [src, updateParticles]); // 主要的 useEffect useEffect(() => { + isMountedRef.current = true; + if (!containerRef.current) return; const width = containerRef.current.offsetWidth; @@ -327,12 +518,20 @@ export const ParticleImage = ({ const renderer = new THREE.WebGLRenderer({ alpha: true, - antialias: true + antialias: window.innerWidth > 768, + powerPreference: 'low-power' }); - renderer.setPixelRatio(window.devicePixelRatio); + renderer.setPixelRatio(Math.min( + window.devicePixelRatio, + window.innerWidth <= 768 ? 2 : 3 + )); renderer.setSize(width, height); rendererRef.current = renderer; - containerRef.current.appendChild(renderer.domElement); + + // 确保容器仍然存在再添加渲染器 + if (containerRef.current && isMountedRef.current) { + containerRef.current.appendChild(renderer.domElement); + } // 检查是否应该显示笑 if (src === '') { @@ -418,7 +617,7 @@ export const ParticleImage = ({ }; } - // 建错误动函数 + // 建错误函数 const showErrorAnimation = () => { if (!scene) return; @@ -476,7 +675,11 @@ export const ParticleImage = ({ const ctx = canvas.getContext('2d'); if (ctx) { - // 计算目标尺寸和裁剪区域 + // 增加一个小的边距以确保完全覆盖 + const padding = 2; // 添加2像素的内边距 + canvas.width = width + padding * 2; + canvas.height = height + padding * 2; + const targetAspect = width / height; const imgAspect = img.width / img.height; @@ -485,36 +688,30 @@ export const ParticleImage = ({ let sourceX = 0; let sourceY = 0; - // 裁源图片,确保比例匹配目标容器 if (imgAspect > targetAspect) { - // 图片较宽,需要裁剪两边 sourceWidth = img.height * targetAspect; sourceX = (img.width - sourceWidth) / 2; } else { - // 图片较高,需要裁剪下 sourceHeight = img.width / targetAspect; sourceY = (img.height - sourceHeight) / 2; } - // 设置画布尺寸为目标显示尺寸 - canvas.width = width; - canvas.height = height; - - // 直接绘制裁剪后的图片到目标尺寸 + // 绘制时考虑padding ctx.drawImage( img, - sourceX, sourceY, sourceWidth, sourceHeight, // 源图片的裁剪区域 - 0, 0, width, height // 目标区域(填满画布) + sourceX, sourceY, sourceWidth, sourceHeight, + padding, padding, width, height ); - - const imageData = ctx.getImageData(0, 0, width, height); + + // 采样时也要考虑padding + const imageData = ctx.getImageData(padding, padding, width, height); const particles: Particle[] = []; const positionArray = []; const colorArray = []; const samplingGap = Math.ceil(Math.max(width, height) / 80); - // 采样已裁剪的图片像素 + // 采样裁剪的图片像素 for (let y = 0; y < height; y += samplingGap) { for (let x = 0; x < width; x += samplingGap) { const i = (y * width + x) * 4; @@ -545,7 +742,7 @@ export const ParticleImage = ({ }); // 随机初始位置(根据距离调整范围) - const spread = 1 - normalizedDistance * 0.5; // 距离越远,初始扩散越小 + const spread = 1 - normalizedDistance * 0.5; // 距离越远,始扩散越小 positionArray.push( (Math.random() - 0.5) * width * spread, (Math.random() - 0.5) * height * spread, @@ -585,7 +782,7 @@ export const ParticleImage = ({ const colorAttribute = geometry.attributes.color; let completedAnimations = 0; - const totalAnimations = particles.length * 2; // 位置和颜色动画 + const totalAnimations = particles.length * 2; // 置和颜色动画 const checkComplete = () => { completedAnimations++; @@ -612,7 +809,7 @@ export const ParticleImage = ({ onComplete: checkComplete }); - // 颜色动画 + // 色动画 gsap.to(colorAttribute.array, { duration: 1, delay: particle.delay + 0.2, @@ -694,7 +891,7 @@ export const ParticleImage = ({ } window.removeEventListener('resize', handleResize); }; - }, [src, handleResize, onLoad, onAnimationComplete]); + }, [cleanup, src, handleResize, onLoad, onAnimationComplete]); return
; }; @@ -719,6 +916,7 @@ export const ImageLoader = ({ const loadingRef = useRef(false); const imageRef = useRef(null); const containerRef = useRef(null); + const [animationComplete, setAnimationComplete] = useState(false); // 处理图片预加载 const preloadImage = useCallback(() => { @@ -726,6 +924,12 @@ export const ImageLoader = ({ loadingRef.current = true; + // 清理之前的资源 + if (imageRef.current) { + imageRef.current.src = ''; + imageRef.current = null; + } + if (timeoutRef.current) { clearTimeout(timeoutRef.current); } @@ -809,7 +1013,7 @@ export const ImageLoader = ({ }); }; - // 确保src存在再设置 + // 确保src存在再设��� if (src) { img.src = src; } @@ -826,26 +1030,79 @@ export const ImageLoader = ({ }; }, [src, preloadImage]); + // 添加一个新的状来控制粒子动画 + const [canShowParticles, setCanShowParticles] = useState(false); + + useEffect(() => { + if (!src) return; + + // 重置状 + setShowImage(false); + setAnimationComplete(false); + setCanShowParticles(false); + + const now = Date.now(); + const timeSinceLastAnimation = now - lastAnimationTime; + + if (particleLoadQueue.size === 0) { + particleLoadQueue.add(src); + setCanShowParticles(true); + lastAnimationTime = now; + return; + } + + const delay = Math.max( + MIN_DELAY, + Math.min(ANIMATION_THRESHOLD, timeSinceLastAnimation) + ); + + const timer = setTimeout(() => { + particleLoadQueue.add(src); + setCanShowParticles(true); + lastAnimationTime = Date.now(); + }, delay); + + return () => { + clearTimeout(timer); + particleLoadQueue.delete(src); + }; + }, [src]); + return ( -
+
- { - // 确保图片已经准备好 - if (imageRef.current) { - setTimeout(() => { + {(!src || (!animationComplete && canShowParticles)) && ( + { + if (imageRef.current) { + // 保持为空 + } + }} + onAnimationComplete={() => { + if (imageRef.current && src) { + // 先显示图片,保持透明 setShowImage(true); - }, 800); - } - }} - onAnimationComplete={() => { - if (imageRef.current) { - setShowImage(true); - } - }} - /> + + // 等待一帧确保图片已经渲染 + requestAnimationFrame(() => { + // 标记动画完成,触发粒子消失 + setAnimationComplete(true); + particleLoadQueue.delete(src); + + // 给图一个短暂延迟再开始淡入 + setTimeout(() => { + const img = document.querySelector(`img[src="${imageRef.current?.src}"]`) as HTMLImageElement; + if (img) { + img.style.opacity = '1'; + } + }, 50); + }); + } + }} + /> + )}
{!status.hasError && !status.timeoutError && imageRef.current && (
@@ -854,14 +1111,17 @@ export const ImageLoader = ({ alt={alt} className={` w-full h-full object-cover - transition-opacity duration-1000 ${className} - ${showImage ? 'opacity-100' : 'opacity-0'} `} style={{ + opacity: 0, visibility: showImage ? 'visible' : 'hidden', objectFit: 'cover', - objectPosition: 'center' + objectPosition: 'center', + willChange: 'opacity, transform', + transition: 'opacity 0.5s ease-in-out', + transform: 'scale(1.015)', // 增加缩放比例到 1.015 + transformOrigin: 'center bottom', // 从底部中心开始缩放 }} />
diff --git a/frontend/hooks/themeMode.tsx b/frontend/hooks/themeMode.tsx index d6f24b5..9f33191 100644 --- a/frontend/hooks/themeMode.tsx +++ b/frontend/hooks/themeMode.tsx @@ -9,14 +9,24 @@ const themeScript = ` (function() { function getInitialTheme() { const savedTheme = localStorage.getItem("${THEME_KEY}"); - if (savedTheme) return savedTheme; + if (savedTheme) { + document.documentElement.className = savedTheme; + return savedTheme; + } const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; const theme = isDark ? "dark" : "light"; + document.documentElement.className = theme; localStorage.setItem("${THEME_KEY}", theme); return theme; } - document.documentElement.className = getInitialTheme(); + + // 确保在 DOM 内容加载前执行 + if (document.documentElement) { + getInitialTheme(); + } else { + document.addEventListener('DOMContentLoaded', getInitialTheme); + } })() `; @@ -27,30 +37,39 @@ export const ThemeScript = () => { export const ThemeModeToggle: React.FC = () => { const [isDark, setIsDark] = useState(null); + // 初始化主题状态 useEffect(() => { if (typeof window !== 'undefined') { - const savedTheme = localStorage.getItem(THEME_KEY); - const initialIsDark = savedTheme === 'dark' || document.documentElement.className === 'dark'; - setIsDark(initialIsDark); - } - }, []); - - useEffect(() => { - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.attributeName === 'class') { - const isDarkTheme = document.documentElement.className === 'dark'; - setIsDark(isDarkTheme); + const initTheme = () => { + const savedTheme = localStorage.getItem(THEME_KEY); + const currentTheme = document.documentElement.className; + + // 确保 localStorage 和 DOM 的主题状态一致 + if (savedTheme && savedTheme !== currentTheme) { + document.documentElement.className = savedTheme; } - }); - }); + + setIsDark(savedTheme === 'dark' || currentTheme === 'dark'); + }; - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['class'] - }); - - return () => observer.disconnect(); + initTheme(); + + // 监听系统主题变化 + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleSystemThemeChange = (e: MediaQueryListEvent) => { + if (!localStorage.getItem(THEME_KEY)) { + const newTheme = e.matches ? 'dark' : 'light'; + document.documentElement.className = newTheme; + setIsDark(e.matches); + } + }; + + mediaQuery.addEventListener('change', handleSystemThemeChange); + + return () => { + mediaQuery.removeEventListener('change', handleSystemThemeChange); + }; + } }, []); const toggleTheme = () => { diff --git a/frontend/interface/fields.ts b/frontend/interface/fields.ts new file mode 100644 index 0000000..66f4ad1 --- /dev/null +++ b/frontend/interface/fields.ts @@ -0,0 +1,83 @@ +// 定义数据库表的字段接口 + +export interface User { + username: string; // 用户名 + avatarUrl?: string; // 头像URL + email: string; // 邮箱 + profileIcon?: string; // 个人图标 + passwordHash: string; // 密码哈希 + role: string; // 角色 + createdAt: Date; // 创建时间 + updatedAt: Date; // 更新时间 + lastLoginAt?: Date; // 上次登录时间 +} + +export interface Page { + id: number; // 自增整数 + title: string; // 标题 + metaKeywords: string; // 元关键词 + metaDescription: string; // 元描述 + content: string; // 内容 + template?: string; // 模板 + customFields?: string; // 自定义字段 + status: string; // 状态 +} + +export interface Post { + id: number; // 自增整数 + authorName: string; // 作者名称 + coverImage?: string; // 封面图片 + title?: string; // 标题 + metaKeywords: string; // 元关键词 + metaDescription: string; // 元描述 + content: string; // 内容 + status: string; // 状态 + isEditor: boolean; // 是否为编辑器 + draftContent?: string; // 草稿内容 + createdAt: Date; // 创建时间 + updatedAt: Date; // 更新时间 + publishedAt?: Date; // 发布时间 +} + +export interface Tag { + name: string; // 标签名 + icon?: string; // 图标 +} + +export interface PostTag { + postId: number; // 文章ID + tagId: string; // 标签ID +} + +export interface Category { + name: string; // 分类名 + parentId?: string; // 父分类ID +} + +export interface PostCategory { + postId: number; // 文章ID + categoryId: string; // 分类ID +} + +export interface Resource { + id: number; // 自增整数 + authorId: string; // 作者ID + name: string; // 名称 + sizeBytes: number; // 大小(字节) + storagePath: string; // 存储路径 + fileType: string; // 文件类型 + category?: string; // 分类 + description?: string; // 描述 + createdAt: Date; // 创建时间 +} + +export interface Setting { + name: string; // 设置名 + data?: string; // 数据 +} + +// 添加一个新的接口用于前端展示 +export interface PostDisplay extends Post { + categories?: Category[]; + tags?: Tag[]; +} \ No newline at end of file diff --git a/frontend/interface/post.ts b/frontend/interface/post.ts deleted file mode 100644 index 7d50201..0000000 --- a/frontend/interface/post.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface Post { - id: number; // 自增整数 - authorName: string; // 作者名称 - coverImage?: string; // 封面图片 - title?: string; // 标题 - metaKeywords: string; // 元关键词 - metaDescription: string; // 元描述 - content: string; // Markdown 格式的内容 - status: string; // 状态 - isEditor: boolean; // 是否为编辑器 - draftContent?: string; // 草稿内容 - createdAt: Date; // 创建时间 - updatedAt: Date; // 更新时间 - publishedAt?: Date; // 发布时间 -} diff --git a/frontend/themes/echoes/about.tsx b/frontend/themes/echoes/about.tsx index 48508a9..4dffd3a 100644 --- a/frontend/themes/echoes/about.tsx +++ b/frontend/themes/echoes/about.tsx @@ -8,7 +8,7 @@ import { } from "@radix-ui/react-icons"; import { useEffect, useRef, useState } from "react"; import { gsap } from "gsap"; -import { ParticleImage } from "hooks/ParticleImage"; +import { ParticleImage } from "hooks/particleImage"; const socialLinks = [ { diff --git a/frontend/themes/echoes/article.tsx b/frontend/themes/echoes/article.tsx index 66796e0..a8b1039 100644 --- a/frontend/themes/echoes/article.tsx +++ b/frontend/themes/echoes/article.tsx @@ -1,18 +1,19 @@ import { Template } from "interface/template"; -import { Container, Heading, Text, Flex, Card, Button } from "@radix-ui/themes"; +import { Container, Heading, Text, Flex, Card, Button, ScrollArea } from "@radix-ui/themes"; import { CalendarIcon, PersonIcon, ChevronLeftIcon, ChevronRightIcon, } from "@radix-ui/react-icons"; -import { Post } from "interface/post"; +import { Post, PostDisplay, Tag } from "interface/fields"; import { useMemo } from "react"; -import { ImageLoader } from "hooks/ParticleImage"; +import { ImageLoader } from "hooks/particleImage"; +import { getColorScheme, hashString } from "themes/echoes/utils/colorScheme"; -// 模拟文章列表数据 -const mockArticles: Post[] = [ +// 修改模拟文章列表数据 +const mockArticles: PostDisplay[] = [ { id: 1, title: "构建现代化的前端开发工作流", @@ -26,12 +27,19 @@ const mockArticles: Post[] = [ isEditor: false, createdAt: new Date("2024-03-15"), updatedAt: new Date("2024-03-15"), + // 添加分类和标签 + categories: [ + { name: "前端开发" } + ], + tags: [ + { name: "工程化" }, + { name: "效率提升" } + ] }, { id: 2, title: "React 18 新特性详解", - content: - "React 18 带来了许多令人兴奋的新特性,包括并发渲染、自动批处理更新...", + content: "React 18 带来了许多令人兴奋的新特性,包括并发渲染、自动批处理更新...", authorName: "李四", publishedAt: new Date("2024-03-14"), coverImage: "https://haowallpaper.com/link/common/file/previewFileIm", @@ -41,12 +49,18 @@ const mockArticles: Post[] = [ isEditor: false, createdAt: new Date("2024-03-14"), updatedAt: new Date("2024-03-14"), + categories: [ + { name: "前端开发" } + ], + tags: [ + { name: "React" }, + { name: "JavaScript" } + ] }, { id: 3, title: "JavaScript 性能优化技巧", - content: - "在这篇文章中,我们将探讨一些提高 JavaScript 性能的技巧和最佳实践...", + content: "在这篇文章中,我们将探讨一些提高 JavaScript 性能的技巧和最佳实践...", authorName: "王五", publishedAt: new Date("2024-03-13"), coverImage: "https://haowallpaper.com/link/common/file/previewFileImg/15789130517090624", @@ -56,12 +70,18 @@ const mockArticles: Post[] = [ isEditor: false, createdAt: new Date("2024-03-13"), updatedAt: new Date("2024-03-13"), + categories: [ + { name: "性能优化" } + ], + tags: [ + { name: "JavaScript" }, + { name: "性能" } + ] }, { id: 4, - title: "JavaScript 性能优化技巧", - content: - "在这篇文章中,我们将探讨一些提高 JavaScript 性能的技巧和最佳实践...", + title: "移动端适配最佳实践", + content: "移动端开发中的各种适配问题及解决方案...", authorName: "田六", publishedAt: new Date("2024-03-13"), coverImage: "https://avatars.githubusercontent.com/u/2?v=4", @@ -71,126 +91,217 @@ const mockArticles: Post[] = [ isEditor: false, createdAt: new Date("2024-03-13"), updatedAt: new Date("2024-03-13"), + categories: [ + { name: "移动开发" } + ], + tags: [ + { name: "移动端" }, + { name: "响应式" } + ] }, - // 可以添加更多模拟文章 + { + id: 5, + title: "全栈开发:从前端到云原生的完整指南", + content: "本文将深入探讨现代全栈开发的各个方面,包括前端框架选择、后端架构设计、数据库优化、微服务部署以及云原生实践...", + authorName: "赵七", + publishedAt: new Date("2024-03-12"), + coverImage: "https://avatars.githubusercontent.com/u/3?v=4", + metaKeywords: "", + metaDescription: "", + status: "published", + isEditor: false, + createdAt: new Date("2024-03-12"), + updatedAt: new Date("2024-03-12"), + categories: [ + { name: "全栈开发" }, + { name: "云原生" }, + { name: "微服务" }, + { name: "DevOps" }, + { name: "系统架构" } + ], + tags: [ + { name: "React" }, + { name: "Node.js" }, + { name: "Docker" }, + { name: "Kubernetes" }, + { name: "MongoDB" }, + { name: "微服务" }, + { name: "CI/CD" }, + { name: "云计算" } + ] + }, + { + id: 6, + title: "深入浅出 TypeScript 高级特性", + content: "探索 TypeScript 的高级类型系统、装饰器、类型编程等特性,以及在大型项目中的最佳实践...", + authorName: "孙八", + publishedAt: new Date("2024-03-11"), + coverImage: "https://avatars.githubusercontent.com/u/4?v=4", + metaKeywords: "", + metaDescription: "", + status: "published", + isEditor: false, + createdAt: new Date("2024-03-11"), + updatedAt: new Date("2024-03-11"), + categories: [ + { name: "TypeScript" }, + { name: "编程语言" } + ], + tags: [ + { name: "类型系统" }, + { name: "泛型编程" }, + { name: "装饰器" }, + { name: "类型推导" } + ] + }, + { + id: 7, + title: "Web 性能优化:从理论到实践", + content: "全面解析 Web 性能优化策略,包括资源加载优化、渲染性能优化、网络优化等多个维度...", + authorName: "周九", + publishedAt: new Date("2024-03-10"), + coverImage: "https://avatars.githubusercontent.com/u/5?v=4", + metaKeywords: "", + metaDescription: "", + status: "published", + isEditor: false, + createdAt: new Date("2024-03-10"), + updatedAt: new Date("2024-03-10"), + categories: [ + { name: "性能优化" }, + { name: "前端开发" } + ], + tags: [ + { name: "性能监控" }, + { name: "懒加载" }, + { name: "缓存策略" }, + { name: "代码分割" } + ] + }, + { + id: 8, + title: "微前端架构实践指南", + content: "详细介绍微前端的架构设计、实现方案、应用集成以及实际项目中的经验总结...", + authorName: "吴十", + publishedAt: new Date("2024-03-09"), + coverImage: "https://avatars.githubusercontent.com/u/6?v=4", + metaKeywords: "", + metaDescription: "", + status: "published", + isEditor: false, + createdAt: new Date("2024-03-09"), + updatedAt: new Date("2024-03-09"), + categories: [ + { name: "架构设计" }, + { name: "微前端" } + ], + tags: [ + { name: "qiankun" }, + { name: "single-spa" }, + { name: "模块联邦" }, + { name: "应用通信" } + ] + } ]; -// 修改颜色组合数组,增加更多颜色选项 -const colorSchemes = [ - { bg: "bg-blue-100", text: "text-blue-600" }, - { bg: "bg-green-100", text: "text-green-600" }, - { bg: "bg-purple-100", text: "text-purple-600" }, - { bg: "bg-pink-100", text: "text-pink-600" }, - { bg: "bg-orange-100", text: "text-orange-600" }, - { bg: "bg-teal-100", text: "text-teal-600" }, - { bg: "bg-red-100", text: "text-red-600" }, - { bg: "bg-indigo-100", text: "text-indigo-600" }, - { bg: "bg-yellow-100", text: "text-yellow-600" }, - { bg: "bg-cyan-100", text: "text-cyan-600" }, -]; - -const categories = ["前端开发", "后端开发", "UI设计", "移动开发", "人工智能"]; -const tags = [ - "React", - "TypeScript", - "Vue", - "Node.js", - "Flutter", - "Python", - "Docker", -]; - -// 定义 SlideGeometry 类 - export default new Template({}, ({ http, args }) => { - const articleData = useMemo(() => { - return mockArticles.map((article) => { - // 使用更复杂的散列函数来生成看起来更随机的索引 - const hash = (str: string) => { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; - } - return Math.abs(hash); - }; + const articleData = useMemo(() => mockArticles, []); + const totalPages = 25; // 假设有25页 + const currentPage = 1; // 当前页码 + + // 修改生成分页数组的函数,不再需要省略号 + const getPageNumbers = (total: number) => { + return Array.from({ length: total }, (_, i) => i + 1); + }; - // 使用文章的不同属性来生成索引 - const categoryIndex = - hash(article.title + article.id.toString()) % categories.length; - const colorIndex = - hash(article.authorName + article.id.toString()) % colorSchemes.length; - - // 为标签生成不同的索引 - const tagIndices = tags - .map((_, index) => ({ - index, - sort: hash(article.title + index.toString() + article.id.toString()), - })) - .sort((a, b) => a.sort - b.sort) - .slice(0, 2) - .map((item) => item.index); - - return { - ...article, - category: categories[categoryIndex], - categoryColor: colorSchemes[colorIndex], - tags: tagIndices.map((index) => ({ - name: tags[index], - color: - colorSchemes[ - hash(tags[index] + article.id.toString()) % colorSchemes.length - ], - })), - }; - }); - }, []); + // 修改分页部分的渲染 + const renderPageNumbers = () => { + const pages = getPageNumbers(totalPages); + + return pages.map(page => ( +
+ + {page} + +
+ )); + }; return ( - -
+ +
{articleData.map((article) => (
-
+
{article.title} - + {article.content}
-
-
- - {article.category} - +
+
+ + + {article.categories?.map((category) => ( + + {category.name} + + ))} + + - - - + + + {article.publishedAt?.toLocaleDateString("zh-CN", { year: "numeric", month: "long", @@ -198,19 +309,29 @@ export default new Template({}, ({ http, args }) => { })} · - + {article.authorName}
- {article.tags.map((tag) => ( + {article.tags?.map((tag: Tag) => ( + {tag.name} ))} @@ -221,30 +342,61 @@ export default new Template({}, ({ http, args }) => { ))}
- - - - - - - -
...
- -
- -
+ + + + + + {renderPageNumbers()} + + + + + 共 {totalPages} 页 + + + + + + + +
); }); diff --git a/frontend/themes/echoes/layout.tsx b/frontend/themes/echoes/layout.tsx index 44fa5f7..ea6b3bb 100644 --- a/frontend/themes/echoes/layout.tsx +++ b/frontend/themes/echoes/layout.tsx @@ -10,7 +10,7 @@ import { AvatarIcon, } from "@radix-ui/react-icons"; import { Theme } from "@radix-ui/themes"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import throttle from "lodash/throttle"; import "./styles/layouts.css"; @@ -20,37 +20,59 @@ import parse from 'html-react-parser'; export default new Layout(({ children, args }) => { const [moreState, setMoreState] = useState(false); const [loginState, setLoginState] = useState(true); - const [device, setDevice] = useState(""); + const [scrollProgress, setScrollProgress] = useState(0); - // 添加窗口尺寸变化监听 - useEffect(() => { - // 立即执行一次设备检测 - if (window.innerWidth >= 1024) { - setDevice("desktop"); - } else { - setDevice("mobile"); - } - - // 创建节流函数,200ms 内只执行一次 - const handleResize = throttle(() => { - if (window.innerWidth >= 1024) { - setDevice("desktop"); - setMoreState(false); - } else { - setDevice("mobile"); - } - }, 200); - - window.addEventListener("resize", handleResize); - - return () => { - window.removeEventListener("resize", handleResize); - handleResize.cancel(); - }; + const handleScroll = useCallback(() => { + const container = document.querySelector('#main-content'); + if (!container) return; + + const scrollTop = container.scrollTop; + const scrollHeight = container.scrollHeight; + const clientHeight = container.clientHeight; + const scrolled = (scrollTop / (scrollHeight - clientHeight)) * 100; + setScrollProgress(Math.min(scrolled, 100)); }, []); + useEffect(() => { + if (typeof window === 'undefined') return; + + const container = document.querySelector('#main-content'); + if (container) { + container.addEventListener('scroll', handleScroll); + } + + const throttledResize = throttle(() => { + requestAnimationFrame(() => { + if (window.innerWidth >= 1024) { + setMoreState(false); + } + }); + }, 200); + + window.addEventListener("resize", throttledResize); + + return () => { + if (container) { + container.removeEventListener('scroll', handleScroll); + } + window.removeEventListener("resize", throttledResize); + throttledResize.cancel(); + }; + }, [handleScroll]); + const navString = typeof args === 'object' && args && 'nav' in args ? args.nav as string : ''; + // 添加回到顶部的处理函数 + const scrollToTop = () => { + const container = document.querySelector('#main-content'); + if (container) { + container.scrollTo({ + top: 0, + behavior: 'smooth' + }); + } + }; + return ( { {/* 导航栏 */}