前端:重构粒子图像组件,更新文章数据结构,改进主题切换和样式,修复多个小问题。

This commit is contained in:
lsy 2024-12-07 02:25:28 +08:00
parent 0628d5588f
commit 2aaffb9e2b
10 changed files with 1178 additions and 445 deletions

View File

@ -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>

View File

@ -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);
}
}, []);
const initTheme = () => {
const savedTheme = localStorage.getItem(THEME_KEY);
const currentTheme = document.documentElement.className;
useEffect(() => {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
const isDarkTheme = document.documentElement.className === 'dark';
setIsDark(isDarkTheme);
// 确保 localStorage 和 DOM 的主题状态一致
if (savedTheme && savedTheme !== currentTheme) {
document.documentElement.className = savedTheme;
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
setIsDark(savedTheme === 'dark' || currentTheme === 'dark');
};
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 = () => {

View 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[];
}

View File

@ -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; // 发布时间
}

View File

@ -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 = [
{

View File

@ -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 categoryIndex =
hash(article.title + article.id.toString()) % categories.length;
const colorIndex =
hash(article.authorName + article.id.toString()) % colorSchemes.length;
// 修改生成分页数组的函数,不再需要省略号
const getPageNumbers = (total: number) => {
return Array.from({ length: total }, (_, i) => i + 1);
};
// 为标签生成不同的索引
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);
// 修改分页部分的渲染
const renderPageNumbers = () => {
const pages = getPageNumbers(totalPages);
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
],
})),
};
});
}, []);
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">
<div className="px-4 md:px-0">
<Flex
align="center"
justify="between"
className="max-w-[800px] mx-auto"
>
<Button
variant="solid"
className="bg-[--accent-9] text-white hover:bg-[--accent-10]"
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>
);
});

View File

@ -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");
}
const handleScroll = useCallback(() => {
const container = document.querySelector('#main-content');
if (!container) return;
// 创建节流函数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 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>
{/* 主题切换和进度指示器容器 */}
<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"

View File

@ -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
#
##
- npmyarn pnpm
- Vitewebpack 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',

View File

@ -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%); /* 更柔和的强调色 */
}

View 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`
};
}