阅读视图

发现新文章,点击刷新页面。

做一个 3D 图片画廊

有时候我会在深夜翻看自己硬盘里堆积的照片,从旅行的风景到日常的瞬间,它们零散地躺在文件夹里,显得有些杂乱。每次打开都要一张一张点开,不仅体验差,还很难有那种“沉浸感”。于是我萌生了一个想法:为什么不做一个 3D 图片画廊呢?让相册的浏览方式更有仪式感,不再是普通的平铺,而是像旋转的立体展示柜一样,既美观又有趣。这个想法听起来挺酷,但真正要实现却没那么简单。我决定从零开始搭建一个属于自己的 3D 图片画廊。

最初我脑海中想象的是一个可以用鼠标或触控去旋转的立方体,每一面都展示不同的照片,用户可以通过交互来切换视角。我也考虑过环形画廊的形式,类似一个旋转的圆盘,照片沿着圆周分布,随着操作而流动切换。最终我确定要先做一个环形画廊版本,后续再扩展成立方体或其他形状,这样循序渐进会更容易。

在这里插入图片描述

有了这样的整体逻辑,我心里就更清晰了。接下来要考虑的就是技术栈的选择。我的第一直觉是用 CSS3 的 transform 来实现,因为 CSS3 已经支持 rotateYtranslateZ 等属性,可以比较轻松地做出立体效果。如果后续想扩展更复杂的场景,再考虑用 Three.js 这样的专业 3D 库。于是我的第一版决定完全依赖 HTML + CSS + JavaScript 来完成。

我先搭了一个基本的 HTML 结构:一个容器 div 作为画廊主体,里面放若干张图片。每张图片都用一个 figure 元素包裹,这样更语义化一些。代码大致是这样的:

<div class="gallery">
  <figure><img src="img1.jpg" alt="pic1"></figure>
  <figure><img src="img2.jpg" alt="pic2"></figure>
  <figure><img src="img3.jpg" alt="pic3"></figure>
  <figure><img src="img4.jpg" alt="pic4"></figure>
  <figure><img src="img5.jpg" alt="pic5"></figure>
</div>

接下来重点就是 CSS。我给 .gallery 添加了 transform-style: preserve-3d;perspective,让其子元素能够在三维空间里呈现。我希望这些图片分布在一个圆环上,于是用了一个小技巧:假设有 N 张图片,那么每张图沿着圆周平均分布,每个的角度就是 360 / N。例如 5 张图片,角度间隔就是 72°。于是我给每个 figure 设置类似 rotateY(72deg * i) translateZ(300px) 的 transform,就能让它们围绕 Y 轴排成一个圆。

代码如下:

.gallery {
  width: 600px;
  height: 400px;
  margin: 100px auto;
  position: relative;
  transform-style: preserve-3d;
  perspective: 1200px;
}
.gallery figure {
  position: absolute;
  width: 260px;
  height: 180px;
  left: 170px;
  top: 110px;
  transform-origin: 50% 50% 300px;
  transition: transform 1s;
}
.gallery figure img {
  width: 100%;
  height: 100%;
  border-radius: 12px;
  box-shadow: 0 10px 25px rgba(0,0,0,0.3);
}

不过单靠 CSS 还不够,我需要用 JavaScript 来动态控制旋转。于是我加了一段逻辑:当用户点击左右按钮时,整个 gallery 容器绕 Y 轴旋转一个角度,从而展示下一个或上一个图片。角度的增量和数量相关,比如 angle = 360 / N

const gallery = document.querySelector('.gallery');
const figures = document.querySelectorAll('.gallery figure');
let angle = 360 / figures.length;
let curr = 0;

document.getElementById('next').onclick = () => {
  curr++;
  gallery.style.transform = `rotateY(${-angle * curr}deg)`;
}
document.getElementById('prev').onclick = () => {
  curr--;
  gallery.style.transform = `rotateY(${-angle * curr}deg)`;
}

运行之后,画廊真的能转起来了!当我点击“下一张”,整个环形顺时针旋转 72°,新的图片就正对用户了。那一刻的成就感特别强烈。不过问题也随之出现:图片之间的切换显得有点生硬,没有过渡动画。我仔细检查,发现我把 transition 写在了 figure 元素上,但其实旋转的是 gallery 容器,所以动画没有生效。把 transition 移到 .gallery 的 transform 上,就自然顺滑了。

再进一步,我希望这个画廊不仅能用按钮控制,还能用拖拽来旋转。于是我监听了鼠标按下、移动和松开的事件,根据拖动的距离来计算旋转角度。这样用户就能用手势滑动的方式去浏览,体验更自然。我在实现过程中遇到一个小坑:如果直接用 mousemove 的距离来控制旋转,很容易出现旋转过快或过慢的问题。最后我通过一个比例系数来调整,类似于 rotateY(totalX / 5),才让手感合适。

随着功能一点点完善,我还给画廊加上了缩放与景深效果。比如当某张图片正对用户时,可以稍微放大一点,并且加重阴影,让它成为视觉焦点。这是通过在旋转结束时计算当前角度来实现的。我写了一个小函数,根据 curr 的值判断哪张图片处于正中间,然后给它添加一个 active 类名,CSS 再去控制样式。

.gallery figure.active img {
  transform: scale(1.2);
  box-shadow: 0 20px 40px rgba(0,0,0,0.5);
  transition: all 0.6s;
}

那一刻整个画廊终于有了“灵魂”,不再只是单纯的旋转,而是真的有层次感和焦点。我甚至忍不住放上了几张旅行的照片,配合着 3D 的效果,就像重新走进了当时的场景。

当然,过程中还有很多小问题,比如不同分辨率下如何保持居中,图片大小不一致时如何裁剪适配,如何在移动端保持流畅度等等。我慢慢一点点解决,比如用 object-fit: cover 来保证图片比例不变形,或者用媒体查询去适配不同的屏幕。

写到这里,我发现这篇文章已经写了不少内容,不过这还只是开始。我还想讲讲我如何给画廊加上自动旋转、背景音乐、灯光特效,甚至用 Three.js 做进阶版的立体空间效果。


基本的旋转功能做出来之后,我就开始思考如何让这个画廊更加生动。最直接的一个想法就是增加自动旋转。毕竟,如果用户不操作,整个画廊停在那里未免显得有些死板。于是我在 JavaScript 里写了一个 setInterval 定时器,每隔三秒自动执行一次 curr++ 并触发旋转。这样画廊就会缓慢自转,照片一张张轮流呈现在正中央。

不过实际运行后,我发现自动旋转和手动拖拽之间存在冲突。当我在拖动时,如果定时器也在运行,就会出现画廊突然“抢走”控制权的情况。我想了一个办法:当检测到用户在按下鼠标或手指触摸时,就 clearInterval 停止自动旋转,等松开之后再重新启动定时器。这样逻辑就顺畅多了。

let autoRotate = setInterval(() => {
  curr++;
  gallery.style.transform = `rotateY(${-angle * curr}deg)`;
}, 3000);

gallery.addEventListener('mousedown', () => clearInterval(autoRotate));
gallery.addEventListener('mouseup', () => {
  autoRotate = setInterval(() => {
    curr++;
    gallery.style.transform = `rotateY(${-angle * curr}deg)`;
  }, 3000);
});

当我第一次看到画廊自己慢慢转动的时候,那种观感真的很舒服,就像商场里旋转展示的橱窗一样,静静地展示着每张照片。

不过在优化体验时,我也注意到一个问题:旋转的速度不能太快,否则眼睛会跟不上;也不能太慢,否则就没什么意思。我测试了几次,发现三秒一张是比较理想的节奏。同时我在 CSS 动画里调了 transition 的时间,让旋转的过渡在 1 秒左右完成,这样既不会显得突兀,也不会太拖沓。


