前端:重构粒子图像组件,更新文章数据结构,改进主题切换和样式,修复多个小问题。
This commit is contained in:
parent
0628d5588f
commit
2aaffb9e2b
@ -13,6 +13,11 @@ interface Particle {
|
||||
delay: number;
|
||||
}
|
||||
|
||||
const particleLoadQueue = new Set<string>();
|
||||
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;
|
||||
|
||||
// 移动端使用更大的采样间隔来减少<E5878F><E5B091><EFBFBD>数量
|
||||
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<THREE.OrthographicCamera>();
|
||||
const rendererRef = useRef<THREE.WebGLRenderer>();
|
||||
const animationFrameRef = useRef<number>();
|
||||
const animationTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
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 确保在一帧进<E5B8A7><E8BF9B> 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 <div ref={containerRef} className="w-full h-full" />;
|
||||
};
|
||||
@ -719,6 +916,7 @@ export const ImageLoader = ({
|
||||
const loadingRef = useRef(false);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(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存在再设<EFBFBD><EFBFBD><EFBFBD>
|
||||
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 (
|
||||
<div ref={containerRef} className="relative w-full h-full overflow-hidden">
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<div className={`absolute inset-0 ${BG_CONFIG.className} rounded-lg overflow-hidden`}>
|
||||
<ParticleImage
|
||||
src={src}
|
||||
status={status}
|
||||
onLoad={() => {
|
||||
// 确保图片已经准备好
|
||||
if (imageRef.current) {
|
||||
setTimeout(() => {
|
||||
{(!src || (!animationComplete && canShowParticles)) && (
|
||||
<ParticleImage
|
||||
src={src}
|
||||
status={status}
|
||||
onLoad={() => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!status.hasError && !status.timeoutError && imageRef.current && (
|
||||
<div className="absolute inset-0 rounded-lg overflow-hidden">
|
||||
@ -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', // 从底部中心开始缩放
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -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<boolean | null>(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 = () => {
|
||||
|
83
frontend/interface/fields.ts
Normal file
83
frontend/interface/fields.ts
Normal file
@ -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[];
|
||||
}
|
@ -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; // 发布时间
|
||||
}
|
@ -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 = [
|
||||
{
|
||||
|
@ -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 => (
|
||||
<div
|
||||
key={page}
|
||||
className={`min-w-[32px] h-8 rounded-md transition-all duration-300 cursor-pointer
|
||||
flex items-center justify-center group/item whitespace-nowrap
|
||||
${page === currentPage
|
||||
? 'bg-[--accent-9] text-[--text-primary]'
|
||||
: 'text-[--text-secondary] hover:text-[--text-primary] hover:bg-[--accent-3]'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
size="1"
|
||||
weight={page === currentPage ? "medium" : "regular"}
|
||||
className="group-hover/item:scale-110 transition-transform"
|
||||
>
|
||||
{page}
|
||||
</Text>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="3" className="pt-2 pb-4 md:pb-6 relative">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6 px-4 md:px-0">
|
||||
<Container size="3" className="pt-2 pb-4 relative">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6 px-4 md:px-0 mb-6 md:mb-8">
|
||||
{articleData.map((article) => (
|
||||
<Card
|
||||
key={article.id}
|
||||
className="group cursor-pointer hover:shadow-lg transition-all duration-300 border border-[--gray-5] hover:border-[--accent-8] relative overflow-hidden"
|
||||
className="group cursor-pointer transition-all duration-300
|
||||
bg-[--card-bg] border-[--border-color]
|
||||
hover:shadow-lg hover:shadow-[--card-bg]/10
|
||||
hover:border-[--accent-9]/50"
|
||||
>
|
||||
<div className="p-4 relative flex flex-col gap-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="w-[120px] md:w-[140px] h-[120px] md:h-[140px]">
|
||||
<div className="w-[120px] h-[90px] flex-shrink-0">
|
||||
<ImageLoader
|
||||
src={article.coverImage}
|
||||
alt={article.title || ""}
|
||||
className="group-hover:scale-105 transition-transform duration-500 relative z-[1] object-cover rounded-lg"
|
||||
className="w-full h-full group-hover:scale-105 transition-transform duration-500
|
||||
relative z-[1] object-cover rounded-lg opacity-90
|
||||
group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<Heading
|
||||
size="3"
|
||||
className="group-hover:text-[--accent-9] transition-colors duration-200 line-clamp-2 text-base mb-2"
|
||||
size="2"
|
||||
className="text-[--text-primary] group-hover:text-[--accent-9]
|
||||
transition-colors duration-200 line-clamp-2 mb-2"
|
||||
>
|
||||
{article.title}
|
||||
</Heading>
|
||||
|
||||
<Text className="text-[--gray-11] text-xs md:text-sm line-clamp-2 leading-relaxed">
|
||||
<Text className="text-[--text-secondary] text-xs
|
||||
line-clamp-2 leading-relaxed">
|
||||
{article.content}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Text
|
||||
size="1"
|
||||
className={`px-2 py-0.5 rounded-full font-medium ${article.categoryColor.bg} ${article.categoryColor.text}`}
|
||||
>
|
||||
{article.category}
|
||||
</Text>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<ScrollArea type="hover" scrollbars="horizontal" className="flex-1">
|
||||
<Flex gap="2" className="flex-nowrap">
|
||||
{article.categories?.map((category) => (
|
||||
<Text
|
||||
key={category.name}
|
||||
size="2"
|
||||
className={`px-3 py-0.5 rounded-md font-medium transition-colors cursor-pointer
|
||||
whitespace-nowrap
|
||||
${getColorScheme(category.name).bg}
|
||||
${getColorScheme(category.name).text}
|
||||
border ${getColorScheme(category.name).border}
|
||||
${getColorScheme(category.name).hover}`}
|
||||
>
|
||||
{category.name}
|
||||
</Text>
|
||||
))}
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
|
||||
<Flex gap="2" align="center" className="text-[--gray-11]">
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
<Text size="1">
|
||||
<Flex gap="2" align="center" className="text-[--text-tertiary] flex-shrink-0">
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
<Text size="2">
|
||||
{article.publishedAt?.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
@ -198,19 +309,29 @@ export default new Template({}, ({ http, args }) => {
|
||||
})}
|
||||
</Text>
|
||||
<span className="mx-1">·</span>
|
||||
<Text size="1" weight="medium">
|
||||
<Text size="2" weight="medium">
|
||||
{article.authorName}
|
||||
</Text>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<Flex gap="2" className="flex-wrap">
|
||||
{article.tags.map((tag) => (
|
||||
{article.tags?.map((tag: Tag) => (
|
||||
<Text
|
||||
key={tag.name}
|
||||
size="1"
|
||||
className={`px-2 py-0.5 rounded-full border border-current ${tag.color.text} hover:bg-[--gray-a3] transition-colors`}
|
||||
className={`px-2.5 py-0.5 rounded-md transition-colors cursor-pointer
|
||||
flex items-center gap-1.5
|
||||
${getColorScheme(tag.name).bg} ${getColorScheme(tag.name).text}
|
||||
border ${getColorScheme(tag.name).border} ${getColorScheme(tag.name).hover}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-1 h-1 rounded-full ${getColorScheme(tag.name).dot}`}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
opacity: 0.8
|
||||
}}
|
||||
/>
|
||||
{tag.name}
|
||||
</Text>
|
||||
))}
|
||||
@ -221,30 +342,61 @@ export default new Template({}, ({ http, args }) => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Flex justify="center" align="center" gap="2" className="mt-8">
|
||||
<Button variant="soft" className="group" disabled>
|
||||
<ChevronLeftIcon className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
|
||||
上一页
|
||||
</Button>
|
||||
|
||||
<Flex gap="1">
|
||||
<Button
|
||||
variant="solid"
|
||||
className="bg-[--accent-9] text-white hover:bg-[--accent-10]"
|
||||
<div className="px-4 md:px-0">
|
||||
<Flex
|
||||
align="center"
|
||||
justify="between"
|
||||
className="max-w-[800px] mx-auto"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="group/nav h-8 md:px-3 text-sm hidden md:flex"
|
||||
disabled={true}
|
||||
>
|
||||
1
|
||||
<ChevronLeftIcon className="w-4 h-4 md:mr-1 text-[--text-tertiary] group-hover/nav:-translate-x-0.5 transition-transform" />
|
||||
<span className="hidden md:inline">上一页</span>
|
||||
</Button>
|
||||
<Button variant="soft">2</Button>
|
||||
<Button variant="soft">3</Button>
|
||||
<div className="flex items-center px-2 text-[--gray-11]">...</div>
|
||||
<Button variant="soft">10</Button>
|
||||
</Flex>
|
||||
|
||||
<Button variant="soft" className="group">
|
||||
下一页
|
||||
<ChevronRightIcon className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
|
||||
</Button>
|
||||
</Flex>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="group/nav w-8 h-8 md:hidden"
|
||||
disabled={true}
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4 text-[--text-tertiary]" />
|
||||
</Button>
|
||||
|
||||
<Flex align="center" gap="2" className="flex-1 md:flex-none justify-center">
|
||||
<ScrollArea
|
||||
type="hover"
|
||||
scrollbars="horizontal"
|
||||
className="w-[240px] md:w-[400px]"
|
||||
>
|
||||
<Flex gap="1" className="px-2">
|
||||
{renderPageNumbers()}
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
|
||||
<Text size="1" className="text-[--text-tertiary] whitespace-nowrap hidden md:block">
|
||||
共 {totalPages} 页
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="group/nav h-8 md:px-3 text-sm hidden md:flex"
|
||||
>
|
||||
<span className="hidden md:inline">下一页</span>
|
||||
<ChevronRightIcon className="w-4 h-4 md:ml-1 text-[--text-tertiary] group-hover/nav:translate-x-0.5 transition-transform" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="group/nav w-8 h-8 md:hidden"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4 text-[--text-tertiary]" />
|
||||
</Button>
|
||||
</Flex>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
@ -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 (
|
||||
<Theme
|
||||
grayColor="gray"
|
||||
@ -65,14 +87,14 @@ export default new Layout(({ children, args }) => {
|
||||
{/* 导航栏 */}
|
||||
<Box
|
||||
asChild
|
||||
className="w-full backdrop-blur-sm border-b border-[--gray-a5] z-60"
|
||||
className="w-full backdrop-blur-sm border-b border-[--gray-a5] z-60 sticky top-0"
|
||||
>
|
||||
<nav>
|
||||
<Container size="4">
|
||||
<Flex
|
||||
justify="between"
|
||||
align="center"
|
||||
className="h-16 px-4"
|
||||
className="h-20 px-4"
|
||||
>
|
||||
{/* Logo 区域 */}
|
||||
<Flex align="center">
|
||||
@ -80,55 +102,52 @@ export default new Layout(({ children, args }) => {
|
||||
href="/"
|
||||
className="flex items-center group transition-all"
|
||||
>
|
||||
<Box className="w-16 h-16 [&_path]:transition-all [&_path]:duration-200 group-hover:[&_path]:stroke-[--accent-9]">
|
||||
<Box className="w-20 h-20 [&_path]:transition-all [&_path]:duration-200 group-hover:[&_path]:stroke-[--accent-9]">
|
||||
<Echoes />
|
||||
</Box>
|
||||
</Link>
|
||||
</Flex>
|
||||
|
||||
{/* 右侧导航链接 */}
|
||||
<Flex
|
||||
align="center"
|
||||
gap="5"
|
||||
>
|
||||
<Flex align="center" gap="3">
|
||||
{/* 桌面端导航 */}
|
||||
{device === "desktop" && (
|
||||
<Box className="flex items-center gap-6">
|
||||
<TextField.Root
|
||||
size="2"
|
||||
variant="surface"
|
||||
placeholder="搜索..."
|
||||
className="w-[240px] [&_input]:pl-3 hover:border-[--accent-9] border transition-colors group"
|
||||
id="search"
|
||||
>
|
||||
<TextField.Slot
|
||||
side="right"
|
||||
className="p-2"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
<Box className="hidden lg:flex items-center gap-4">
|
||||
{/* 导航链接 */}
|
||||
<Box className="flex items-center gap-5 [&>a]:text-[--gray-12] [&>a]:text-lg [&>a]:transition-colors [&>a:hover]:text-[--accent-9]">
|
||||
{parse(navString)}
|
||||
</Box>
|
||||
|
||||
<Box className="flex items-center gap-6">
|
||||
<Box className="flex items-center gap-6 [&>a]:text-[--gray-12] [&>a]:transition-colors [&>a:hover]:text-[--accent-9]">
|
||||
{parse(navString)}
|
||||
</Box>
|
||||
</Box>
|
||||
{/* 搜索框 */}
|
||||
<TextField.Root
|
||||
size="3"
|
||||
variant="surface"
|
||||
placeholder="搜索..."
|
||||
className="w-[240px] [&_input]:pl-3 hover:border-[--accent-9] border transition-colors group mr-4"
|
||||
id="search"
|
||||
>
|
||||
<TextField.Slot side="right" className="p-2">
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
|
||||
<DropdownMenuPrimitive.Root>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-10 h-10 p-0 hover:text-[--accent-9] transition-colors flex items-center justify-center group"
|
||||
>
|
||||
{loginState ? (
|
||||
<AvatarIcon className="w-6 h-6 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
||||
) : (
|
||||
<PersonIcon className="w-6 h-6 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
{/* 用户和主题切换区域 */}
|
||||
<Box className="flex items-center border-l border-[--gray-a5] pl-6">
|
||||
{/* 用户头像/登录按钮 */}
|
||||
<Box className="flex items-center">
|
||||
<DropdownMenuPrimitive.Root>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-10 h-10 p-0 text-[--gray-12] hover:text-[--accent-9] transition-colors flex items-center justify-center"
|
||||
>
|
||||
{loginState ? (
|
||||
<AvatarIcon className="w-6 h-6" />
|
||||
) : (
|
||||
<PersonIcon className="w-6 h-6" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
@ -153,73 +172,133 @@ export default new Layout(({ children, args }) => {
|
||||
</DropdownMenuPrimitive.Item>
|
||||
)}
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
</Box>
|
||||
)}
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
</Box>
|
||||
|
||||
{/* 移动端菜单 */}
|
||||
{device === "mobile" && (
|
||||
<Box className="flex gap-3">
|
||||
<DropdownMenuPrimitive.Root
|
||||
open={moreState}
|
||||
onOpenChange={setMoreState}
|
||||
>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
<Button
|
||||
{/* 主题切换和进度指示器容器 */}
|
||||
<Box className="flex items-center gap-2 ml-4">
|
||||
{/* 主题切换按钮 */}
|
||||
<Box className="w-10 h-10 flex items-center justify-center [&_button]:w-10 [&_button]:h-10 [&_svg]:w-6 [&_svg]:h-6 [&_button]:text-[--gray-12] [&_button:hover]:text-[--accent-9]">
|
||||
<ThemeModeToggle />
|
||||
</Box>
|
||||
|
||||
{/* 读进度指示器 */}
|
||||
<Box
|
||||
className={`w-10 h-10 flex items-center justify-center ${
|
||||
scrollProgress > 0
|
||||
? 'relative translate-x-0 opacity-100 transition-all duration-300 ease-out'
|
||||
: 'pointer-events-none absolute translate-x-2 opacity-0 transition-all duration-300 ease-in'
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-10 h-10 p-0 hover:text-[--accent-9] transition-colors flex items-center justify-center group"
|
||||
className="w-10 h-10 p-0 text-[--gray-12] hover:text-[--accent-9] transition-colors flex items-center justify-center [&_text]:text-[--gray-12] [&_text:hover]:text-[--accent-9]"
|
||||
onClick={scrollToTop}
|
||||
>
|
||||
{moreState ? (
|
||||
<Cross1Icon className="h-5 w-5 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
||||
) : (
|
||||
<HamburgerMenuIcon className="h-5 w-5 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<Theme
|
||||
grayColor="gray"
|
||||
accentColor="gray"
|
||||
radius="large"
|
||||
panelBackground="solid"
|
||||
>
|
||||
<DropdownMenuPrimitive.Content
|
||||
align="end"
|
||||
sideOffset={5}
|
||||
className="mt-2 p-3 min-w-[280px] rounded-md bg-[--color-panel] border border-[--gray-a5] shadow-lg animate-in fade-in slide-in-from-top-2"
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<Box className="flex flex-col gap-2 [&>a]:text-[--gray-12] [&>a]:transition-colors [&>a:hover]:text-[--accent-9]">
|
||||
{parse(navString)}
|
||||
</Box>
|
||||
<Box className="mt-3 pt-3 border-t border-[--gray-a5]">
|
||||
<TextField.Root
|
||||
size="2"
|
||||
variant="surface"
|
||||
placeholder="搜索..."
|
||||
className="w-full [&_input]:pl-3"
|
||||
id="search"
|
||||
>
|
||||
<TextField.Slot
|
||||
side="right"
|
||||
className="p-2"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-[--gray-a12]" />
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
</Box>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</Theme>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
<text
|
||||
x="50"
|
||||
y="55"
|
||||
className="progress-indicator font-bold transition-colors"
|
||||
dominantBaseline="middle"
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '56px',
|
||||
fill: 'currentColor'
|
||||
}}
|
||||
>
|
||||
{Math.round(scrollProgress)}
|
||||
</text>
|
||||
</svg>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 题切换按钮 */}
|
||||
<Box className="flex items-center">
|
||||
<Box className="w-6 h-6 flex items-center justify-center">
|
||||
<ThemeModeToggle />
|
||||
{/* 移动菜单按钮 */}
|
||||
<Box className="flex lg:hidden gap-2 items-center">
|
||||
{/* 添加移动端进度指示器 */}
|
||||
<Box
|
||||
className={`w-10 h-10 flex items-center justify-center ${
|
||||
scrollProgress > 0
|
||||
? 'relative translate-x-0 opacity-100 transition-all duration-300 ease-out'
|
||||
: 'pointer-events-none absolute translate-x-2 opacity-0 transition-all duration-300 ease-in'
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-10 h-10 p-0 text-[--gray-12] hover:text-[--accent-9] transition-colors flex items-center justify-center [&_text]:text-[--gray-12] [&_text:hover]:text-[--accent-9]"
|
||||
onClick={scrollToTop}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<text
|
||||
x="50"
|
||||
y="55"
|
||||
className="progress-indicator font-bold transition-colors"
|
||||
dominantBaseline="middle"
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '56px',
|
||||
fill: 'currentColor'
|
||||
}}
|
||||
>
|
||||
{Math.round(scrollProgress)}
|
||||
</text>
|
||||
</svg>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<DropdownMenuPrimitive.Root
|
||||
open={moreState}
|
||||
onOpenChange={setMoreState}
|
||||
>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
<Button
|
||||
className="w-10 h-10 p-0 hover:text-[--accent-9] transition-colors flex items-center justify-center group bg-transparent border-0"
|
||||
>
|
||||
{moreState ? (
|
||||
<Cross1Icon className="h-5 w-5 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
||||
) : (
|
||||
<HamburgerMenuIcon className="h-5 w-5 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
align="end"
|
||||
sideOffset={5}
|
||||
className="mt-2 p-3 min-w-[280px] rounded-md bg-[--gray-1] border border-[--gray-a5] shadow-lg animate-in fade-in slide-in-from-top-2"
|
||||
>
|
||||
<Box className="flex flex-col gap-2 [&>a]:text-[--gray-12] [&>a]:transition-colors [&>a:hover]:text-[--accent-9]">
|
||||
{parse(navString)}
|
||||
</Box>
|
||||
<Box className="mt-3 pt-3 border-t border-[--gray-a5]">
|
||||
<TextField.Root
|
||||
size="2"
|
||||
variant="surface"
|
||||
placeholder="搜索..."
|
||||
className="w-full [&_input]:pl-3"
|
||||
id="search"
|
||||
>
|
||||
<TextField.Slot
|
||||
side="right"
|
||||
className="p-2"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-[--gray-a12]" />
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
</Box>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@ -228,7 +307,10 @@ export default new Layout(({ children, args }) => {
|
||||
</Box>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<Box className="flex-1 w-full overflow-auto">
|
||||
<Box
|
||||
id="main-content"
|
||||
className="flex-1 w-full overflow-auto"
|
||||
>
|
||||
<Container
|
||||
size="4"
|
||||
className="py-8"
|
||||
|
@ -24,23 +24,24 @@ import {
|
||||
EyeOpenIcon,
|
||||
CodeIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { Post } from "interface/post";
|
||||
import { Post, Category, Tag, PostDisplay } from "interface/fields";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import type { Components } from 'react-markdown';
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import { getColorScheme, hashString } from "themes/echoes/utils/colorScheme";
|
||||
|
||||
// 示例文章数据
|
||||
const mockPost: Post = {
|
||||
const mockPost: PostDisplay = {
|
||||
id: 1,
|
||||
title: "构建现代化的前端开发工作流",
|
||||
content: `
|
||||
# 构建现代化的前端开发工作流sssssssssssssssss
|
||||
# 构建现代化的前端开发工作流
|
||||
|
||||
在现代前端开发中,一个高效的工作流程对于提高开发效率至关重要。本文将详细介绍如何建一个现代化的前端开发工作流。
|
||||
|
||||
## 工具链选择
|
||||
|
||||
选择合适的工具链效工作流的第一步。我们需要考虑
|
||||
选择合适的工具链工作流的第一我们需要考虑
|
||||
|
||||
- 包管理器:npm、yarn 或 pnpm
|
||||
- 构建工具:Vite、webpack 或 Rollup
|
||||
@ -86,6 +87,14 @@ let a=1
|
||||
isEditor: true,
|
||||
createdAt: new Date("2024-03-15"),
|
||||
updatedAt: new Date("2024-03-15"),
|
||||
categories: [
|
||||
{ name: "前端开发" }
|
||||
],
|
||||
tags: [
|
||||
{ name: "工程化" },
|
||||
{ name: "效率提升" },
|
||||
{ name: "开发工具" }
|
||||
]
|
||||
};
|
||||
|
||||
// 添加标题项接口
|
||||
@ -95,14 +104,14 @@ interface TocItem {
|
||||
level: number;
|
||||
}
|
||||
|
||||
// 在 TocItem 接口旁边添加
|
||||
// 在 TocItem 接口旁添加
|
||||
interface MarkdownCodeProps {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// 添加 meta 函数
|
||||
// 添 meta 函数
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: mockPost.title },
|
||||
@ -129,6 +138,32 @@ interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// 添加复制功能的接口
|
||||
interface CopyButtonProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// 添加 CopyButton 组件
|
||||
const CopyButton: React.FC<CopyButtonProps> = ({ code }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
className="h-7 px-2 text-xs hover:bg-[--gray-4]"
|
||||
>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// 创建一 React 组件
|
||||
export default new Template({}, ({ http, args }) => {
|
||||
const [toc, setToc] = useState<TocItem[]>([]);
|
||||
@ -196,21 +231,24 @@ export default new Template({}, ({ http, args }) => {
|
||||
<Box className="mb-8">
|
||||
<Heading
|
||||
size="8"
|
||||
className="mb-4 leading-tight text-[--gray-12] font-bold tracking-tight"
|
||||
className="mb-6 leading-tight text-[--gray-12] font-bold tracking-tight"
|
||||
>
|
||||
{mockPost.title}
|
||||
</Heading>
|
||||
|
||||
<Flex gap="4" align="center" className="text-[--gray-11]">
|
||||
<Avatar
|
||||
size="3"
|
||||
fallback={mockPost.authorName[0]}
|
||||
className="border-2 border-[--gray-a5]"
|
||||
/>
|
||||
<Text size="2" weight="medium">{mockPost.authorName}</Text>
|
||||
<Text size="2">·</Text>
|
||||
<Flex gap="6" className="items-center text-[--gray-11] flex-wrap">
|
||||
{/* 作者名字 */}
|
||||
<Text size="2" weight="medium">
|
||||
{mockPost.authorName}
|
||||
</Text>
|
||||
|
||||
|
||||
{/* 分隔符 */}
|
||||
<Box className="w-px h-4 bg-[--gray-6]" />
|
||||
|
||||
{/* 发布日期 */}
|
||||
<Flex align="center" gap="2">
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
<Text size="2">
|
||||
{mockPost.publishedAt?.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
@ -219,6 +257,55 @@ export default new Template({}, ({ http, args }) => {
|
||||
})}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<Box className="w-px h-4 bg-[--gray-6]" />
|
||||
|
||||
{/* 分类 */}
|
||||
<Flex gap="2">
|
||||
{mockPost.categories?.map((category) => {
|
||||
const color = getColorScheme(category.name);
|
||||
return (
|
||||
<Text
|
||||
key={category.name}
|
||||
size="2"
|
||||
className={`px-3 py-0.5 ${color.bg} ${color.text} rounded-md
|
||||
border ${color.border} font-medium ${color.hover}
|
||||
transition-colors cursor-pointer`}
|
||||
>
|
||||
{category.name}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<Box className="w-px h-4 bg-[--gray-6]" />
|
||||
|
||||
{/* 标签 */}
|
||||
<Flex gap="2">
|
||||
{mockPost.tags?.map((tag) => {
|
||||
const color = getColorScheme(tag.name);
|
||||
return (
|
||||
<Text
|
||||
key={tag.name}
|
||||
size="2"
|
||||
className={`px-3 py-1 ${color.bg} ${color.text} rounded-md
|
||||
border ${color.border} ${color.hover}
|
||||
transition-colors cursor-pointer flex items-center gap-2`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-1.5 h-1.5 rounded-full ${color.dot}`}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
opacity: 0.8
|
||||
}}
|
||||
/>
|
||||
{tag.name}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
@ -286,11 +373,14 @@ export default new Template({}, ({ http, args }) => {
|
||||
</code>
|
||||
) : (
|
||||
<pre className="relative my-6 rounded-lg border border-[--gray-6] bg-[--gray-2]">
|
||||
{lang && (
|
||||
<div className="absolute top-3 right-3 px-3 py-1 text-xs text-[--gray-11] bg-[--gray-3] rounded-full">
|
||||
{lang}
|
||||
<div className="flex justify-between items-center absolute top-0 left-0 right-0 h-9 px-4 border-b border-[--gray-6]">
|
||||
{/* 左侧语言类型 */}
|
||||
<div className="text-xs text-[--gray-11]">
|
||||
{lang || 'text'}
|
||||
</div>
|
||||
)}
|
||||
{/* 右侧复制按钮 */}
|
||||
<CopyButton code={String(children)} />
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={lang || 'text'}
|
||||
PreTag="div"
|
||||
@ -313,7 +403,7 @@ export default new Template({}, ({ http, args }) => {
|
||||
}}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1.5rem',
|
||||
padding: '3rem 1.5rem 1.5rem', // 增加顶部内边距,为头部工具栏留出空间
|
||||
background: 'none',
|
||||
fontSize: '0.95rem',
|
||||
lineHeight: '1.6',
|
||||
|
@ -1,11 +1,8 @@
|
||||
* {
|
||||
color: var(--gray-a12);
|
||||
}
|
||||
|
||||
/* 导航链接样式 */
|
||||
#nav a {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--gray-11);
|
||||
color: var(--gray-12);
|
||||
}
|
||||
|
||||
#nav a:hover {
|
||||
@ -28,27 +25,64 @@
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
#search {
|
||||
/* 进度指示器动画 */
|
||||
@keyframes flow {
|
||||
0% {
|
||||
background-position: 0% center;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% center;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-indicator {
|
||||
color: var(--gray-11);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-indicator:hover {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--accent-11) 0%,
|
||||
var(--accent-9) 50%,
|
||||
var(--accent-11) 100%
|
||||
);
|
||||
background-size: 200% auto;
|
||||
animation: flow 2s linear infinite;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* 添加以下暗色主题的自定义变量 */
|
||||
.dark-theme-custom {
|
||||
--gray-1: hsl(220, 15%, 12%); /* 背景色,更柔和的深色 */
|
||||
--gray-2: hsl(220, 15%, 14%);
|
||||
--gray-3: hsl(220, 15%, 16%);
|
||||
--gray-12: hsl(220, 15%, 85%); /* 文本颜色,不要太白 */
|
||||
|
||||
/* 减少对比度,使文字更柔和 */
|
||||
--gray-11: hsl(220, 15%, 65%);
|
||||
|
||||
/* 边框颜色调整 */
|
||||
--gray-a5: hsla(220, 15%, 50%, 0.2);
|
||||
|
||||
/* 重要:确保背景和文本的对比度适中 */
|
||||
background-color: var(--gray-1);
|
||||
color: var(--gray-12);
|
||||
|
||||
/* 添加微弱的蓝光过滤 */
|
||||
filter: brightness(0.96) saturate(0.95);
|
||||
}
|
||||
|
||||
a:not(#nav a) {
|
||||
transition: color 0.2s ease;
|
||||
color: var(--gray-12);
|
||||
/* 优化暗色主题下的阴影效果 */
|
||||
.dark-theme-custom [class*='shadow'] {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
a:not(#nav a):hover {
|
||||
color: var(--accent-12);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
color: var(--accent-12);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--accent-9);
|
||||
}
|
||||
|
||||
.card:hover .card-title {
|
||||
color: var(--accent-12);
|
||||
/* 优化链接和交互元素的高亮颜色 */
|
||||
.dark-theme-custom a:hover,
|
||||
.dark-theme-custom button:hover {
|
||||
--accent-9: hsl(226, 70%, 65%); /* 更柔和的强调色 */
|
||||
}
|
||||
|
28
frontend/themes/echoes/utils/colorScheme.ts
Normal file
28
frontend/themes/echoes/utils/colorScheme.ts
Normal file
@ -0,0 +1,28 @@
|
||||
export function hashString(str: string): number {
|
||||
str = str.toLowerCase().trim();
|
||||
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);
|
||||
}
|
||||
|
||||
export function getColorScheme(name: string) {
|
||||
const colorSchemes = [
|
||||
'amber', 'blue', 'crimson', 'cyan', 'grass',
|
||||
'green', 'indigo', 'orange', 'pink', 'purple'
|
||||
];
|
||||
|
||||
const index = hashString(name) % colorSchemes.length;
|
||||
const color = colorSchemes[index];
|
||||
|
||||
return {
|
||||
bg: `bg-[--${color}-4]`,
|
||||
text: `text-[--${color}-11]`,
|
||||
border: `border-[--${color}-6]`,
|
||||
hover: `hover:bg-[--${color}-5]`,
|
||||
dot: `bg-current`
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue
Block a user