前端:重构粒子图像组件,更新文章数据结构,改进主题切换和样式,修复多个小问题。
This commit is contained in:
parent
0628d5588f
commit
2aaffb9e2b
@ -13,6 +13,11 @@ interface Particle {
|
|||||||
delay: number;
|
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 createErrorParticles = (width: number, height: number) => {
|
||||||
const particles: Particle[] = [];
|
const particles: Particle[] = [];
|
||||||
const positionArray: number[] = [];
|
const positionArray: number[] = [];
|
||||||
@ -20,7 +25,7 @@ const createErrorParticles = (width: number, height: number) => {
|
|||||||
|
|
||||||
const errorColor = new THREE.Color(0.8, 0, 0);
|
const errorColor = new THREE.Color(0.8, 0, 0);
|
||||||
const size = Math.min(width, height);
|
const size = Math.min(width, height);
|
||||||
const scaleFactor = size * 0.3;
|
const scaleFactor = size * 0.35;
|
||||||
const particlesPerLine = 50;
|
const particlesPerLine = 50;
|
||||||
|
|
||||||
// X 形状的两条线
|
// X 形状的两条线
|
||||||
@ -82,7 +87,7 @@ const createSmileParticles = (width: number, height: number) => {
|
|||||||
|
|
||||||
const size = Math.min(width, height);
|
const size = Math.min(width, height);
|
||||||
const scale = size / 200;
|
const scale = size / 200;
|
||||||
const radius = size * 0.35;
|
const radius = size * 0.4;
|
||||||
const particleSize = Math.max(1.2, scale * 1.2);
|
const particleSize = Math.max(1.2, scale * 1.2);
|
||||||
const particleColor = new THREE.Color(0.8, 0.6, 0);
|
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 outlinePoints = Math.floor(60 * scale);
|
||||||
|
const offsetX = radius * 0.05; // 添加水平偏移
|
||||||
|
const offsetY = radius * 0.05; // 添加垂直偏移
|
||||||
|
|
||||||
for (let i = 0; i < outlinePoints; i++) {
|
for (let i = 0; i < outlinePoints; i++) {
|
||||||
const angle = (i / outlinePoints) * Math.PI * 2;
|
const angle = (i / outlinePoints) * Math.PI * 2;
|
||||||
allPoints.push({
|
allPoints.push({
|
||||||
x: Math.cos(angle) * radius,
|
x: Math.cos(angle) * radius + offsetX,
|
||||||
y: Math.sin(angle) * radius
|
y: Math.sin(angle) * radius + offsetY
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改眼睛的生成方式
|
// 改眼睛的生成方式
|
||||||
const eyeOffset = radius * 0.3;
|
const eyeOffset = radius * 0.3;
|
||||||
const eyeY = radius * 0.15;
|
const eyeY = radius * 0.15 + offsetY;
|
||||||
const eyeSize = radius * 0.1; // 稍微减小眼睛尺寸
|
const eyeSize = radius * 0.1; // 稍微减小眼睛尺寸
|
||||||
const eyePoints = Math.floor(20 * scale);
|
const eyePoints = Math.floor(20 * scale);
|
||||||
|
|
||||||
[-1, 1].forEach(side => {
|
[-1, 1].forEach(side => {
|
||||||
// 使用同心圆的方式生成眼睛
|
// 使用同心圆的方式生成眼睛
|
||||||
const eyeCenterX = side * eyeOffset;
|
const eyeCenterX = side * eyeOffset + offsetX;
|
||||||
const rings = 3; // 同心圆的数量
|
const rings = 3; // 同心圆的数量
|
||||||
|
|
||||||
for (let ring = 0; ring < rings; ring++) {
|
for (let ring = 0; ring < rings; ring++) {
|
||||||
@ -132,13 +140,13 @@ const createSmileParticles = (width: number, height: number) => {
|
|||||||
|
|
||||||
// 计算嘴巴的点
|
// 计算嘴巴的点
|
||||||
const smileWidth = radius * 0.6;
|
const smileWidth = radius * 0.6;
|
||||||
const smileY = -radius * 0.35;
|
const smileY = -radius * 0.35 + offsetY;
|
||||||
const smilePoints = Math.floor(25 * scale);
|
const smilePoints = Math.floor(25 * scale);
|
||||||
|
|
||||||
for (let i = 0; i < smilePoints; i++) {
|
for (let i = 0; i < smilePoints; i++) {
|
||||||
const t = i / (smilePoints - 1);
|
const t = i / (smilePoints - 1);
|
||||||
const x = (t * 2 - 1) * smileWidth;
|
const x = (t * 2 - 1) * smileWidth + offsetX;
|
||||||
const y = smileY + Math.pow(x / smileWidth, 2) * radius * 0.2;
|
const y = smileY + Math.pow(x / smileWidth, 2) * radius * 0.2 + offsetY;
|
||||||
allPoints.push({ x, y });
|
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)]'
|
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 = ({
|
export const ParticleImage = ({
|
||||||
src,
|
src,
|
||||||
status,
|
status,
|
||||||
@ -221,10 +287,182 @@ export const ParticleImage = ({
|
|||||||
const cameraRef = useRef<THREE.OrthographicCamera>();
|
const cameraRef = useRef<THREE.OrthographicCamera>();
|
||||||
const rendererRef = useRef<THREE.WebGLRenderer>();
|
const rendererRef = useRef<THREE.WebGLRenderer>();
|
||||||
const animationFrameRef = useRef<number>();
|
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 处理逻辑移到组件顶层
|
// 将 resize 处理逻辑移到组件顶层
|
||||||
const handleResize = useCallback(() => {
|
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 width = containerRef.current.offsetWidth;
|
||||||
const height = containerRef.current.offsetHeight;
|
const height = containerRef.current.offsetHeight;
|
||||||
@ -240,71 +478,24 @@ export const ParticleImage = ({
|
|||||||
// 更新渲染器大小
|
// 更新渲染器大小
|
||||||
rendererRef.current.setSize(width, height);
|
rendererRef.current.setSize(width, height);
|
||||||
|
|
||||||
// 只有当尺寸变化超阈值时才重生成粒子
|
// 只在尺寸显著变化时重新生成粒子
|
||||||
const currentSize = Math.min(width, height);
|
const currentSize = Math.min(width, height);
|
||||||
const previousSize = sceneRef.current.userData.previousSize || currentSize;
|
const previousSize = sceneRef.current.userData.previousSize || currentSize;
|
||||||
const sizeChange = Math.abs(currentSize - previousSize) / previousSize;
|
const sizeChange = Math.abs(currentSize - previousSize) / previousSize;
|
||||||
|
|
||||||
if (sizeChange > 0.2 && src === '') {
|
if (sizeChange > 0.2 && src === '') {
|
||||||
|
if (sceneRef.current) {
|
||||||
|
cleanupResources(sceneRef.current);
|
||||||
|
}
|
||||||
sceneRef.current.userData.previousSize = currentSize;
|
sceneRef.current.userData.previousSize = currentSize;
|
||||||
updateParticles(width, height);
|
updateParticles(width, height);
|
||||||
}
|
}
|
||||||
}, [src]);
|
}, [src, updateParticles]);
|
||||||
|
|
||||||
// 将粒子更新逻辑抽取为单独的函数
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 主要的 useEffect
|
// 主要的 useEffect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
const width = containerRef.current.offsetWidth;
|
const width = containerRef.current.offsetWidth;
|
||||||
@ -327,12 +518,20 @@ export const ParticleImage = ({
|
|||||||
|
|
||||||
const renderer = new THREE.WebGLRenderer({
|
const renderer = new THREE.WebGLRenderer({
|
||||||
alpha: true,
|
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);
|
renderer.setSize(width, height);
|
||||||
rendererRef.current = renderer;
|
rendererRef.current = renderer;
|
||||||
containerRef.current.appendChild(renderer.domElement);
|
|
||||||
|
// 确保容器仍然存在再添加渲染器
|
||||||
|
if (containerRef.current && isMountedRef.current) {
|
||||||
|
containerRef.current.appendChild(renderer.domElement);
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否应该显示笑
|
// 检查是否应该显示笑
|
||||||
if (src === '') {
|
if (src === '') {
|
||||||
@ -418,7 +617,7 @@ export const ParticleImage = ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 建错误动函数
|
// 建错误函数
|
||||||
const showErrorAnimation = () => {
|
const showErrorAnimation = () => {
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
|
|
||||||
@ -476,7 +675,11 @@ export const ParticleImage = ({
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
// 计算目标尺寸和裁剪区域
|
// 增加一个小的边距以确保完全覆盖
|
||||||
|
const padding = 2; // 添加2像素的内边距
|
||||||
|
canvas.width = width + padding * 2;
|
||||||
|
canvas.height = height + padding * 2;
|
||||||
|
|
||||||
const targetAspect = width / height;
|
const targetAspect = width / height;
|
||||||
const imgAspect = img.width / img.height;
|
const imgAspect = img.width / img.height;
|
||||||
|
|
||||||
@ -485,36 +688,30 @@ export const ParticleImage = ({
|
|||||||
let sourceX = 0;
|
let sourceX = 0;
|
||||||
let sourceY = 0;
|
let sourceY = 0;
|
||||||
|
|
||||||
// 裁源图片,确保比例匹配目标容器
|
|
||||||
if (imgAspect > targetAspect) {
|
if (imgAspect > targetAspect) {
|
||||||
// 图片较宽,需要裁剪两边
|
|
||||||
sourceWidth = img.height * targetAspect;
|
sourceWidth = img.height * targetAspect;
|
||||||
sourceX = (img.width - sourceWidth) / 2;
|
sourceX = (img.width - sourceWidth) / 2;
|
||||||
} else {
|
} else {
|
||||||
// 图片较高,需要裁剪下
|
|
||||||
sourceHeight = img.width / targetAspect;
|
sourceHeight = img.width / targetAspect;
|
||||||
sourceY = (img.height - sourceHeight) / 2;
|
sourceY = (img.height - sourceHeight) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置画布尺寸为目标显示尺寸
|
// 绘制时考虑padding
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
|
|
||||||
// 直接绘制裁剪后的图片到目标尺寸
|
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
img,
|
img,
|
||||||
sourceX, sourceY, sourceWidth, sourceHeight, // 源图片的裁剪区域
|
sourceX, sourceY, sourceWidth, sourceHeight,
|
||||||
0, 0, width, height // 目标区域(填满画布)
|
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 particles: Particle[] = [];
|
||||||
const positionArray = [];
|
const positionArray = [];
|
||||||
const colorArray = [];
|
const colorArray = [];
|
||||||
const samplingGap = Math.ceil(Math.max(width, height) / 80);
|
const samplingGap = Math.ceil(Math.max(width, height) / 80);
|
||||||
|
|
||||||
// 采样已裁剪的图片像素
|
// 采样裁剪的图片像素
|
||||||
for (let y = 0; y < height; y += samplingGap) {
|
for (let y = 0; y < height; y += samplingGap) {
|
||||||
for (let x = 0; x < width; x += samplingGap) {
|
for (let x = 0; x < width; x += samplingGap) {
|
||||||
const i = (y * width + x) * 4;
|
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(
|
positionArray.push(
|
||||||
(Math.random() - 0.5) * width * spread,
|
(Math.random() - 0.5) * width * spread,
|
||||||
(Math.random() - 0.5) * height * spread,
|
(Math.random() - 0.5) * height * spread,
|
||||||
@ -585,7 +782,7 @@ export const ParticleImage = ({
|
|||||||
const colorAttribute = geometry.attributes.color;
|
const colorAttribute = geometry.attributes.color;
|
||||||
|
|
||||||
let completedAnimations = 0;
|
let completedAnimations = 0;
|
||||||
const totalAnimations = particles.length * 2; // 位置和颜色动画
|
const totalAnimations = particles.length * 2; // 置和颜色动画
|
||||||
|
|
||||||
const checkComplete = () => {
|
const checkComplete = () => {
|
||||||
completedAnimations++;
|
completedAnimations++;
|
||||||
@ -612,7 +809,7 @@ export const ParticleImage = ({
|
|||||||
onComplete: checkComplete
|
onComplete: checkComplete
|
||||||
});
|
});
|
||||||
|
|
||||||
// 颜色动画
|
// 色动画
|
||||||
gsap.to(colorAttribute.array, {
|
gsap.to(colorAttribute.array, {
|
||||||
duration: 1,
|
duration: 1,
|
||||||
delay: particle.delay + 0.2,
|
delay: particle.delay + 0.2,
|
||||||
@ -694,7 +891,7 @@ export const ParticleImage = ({
|
|||||||
}
|
}
|
||||||
window.removeEventListener('resize', handleResize);
|
window.removeEventListener('resize', handleResize);
|
||||||
};
|
};
|
||||||
}, [src, handleResize, onLoad, onAnimationComplete]);
|
}, [cleanup, src, handleResize, onLoad, onAnimationComplete]);
|
||||||
|
|
||||||
return <div ref={containerRef} className="w-full h-full" />;
|
return <div ref={containerRef} className="w-full h-full" />;
|
||||||
};
|
};
|
||||||
@ -719,6 +916,7 @@ export const ImageLoader = ({
|
|||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [animationComplete, setAnimationComplete] = useState(false);
|
||||||
|
|
||||||
// 处理图片预加载
|
// 处理图片预加载
|
||||||
const preloadImage = useCallback(() => {
|
const preloadImage = useCallback(() => {
|
||||||
@ -726,6 +924,12 @@ export const ImageLoader = ({
|
|||||||
|
|
||||||
loadingRef.current = true;
|
loadingRef.current = true;
|
||||||
|
|
||||||
|
// 清理之前的资源
|
||||||
|
if (imageRef.current) {
|
||||||
|
imageRef.current.src = '';
|
||||||
|
imageRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
}
|
}
|
||||||
@ -809,7 +1013,7 @@ export const ImageLoader = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 确保src存在再设置
|
// 确保src存在再设<EFBFBD><EFBFBD><EFBFBD>
|
||||||
if (src) {
|
if (src) {
|
||||||
img.src = src;
|
img.src = src;
|
||||||
}
|
}
|
||||||
@ -826,26 +1030,79 @@ export const ImageLoader = ({
|
|||||||
};
|
};
|
||||||
}, [src, preloadImage]);
|
}, [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 (
|
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`}>
|
<div className={`absolute inset-0 ${BG_CONFIG.className} rounded-lg overflow-hidden`}>
|
||||||
<ParticleImage
|
{(!src || (!animationComplete && canShowParticles)) && (
|
||||||
src={src}
|
<ParticleImage
|
||||||
status={status}
|
src={src}
|
||||||
onLoad={() => {
|
status={status}
|
||||||
// 确保图片已经准备好
|
onLoad={() => {
|
||||||
if (imageRef.current) {
|
if (imageRef.current) {
|
||||||
setTimeout(() => {
|
// 保持为空
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onAnimationComplete={() => {
|
||||||
|
if (imageRef.current && src) {
|
||||||
|
// 先显示图片,保持透明
|
||||||
setShowImage(true);
|
setShowImage(true);
|
||||||
}, 800);
|
|
||||||
}
|
// 等待一帧确保图片已经渲染
|
||||||
}}
|
requestAnimationFrame(() => {
|
||||||
onAnimationComplete={() => {
|
// 标记动画完成,触发粒子消失
|
||||||
if (imageRef.current) {
|
setAnimationComplete(true);
|
||||||
setShowImage(true);
|
particleLoadQueue.delete(src);
|
||||||
}
|
|
||||||
}}
|
// 给图一个短暂延迟再开始淡入
|
||||||
/>
|
setTimeout(() => {
|
||||||
|
const img = document.querySelector(`img[src="${imageRef.current?.src}"]`) as HTMLImageElement;
|
||||||
|
if (img) {
|
||||||
|
img.style.opacity = '1';
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!status.hasError && !status.timeoutError && imageRef.current && (
|
{!status.hasError && !status.timeoutError && imageRef.current && (
|
||||||
<div className="absolute inset-0 rounded-lg overflow-hidden">
|
<div className="absolute inset-0 rounded-lg overflow-hidden">
|
||||||
@ -854,14 +1111,17 @@ export const ImageLoader = ({
|
|||||||
alt={alt}
|
alt={alt}
|
||||||
className={`
|
className={`
|
||||||
w-full h-full object-cover
|
w-full h-full object-cover
|
||||||
transition-opacity duration-1000
|
|
||||||
${className}
|
${className}
|
||||||
${showImage ? 'opacity-100' : 'opacity-0'}
|
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
|
opacity: 0,
|
||||||
visibility: showImage ? 'visible' : 'hidden',
|
visibility: showImage ? 'visible' : 'hidden',
|
||||||
objectFit: 'cover',
|
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>
|
</div>
|
||||||
|
@ -9,14 +9,24 @@ const themeScript = `
|
|||||||
(function() {
|
(function() {
|
||||||
function getInitialTheme() {
|
function getInitialTheme() {
|
||||||
const savedTheme = localStorage.getItem("${THEME_KEY}");
|
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 isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
const theme = isDark ? "dark" : "light";
|
const theme = isDark ? "dark" : "light";
|
||||||
|
document.documentElement.className = theme;
|
||||||
localStorage.setItem("${THEME_KEY}", theme);
|
localStorage.setItem("${THEME_KEY}", theme);
|
||||||
return 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 = () => {
|
export const ThemeModeToggle: React.FC = () => {
|
||||||
const [isDark, setIsDark] = useState<boolean | null>(null);
|
const [isDark, setIsDark] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
// 初始化主题状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const savedTheme = localStorage.getItem(THEME_KEY);
|
const initTheme = () => {
|
||||||
const initialIsDark = savedTheme === 'dark' || document.documentElement.className === 'dark';
|
const savedTheme = localStorage.getItem(THEME_KEY);
|
||||||
setIsDark(initialIsDark);
|
const currentTheme = document.documentElement.className;
|
||||||
}
|
|
||||||
}, []);
|
// 确保 localStorage 和 DOM 的主题状态一致
|
||||||
|
if (savedTheme && savedTheme !== currentTheme) {
|
||||||
useEffect(() => {
|
document.documentElement.className = savedTheme;
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
mutations.forEach((mutation) => {
|
|
||||||
if (mutation.attributeName === 'class') {
|
|
||||||
const isDarkTheme = document.documentElement.className === 'dark';
|
|
||||||
setIsDark(isDarkTheme);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
setIsDark(savedTheme === 'dark' || currentTheme === 'dark');
|
||||||
|
};
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
initTheme();
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['class']
|
// 监听系统主题变化
|
||||||
});
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
|
||||||
return () => observer.disconnect();
|
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 = () => {
|
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";
|
} from "@radix-ui/react-icons";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { gsap } from "gsap";
|
import { gsap } from "gsap";
|
||||||
import { ParticleImage } from "hooks/ParticleImage";
|
import { ParticleImage } from "hooks/particleImage";
|
||||||
|
|
||||||
const socialLinks = [
|
const socialLinks = [
|
||||||
{
|
{
|
||||||
|
@ -1,18 +1,19 @@
|
|||||||
import { Template } from "interface/template";
|
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 {
|
import {
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
PersonIcon,
|
PersonIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
} from "@radix-ui/react-icons";
|
} from "@radix-ui/react-icons";
|
||||||
import { Post } from "interface/post";
|
import { Post, PostDisplay, Tag } from "interface/fields";
|
||||||
import { useMemo } from "react";
|
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,
|
id: 1,
|
||||||
title: "构建现代化的前端开发工作流",
|
title: "构建现代化的前端开发工作流",
|
||||||
@ -26,12 +27,19 @@ const mockArticles: Post[] = [
|
|||||||
isEditor: false,
|
isEditor: false,
|
||||||
createdAt: new Date("2024-03-15"),
|
createdAt: new Date("2024-03-15"),
|
||||||
updatedAt: new Date("2024-03-15"),
|
updatedAt: new Date("2024-03-15"),
|
||||||
|
// 添加分类和标签
|
||||||
|
categories: [
|
||||||
|
{ name: "前端开发" }
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ name: "工程化" },
|
||||||
|
{ name: "效率提升" }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
title: "React 18 新特性详解",
|
title: "React 18 新特性详解",
|
||||||
content:
|
content: "React 18 带来了许多令人兴奋的新特性,包括并发渲染、自动批处理更新...",
|
||||||
"React 18 带来了许多令人兴奋的新特性,包括并发渲染、自动批处理更新...",
|
|
||||||
authorName: "李四",
|
authorName: "李四",
|
||||||
publishedAt: new Date("2024-03-14"),
|
publishedAt: new Date("2024-03-14"),
|
||||||
coverImage: "https://haowallpaper.com/link/common/file/previewFileIm",
|
coverImage: "https://haowallpaper.com/link/common/file/previewFileIm",
|
||||||
@ -41,12 +49,18 @@ const mockArticles: Post[] = [
|
|||||||
isEditor: false,
|
isEditor: false,
|
||||||
createdAt: new Date("2024-03-14"),
|
createdAt: new Date("2024-03-14"),
|
||||||
updatedAt: new Date("2024-03-14"),
|
updatedAt: new Date("2024-03-14"),
|
||||||
|
categories: [
|
||||||
|
{ name: "前端开发" }
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ name: "React" },
|
||||||
|
{ name: "JavaScript" }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
title: "JavaScript 性能优化技巧",
|
title: "JavaScript 性能优化技巧",
|
||||||
content:
|
content: "在这篇文章中,我们将探讨一些提高 JavaScript 性能的技巧和最佳实践...",
|
||||||
"在这篇文章中,我们将探讨一些提高 JavaScript 性能的技巧和最佳实践...",
|
|
||||||
authorName: "王五",
|
authorName: "王五",
|
||||||
publishedAt: new Date("2024-03-13"),
|
publishedAt: new Date("2024-03-13"),
|
||||||
coverImage: "https://haowallpaper.com/link/common/file/previewFileImg/15789130517090624",
|
coverImage: "https://haowallpaper.com/link/common/file/previewFileImg/15789130517090624",
|
||||||
@ -56,12 +70,18 @@ const mockArticles: Post[] = [
|
|||||||
isEditor: false,
|
isEditor: false,
|
||||||
createdAt: new Date("2024-03-13"),
|
createdAt: new Date("2024-03-13"),
|
||||||
updatedAt: new Date("2024-03-13"),
|
updatedAt: new Date("2024-03-13"),
|
||||||
|
categories: [
|
||||||
|
{ name: "性能优化" }
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ name: "JavaScript" },
|
||||||
|
{ name: "性能" }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
title: "JavaScript 性能优化技巧",
|
title: "移动端适配最佳实践",
|
||||||
content:
|
content: "移动端开发中的各种适配问题及解决方案...",
|
||||||
"在这篇文章中,我们将探讨一些提高 JavaScript 性能的技巧和最佳实践...",
|
|
||||||
authorName: "田六",
|
authorName: "田六",
|
||||||
publishedAt: new Date("2024-03-13"),
|
publishedAt: new Date("2024-03-13"),
|
||||||
coverImage: "https://avatars.githubusercontent.com/u/2?v=4",
|
coverImage: "https://avatars.githubusercontent.com/u/2?v=4",
|
||||||
@ -71,126 +91,217 @@ const mockArticles: Post[] = [
|
|||||||
isEditor: false,
|
isEditor: false,
|
||||||
createdAt: new Date("2024-03-13"),
|
createdAt: new Date("2024-03-13"),
|
||||||
updatedAt: 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 }) => {
|
export default new Template({}, ({ http, args }) => {
|
||||||
const articleData = useMemo(() => {
|
const articleData = useMemo(() => mockArticles, []);
|
||||||
return mockArticles.map((article) => {
|
const totalPages = 25; // 假设有25页
|
||||||
// 使用更复杂的散列函数来生成看起来更随机的索引
|
const currentPage = 1; // 当前页码
|
||||||
const hash = (str: string) => {
|
|
||||||
let hash = 0;
|
// 修改生成分页数组的函数,不再需要省略号
|
||||||
for (let i = 0; i < str.length; i++) {
|
const getPageNumbers = (total: number) => {
|
||||||
const char = str.charCodeAt(i);
|
return Array.from({ length: total }, (_, i) => i + 1);
|
||||||
hash = (hash << 5) - hash + char;
|
};
|
||||||
hash = hash & hash;
|
|
||||||
}
|
|
||||||
return Math.abs(hash);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 使用文章的不同属性来生成索引
|
// 修改分页部分的渲染
|
||||||
const categoryIndex =
|
const renderPageNumbers = () => {
|
||||||
hash(article.title + article.id.toString()) % categories.length;
|
const pages = getPageNumbers(totalPages);
|
||||||
const colorIndex =
|
|
||||||
hash(article.authorName + article.id.toString()) % colorSchemes.length;
|
return pages.map(page => (
|
||||||
|
<div
|
||||||
// 为标签生成不同的索引
|
key={page}
|
||||||
const tagIndices = tags
|
className={`min-w-[32px] h-8 rounded-md transition-all duration-300 cursor-pointer
|
||||||
.map((_, index) => ({
|
flex items-center justify-center group/item whitespace-nowrap
|
||||||
index,
|
${page === currentPage
|
||||||
sort: hash(article.title + index.toString() + article.id.toString()),
|
? 'bg-[--accent-9] text-[--text-primary]'
|
||||||
}))
|
: 'text-[--text-secondary] hover:text-[--text-primary] hover:bg-[--accent-3]'
|
||||||
.sort((a, b) => a.sort - b.sort)
|
}`}
|
||||||
.slice(0, 2)
|
>
|
||||||
.map((item) => item.index);
|
<Text
|
||||||
|
size="1"
|
||||||
return {
|
weight={page === currentPage ? "medium" : "regular"}
|
||||||
...article,
|
className="group-hover/item:scale-110 transition-transform"
|
||||||
category: categories[categoryIndex],
|
>
|
||||||
categoryColor: colorSchemes[colorIndex],
|
{page}
|
||||||
tags: tagIndices.map((index) => ({
|
</Text>
|
||||||
name: tags[index],
|
</div>
|
||||||
color:
|
));
|
||||||
colorSchemes[
|
};
|
||||||
hash(tags[index] + article.id.toString()) % colorSchemes.length
|
|
||||||
],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="3" className="pt-2 pb-4 md:pb-6 relative">
|
<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">
|
<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) => (
|
{articleData.map((article) => (
|
||||||
<Card
|
<Card
|
||||||
key={article.id}
|
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="p-4 relative flex flex-col gap-4">
|
||||||
<div className="flex 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
|
<ImageLoader
|
||||||
src={article.coverImage}
|
src={article.coverImage}
|
||||||
alt={article.title || ""}
|
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>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Heading
|
<Heading
|
||||||
size="3"
|
size="2"
|
||||||
className="group-hover:text-[--accent-9] transition-colors duration-200 line-clamp-2 text-base mb-2"
|
className="text-[--text-primary] group-hover:text-[--accent-9]
|
||||||
|
transition-colors duration-200 line-clamp-2 mb-2"
|
||||||
>
|
>
|
||||||
{article.title}
|
{article.title}
|
||||||
</Heading>
|
</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}
|
{article.content}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<Text
|
<ScrollArea type="hover" scrollbars="horizontal" className="flex-1">
|
||||||
size="1"
|
<Flex gap="2" className="flex-nowrap">
|
||||||
className={`px-2 py-0.5 rounded-full font-medium ${article.categoryColor.bg} ${article.categoryColor.text}`}
|
{article.categories?.map((category) => (
|
||||||
>
|
<Text
|
||||||
{article.category}
|
key={category.name}
|
||||||
</Text>
|
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]">
|
<Flex gap="2" align="center" className="text-[--text-tertiary] flex-shrink-0">
|
||||||
<CalendarIcon className="w-3 h-3" />
|
<CalendarIcon className="w-4 h-4" />
|
||||||
<Text size="1">
|
<Text size="2">
|
||||||
{article.publishedAt?.toLocaleDateString("zh-CN", {
|
{article.publishedAt?.toLocaleDateString("zh-CN", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
@ -198,19 +309,29 @@ export default new Template({}, ({ http, args }) => {
|
|||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<span className="mx-1">·</span>
|
<span className="mx-1">·</span>
|
||||||
<Text size="1" weight="medium">
|
<Text size="2" weight="medium">
|
||||||
{article.authorName}
|
{article.authorName}
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Flex gap="2" className="flex-wrap">
|
<Flex gap="2" className="flex-wrap">
|
||||||
{article.tags.map((tag) => (
|
{article.tags?.map((tag: Tag) => (
|
||||||
<Text
|
<Text
|
||||||
key={tag.name}
|
key={tag.name}
|
||||||
size="1"
|
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}
|
{tag.name}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
@ -221,30 +342,61 @@ export default new Template({}, ({ http, args }) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Flex justify="center" align="center" gap="2" className="mt-8">
|
<div className="px-4 md:px-0">
|
||||||
<Button variant="soft" className="group" disabled>
|
<Flex
|
||||||
<ChevronLeftIcon className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
|
align="center"
|
||||||
上一页
|
justify="between"
|
||||||
</Button>
|
className="max-w-[800px] mx-auto"
|
||||||
|
>
|
||||||
<Flex gap="1">
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="solid"
|
className="group/nav h-8 md:px-3 text-sm hidden md:flex"
|
||||||
className="bg-[--accent-9] text-white hover:bg-[--accent-10]"
|
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>
|
||||||
<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">
|
<Button
|
||||||
下一页
|
variant="ghost"
|
||||||
<ChevronRightIcon className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
|
className="group/nav w-8 h-8 md:hidden"
|
||||||
</Button>
|
disabled={true}
|
||||||
</Flex>
|
>
|
||||||
|
<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>
|
</Container>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -10,7 +10,7 @@ import {
|
|||||||
AvatarIcon,
|
AvatarIcon,
|
||||||
} from "@radix-ui/react-icons";
|
} from "@radix-ui/react-icons";
|
||||||
import { Theme } from "@radix-ui/themes";
|
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 * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import throttle from "lodash/throttle";
|
import throttle from "lodash/throttle";
|
||||||
import "./styles/layouts.css";
|
import "./styles/layouts.css";
|
||||||
@ -20,37 +20,59 @@ import parse from 'html-react-parser';
|
|||||||
export default new Layout(({ children, args }) => {
|
export default new Layout(({ children, args }) => {
|
||||||
const [moreState, setMoreState] = useState(false);
|
const [moreState, setMoreState] = useState(false);
|
||||||
const [loginState, setLoginState] = useState(true);
|
const [loginState, setLoginState] = useState(true);
|
||||||
const [device, setDevice] = useState("");
|
const [scrollProgress, setScrollProgress] = useState(0);
|
||||||
|
|
||||||
// 添加窗口尺寸变化监听
|
const handleScroll = useCallback(() => {
|
||||||
useEffect(() => {
|
const container = document.querySelector('#main-content');
|
||||||
// 立即执行一次设备检测
|
if (!container) return;
|
||||||
if (window.innerWidth >= 1024) {
|
|
||||||
setDevice("desktop");
|
const scrollTop = container.scrollTop;
|
||||||
} else {
|
const scrollHeight = container.scrollHeight;
|
||||||
setDevice("mobile");
|
const clientHeight = container.clientHeight;
|
||||||
}
|
const scrolled = (scrollTop / (scrollHeight - clientHeight)) * 100;
|
||||||
|
setScrollProgress(Math.min(scrolled, 100));
|
||||||
// 创建节流函数,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();
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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 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 (
|
return (
|
||||||
<Theme
|
<Theme
|
||||||
grayColor="gray"
|
grayColor="gray"
|
||||||
@ -65,14 +87,14 @@ export default new Layout(({ children, args }) => {
|
|||||||
{/* 导航栏 */}
|
{/* 导航栏 */}
|
||||||
<Box
|
<Box
|
||||||
asChild
|
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>
|
<nav>
|
||||||
<Container size="4">
|
<Container size="4">
|
||||||
<Flex
|
<Flex
|
||||||
justify="between"
|
justify="between"
|
||||||
align="center"
|
align="center"
|
||||||
className="h-16 px-4"
|
className="h-20 px-4"
|
||||||
>
|
>
|
||||||
{/* Logo 区域 */}
|
{/* Logo 区域 */}
|
||||||
<Flex align="center">
|
<Flex align="center">
|
||||||
@ -80,55 +102,52 @@ export default new Layout(({ children, args }) => {
|
|||||||
href="/"
|
href="/"
|
||||||
className="flex items-center group transition-all"
|
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 />
|
<Echoes />
|
||||||
</Box>
|
</Box>
|
||||||
</Link>
|
</Link>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* 右侧导航链接 */}
|
{/* 右侧导航链接 */}
|
||||||
<Flex
|
<Flex align="center" gap="3">
|
||||||
align="center"
|
|
||||||
gap="5"
|
|
||||||
>
|
|
||||||
{/* 桌面端导航 */}
|
{/* 桌面端导航 */}
|
||||||
{device === "desktop" && (
|
<Box className="hidden lg:flex items-center gap-4">
|
||||||
<Box className="flex items-center gap-6">
|
{/* 导航链接 */}
|
||||||
<TextField.Root
|
<Box className="flex items-center gap-5 [&>a]:text-[--gray-12] [&>a]:text-lg [&>a]:transition-colors [&>a:hover]:text-[--accent-9]">
|
||||||
size="2"
|
{parse(navString)}
|
||||||
variant="surface"
|
</Box>
|
||||||
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="flex items-center gap-6">
|
{/* 搜索框 */}
|
||||||
<Box className="flex items-center gap-6 [&>a]:text-[--gray-12] [&>a]:transition-colors [&>a:hover]:text-[--accent-9]">
|
<TextField.Root
|
||||||
{parse(navString)}
|
size="3"
|
||||||
</Box>
|
variant="surface"
|
||||||
</Box>
|
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>
|
<Box className="flex items-center border-l border-[--gray-a5] pl-6">
|
||||||
<Button
|
{/* 用户头像/登录按钮 */}
|
||||||
variant="ghost"
|
<Box className="flex items-center">
|
||||||
className="w-10 h-10 p-0 hover:text-[--accent-9] transition-colors flex items-center justify-center group"
|
<DropdownMenuPrimitive.Root>
|
||||||
>
|
<DropdownMenuPrimitive.Trigger asChild>
|
||||||
{loginState ? (
|
<Button
|
||||||
<AvatarIcon className="w-6 h-6 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
variant="ghost"
|
||||||
) : (
|
className="w-10 h-10 p-0 text-[--gray-12] hover:text-[--accent-9] transition-colors flex items-center justify-center"
|
||||||
<PersonIcon className="w-6 h-6 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
>
|
||||||
)}
|
{loginState ? (
|
||||||
</Button>
|
<AvatarIcon className="w-6 h-6" />
|
||||||
</DropdownMenuPrimitive.Trigger>
|
) : (
|
||||||
<DropdownMenuPrimitive.Portal>
|
<PersonIcon className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuPrimitive.Trigger>
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
@ -153,73 +172,133 @@ export default new Layout(({ children, args }) => {
|
|||||||
</DropdownMenuPrimitive.Item>
|
</DropdownMenuPrimitive.Item>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuPrimitive.Content>
|
</DropdownMenuPrimitive.Content>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
</DropdownMenuPrimitive.Root>
|
</DropdownMenuPrimitive.Root>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 移动端菜单 */}
|
{/* 主题切换和进度指示器容器 */}
|
||||||
{device === "mobile" && (
|
<Box className="flex items-center gap-2 ml-4">
|
||||||
<Box className="flex gap-3">
|
{/* 主题切换按钮 */}
|
||||||
<DropdownMenuPrimitive.Root
|
<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]">
|
||||||
open={moreState}
|
<ThemeModeToggle />
|
||||||
onOpenChange={setMoreState}
|
</Box>
|
||||||
>
|
|
||||||
<DropdownMenuPrimitive.Trigger asChild>
|
{/* 读进度指示器 */}
|
||||||
<Button
|
<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"
|
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 ? (
|
<svg
|
||||||
<Cross1Icon className="h-5 w-5 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
className="w-6 h-6"
|
||||||
) : (
|
viewBox="0 0 100 100"
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
<Box className="flex flex-col gap-2 [&>a]:text-[--gray-12] [&>a]:transition-colors [&>a:hover]:text-[--accent-9]">
|
<text
|
||||||
{parse(navString)}
|
x="50"
|
||||||
</Box>
|
y="55"
|
||||||
<Box className="mt-3 pt-3 border-t border-[--gray-a5]">
|
className="progress-indicator font-bold transition-colors"
|
||||||
<TextField.Root
|
dominantBaseline="middle"
|
||||||
size="2"
|
textAnchor="middle"
|
||||||
variant="surface"
|
style={{
|
||||||
placeholder="搜索..."
|
fontSize: '56px',
|
||||||
className="w-full [&_input]:pl-3"
|
fill: 'currentColor'
|
||||||
id="search"
|
}}
|
||||||
>
|
>
|
||||||
<TextField.Slot
|
{Math.round(scrollProgress)}
|
||||||
side="right"
|
</text>
|
||||||
className="p-2"
|
</svg>
|
||||||
>
|
</Button>
|
||||||
<MagnifyingGlassIcon className="h-4 w-4 text-[--gray-a12]" />
|
</Box>
|
||||||
</TextField.Slot>
|
</Box>
|
||||||
</TextField.Root>
|
|
||||||
</Box>
|
|
||||||
</DropdownMenuPrimitive.Content>
|
|
||||||
</Theme>
|
|
||||||
</DropdownMenuPrimitive.Portal>
|
|
||||||
</DropdownMenuPrimitive.Root>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
</Box>
|
||||||
|
|
||||||
{/* 题切换按钮 */}
|
{/* 移动菜单按钮 */}
|
||||||
<Box className="flex items-center">
|
<Box className="flex lg:hidden gap-2 items-center">
|
||||||
<Box className="w-6 h-6 flex items-center justify-center">
|
{/* 添加移动端进度指示器 */}
|
||||||
<ThemeModeToggle />
|
<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>
|
</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>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
@ -228,7 +307,10 @@ export default new Layout(({ children, args }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 主要内容区域 */}
|
{/* 主要内容区域 */}
|
||||||
<Box className="flex-1 w-full overflow-auto">
|
<Box
|
||||||
|
id="main-content"
|
||||||
|
className="flex-1 w-full overflow-auto"
|
||||||
|
>
|
||||||
<Container
|
<Container
|
||||||
size="4"
|
size="4"
|
||||||
className="py-8"
|
className="py-8"
|
||||||
|
@ -24,23 +24,24 @@ import {
|
|||||||
EyeOpenIcon,
|
EyeOpenIcon,
|
||||||
CodeIcon,
|
CodeIcon,
|
||||||
} from "@radix-ui/react-icons";
|
} 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 { useMemo, useState, useEffect } from "react";
|
||||||
import type { Components } from 'react-markdown';
|
import type { Components } from 'react-markdown';
|
||||||
import type { MetaFunction } from "@remix-run/node";
|
import type { MetaFunction } from "@remix-run/node";
|
||||||
|
import { getColorScheme, hashString } from "themes/echoes/utils/colorScheme";
|
||||||
|
|
||||||
// 示例文章数据
|
// 示例文章数据
|
||||||
const mockPost: Post = {
|
const mockPost: PostDisplay = {
|
||||||
id: 1,
|
id: 1,
|
||||||
title: "构建现代化的前端开发工作流",
|
title: "构建现代化的前端开发工作流",
|
||||||
content: `
|
content: `
|
||||||
# 构建现代化的前端开发工作流sssssssssssssssss
|
# 构建现代化的前端开发工作流
|
||||||
|
|
||||||
在现代前端开发中,一个高效的工作流程对于提高开发效率至关重要。本文将详细介绍如何建一个现代化的前端开发工作流。
|
在现代前端开发中,一个高效的工作流程对于提高开发效率至关重要。本文将详细介绍如何建一个现代化的前端开发工作流。
|
||||||
|
|
||||||
## 工具链选择
|
## 工具链选择
|
||||||
|
|
||||||
选择合适的工具链效工作流的第一步。我们需要考虑
|
选择合适的工具链工作流的第一我们需要考虑
|
||||||
|
|
||||||
- 包管理器:npm、yarn 或 pnpm
|
- 包管理器:npm、yarn 或 pnpm
|
||||||
- 构建工具:Vite、webpack 或 Rollup
|
- 构建工具:Vite、webpack 或 Rollup
|
||||||
@ -86,6 +87,14 @@ let a=1
|
|||||||
isEditor: true,
|
isEditor: true,
|
||||||
createdAt: new Date("2024-03-15"),
|
createdAt: new Date("2024-03-15"),
|
||||||
updatedAt: 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;
|
level: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在 TocItem 接口旁边添加
|
// 在 TocItem 接口旁添加
|
||||||
interface MarkdownCodeProps {
|
interface MarkdownCodeProps {
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加 meta 函数
|
// 添 meta 函数
|
||||||
export const meta: MetaFunction = () => {
|
export const meta: MetaFunction = () => {
|
||||||
return [
|
return [
|
||||||
{ title: mockPost.title },
|
{ title: mockPost.title },
|
||||||
@ -129,6 +138,32 @@ interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {
|
|||||||
children: React.ReactNode;
|
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 组件
|
// 创建一 React 组件
|
||||||
export default new Template({}, ({ http, args }) => {
|
export default new Template({}, ({ http, args }) => {
|
||||||
const [toc, setToc] = useState<TocItem[]>([]);
|
const [toc, setToc] = useState<TocItem[]>([]);
|
||||||
@ -196,21 +231,24 @@ export default new Template({}, ({ http, args }) => {
|
|||||||
<Box className="mb-8">
|
<Box className="mb-8">
|
||||||
<Heading
|
<Heading
|
||||||
size="8"
|
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}
|
{mockPost.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<Flex gap="4" align="center" className="text-[--gray-11]">
|
<Flex gap="6" className="items-center text-[--gray-11] flex-wrap">
|
||||||
<Avatar
|
{/* 作者名字 */}
|
||||||
size="3"
|
<Text size="2" weight="medium">
|
||||||
fallback={mockPost.authorName[0]}
|
{mockPost.authorName}
|
||||||
className="border-2 border-[--gray-a5]"
|
</Text>
|
||||||
/>
|
|
||||||
<Text size="2" weight="medium">{mockPost.authorName}</Text>
|
|
||||||
<Text size="2">·</Text>
|
{/* 分隔符 */}
|
||||||
|
<Box className="w-px h-4 bg-[--gray-6]" />
|
||||||
|
|
||||||
|
{/* 发布日期 */}
|
||||||
<Flex align="center" gap="2">
|
<Flex align="center" gap="2">
|
||||||
<CalendarIcon className="w-4 h-4" />
|
<CalendarIcon className="w-3.5 h-3.5" />
|
||||||
<Text size="2">
|
<Text size="2">
|
||||||
{mockPost.publishedAt?.toLocaleDateString("zh-CN", {
|
{mockPost.publishedAt?.toLocaleDateString("zh-CN", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@ -219,6 +257,55 @@ export default new Template({}, ({ http, args }) => {
|
|||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</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>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@ -286,11 +373,14 @@ export default new Template({}, ({ http, args }) => {
|
|||||||
</code>
|
</code>
|
||||||
) : (
|
) : (
|
||||||
<pre className="relative my-6 rounded-lg border border-[--gray-6] bg-[--gray-2]">
|
<pre className="relative my-6 rounded-lg border border-[--gray-6] bg-[--gray-2]">
|
||||||
{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="absolute top-3 right-3 px-3 py-1 text-xs text-[--gray-11] bg-[--gray-3] rounded-full">
|
{/* 左侧语言类型 */}
|
||||||
{lang}
|
<div className="text-xs text-[--gray-11]">
|
||||||
|
{lang || 'text'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* 右侧复制按钮 */}
|
||||||
|
<CopyButton code={String(children)} />
|
||||||
|
</div>
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language={lang || 'text'}
|
language={lang || 'text'}
|
||||||
PreTag="div"
|
PreTag="div"
|
||||||
@ -313,7 +403,7 @@ export default new Template({}, ({ http, args }) => {
|
|||||||
}}
|
}}
|
||||||
customStyle={{
|
customStyle={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
padding: '1.5rem',
|
padding: '3rem 1.5rem 1.5rem', // 增加顶部内边距,为头部工具栏留出空间
|
||||||
background: 'none',
|
background: 'none',
|
||||||
fontSize: '0.95rem',
|
fontSize: '0.95rem',
|
||||||
lineHeight: '1.6',
|
lineHeight: '1.6',
|
||||||
|
@ -1,11 +1,8 @@
|
|||||||
* {
|
/* 导航链接样式 */
|
||||||
color: var(--gray-a12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#nav a {
|
#nav a {
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
color: var(--gray-11);
|
color: var(--gray-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
#nav a:hover {
|
#nav a:hover {
|
||||||
@ -28,27 +25,64 @@
|
|||||||
transform: scaleX(1);
|
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);
|
color: var(--gray-12);
|
||||||
|
|
||||||
|
/* 添加微弱的蓝光过滤 */
|
||||||
|
filter: brightness(0.96) saturate(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
a:not(#nav a) {
|
/* 优化暗色主题下的阴影效果 */
|
||||||
transition: color 0.2s ease;
|
.dark-theme-custom [class*='shadow'] {
|
||||||
color: var(--gray-12);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
a:not(#nav a):hover {
|
/* 优化链接和交互元素的高亮颜色 */
|
||||||
color: var(--accent-12);
|
.dark-theme-custom a:hover,
|
||||||
}
|
.dark-theme-custom button:hover {
|
||||||
|
--accent-9: hsl(226, 70%, 65%); /* 更柔和的强调色 */
|
||||||
button:hover {
|
|
||||||
color: var(--accent-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
border-color: var(--accent-9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover .card-title {
|
|
||||||
color: var(--accent-12);
|
|
||||||
}
|
}
|
||||||
|
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