我还想给画廊增加一点“舞台感”。光有旋转还不够,我希望背景也能有层次,于是我给画廊容器外层加了一个渐变背景,类似舞台灯光的感觉。

body {
  background: radial-gradient(circle at center, #222, #000);
  overflow: hidden;
}

效果一出来,画廊就像悬浮在舞台中央,被一束聚光灯照亮。我还给每张图片加了一点反射效果,用 ::after 做了一个渐变透明的镜像,这让照片看起来像摆在玻璃台面上。

.gallery figure img::after {
  content: '';
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  height: 100%;
  background: linear-gradient(to bottom, rgba(255,255,255,0.3), transparent);
  transform: scaleY(-1);
}

虽然只是一个小小的装饰,但视觉效果立刻丰富了很多。


在交互上,我想让用户能够用键盘操作。比如按左右方向键可以旋转,按空格键暂停或恢复自动播放。实现起来很简单,只需要监听 keydown 事件:

document.addEventListener('keydown', (e) => {
  if(e.key === 'ArrowRight') {
    curr++;
    gallery.style.transform = `rotateY(${-angle * curr}deg)`;
  }
  if(e.key === 'ArrowLeft') {
    curr--;
    gallery.style.transform = `rotateY(${-angle * curr}deg)`;
  }
  if(e.key === ' ') {
    if(autoRotate) {
      clearInterval(autoRotate);
      autoRotate = null;
    } else {
      autoRotate = setInterval(() => {
        curr++;
        gallery.style.transform = `rotateY(${-angle * curr}deg)`;
      }, 3000);
    }
  }
});

这样一来,用户可以完全不用鼠标,只靠键盘就能操控整个画廊。


到这里,功能算是比较完整了,但我还不满意。我想要更“沉浸”的感觉。于是我尝试给画廊加上背景音乐。进入页面时,音乐自动播放,伴随着图片旋转,整个人的心境会进入一种“回忆”的氛围。

我用 HTML5 <audio> 标签加载一首轻音乐:

<audio id="bgm" autoplay loop>
  <source src="music.mp3" type="audio/mpeg">
</audio>

不过自动播放在很多浏览器上默认是禁止的,除非用户有过交互。我就加了一个“播放音乐”的按钮,用户点一下就能开启背景音乐。顺便我还给音乐播放加了一个小小的旋转唱片动画,摆在画廊右下角,既是装饰,也是控制按钮。

在这里插入图片描述


这次的 3D 画廊不仅让我学到了很多 CSS3 和 JavaScript 的知识,更重要的是让我体会到了从无到有构建一个作品的乐趣。

用 Electron 做一个屏幕取色器

作为一名开发者,我经常会闲的没事想做些什么。于是我决定自己动手,用Electron构建一个功能完善、界面现代的屏幕取色器应用。

项目架构设计与技术选型

在开始编码之前,我花了不少时间思考整个应用的架构设计。Electron应用本质上是一个多进程架构,主进程负责应用生命周期管理和系统级操作,渲染进程负责UI展示和用户交互。对于取色器这样的应用,我需要特别考虑以下几个关键点:

在这里插入图片描述

整个应用的核心流程可以分为几个主要阶段:应用启动与初始化、取色器窗口创建、屏幕捕获与颜色提取、颜色数据管理与存储。每个阶段都有其独特的技术挑战和解决方案。

在技术选型上,我选择了Electron 28.0.0作为基础框架,这个版本在安全性和性能方面都有显著提升。为了数据持久化,我使用了electron-store库来管理用户的颜色历史和偏好设置。在UI设计方面,我采用了Microsoft的Fluent Design设计语言,通过CSS3实现了玻璃态效果和流畅的动画过渡。

主进程架构与窗口管理

主进程是整个应用的控制中心,负责管理应用的生命周期、创建和控制窗口、处理系统级操作。在我的实现中,主进程需要管理两个不同类型的窗口:主应用窗口和全屏取色器窗口。

function createMainWindow() {
  const windowConfig = store.get('windowBounds') || { width: 400, height: 600 };
  
  mainWindow = new BrowserWindow({
    width: windowConfig.width,
    height: windowConfig.height,
    minWidth: 320,
    minHeight: 450,
    frame: false,
    transparent: true,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js'),
      webSecurity: true
    },
    icon: path.join(__dirname, 'assets/icons/icon.png')
  });
}

主窗口的设计考虑了现代应用的用户体验需求。我禁用了默认的窗口框架(frame: false),这样可以实现自定义的标题栏设计,让应用看起来更加现代和统一。透明窗口(transparent: true)的设置让我能够实现玻璃态效果和圆角边框。在安全配置方面,我严格遵循了Electron的最佳实践:禁用Node.js集成、启用上下文隔离、使用预加载脚本进行安全的API暴露。

取色器窗口的创建更加复杂,因为它需要覆盖整个屏幕并捕获屏幕内容:

function createPickerWindow() {
    const { width, height } = screen.getPrimaryDisplay().workArea;
    
    pickerWindow = new BrowserWindow({
        width,
        height,
        x: 0,
        y: 0,
        frame: false,
        transparent: true,
        fullscreen: true,
        alwaysOnTop: true,
        skipTaskbar: true,
        resizable: false,
        movable: false,
        webPreferences: {
            nodeIntegration: false,
            contextIsolation: true,
            preload: path.join(__dirname, 'preload.js'),
            webSecurity: true
        }
    });
}

这个窗口需要始终保持在最顶层(alwaysOnTop: true),不出现在任务栏中(skipTaskbar: true),并且不能被用户调整大小或移动。这些设置确保了取色器能够正确地覆盖整个屏幕,为用户提供无干扰的取色体验。

安全的IPC通信机制

在Electron应用中,主进程和渲染进程之间的通信是通过IPC(Inter-Process Communication)机制实现的。为了确保应用的安全性,我在预加载脚本中创建了一个安全的API桥接层:

contextBridge.exposeInMainWorld('electronAPI', {
    // 颜色选择相关
    colorPicked: (colorData) => ipcRenderer.send('color-picked', colorData),
    cancelPicking: () => ipcRenderer.send('cancel-picking'),
    closePicker: () => ipcRenderer.send('close-picker-window'),
    
    // 屏幕捕获
    getScreenSources: () => ipcRenderer.invoke('get-screen-sources'),
    
    // 窗口控制
    minimizeWindow: () => ipcRenderer.send('minimize-window'),
    maximizeWindow: () => ipcRenderer.send('toggle-maximize-window'),
    closeWindow: () => ipcRenderer.send('close-window'),
    
    // 应用功能
    startPicking: () => ipcRenderer.send('start-picking'),
    saveColors: (colors) => ipcRenderer.send('save-colors', colors),
    exportColors: (colors) => ipcRenderer.send('export-colors', colors)
});

这种设计模式的优势在于,渲染进程只能访问我明确暴露的API,而不能直接访问Node.js的原生模块或Electron的主进程API。这大大降低了安全风险,特别是在处理用户输入或外部数据时。

在主进程中,我使用ipcMain来处理来自渲染进程的消息。对于一些需要返回值的操作,我使用了handle/invoke模式而不是传统的send/on模式,这样可以更好地处理异步操作和错误情况:

ipcMain.handle('get-screen-sources', async () => {
    try {
        const sources = await desktopCapturer.getSources({
            types: ['screen'],
            thumbnailSize: { width: 1, height: 1 }
        });
        
        if (!sources || sources.length === 0) {
            throw new Error('未找到屏幕源');
        }
        
        return sources[0].id;
    } catch (error) {
        console.error('获取屏幕源失败:', error);
        throw error;
    }
});

屏幕捕获与颜色提取技术

屏幕取色的核心技术是屏幕捕获和颜色提取。在Electron中,我使用了desktopCapturer API来获取屏幕内容,然后通过getUserMedia API将其转换为视频流。这个过程涉及到几个关键的技术细节:

在这里插入图片描述

首先,我需要获取屏幕的访问权限并创建视频流:

async function setupVideoStream(sourceId) {
    try {
        const stream = await navigator.mediaDevices.getUserMedia({
            audio: false,
            video: {
                mandatory: {
                    chromeMediaSource: 'desktop',
                    chromeMediaSourceId: sourceId
                }
            }
        });

        videoElement = document.createElement('video');
        videoElement.srcObject = stream;
        videoElement.style.display = 'none';
        document.body.appendChild(videoElement);
        
        return new Promise((resolve) => {
            videoElement.onloadedmetadata = async () => {
                await videoElement.play();
                isVideoReady = true;
                resolve();
            };
        });
    } catch (error) {
        console.error('设置视频流失败:', error);
        throw error;
    }
}

这里有一个重要的技术细节:我将video元素设置为不可见(display: none),因为它只是作为数据源使用,用户不需要看到实际的视频内容。真正的显示是通过Canvas来实现的。

放大镜功能是整个取色器的核心用户体验。我使用Canvas API来实现实时的屏幕内容放大显示:

function updateMagnifier(x, y) {
    if (!isVideoReady || !videoElement) return;
    
    try {
        const centerX = magnifierSize / 2;
        const centerY = magnifierSize / 2;
        
        // 计算缩放比例
        const scaleX = videoElement.videoWidth / window.innerWidth;
        const scaleY = videoElement.videoHeight / window.innerHeight;
        
        // 优化取样区域的计算
        const sourceX = Math.max(0, Math.min(x * scaleX - centerX / zoomFactor, 
            videoElement.videoWidth - magnifierSize / zoomFactor));
        const sourceY = Math.max(0, Math.min(y * scaleY - centerY / zoomFactor, 
            videoElement.videoHeight - magnifierSize / zoomFactor));
        
        magnifierCtx.clearRect(0, 0, magnifierSize, magnifierSize);
        
        // 使用 imageSmoothingEnabled 提高放大质量
        magnifierCtx.imageSmoothingEnabled = false;
        
        magnifierCtx.drawImage(
            videoElement,
            sourceX,
            sourceY,
            magnifierSize / zoomFactor,
            magnifierSize / zoomFactor,
            0,
            0,
            magnifierSize,
            magnifierSize
        );
    } catch (error) {
        console.error('更新放大镜错误:', error);
    }
}

这段代码中有几个关键的优化点。首先是坐标系的转换:屏幕坐标需要转换为视频坐标,因为视频的分辨率可能与屏幕显示分辨率不同。其次是边界检查:确保取样区域不会超出视频的边界。最重要的是设置imageSmoothingEnabled为false,这样可以保持像素的锐利度,避免在放大时出现模糊效果。

颜色提取是通过Canvas的getImageData API实现的:

function handleClick(e) {
    if (!isVideoReady) return;
    
    const centerX = magnifierSize / 2;
    const centerY = magnifierSize / 2;
    
    try {
        const pixelData = magnifierCtx.getImageData(centerX, centerY, 1, 1).data;
        const [r, g, b] = pixelData;
        const hexColor = rgbToHex(r, g, b);
        
        window.electronAPI.colorPicked({
            hex: hexColor,
            rgb: `rgb(${r}, ${g}, ${b})`,
            hsl: rgbToHsl(r, g, b),
            timestamp: Date.now()
        });
    } catch (error) {
        console.error('取色错误:', error);
    }
}

高性能渲染优化

在开发过程中,我发现放大镜的实时更新会带来性能问题,特别是在高分辨率屏幕上。鼠标移动事件的频率非常高,如果每次都立即更新Canvas,会导致CPU使用率过高和界面卡顿。为了解决这个问题,我采用了几种优化策略。

首先是使用requestAnimationFrame来控制更新频率:

function handleMouseMove(e) {
    if (!isVideoReady) return;
    
    // 取消上一帧的请求
    if (lastRaf) {
        cancelAnimationFrame(lastRaf);
    }
    
    // 使用 requestAnimationFrame 优化性能
    lastRaf = requestAnimationFrame(() => {
        const x = e.clientX;
        const y = e.clientY;
        
        // 使用 transform 代替 left/top 提高性能
        magnifier.style.transform = `translate(${x}px, ${y}px)`;
        updateMagnifier(x, y);
    });
}

这种方法确保了更新频率不会超过浏览器的刷新率(通常是60fps),同时通过取消上一帧的请求来避免积压。另外,我使用CSS的transform属性而不是left/top来移动放大镜,因为transform会触发GPU加速,性能更好。

在CSS方面,我也做了相应的优化:

#magnifier {
    will-change: transform;  /* 优化性能 */
    transition: transform 0.05s cubic-bezier(0.23, 1, 0.32, 1);
}

will-change属性告诉浏览器这个元素的transform属性会频繁变化,浏览器会为其创建合成层,从而提高渲染性能。

现代化UI设计与用户体验

在UI设计方面,我采用了Microsoft的Fluent Design设计语言,这是一种强调光线、深度、运动和材质的现代设计风格。整个应用的视觉设计围绕着几个核心原则:简洁性、一致性、可访问性和美观性。

:root {
  /* Fluent Design 风格的配色方案 */
  --primary: #0078d4;
  --primary-light: #2b88d8;
  --primary-dark: #106ebe;
  
  /* 中性色 */
  --bg: #fafafa;
  --surface: rgba(255, 255, 255, 0.98);
  --text: #323130;
  --text-secondary: #605e5c;
  
  /* Fluent 设计阴影 */
  --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.08);
  --shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
  --shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.14);
  
  /* 流畅动画 */
  --transition: 180ms cubic-bezier(0.16, 1, 0.3, 1);
}

玻璃态效果是现代UI设计的一个重要元素,我通过CSS的backdrop-filter属性来实现:

.glass-effect {
  background: var(--surface);
  backdrop-filter: blur(20px) saturate(180%);
  border: 1px solid rgba(0, 0, 0, 0.06);
  box-shadow: var(--shadow-sm);
}

这种效果创造了一种半透明的玻璃质感,让界面看起来更加现代和精致。backdrop-filter属性会对元素后面的内容应用模糊和饱和度调整,创造出真实的玻璃效果。

在动画设计方面,我使用了精心调校的缓动函数来创造流畅自然的过渡效果:

.primary-button {
  transition: var(--transition);
}

.primary-button:hover {
  background: var(--primary-light);
  transform: translateY(-1px);
}

.primary-button:active {
  background: var(--primary-dark);
  transform: translateY(0);
}

这种微妙的垂直移动效果模拟了按钮被按下的物理感觉,增强了用户的交互反馈。

颜色数据管理与持久化

颜色数据的管理是这个应用的核心功能之一。用户需要能够查看历史记录、收藏常用颜色、导出颜色数据等。我设计了一个完整的颜色数据管理系统来处理这些需求。

在这里插入图片描述

数据持久化是通过localStorage和electron-store两种方式实现的。localStorage用于快速的本地存储,而electron-store用于更可靠的跨会话数据保存:

saveColorsToStorage() {
    try {
        const colorsData = {
            history: this.colorHistoryData,
            favorites: this.colorFavoritesData
        };
        
        localStorage.setItem('savedColors', JSON.stringify(colorsData));
        window.electronAPI.saveColors(colorsData);
    } catch (error) {
        console.error('保存颜色数据失败:', error);
        this.showNotification('保存失败', 'error');
    }
}

在颜色历史管理方面,我实现了智能的去重和排序机制。当用户选择一个已存在的颜色时,系统会将其移动到历史记录的顶部,而不是创建重复条目:

addColor(colorData) {
    // 检查是否已存在
    const existingIndex = this.colorHistoryData.findIndex(c => c.hex === colorData.hex);
    if (existingIndex !== -1) {
        // 移动到顶部
        this.colorHistoryData.splice(existingIndex, 1);
    }
    
    // 添加到历史记录顶部
    this.colorHistoryData.unshift(colorData);
    
    // 限制历史记录数量
    if (this.colorHistoryData.length > 100) {
        this.colorHistoryData = this.colorHistoryData.slice(0, 100);
    }
    
    this.saveColorsToStorage();
    this.updateUI();
}

多格式颜色导出功能

颜色导出功能是为了满足不同用户的需求而设计的。设计师可能需要JSON格式的颜色数据,前端开发者可能更喜欢CSS变量格式,而使用Sass的开发者则需要SCSS变量格式。我实现了一个灵活的导出系统来支持这些不同的格式:

generateExportContent(format) {
    const allColors = [...this.colorFavoritesData, ...this.colorHistoryData];
    const uniqueColors = allColors.filter((color, index, self) => 
        index === self.findIndex(c => c.hex === color.hex)
    );
    
    switch (format) {
        case 'json':
            return JSON.stringify(uniqueColors, null, 2);
        
        case 'css':
            let cssContent = ':root {\n';
            uniqueColors.forEach((color, index) => {
                const name = `--color-${index + 1}`;
                cssContent += `  ${name}: ${color.hex};\n`;
            });
            cssContent += '}\n';
            return cssContent;
        
        case 'scss':
            let scssContent = '';
            uniqueColors.forEach((color, index) => {
                const name = `$color-${index + 1}`;
                scssContent += `${name}: ${color.hex};\n`;
            });
            return scssContent;
        
        default:
            return '';
    }
}

导出功能还包括实时预览,用户可以在导出前看到生成的内容格式,这大大提高了用户体验。

系统集成与全局快捷键

为了让应用更加便于使用,我实现了系统托盘集成和全局快捷键功能。用户可以通过系统托盘快速访问应用功能,也可以使用键盘快捷键在任何时候启动取色功能。

function createTray() {
  try {
    tray = new Tray(path.join(__dirname, 'assets/icons/tray-icon.png'));
    
    const contextMenu = Menu.buildFromTemplate([
      { label: '显示', click: () => mainWindow.show() },
      { label: '取色', click: startColorPicking },
      { type: 'separator' },
      { label: '退出', click: () => app.quit() }
    ]);
    
    tray.setToolTip('屏幕取色器');
    tray.setContextMenu(contextMenu);
    
    tray.on('click', () => {
      if (mainWindow) {
        if (mainWindow.isVisible()) {
          mainWindow.hide();
        } else {
          mainWindow.show();
        }
      }
    });
  } catch (error) {
    console.error('创建系统托盘失败:', error);
  }
}

全局快捷键的实现让用户可以在任何应用中快速启动取色功能:

// 注册全局快捷键
globalShortcut.register('CommandOrControl+Shift+C', startColorPicking);

这个快捷键组合(Ctrl+Shift+C)是经过仔细考虑的,它不太可能与其他应用的快捷键冲突,同时也容易记忆(C代表Color)。

在这里插入图片描述


这个屏幕取色器不仅仅是一个工具,更是我对现代应用开发理念的实践。从用户体验设计到技术架构选择,从性能优化到安全考虑,每一个细节都体现了对品质的追求。虽然开发过程中遇到了不少挑战,但正是这些挑战让我学到了更多宝贵的经验。

做一个石头剪刀布小游戏

最近我无意间看到一个非常有趣的小游戏,玩法看起来简单却颇有意思。它不是传统意义上的石头剪刀布,而是把这种经典的博弈规则搬到屏幕上,变成了一场群体之间的“进化战争”。石头、剪刀和布不再只是单一的手势,而是成群结队的小兵,它们在屏幕上乱跑,彼此相遇时遵循石头剪刀布的规则来吞并对方,逐渐演化出谁能统治整个画布的局面。第一次看到的时候,我就被这种群体对抗的视觉效果吸引住了,心里也萌生了一个念头:我是不是可以自己实现一个这样的游戏,并且在这个过程中做一些个性化改造?

最初我想的很简单,就是在屏幕上生成一些石头、剪刀和布,用 emoji 表情代替素材,毕竟这样省去了加载图片的麻烦,而且在现代浏览器里表情符号的兼容性已经相当不错。但我很快就发现,有些表情符号在部分浏览器环境下可能并不能正常显示,于是我也准备了退路,如果遇到显示问题,我会考虑换成其他符号或者干脆画一些简化的几何图形。不过幸运的是,在我的环境下石头(✊)、剪刀(✌️)和布(✋)都能正常展示,这让我可以毫无心理负担地继续推进。

真正让我觉得要动手做的一个原因是,我不满足于原本那个版本的“单个控制”。很多实现只是让你选中某一个棋子去移动,而这种设计的问题很明显:一旦你控制的那个棋子被对方吃掉,游戏体验就会戛然而止。这显然和我想要的群体策略对抗完全不一样。于是我决定让玩家控制的是一个“阵营”,比如你选择剪刀,那么整个剪刀军团都会响应你的操作,整体朝某个方向缓慢移动,而不是操控某个单体单位。这样一来,游戏体验就完全不同了,你不是某个兵卒,而是整个军团的将军。

在开始编程之前,我习惯性地先把这个游戏的逻辑梳理了一遍,用一个简单的流程图来帮助我理清思路。整个游戏的循环大致是这样的:首先生成随机分布的石头、剪刀和布单位;然后不断进行更新,每个单位都会根据速度移动;玩家可以通过输入来调整自己阵营的整体趋势;当两个不同阵营的单位接触时,按照石头剪刀布的规则来决定胜负,败者被转化为胜者阵营;不断循环下去,直到某个阵营统治全场。

在这里插入图片描述

在脑子里理顺了逻辑之后,我就开始着手写代码。我选择用 HTML + JavaScript + Canvas 来实现,原因很简单:这种组合足够轻量,不需要复杂的构建工具,写出来的东西直接放到浏览器里就能运行,调试和修改都很方便。而且 Canvas 天生就适合绘制大量的小元素并不断刷新,刚好契合这个项目的需求。

我首先搭建了一个最基本的框架:在页面里放一个全屏的 <canvas>,然后通过 JavaScript 获取上下文对象。接着我写了一个实体类 Entity 来代表游戏中的每个小兵,它包含 type(石头、剪刀或布)、坐标 xy、速度 vxvy,还有一个绘制方法 draw,用来把对应的 emoji 渲染到画布上。这个阶段的目标只是让屏幕上出现一些随机分布的表情符号,能动起来就算成功。

写完之后我运行了一下,果然屏幕上出现了一堆 ✊✋✌️,它们各自朝着随机方向移动,看起来像一群小精灵在乱舞。不过很快问题就来了,这种随机直线运动实在是太生硬了,每个单位看起来像一颗子弹,直来直去,完全没有那种“漂移”的丝滑感。于是我意识到,需要在移动逻辑上做改造。具体来说,我不能让它们每一帧都沿着固定方向移动固定距离,而应该引入一种“目标趋势”的概念。比如说玩家输入了向右移动的指令,那么我给整个阵营设定一个目标速度向量,而当前速度会逐渐逼近这个目标速度,这样就会产生一种缓动效果,画面看上去更自然。

我把这个逻辑写进了更新函数里,大致的公式是这样的: vx = vx * 0.9 + targetVx * 0.1 vy = vy * 0.9 + targetVy * 0.1 这样一来,当前速度会在每一帧都逐渐贴近目标速度,而不会立即跳过去,从而实现类似漂移的感觉。等我重新跑一遍,果然画面舒服了很多,每个单位的运动不再是死板的直线,而是带有惯性,移动过程有一种柔和的流畅感。

当基本的移动效果做出来之后,我开始考虑玩家控制逻辑。我决定采用“阵营整体控制”的方式,也就是当玩家选择了某个阵营,比如剪刀,那么在键盘上按方向键或者在手机上滑动,就能让整个剪刀军团一起调整方向。这部分实现的关键在于给每个属于该阵营的单位施加一个额外的速度偏移,让它们整体向某个方向漂移。至于其他阵营,它们则保持原有的随机运动,不受玩家影响。这样一来,玩家就能感受到带领军团作战的快感,而不是局限于一个小兵的命运。

在这里插入图片描述

代码里我写了一个全局变量 playerFaction 来记录玩家阵营,值可以是 "rock", "scissors", "paper"。在初始化的时候,玩家可以选择控制哪一方。然后我在键盘事件监听函数里检测方向键,如果按下了右方向键,就把该阵营的 targetVx 设为正数;如果是上方向键,就把 targetVy 设为负数。移动端我也准备了一个方案,可以通过监听触摸事件,根据手指滑动方向来设定偏移。这样就实现了 PC 和移动端的双重适配。

接下来最核心的部分就是碰撞检测与规则判定了。逻辑很清晰:如果一个石头和一个剪刀碰撞,那么剪刀会变成石头;如果一个剪刀和一张布碰撞,那么布会变成剪刀;如果一张布和一个石头碰撞,那么石头会变成布。为了让这一过程更直观,我又画了一个规则图:

在这里插入图片描述

我在代码里写了一个函数 beats(a, b),用来判断 a 是否能战胜 b,返回布尔值。然后在每一帧更新时,我遍历所有单位,检测它们是否彼此接触,如果是,就调用规则函数来决定结果,最后把败者的 type 改成胜者的类型。这种“转化”效果看起来非常有趣,画面里会出现不断扩张和收缩的阵营,像是生物之间的捕食与进化。


当我把石头剪刀布的碰撞规则写完以后,屏幕上的局面终于开始“活”了起来。每一帧更新时,我看到不同阵营的小兵在不断地吞并和被吞并,颜色和符号在画布上动态变化。第一次看到石头群体一口气吞掉一片剪刀的时候,我甚至有点小激动,因为这种变化是完全由简单的规则驱动出来的复杂动态,而我只是提供了最基本的框架。

不过很快我就发现了一些问题。第一个问题是性能瓶颈。因为在最初的实现里,我用了一个双重循环去检测所有单位之间的碰撞,这意味着如果我有 200 个单位,就需要进行接近 200 × 200 次检测,而这是一个平方级的复杂度。虽然在低数量下没什么问题,但当我尝试把单位数量提升到 500 以上的时候,帧率明显下降,画面开始变得卡顿。我立刻意识到必须优化碰撞检测逻辑。

我想到的解决办法是“空间划分”。与其让所有单位都两两比较,不如把画布划分为一个个小格子,每个单位只需要和同格子或相邻格子的单位进行比较。这样就能大大减少不必要的检测次数,因为相隔很远的单位根本不可能发生碰撞。我在代码里实现了一个简单的网格系统:比如把整个画布分成 50×50 的小块,每个单位根据自己的位置放到对应的桶里,然后只检查桶里的元素。这样优化之后,即使单位数提升到 1000,帧率依然能保持流畅。

我用一张图把这个“空间划分”的思想画了出来,方便以后给别人解释:

在这里插入图片描述

第二个问题是边界处理。在没有做边界限制之前,小兵们会很快跑到画布之外,结果整个游戏的画面越来越空,直到什么也看不到。这显然不符合预期,于是我决定让所有单位在碰到边界时“反弹”。实现也很简单:如果 x 超过画布宽度或小于零,就把 vx 取负;如果 y 超过画布高度或小于零,就把 vy 取负。这样一来,所有小兵就被限制在了画布内,不会消失掉。不过我又觉得反弹的效果太过生硬,像是台球一样。我后来调整了一下,把速度乘上一个衰减系数,这样在撞墙时会略微减速,看起来更自然一些。

接着我考虑了游戏的“终局”问题。如果任由系统无限运行下去,很可能会形成一种僵持状态,但通常情况是某一个阵营逐渐吞并掉所有对手。我希望能在某一方统治全场的时候给出一个提示,宣布获胜。于是我写了一个统计函数,每隔一定帧数就数一遍画布上三种类型的单位数量。如果有一方数量达到总数的 95% 以上,我就宣布该阵营获胜,并弹出一个简单的提示框。这样玩家就能明确知道游戏什么时候结束,而不是永远盯着屏幕。

随着逻辑越来越完整,我开始想要给整个项目写一份更清晰的结构设计。我画了一个比较精致的系统架构图,概括了游戏循环和各个模块的关系:

在这里插入图片描述

写到这里,我突然想起之前在设计“漂移效果”的时候只给玩家阵营实现了缓动,但其他阵营依然是随机直线移动的。这样看久了就觉得有点别扭,因为玩家阵营在优雅地漂移,而其他小兵却还在傻乎乎地直来直去。我于是干脆把缓动逻辑推广到所有单位,让它们在每一帧都稍微朝着一个目标速度靠拢。区别在于玩家阵营的目标速度由输入决定,而其他阵营的目标速度则是每隔一段时间随机改变一次。这样一来,整个画面都呈现出流畅的漂移动作,看起来非常协调。

在这个过程中我踩了一个小坑,就是在移动端的适配上。键盘控制在 PC 上完全没问题,但在手机上显然没人会带着方向键。我原本考虑用屏幕上的虚拟摇杆,后来觉得实现起来稍微复杂一些,于是先用了一种简化方案:监听触摸事件,根据手指滑动的方向来决定目标速度。如果玩家从左往右滑,就给阵营一个向右的目标速度;如果从上往下滑,就让阵营整体下移。虽然不如虚拟摇杆精准,但对于休闲小游戏来说已经足够了。等我调试好以后,用手机点开网页,也能轻松玩起来,那种“随时随地开一局”的感觉让我挺满足的。

到这个阶段,核心玩法和主要功能已经全部实现,我决定开始对代码进行一些整理。最重要的是把不同部分的逻辑分开,避免写成一团乱麻。我给每个模块起了单独的函数:

  • updateEntities() 用来更新位置和速度;
  • handleCollisions() 专门负责碰撞检测与规则判定;
  • render() 负责绘制画布;
  • checkVictory() 负责胜负检测;
  • handleInput() 负责处理键盘和触摸输入。

这样一来,整个代码就变得结构清晰,任何时候想修改某个功能,都可以直接去找对应的函数,而不是在上千行的循环里苦苦翻找。

我还给 Entity 类加了一些辅助方法,比如 move()changeType(newType),前者封装了速度和位置的更新,后者则用来在碰撞时改变阵营。这样每个实体的行为就相对独立,而不是完全依赖外部函数去操控。

在整理的过程中,我也顺便加了一些小的美化,比如在画布左上角显示当前三种阵营的数量,让玩家随时能看到局势的变化。我甚至加了一个小动画,当某个阵营被吞并到只剩下个位数的时候,数字会闪烁,营造一种紧张感。这些都是锦上添花的功能,但对于游戏体验来说确实有加分。

写到这里,我已经有了一个完整可玩的“石头剪刀布群体战争”,无论是在 PC 还是手机上,都能打开网页直接玩。整个开发过程让我非常享受,因为它并不是一个庞大的工程,却能在很短的时间内给我带来即时的反馈和成就感。尤其是当我解决了漂移效果和群体控制的难题以后,画面一下子从生硬变得灵动,这种转变特别直观,就像给游戏注入了生命一样。

在这里插入图片描述

从零到一,制作一个项目展示平台

作为一名开发者,我一直想要一个能够完美展示自己项目作品的平台。市面上虽然有GitHub、个人博客等展示方式,但总感觉缺少一些视觉冲击力和现代感。我希望能够创建一个既有科技感又实用的项目展示平台,不仅能够清晰地展示项目信息,还能给访问者带来震撼的视觉体验。经过几个月的构思和开发,SoftHub——一个融合了科幻美学与实用功能的现代化项目展示平台终于诞生了。

项目构思与技术选型

在开始编码之前,我花了不少时间思考这个项目展示平台的核心理念。我希望这个平台不仅仅是一个简单的项目列表,而是要具备强烈的视觉冲击力和沉浸式的用户体验。我想要创造一种科幻电影般的氛围,让访问者在浏览项目的同时,也能感受到技术的魅力和未来感。

经过反复权衡,我最终选择了React + TypeScript + Vite的技术栈。React的组件化开发模式非常适合构建这种视觉效果丰富的应用,TypeScript能够提供强类型支持,确保复杂动画逻辑的稳定性,而Vite则能带来极快的开发体验,让我能够快速迭代视觉效果。为了实现科幻风格的视觉效果,我引入了Framer Motion动画库来处理复杂的页面转场和交互动画,同时还自己实现了粒子系统和天体背景动画来营造太空科幻的氛围。

在这里插入图片描述

项目架构设计

在正式开发之前,我设计了整个应用的架构。SoftHub采用了模块化的单页应用(SPA)架构,主要包含几个核心模块:科幻风格首页模块、项目展示网格模块、项目详情模块、二维码生成模块和多种视觉背景系统。整个应用的数据流采用了React的状态管理模式,通过自定义Hook来管理视觉效果、时间感知UI和滚动动画等复杂交互状态。

在这里插入图片描述

核心功能实现

时间感知UI系统设计

项目最独特的功能之一是时间感知UI系统。我希望这个平台能够根据当前时间动态调整视觉效果,营造出更加沉浸式的体验。通过分析一天中不同时段的光线变化和色彩心理学,我设计了一套完整的时间感知配色系统。

interface TimeAwareConfig {
  primaryColor: string;
  secondaryColor: string;
  particleDensity: number;
  animationIntensity: number;
  glowIntensity: number;
}

const useTimeAwareUI = (): TimeAwareConfig => {
  const [timeConfig, setTimeConfig] = useState<TimeAwareConfig>({
    primaryColor: '#00A3FF',
    secondaryColor: '#0066CC',
    particleDensity: 50,
    animationIntensity: 1.0,
    glowIntensity: 15
  });

  useEffect(() => {
    const updateTimeAwareUI = () => {
      const now = new Date();
      const hour = now.getHours();
      const minute = now.getMinutes();
      
      let config = { ...timeConfig };
      
      // 根据时间段调整主题色彩
      if (hour >= 6 && hour < 12) {
        // 清晨:清新的蓝色调
        config.primaryColor = '#00A3FF';
        config.secondaryColor = '#0066CC';
        config.glowIntensity = 12;
      } else if (hour >= 12 && hour < 18) {
        // 下午:温暖的青色调
        config.primaryColor = '#00D4FF';
        config.secondaryColor = '#0099CC';
        config.glowIntensity = 18;
      } else if (hour >= 18 && hour < 22) {
        // 傍晚:神秘的紫色调
        config.primaryColor = '#6A5ACD';
        config.secondaryColor = '#483D8B';
        config.glowIntensity = 20;
      } else {
        // 深夜:深邃的蓝紫色调
        config.primaryColor = '#1E1E3F';
        config.secondaryColor = '#2D2D5F';
        config.glowIntensity = 25;
      }
      
      // 根据分钟微调颜色亮度
      const minuteFactor = minute / 60;
      config.primaryColor = adjustColorBrightness(config.primaryColor, minuteFactor * 0.1);
      
      setTimeConfig(config);
    };

    updateTimeAwareUI();
    const interval = setInterval(updateTimeAwareUI, 60000);
    
    return () => clearInterval(interval);
  }, []);

  return timeConfig;
};

这个系统不仅仅是简单的颜色切换,它还会影响粒子密度、动画强度和发光效果,让整个界面呈现出随时间变化的动态美感。当用户在不同时间访问网站时,会看到完全不同的视觉风格,这种细节上的用心让平台具有了独特的生命力。

在这里插入图片描述

科幻粒子背景系统

为了营造科幻太空的氛围,我开发了一个复杂的粒子背景系统。这个系统不仅仅是简单的粒子动画,而是一个完整的视觉生态系统,包含了粒子生成、运动轨迹计算、粒子间连接、性能优化等多个层面的技术实现。

interface Particle {
  x: number;
  y: number;
  speedX: number;
  speedY: number;
  size: number;
  color: string;
  opacity: number;
  life: number;
  maxLife: number;
}

const SciFiParticleBackground: React.FC = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const { primaryColor, secondaryColor, particleDensity, animationIntensity } = useTimeAwareUI();

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    
    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    // 动态调整画布尺寸
    const resizeCanvas = () => {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
    };

    // 创建粒子系统 - 根据屏幕尺寸和性能动态调整粒子数量
    const particles: Particle[] = [];
    const particleCount = Math.floor(window.innerWidth * window.innerHeight / 20000 * particleDensity / 50);
    
    for (let i = 0; i < particleCount; i++) {
      particles.push({
        x: Math.random() * canvas.width,
        y: Math.random() * canvas.height,
        speedX: (Math.random() - 0.5) * 0.5 * animationIntensity,
        speedY: (Math.random() - 0.5) * 0.5 * animationIntensity,
        size: Math.random() * 2 + 0.5,
        color: Math.random() > 0.5 ? primaryColor : secondaryColor,
        opacity: Math.random() * 0.8 + 0.2,
        life: Math.random() * 1000 + 500,
        maxLife: Math.random() * 1000 + 500
      });
    }

    // 粒子更新逻辑
    const updateParticle = (particle: Particle) => {
      particle.x += particle.speedX;
      particle.y += particle.speedY;
      particle.life--;
      
      // 边界检测和循环
      if (particle.x < 0) particle.x = canvas.width;
      if (particle.x > canvas.width) particle.x = 0;
      if (particle.y < 0) particle.y = canvas.height;
      if (particle.y > canvas.height) particle.y = 0;
      
      // 生命周期管理
      if (particle.life <= 0) {
        particle.life = particle.maxLife;
        particle.opacity = Math.random() * 0.8 + 0.2;
      }
      
      // 动态透明度变化
      particle.opacity = Math.sin(particle.life / particle.maxLife * Math.PI) * 0.8 + 0.2;
    };

    // 粒子连接系统 - 创造网络效果
    const connectParticles = (particles: Particle[], context: CanvasRenderingContext2D) => {
      for (let a = 0; a < particles.length; a++) {
        for (let b = a + 1; b < Math.min(a + 10, particles.length); b++) {
          const dx = particles[a].x - particles[b].x;
          const dy = particles[a].y - particles[b].y;
          const distance = Math.sqrt(dx * dx + dy * dy);
          
          if (distance < 120) {
            const opacity = (120 - distance) / 120 * 0.3;
            context.strokeStyle = `rgba(0, 212, 255, ${opacity})`;
            context.lineWidth = 0.5;
            context.beginPath();
            context.moveTo(particles[a].x, particles[a].y);
            context.lineTo(particles[b].x, particles[b].y);
            context.stroke();
          }
        }
      }
    };

    // 高性能动画循环
    let lastTime = 0;
    const targetFPS = 30;
    const frameInterval = 1000 / targetFPS;
    let animationId: number;

    const animate = (currentTime: number) => {
      animationId = requestAnimationFrame(animate);
      
      const deltaTime = currentTime - lastTime;
      if (deltaTime < frameInterval) return;
      
      lastTime = currentTime;
      
      // 清空画布
      ctx.fillStyle = 'rgba(10, 10, 10, 0.1)';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      
      // 更新和绘制粒子
      particles.forEach(particle => {
        updateParticle(particle);
        
        ctx.fillStyle = `rgba(${hexToRgb(particle.color)}, ${particle.opacity})`;
        ctx.beginPath();
        ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
        ctx.fill();
      });
      
      // 绘制粒子连接
      connectParticles(particles, ctx);
    };

    resizeCanvas();
    window.addEventListener('resize', resizeCanvas);
    animate(0);

    return () => {
      window.removeEventListener('resize', resizeCanvas);
      cancelAnimationFrame(animationId);
    };
  }, [primaryColor, secondaryColor, particleDensity, animationIntensity]);

  return (
    <canvas
      ref={canvasRef}
      className="fixed inset-0 pointer-events-none z-0"
      style={{ background: 'radial-gradient(ellipse at center, #1a1a2e 0%, #0a0a0a 100%)' }}
    />
  );
};

这个粒子系统的设计考虑了多个方面的优化:首先是性能优化,通过限制帧率和减少不必要的计算来确保流畅运行;其次是视觉效果的丰富性,粒子不仅有基本的运动,还有生命周期、透明度变化和相互连接等复杂效果;最后是与时间感知系统的深度集成,粒子的颜色、密度和动画强度都会根据时间动态调整。

项目展示与交互设计

项目展示是这个平台的核心功能。我设计了一个多层次的项目展示系统,包括首页的精选项目网格、完整的项目列表页面和详细的项目展示页面。每个层级都有不同的展示重点和交互方式,确保用户能够从不同角度了解项目信息。

在ProjectGrid组件中,我实现了一个响应式的项目卡片网格系统。每个项目卡片都包含了项目的核心信息,并且具有丰富的悬停效果和点击交互。

interface Project {
  id: number;
  title: string;
  description: string;
  image: string;
  tags: string[];
  category: string;
  status: 'completed' | 'in-progress' | 'planning';
  githubUrl?: string;
  liveUrl?: string;
  downloadUrl?: string;
}

const ProjectCard: React.FC<ProjectCardProps> = ({ project, index }) => {
  return (
    <motion.div
      className="project-card"
      initial={{ opacity: 0, y: 50 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ 
        duration: 0.6, 
        delay: index * 0.1,
        type: "spring",
        stiffness: 100 
      }}
      whileHover={{ 
        y: -10,
        boxShadow: "0 20px 40px rgba(0, 212, 255, 0.3)",
        transition: { duration: 0.3 }
      }}
    >
      <div className="project-image-container">
        <img 
          src={project.image} 
          alt={project.title}
          className="project-image"
        />
        <div className="project-overlay">
          <div className="project-actions">
            <Link 
              to={`/project/${project.id}`}
              className="action-button primary"
            >
              查看详情
            </Link>
            {project.liveUrl && (
              <a 
                href={project.liveUrl}
                target="_blank"
                rel="noopener noreferrer"
                className="action-button secondary"
              >
                在线预览
              </a>
            )}
          </div>
        </div>
      </div>
      
      <div className="project-content">
        <h3 className="project-title">{project.title}</h3>
        <p className="project-description">{project.description}</p>
        
        <div className="project-tags">
          {project.tags.map(tag => (
            <span key={tag} className="project-tag">
              {tag}
            </span>
          ))}
        </div>
        
        <div className="project-meta">
          <span className={`project-status ${project.status}`}>
            {project.status === 'completed' ? '已完成' : 
             project.status === 'in-progress' ? '开发中' : '计划中'}
          </span>
        </div>
      </div>
    </motion.div>
  );
};

为了让项目展示更加生动,我还实现了一个复杂的滚动动画系统。当用户滚动页面时,项目卡片会依次出现,每个卡片都有独特的入场动画,创造出一种电影般的视觉效果。

const useScrollAnimation = (threshold: number = 0.1) => {
  const ref = useRef<HTMLDivElement>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
        }
      },
      { threshold }
    );

    if (ref.current) {
      observer.observe(ref.current);
    }

    return () => {
      if (ref.current) {
        observer.unobserve(ref.current);
      }
    };
  }, [threshold]);

  return { ref, isVisible };
};

在这里插入图片描述

科幻风格首页设计

首页是整个平台的门面,我希望它能够立即抓住访问者的注意力,传达出强烈的科技感和未来感。SciFiHomePage组件是我花费最多心思设计的部分,它不仅仅是一个静态的展示页面,而是一个充满动态效果的沉浸式体验空间。

const SciFiHomePage: React.FC = () => {
  const {
    primaryColor,
    secondaryColor,
    glowIntensity,
    animationIntensity
  } = useTimeAwareUI();

  // 使用useMemo优化配置对象,避免不必要的重渲染
  const heroStyle = React.useMemo(() => ({
    '--primary-glow': `0 0 20px ${glowIntensity}px ${primaryColor}`,
    '--secondary-glow': `0 0 15px ${glowIntensity * 0.8}px ${secondaryColor}`,
    '--primary-color': primaryColor,
    '--secondary-color': secondaryColor,
  }), [primaryColor, secondaryColor, glowIntensity]);

  const containerVariants = {
    hidden: { opacity: 0 },
    visible: {
      opacity: 1,
      transition: {
        delayChildren: 0.3,
        staggerChildren: 0.2
      }
    }
  };

  const itemVariants = {
    hidden: { y: 20, opacity: 0 },
    visible: {
      y: 0,
      opacity: 1,
      transition: {
        type: "spring" as const,
        stiffness: 100
      }
    }
  };

  return (
    <div className="scifi-hero" style={heroStyle}>
      <SciFiParticleBackground />
      
      <motion.div
        className="hero-content"
        variants={containerVariants}
        initial="hidden"
        animate="visible"
      >
        <motion.div className="hero-title-container" variants={itemVariants}>
          <h1 className="hero-title">
            <span className="title-main">SoftHub</span>
            <span className="title-sub">未来科技项目展示平台</span>
          </h1>
        </motion.div>

        <motion.div className="hero-description" variants={itemVariants}>
          <p>探索创新技术的无限可能,展示前沿项目的卓越成果</p>
        </motion.div>

        <motion.div className="hero-actions" variants={itemVariants}>
          <TouchButton
            variant="primary"
            size="large"
            className="hero-button"
            onClick={() => document.getElementById('projects')?.scrollIntoView({ behavior: 'smooth' })}
          >
            探索项目
          </TouchButton>
          
          <TouchButton
            variant="outline"
            size="large"
            className="hero-button"
            onClick={() => window.open('/qrcodes', '_blank')}
          >
            联系方式
          </TouchButton>
        </motion.div>

        <motion.div className="hero-stats" variants={itemVariants}>
          <div className="stat-item">
            <span className="stat-number">15+</span>
            <span className="stat-label">完成项目</span>
          </div>
          <div className="stat-item">
            <span className="stat-number">8</span>
            <span className="stat-label">技术栈</span>
          </div>
          <div className="stat-item">
            <span className="stat-number">3</span>
            <span className="stat-label">年经验</span>
          </div>
        </motion.div>
      </motion.div>

      <div className="hero-scroll-indicator">
        <motion.div
          className="scroll-arrow"
          animate={{ y: [0, 10, 0] }}
          transition={{ repeat: Infinity, duration: 2 }}
        ></motion.div>
      </div>
    </div>
  );
};

首页的设计采用了多层次的视觉结构:最底层是动态的粒子背景,中间层是渐变色彩和光效,最上层是文字内容和交互元素。通过精心调配的CSS变量系统,整个首页的色彩会随着时间感知系统动态变化,创造出一种活生生的视觉体验。

我特别注重了首页的性能优化,使用了React.useMemo来缓存样式对象,避免不必要的重渲染。同时,所有的动画都经过精心调校,确保在不同性能的设备上都能流畅运行。

应用详情与下载管理

当用户点击某个应用卡片时,会弹出一个详细的应用信息模态框。这个模态框包含了应用的完整信息,包括详细描述、功能特性、系统要求、版本历史等。我特别注重这个组件的信息架构设计,确保用户能够快速找到他们需要的信息。

在这里插入图片描述

在AppDetail组件中,我实现了多种下载方式。对于桌面用户,提供直接下载链接;对于移动设备用户,则生成二维码供扫描下载。这种设计考虑了不同使用场景的需求,让用户能够选择最适合的下载方式。

const AppDetail: React.FC<AppDetailProps> = ({ app, onClose, onShowQR }) => {
  const handleDownload = () => {
    // 检测设备类型
    const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
    
    if (isMobile) {
      onShowQR();
    } else {
      window.open(app.downloadUrl, '_blank');
    }
  };

  return (
    <motion.div
      className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
      onClick={onClose}
    >
      <motion.div
        className="bg-white rounded-lg max-w-4xl max-h-[90vh] overflow-y-auto m-4"
        initial={{ scale: 0.9, opacity: 0 }}
        animate={{ scale: 1, opacity: 1 }}
        exit={{ scale: 0.9, opacity: 0 }}
        onClick={(e) => e.stopPropagation()}
      >
        <div className="p-6">
          <div className="flex items-start justify-between mb-6">
            <div className="flex items-center">
              <img 
                src={app.icon} 
                alt={app.name}
                className="w-16 h-16 rounded-lg mr-4"
              />
              <div>
                <h2 className="text-2xl font-bold text-gray-900">{app.name}</h2>
                <p className="text-gray-600">{app.developer}</p>
                <div className="flex items-center mt-1">
                  <StarIcon className="w-5 h-5 text-yellow-400 mr-1" />
                  <span className="text-gray-600">{app.rating}</span>
                  <span className="text-gray-400 ml-2">({app.downloads} 下载)</span>
                </div>
              </div>
            </div>
            <button
              onClick={onClose}
              className="text-gray-400 hover:text-gray-600"
            >
              <XIcon className="w-6 h-6" />
            </button>
          </div>

          <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
            <div className="lg:col-span-2">
              <div className="mb-6">
                <h3 className="text-lg font-semibold mb-2">应用描述</h3>
                <p className="text-gray-600 leading-relaxed">{app.description}</p>
              </div>

              <div className="mb-6">
                <h3 className="text-lg font-semibold mb-2">主要功能</h3>
                <ul className="list-disc list-inside text-gray-600 space-y-1">
                  {app.features.map((feature, index) => (
                    <li key={index}>{feature}</li>
                  ))}
                </ul>
              </div>

              {app.screenshots.length > 0 && (
                <div className="mb-6">
                  <h3 className="text-lg font-semibold mb-2">应用截图</h3>
                  <div className="grid grid-cols-2 gap-2">
                    {app.screenshots.map((screenshot, index) => (
                      <img 
                        key={index}
                        src={screenshot} 
                        alt={`${app.name} 截图 ${index + 1}`}
                        className="rounded-lg border"
                      />
                    ))}
                  </div>
                </div>
              )}
            </div>

            <div>
              <div className="bg-gray-50 rounded-lg p-4 mb-4">
                <h3 className="font-semibold mb-3">应用信息</h3>
                <div className="space-y-2 text-sm">
                  <div className="flex justify-between">
                    <span className="text-gray-600">版本</span>
                    <span>{app.version}</span>
                  </div>
                  <div className="flex justify-between">
                    <span className="text-gray-600">大小</span>
                    <span>{app.size}</span>
                  </div>
                  <div className="flex justify-between">
                    <span className="text-gray-600">分类</span>
                    <span>{app.category}</span>
                  </div>
                </div>
              </div>

              <div className="bg-gray-50 rounded-lg p-4 mb-4">
                <h3 className="font-semibold mb-3">系统要求</h3>
                <div className="space-y-2 text-sm">
                  <div>
                    <span className="text-gray-600">操作系统:</span>
                    <span>{app.systemRequirements.os.join(', ')}</span>
                  </div>
                  <div>
                    <span className="text-gray-600">内存:</span>
                    <span>{app.systemRequirements.memory}</span>
                  </div>
                  <div>
                    <span className="text-gray-600">存储空间:</span>
                    <span>{app.systemRequirements.storage}</span>
                  </div>
                </div>
              </div>

              <button
                onClick={handleDownload}
                className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium"
              >
                立即下载
              </button>

              <button
                onClick={onShowQR}
                className="w-full mt-2 border border-gray-300 text-gray-700 py-3 px-4 rounded-lg hover:bg-gray-50 transition-colors"
              >
                生成二维码
              </button>
            </div>
          </div>
        </div>
      </motion.div>
    </motion.div>
  );
};

二维码功能实现

考虑到现代用户经常需要在移动设备上下载软件,我特别实现了二维码下载功能。使用qrcode.react库,我能够动态生成包含下载链接的二维码,用户只需要用手机扫描就能直接跳转到下载页面。

QRCodeModal组件不仅仅是简单地显示二维码,我还添加了一些贴心的功能,比如显示下载链接的文本版本,方便用户复制分享,以及提供不同尺寸的二维码选项来适应不同的使用场景。

const QRCodeModal: React.FC<QRCodeModalProps> = ({ app, onClose }) => {
  const [qrSize, setQrSize] = useState(200);

  return (
    <motion.div
      className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
      onClick={onClose}
    >
      <motion.div
        className="bg-white rounded-lg p-6 max-w-md w-full m-4"
        initial={{ scale: 0.9, opacity: 0 }}
        animate={{ scale: 1, opacity: 1 }}
        exit={{ scale: 0.9, opacity: 0 }}
        onClick={(e) => e.stopPropagation()}
      >
        <div className="text-center">
          <h3 className="text-lg font-semibold mb-4">扫码下载 {app.name}</h3>
          
          <div className="flex justify-center mb-4">
            <QRCode 
              value={app.downloadUrl}
              size={qrSize}
              level="M"
              includeMargin={true}
            />
          </div>

          <div className="mb-4">
            <label className="block text-sm text-gray-600 mb-2">二维码尺寸</label>
            <div className="flex justify-center space-x-2">
              {[150, 200, 250].map(size => (
                <button
                  key={size}
                  onClick={() => setQrSize(size)}
                  className={`px-3 py-1 rounded text-sm ${
                    qrSize === size 
                      ? 'bg-blue-600 text-white' 
                      : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
                  }`}
                >
                  {size}px
                </button>
              ))}
            </div>
          </div>

          <div className="mb-4 p-3 bg-gray-50 rounded text-sm">
            <p className="text-gray-600 mb-1">下载链接:</p>
            <p className="text-blue-600 break-all">{app.downloadUrl}</p>
          </div>

          <div className="flex space-x-3">
            <button
              onClick={() => navigator.clipboard.writeText(app.downloadUrl)}
              className="flex-1 bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700 transition-colors"
            >
              复制链接
            </button>
            <button
              onClick={onClose}
              className="flex-1 border border-gray-300 text-gray-700 py-2 px-4 rounded hover:bg-gray-50 transition-colors"
            >
              关闭
            </button>
          </div>
        </div>
      </motion.div>
    </motion.div>
  );
};

回顾整个开发过程,我深深感受到了现代前端开发的魅力和挑战。技术在不断进步,用户的期望也在不断提高,作为开发者需要持续学习和改进!

❌