🤖AI摘要此内容根据文章生成，不代表个人观点，仅用于文章内容的解释与总结昨晚心血来潮写了个页面，准备部署到 VPS 上给媳妇展示一下。考虑到机器配置较低（1H1C），且已经运行了其他服务，不想再额外折腾 Nginx 反向代理和 SSL 证书。经过一番研究，发现 Cloudflare 提供的 Cloudflare Tunnel 完美解决了我的痛点。它本质上是一种内网穿透技术：在 VPS 上安装客户端建立隧道，CF 侧直接配置路由。使用体验：无需公网 IP/开放端口： 甚至连防火墙都不用配置，安全性极高。自带 SSL： 自动分配证书，免去配置 HTTPS 的烦恼。部署丝滑： 尤其适合海外机器，几条指令搞定。核心流程如下：1. 创建隧道登录 Cloudflare 控制台，进入 Networks -> Tunnel，点击创建。2. 设置名称为你的隧道起个好记的名字。3. 环境部署根据 VPS 系统选择对应的安装指令，复制运行即可。4. 确认连接看到隧道状态显示为“已连接（Healthy）”即可进行下一步。5. 配置路由规则选择 Public Hostname。6. 绑定域名输入二级域名，服务地址指向本地端口（注意：通常使用 localhost:端口号）。搞定！ 现在可以直接通过域名访问服务了，配置过程非常省心。
🤖AI摘要此内容根据文章生成，不代表个人观点，仅用于文章内容的解释与总结一个简洁优雅的 Typecho 微语（说说/动态）插件，记录生活中的点滴想法。前言最近快年底了，公司不是很忙，终于有时间折腾自己的博客了。前几天收到 @流情 的留言，于是去他的博客回访了一下，发现了很多有趣的创意。其中最吸引我的就是微语这个模块，类似朋友圈或者说说，可以随时记录一些简短的想法和心情。看着流情博客上那些简洁优雅的微语卡片，我心想：这个功能太棒了！虽然市面上可能有类似的插件，但作为一个爱折腾的开发者，我决定自己动手实现一个，顺便加入一些自己的想法。经过几天的开发和调试（以及无数次的样式调整😂），终于完成了这个插件。从最初的基础功能，到后来加入的图片上传、表情选择、点赞功能，每一个细节都经过了反复打磨。现在已经开源到 GitHub 了，欢迎大家使用和 Star！项目地址： GitHub - Whisper 在线演示： 我的微语页面功能规划在开始开发之前，我先梳理了一下想要实现的功能：核心功能✅ 发表微语（支持多行文本）✅ 图片上传（支持多图）✅ 表情选择✅ 点赞功能✅ 删除管理界面设计✅ 清新简洁的卡片式设计✅ 暗黑模式适配✅ 响应式布局✅ 流畅的动画效果用户体验✅ 图片灯箱预览✅ 智能时间显示✅ 设备信息识别✅ 分页浏览开发过程第一步：搭建基础框架作为一个 Typecho 插件，首先要遵循它的开发规范。Typecho 插件需要实现 Typecho_Plugin_Interface 接口，这是最基本的要求：class Whisper_Plugin implements Typecho_Plugin_Interface { public static function activate() { // 插件激活时执行：创建数据表、注册路由等 } public static function deactivate() { // 插件禁用时执行：清理路由等 } public static function config(Typecho_Widget_Helper_Form $form) { // 插件配置界面：添加各种配置项 } }这个接口定义了插件的生命周期，让我们可以在激活、禁用时执行特定的操作。第二步：设计数据库微语需要存储的信息包括：内容、图片、点赞数、发表时间等。经过思考，我设计了这样的表结构：CREATE TABLE `typecho_whispers` ( `id` int(11) NOT NULL AUTO_INCREMENT, `content` text NOT NULL, -- 微语内容 `images` text, -- 图片地址（逗号分隔） `os_info` varchar(100), -- 操作系统信息 `likes` int(11) DEFAULT 0, -- 点赞数 `created_at` int(11) NOT NULL, -- 发表时间戳 `user_id` int(11) NOT NULL, -- 用户ID PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;在插件激活时自动创建表，并且做了兼容处理：如果表已存在，会检查是否缺少 likes 字段（这是后来加的功能），如果缺少就自动添加。这样即使用户升级插件，也不会出现字段缺失的问题。public static function activate() { $db = Typecho_Db::get(); $prefix = $db->getPrefix(); // 创建表 $sql = "CREATE TABLE IF NOT EXISTS `{$prefix}whispers` (...)"; $db->query($sql); // 检查并添加 likes 字段（兼容旧版本） $hasLikes = false; $columns = $db->fetchAll($db->query("SHOW COLUMNS FROM `{$prefix}whispers`")); foreach ($columns as $column) { if ($column['Field'] == 'likes') { $hasLikes = true; break; } } if (!$hasLikes) { $db->query("ALTER TABLE `{$prefix}whispers` ADD `likes` int(11) DEFAULT 0"); } // 注册 Action 路由 Helper::addAction('whisper', 'Whisper_Action'); }这里有个小插曲：最初我忘记添加 likes 字段了，后来加点赞功能时才发现。于是我加了这个检查逻辑，让用户重新启用插件就能自动添加字段，避免手动修改数据库的麻烦。第三步：实现 API 接口创建 Action.php 处理各种操作：class Whisper_Action extends Typecho_Widget implements Widget_Interface_Do { public function action() { $this->on($this->request->is('do=publish'))->publish(); $this->on($this->request->is('do=delete'))->delete(); $this->on($this->request->is('do=list'))->getList(); $this->on($this->request->is('do=upload'))->uploadImage(); $this->on($this->request->is('do=like'))->likeWhisper(); } }发表微语public function publish() { $user = Typecho_Widget::widget('Widget_User'); if (!$user->hasLogin()) { $this->response->throwJson(['success' => false, 'message' => '请先登录']); } $content = $this->request->get('content'); $images = $this->request->get('images', ''); $data = [ 'content' => $content, 'images' => $images, 'os_info' => $this->getOSInfo(), 'created_at' => time(), 'user_id' => $user->uid ]; $this->db->query($this->db->insert('table.whispers')->rows($data)); }图片上传图片上传是个重点，需要考虑安全性和用户体验。最初我想直接用 Typecho 自带的上传接口，结果发现会返回 302 重定向，因为需要 CSRF 令牌验证。于是我决定自己实现一个上传接口：public function uploadImage() { header('Content-Type: application/json'); $user = Typecho_Widget::widget('Widget_User'); if (!$user->hasLogin()) { echo json_encode(['success' => false, 'message' => '请先登录']); exit; } $file = $_FILES['file']; // 1. 检查文件大小（限制5MB） if ($file['size'] > 5 * 1024 * 1024) { echo json_encode(['success' => false, 'message' => '文件大小不能超过5MB']); exit; } // 2. 检查文件类型（使用 MIME 检测，更安全） $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); if (!in_array($mimeType, $allowedTypes)) { echo json_encode(['success' => false, 'message' => '只支持 JPG、PNG、GIF、WebP 格式']); exit; } // 3. 生成唯一文件名，避免冲突 $ext = pathinfo($file['name'], PATHINFO_EXTENSION); $filename = date('YmdHis') . '_' . uniqid() . '.' . $ext; // 4. 保存文件 $uploadDir = __TYPECHO_ROOT_DIR__ . '/usr/uploads/whisper/'; if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } if (move_uploaded_file($file['tmp_name'], $uploadDir . $filename)) { $url = Typecho_Common::url('/usr/uploads/whisper/' . $filename, $this->options->siteUrl); echo json_encode([ ['url' => $url] ]); } else { echo json_encode(['success' => false, 'message' => '文件保存失败']); } exit; }这里我特别注意了几点：使用 finfo_file 检测真实的 MIME 类型，而不是仅仅检查文件扩展名，防止恶意文件上传文件名使用时间戳 + uniqid 生成，确保唯一性自动创建上传目录，避免首次使用时报错返回完整的 URL 地址，方便前端直接使用点赞功能点赞功能需要防止重复点赞，我使用 Cookie 来记录用户的点赞状态。虽然 Cookie 可以被清除，但对于一个博客来说，这个方案已经足够了：public function likeWhisper() { $id = $this->request->get('id'); $likedKey = 'whisper_liked_' . $id; // 1. 检查是否已点赞 if (isset($_COOKIE[$likedKey])) { echo json_encode(['success' => false, 'message' => '您已经点赞过了']); exit; } // 2. 获取当前点赞数 $whisper = $this->db->fetchRow( $this->db->select()->from('table.whispers')->where('id = ?', $id) ); if (!$whisper) { echo json_encode(['success' => false, 'message' => '微语不存在']); exit; } // 3. 增加点赞数 $newLikes = intval($whisper['likes']) + 1; $this->db->query( $this->db->update('table.whispers') ->rows(['likes' => $newLikes]) ->where('id = ?', $id) ); // 4. 设置 Cookie（30天有效） setcookie($likedKey, '1', time() + 2592000, '/'); echo json_encode([ 'success' => true, 'message' => '点赞成功', 'likes' => $newLikes ]); exit; }前端配合心跳动画，点赞时爱心会跳动一下，视觉效果很棒：@keyframes heartBeat { 0%, 100% { transform: scale(1); } 25% { transform: scale(1.3); } 50% { transform: scale(1.1); } 75% { transform: scale(1.2); } }这个动画调了好几次才找到最舒服的节奏，太快显得急促，太慢又不够灵动。第四步：前端页面开发前端是最花时间的部分，因为我的博客使用的是 Jasmine 主题，需要完美适配它的布局结构。适配 Jasmine 主题Jasmine 主题使用了三栏布局（左侧边栏、主内容区、右侧边栏），我需要让微语页面保持一致的风格：<?php if (!defined('__TYPECHO_ROOT_DIR__')) exit; $this->need('header.php'); ?> <?php $this->need('component/sidebar-left.php'); ?> <?php $this->need('component/menu.php'); ?> <?php $this->need('component/sidebar.php'); ?> <?php $this->need('footer.php'); ?>这样微语页面就和博客的其他页面保持了一致的布局，看起来就像是主题自带的功能一样。发表框设计发表框是用户最常接触的部分，我希望它简洁但功能完整： 😊 表情 📷 图片 立即发表 这里有个小技巧：图片上传按钮使用 包裹隐藏的 ，这样可以自定义按钮样式，同时保持原生的文件选择功能。第五步：样式优化样式是最考验耐心的部分，我反复调整了很多次才达到满意的效果。1. 卡片设计参考了流情博客的样式，我设计了清新的卡片效果：.whisper-item { background: white; border: 1px solid #e1e4e8; border-radius: 16px; padding: 20px; transition: all 0.3s; animation: fadeIn 0.5s ease-in; /* 淡入动画 */ } .whisper-item:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); transform: translateY(-2px); /* 悬停时轻微上浮 */ } /* 淡入动画 */ @keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }每条微语加载时都会有一个淡入动画，让页面显得更加生动。2. 图片布局图片布局是个难点，我希望不同数量的图片能有不同的展示效果：/* 基础网格布局 */ .whisper-images { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } .whisper-image { width: 100%; height: 200px; /* 固定高度，统一整齐 */ object-fit: cover; /* 自动裁剪，保持比例 */ border-radius: 12px; cursor: pointer; transition: transform 0.3s; } .whisper-image:hover { transform: scale(1.05); /* 悬停时放大 */ } /* 单张图片：更大的展示 */ .whisper-images:has(:only-child) { grid-template-columns: 1fr; max-width: 400px; } .whisper-images:has(:only-child) .whisper-image { height: 300px; } /* 两张图片：并排显示 */ .whisper-images:has(:nth-child(2):last-child) { grid-template-columns: repeat(2, 1fr); } /* 三张图片：第一张占满，后两张并排 */ .whisper-images:has(:nth-child(3):last-child) { grid-template-columns: repeat(2, 1fr); } .whisper-images:has(:nth-child(3):last-child) .whisper-image:first-child { grid-column: 1 / -1; height: 250px; }这里使用了 CSS 的 :has() 伪类，可以根据子元素数量应用不同的样式。这是一个相对较新的特性，但现代浏览器都已支持。3. 点赞按钮点赞按钮的样式经过了几次迭代。最初我给它加了背景色，但看起来太重了。后来改成了无背景的简洁样式：.whisper-like-btn { background: none; /* 无背景，更简洁 */ border: none; color: #6b7280; cursor: pointer; display: flex; align-items: center; gap: 4px; /* 图标和数字之间的间距 */ padding: 0; transition: all 0.3s; } .whisper-like-btn:hover { color: #ef4444; /* 悬停时变红 */ } .whisper-like-btn.liked { color: #ef4444; /* 已点赞状态 */ } .like-icon { font-size: 18px; line-height: 1; } .like-count { font-size: 14px; font-weight: 500; line-height: 1; }布局上也调整了好几次。最初点赞按钮和设备信息在同一行，显得很拥挤。后来改成了垂直布局，设备信息在上，点赞按钮在下，看起来舒服多了。4. 暗黑模式Jasmine 主题支持暗黑模式，我也为微语页面适配了暗色样式：.dark .whisper-item { background: #1f2937; border-color: #374151; } .dark .whisper-content { color: #d1d5db; } .dark .whisper-textarea { background: #111827; border-color: #374151; color: #e5e7eb; }所有元素都有对应的暗色样式，切换主题时不会有任何违和感。第六步：交互功能1. 表情选择器表情选择器最初是10列布局，但在移动端显得太挤了，后来改成了响应式：桌面端10列，移动端8列。function toggleEmojiPicker(event) { event.stopPropagation(); // 阻止事件冒泡 const picker = document.getElementById('emoji-picker'); // 第一次打开时初始化表情列表 if (picker.innerHTML === '') { const emojis = [ '😊', '😂', '🤣', '😍', '😘', '😭', '😢', '😤', '😡', '🤔', '😎', '🥰', '😜', '🤗', '🤩', '❤️', '💕', '💖', '💗', '💙', '👍', '👎', '👏', '🙏', '💪', '🎉', '🎊', '🎈', '🎁', '🔥', '✨', '⭐', '🌟', '💫', '🌈', '☀️', '🌙', '⚡', '💧', '🌸' ]; emojis.forEach(emoji => { const item = document.createElement('span'); item.className = 'emoji-item'; item.textContent = emoji; item.onclick = () => insertEmoji(emoji); picker.appendChild(item); }); } picker.classList.toggle('show'); } // 点击其他地方关闭表情选择器 document.addEventListener('click', function(event) { const picker = document.getElementById('emoji-picker'); if (picker && !event.target.closest('.whisper-emoji-btn')) { picker.classList.remove('show'); } });这里有个细节：表情列表只在第一次打开时创建，之后只是显示/隐藏，避免重复创建 DOM 元素。2. 图片灯箱图片灯箱支持多种操作：点击放大、缩放控制、ESC 关闭等。// 查看图片 function viewImage(url) { const lightbox = document.getElementById('image-lightbox'); const image = document.getElementById('lightbox-image'); image.src = url; image.style.transform = 'scale(1)'; lightbox.classList.add('show'); document.body.style.overflow = 'hidden'; // 禁止背景滚动 } // 放大 function zoomIn(event) { event.stopPropagation(); const image = document.getElementById('lightbox-image'); const currentScale = parseFloat( image.style.transform.replace('scale(', '').replace(')', '') || '1' ); const newScale = Math.min(currentScale + 0.2, 3); // 最大3倍 image.style.transform = `scale(${newScale})`; } // 缩小 function zoomOut(event) { event.stopPropagation(); const image = document.getElementById('lightbox-image'); const currentScale = parseFloat( image.style.transform.replace('scale(', '').replace(')', '') || '1' ); const newScale = Math.max(currentScale - 0.2, 0.5); // 最小0.5倍 image.style.transform = `scale(${newScale})`; } // ESC键关闭 document.addEventListener('keydown', function(event) { if (event.key === 'Escape') { const lightbox = document.getElementById('image-lightbox'); if (lightbox.classList.contains('show')) { lightbox.classList.remove('show'); document.body.style.overflow = ''; } } });灯箱打开时会禁止背景滚动，关闭时恢复，这样用户体验更好。3. 智能时间显示时间显示格式经过了精心设计，让用户一眼就能看懂：private function timeAgo($timestamp) { $diff = time() - $timestamp; $today = strtotime(date('Y-m-d')); $postDate = strtotime(date('Y-m-d', $timestamp)); // 1分钟内：刚刚 if ($diff < 60) return '刚刚'; // 1小时内：X分钟前 if ($diff < 3600) return floor($diff / 60) . '分钟前'; // 今天：今天 HH:mm if ($postDate == $today) return '今天 ' . date('H:i', $timestamp); // 昨天：昨天 HH:mm if ($postDate == $today - 86400) return '昨天 ' . date('H:i', $timestamp); // 一周内：星期X HH:mm if ($diff < 604800) { $days = ['日', '一', '二', '三', '四', '五', '六']; return '星期' . $days[date('w', $timestamp)] . ' ' . date('H:i', $timestamp); } // 今年内：m月d日 HH:mm if (date('Y', $timestamp) == date('Y')) { return date('m月d日 H:i', $timestamp); } // 更早：Y年m月d日 HH:mm return date('Y年m月d日 H:i', $timestamp); }这个函数考虑了各种时间场景，让时间显示更加人性化。踩过的坑开发过程中遇到了不少问题，这里分享一下解决方案，希望能帮到遇到类似问题的朋友。坑1：点赞功能 500 错误现象： 点赞功能开发完成后，点击按钮时浏览器控制台报 500 错误，返回的是 HTML 错误页面而不是 JSON。排查过程：检查 Action.php 的代码逻辑，没有问题检查路由注册，也正常最后发现是数据库查询报错原因： 原来是我后来才加的点赞功能，数据库表中没有 likes 字段！因为表是在插件激活时创建的，而我是在开发过程中才想到加点赞功能的。解决方案： 在插件激活时增加字段检查逻辑，如果缺少 likes 字段就自动添加：// 检查是否需要添加 likes 字段（兼容旧版本） $hasLikes = false; $columns = $db->fetchAll($db->query("SHOW COLUMNS FROM `{$prefix}whispers`")); foreach ($columns as $column) { if ($column['Field'] == 'likes') { $hasLikes = true; break; } } if (!$hasLikes) { $db->query("ALTER TABLE `{$prefix}whispers` ADD `likes` int(11) DEFAULT 0"); }这样用户只需要重新启用一下插件，就能自动添加字段，不需要手动修改数据库。坑2：图片上传返回 302现象： 最初我想偷懒，直接用 Typecho 自带的上传接口 /action/upload，结果一上传就返回 302 重定向。原因： Typecho 的上传接口有 CSRF 令牌验证，需要在 URL 中带上 _ 参数（一个随机生成的令牌）。虽然可以通过 JavaScript 获取这个令牌，但感觉太麻烦了。解决方案： 干脆自己实现一个上传接口，反正逻辑也不复杂。在 Action.php 中添加 uploadImage 方法，自己处理文件上传、类型检查、大小限制等。这样还能更好地控制上传逻辑，比如自定义保存路径、文件命名规则等。坑3：点赞按钮布局问题现象： 最初我把点赞按钮和设备信息放在同一行，用 justify-content: space-between 让它们分居两端。结果发现在移动端显得很拥挤，而且视觉上不够清晰。迭代过程：第一版：点赞按钮和设备信息在同一行 → 太挤第二版：改为垂直布局，但间距太大 → 不够紧凑第三版：调整间距为 8px，完美！最终方案：.whisper-footer { display: flex; flex-direction: column; /* 垂直布局 */ gap: 8px; /* 适中的间距 */ }有时候设计就是这样，需要反复调整才能找到最舒服的状态。坑4：表情选择器超出屏幕现象： 表情选择器最初是固定宽度，在移动端会超出屏幕，而且表情太小不好点击。解决方案： 使用响应式设计，桌面端 10 列，移动端 8 列，并且调整表情大小：.emoji-picker { grid-template-columns: repeat(10, 1fr); min-width: 400px; } @media (max-width: 768px) { .emoji-picker { grid-template-columns: repeat(8, 1fr); min-width: 320px; } .emoji-item { font-size: 18px; padding: 4px; } }坑5：图片固定高度的选择现象： 最初图片是自适应高度的，结果不同尺寸的图片混在一起，整个页面看起来很乱。解决方案： 统一设置固定高度 200px，使用 object-fit: cover 自动裁剪。这样所有图片都整整齐齐，视觉效果好多了。虽然会裁剪掉部分内容，但点击可以查看完整图片，所以不影响使用。.whisper-image { width: 100%; height: 200px; object-fit: cover; /* 关键属性 */ }功能亮点经过这么多天的打磨，插件有了一些我比较满意的功能点，这里重点介绍一下。1. 智能图片布局这是我花了不少心思的地方。不同数量的图片，会自动应用不同的布局策略：1张图片：单独显示，高度 300px，最大宽度 400px，突出展示2张图片：左右并排，每张宽度 50%3张图片：第一张占满一行（高度 250px），后两张并排显示4-9张图片：网格布局，自动换行，每张高度统一 200px所有图片都使用 object-fit: cover 自动裁剪，保持整齐美观。点击任意图片可以查看完整内容，支持缩放操作。这种布局方式参考了微信朋友圈和微博的设计，既美观又实用。2. 防重复点赞机制点赞功能使用 Cookie 记录用户的点赞状态，有效期 30 天。虽然 Cookie 可以被清除，但对于个人博客来说，这个方案已经足够了。点赞后的体验也做了优化：按钮立即变红并禁用，防止重复点击爱心图标播放心跳动画（0.5秒）点赞数实时更新提示"您已经点赞过了"如果未来需要更严格的限制，可以考虑加入 IP 限制或者用户登录验证。3. 图片灯箱预览点击图片会弹出全屏灯箱，支持丰富的操作：放大/缩小：点击 +/- 按钮，每次缩放 0.2 倍快速切换：点击图片在 1 倍和 1.5 倍之间切换重置：点击 ⟲ 按钮恢复原始大小关闭：ESC 键、点击背景、点击 × 按钮都可以关闭防止滚动：灯箱打开时禁止背景滚动缩放范围限制在 0.5 倍到 3 倍之间，既能看清细节，又不会过度放大导致模糊。4. 完美的暗黑模式作为一个强迫症患者，我不能容忍暗黑模式下有任何违和感。所以每个元素都有对应的暗色样式：卡片背景：#1f2937边框颜色：#374151文字颜色：#e5e7eb / #d1d5db输入框背景：#111827切换主题时，所有元素都会平滑过渡，没有任何闪烁或突兀感。5. 响应式设计移动端体验也做了专门优化：图片网格：从 3-4 列改为 2 列，避免图片太小表情选择器：从 10 列改为 8 列，表情大小也相应调整按钮间距：适当增大，方便手指点击字体大小：保持可读性在 iPhone 和 Android 上测试过，体验都很流畅。6. 智能时间显示时间显示采用了人性化的格式，让用户一眼就能理解：1 分钟内：刚刚1 小时内：5分钟前今天：今天 14:30昨天：昨天 09:15一周内：星期三 18:20今年内：10月15日 12:00更早：2023年8月20日 16:45这种渐进式的时间显示，既精确又友好。7. 设备信息识别每条微语会自动记录发表时的操作系统信息，显示为"来自 Windows 10"、"来自 macOS"等。这个小细节让微语更有生活气息。识别逻辑基于 User-Agent，支持：Windows（包括版本号）macOSLinuxAndroidiOS虽然不是核心功能，但这种小细节能让产品更有温度。技术栈与架构在开始开发之前，先来看看整个项目的技术选型和架构设计。技术选型后端框架： Typecho 1.0+（PHP 5.4+）数据库： MySQL 5.5+前端技术： 原生 JavaScript（无依赖）样式方案： CSS3（Flexbox + Grid）主题适配： Jasmine 主题选择原生 JavaScript 而不是 jQuery 或其他框架，主要是考虑到：现代浏览器对原生 API 的支持已经很好减少依赖，降低插件体积提高加载速度架构设计插件采用了经典的 MVC 架构：Whisper/ ├── Plugin.php # 插件主文件（Controller） ├── Action.php # API 接口处理（Controller） └── whisper.php # 页面模板（View） ├── HTML 结构 ├── CSS 样式 └── JavaScript 交互数据流向：用户操作 → JavaScript → Action API → 数据库 → 返回 JSON → 更新界面这种架构清晰简单，易于维护和扩展。文件结构Whisper/ ├── Plugin.php # 插件主文件 ├── Action.php # API 接口 ├── README.md # 项目说明 ├── LICENSE # MIT 协议 ├── CHANGELOG.md # 更新日志 ├── CONTRIBUTING.md # 贡献指南 └── .gitignore # Git 忽略文件 主题目录/ └── whisper.php # 页面模板 上传目录/ └── usr/uploads/whisper/ # 图片存储（自动创建）开源说明这个插件已经在 GitHub 开源，采用 MIT 协议。项目地址： https://github.com/lovisnd/Whisper功能清单[x] 发表微语[x] 图片上传（多图）[x] 表情选择器[x] 点赞功能[x] 图片灯箱预览[x] 分页浏览[x] 暗黑模式[x] 响应式设计[ ] 评论功能（计划中）[ ] 标签分类（计划中）[ ] 视频上传（计划中）如何使用下载插件cd /usr/plugins/ git clone https://github.com/lovisnd/Whisper.git启用插件后台 → 插件 → 启用"微语"创建页面将 whisper.php 复制到主题目录创建独立页面，选择"微语"模板开始使用登录后访问微语页面发表你的第一条微语！总结与展望开发感悟这次开发微语插件的过程很有趣，从最初看到流情博客的创意，到自己动手实现，再到不断优化细节，整个过程充满了成就感。特别是在调整样式的时候，虽然有时候为了几个像素的间距反复调整，但看到最终效果时，会觉得一切都值得。这就是前端开发的魅力吧——每一个细节都能影响用户体验。通过这个项目，我也更深入地理解了 Typecho 的插件机制，包括：插件生命周期管理数据库操作和字段动态添加Action 路由注册和处理主题模板适配配置项的设计这些经验对以后开发其他插件也很有帮助。未来计划目前插件已经具备了基本的功能，但还有一些想法可以继续完善：短期计划：[ ] 评论功能：允许访客对微语进行评论互动[ ] 标签分类：给微语添加标签，方便分类浏览[ ] 搜索功能：支持搜索微语内容长期计划：[ ] 视频上传：支持上传短视频，像真正的朋友圈一样[ ] Markdown 支持：支持 Markdown 语法，方便写代码片段[ ] RSS 订阅：生成微语的 RSS 源，方便订阅[ ] 导出功能：支持导出为 JSON 或 Markdown 格式[ ] 多用户权限：细化权限控制，支持多人协作这些功能会根据实际使用情况和用户反馈逐步添加。如果你有好的建议，欢迎在 GitHub 提 Issue！致谢最后要特别感谢：@流情：感谢你的创意启发，让我有了开发这个插件的想法Typecho 社区：提供了优秀的博客系统和完善的文档Jasmine 主题作者：清新的主题设计给了我很多灵感所有测试用户：感谢你们的反馈和建议开源地址如果你也在使用 Typecho，欢迎试用这个插件。如果觉得不错，给个 Star 支持一下吧！项目地址： https://github.com/lovisnd/Whisper 在线演示： https://blog.zhangmingrui.top/index.php/whisper.html 我的博客： https://blog.zhangmingrui.top有任何问题或建议，欢迎：在 GitHub 提 Issue在博客留言通过邮件联系我让我们一起把这个插件做得更好！
黄金大涨，我给媳妇手搓了一个“定制版”黄金管理APP
🤖AI摘要此内容根据文章生成，不代表个人观点，仅用于文章内容的解释与总结最近这金价，真是涨得让人心惊肉跳，稍微关注点财经的朋友应该都感受到了。我媳妇也不例外，天天抱着她那些宝贝首饰在那算。前两天我发现她一直盯着一个小程序看（名字叫“金XX”，懂的都懂），是个记录黄金资产和看走势的软件。我看她在那戳了半天，最后在那吐槽：“这软件记个东西还得买会员？好多功能都得收费，真够黑的。我当时正好手头工作忙得差不多了，心血来潮凑过去瞅了一眼。嘿，这逻辑也没多复杂嘛！我直接回了她一句：“别充钱了，这玩意儿还用收费？你等着，我这两天给你复刻一个，功能全免费，还只给你一个人用。”说干就干，程序员的浪漫（或者说强迫症）说来就来。1. 搞定底层：数据爬虫首先得解决最核心的问题——实时金价。这种数据网上到处都是，但也得找个稳定的。我花了点时间研究了一下几个网页的接口，用 Python 写了个小爬虫直接抓取。稍微破解了一下它的接口参数，金价和历史走势图的数据就源源不断地进来了。2. 复刻UI：AI 助攻既然是给媳妇用的，颜值肯定不能太差。这次我偷了个懒，把最近用得贼顺手的 AI 给派上用场了。我把原版软件的截图往里一扔，让它帮我搭建基础框架和前端代码。不得不说，现在的 AI 辅助确实猛，省掉了我大量写琐碎 UI 的时间。我再根据媳妇的审美细节微调了一下，整个页面的还原度直接拉满：首页： 实时金价（Au99.99）置顶，涨跌幅一目了然。走势： 7天、15天、30天的曲线图，该有的都有。记录： 攒金明细、平均克价、预估收益……原版收费的功能，我全给安排上了。3. 快速上线：先打个安卓包本来想搞小程序，但一想现在小程序上架还得各种备案、审核，流程太繁琐，我这心血来潮的火苗等不及。于是直接打包成一个 安卓 APK。装到媳妇手机上的那一刻，成就感简直比做成一个大项目还强。媳妇试了一下，直夸好用，看我的眼神都带光了，哈哈！一点感悟其实这个项目技术含量不算特别高，但在工作之余，能用自己擅长的技术给家人解决一点小烦恼，这种感觉真的挺棒的。这也算是一种程序员式的“解压”方式吧——不为了什么KPI，纯粹是为了爱好和家庭，折腾点有意思的东西。下一步打算：媳妇已经开始提新需求了，比如加入不同金店的金价，看来这个“定制版”还得持续更新啊。
🤖AI摘要此内容根据文章生成，不代表个人观点，仅用于文章内容的解释与总结之前我们公司的项目都是靠手动部署：打包、拷贝、登录服务器、重启服务……一套流程下来经常要二三十分钟，而且每次发版都容易手忙脚乱。为了减少这种重复劳动，我决定在项目里接入 GitLab CI/CD，把部署自动化。下面是我在折腾 GitLab Runner 的完整过程，包含安装、配置、免密登录，以及 .gitlab-ci.yml 的实际案例，还有一些踩坑记录。算是一次实战笔记，写下来给自己留个档，也希望能帮到有类似需求的人。一、GitLab Runner 安装与配置1. 在 Linux 服务器上安装 GitLab Runner安装 Runner 其实很简单，官方提供了一键脚本：# 添加 GitLab Runner 官方仓库 curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh" | sudo bash # 安装最新版本的 GitLab Runner sudo yum install gitlab-runner -y # 如果是 Ubuntu/Debian，用 apt 安装 curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash sudo apt-get install gitlab-runner -y我个人习惯直接用官方源，省事儿，不用再去找 rpm 或 deb 包。2. 注册 GitLab Runner装好 Runner 后，要让它和 GitLab 绑定。这里用 gitlab-runner register：sudo gitlab-runner register一路输入配置信息：GitLab instance URL: http://10.0.5.169/Registration token: 在项目设置 -> CI/CD -> Runners 里获取Description: 比如 Production Deployment RunnerTags: 我用了 deploy,production,warExecutor: 我选了 shell，本地执行脚本最简单第一次填的时候有点紧张，生怕输错 😂，不过输错了也没事，重新来一次就好。（~后面发现gitlab runner里面包含了指令 直接复制就好）2.1 创建部署用户（deploy）为了安全起见，我没有直接用 root，而是单独创建了一个 deploy 用户：sudo useradd -m -s /bin/bash deploy sudo passwd deploy给它加 sudo 权限：# Ubuntu/Debian sudo usermod -aG sudo deploy # CentOS/RHEL sudo usermod -aG wheel deploy第一次忘了加 sudo，结果 Runner 脚本执行的时候各种权限错误，只能回头补上。2.2 配置 sudo 免密码（可选）有些命令（比如重启 Tomcat）如果每次都要输入密码，会很麻烦。于是我给 deploy 用户加了免密码配置：sudo visudo在文件末尾添加：deploy ALL=(ALL) NOPASSWD: /opt/tomcat9/bin/*, \ /usr/bin/systemctl restart tomcat*, \ /usr/bin/systemctl stop tomcat*, \ /usr/bin/systemctl start tomcat*这里千万不要图省事直接给 ALL，只开放必要命令就行。3. 配置 SSH 免密登录Runner 需要免密登录到部署服务器。先在 Runner 机器上切换到 gitlab-runner 用户并生成 SSH 密钥：sudo -u gitlab-runner -H bash ssh-keygen -t rsa -b 2048 -N "" -f ~/.ssh/id_rsa cat ~/.ssh/id_rsa.pub exit然后把公钥加到部署服务器的 authorized_keys：ssh deploy@10.0.5.169 mkdir -p ~/.ssh && chmod 700 ~/.ssh echo "粘贴上面的公钥" >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys更省事的方式：sudo -u gitlab-runner ssh-copy-id deploy@10.0.5.169我第一次用 ssh-copy-id 的时候，因为防火墙没放 22 端口，折腾了半天才反应过来 😂。4. 测试免密连接最后一定要测试：sudo -u gitlab-runner ssh deploy@10.0.5.169 "echo 'SSH连接成功'"能正常输出说明搞定。二、编写 .gitlab-ci.ymlRunner 配好后，还需要在项目里写 .gitlab-ci.yml，告诉它每一步该怎么执行。下面是我的服务器配置，大家可以参考，主要操作就是先打war包，然后把war推送到目标部署服务器。服务器tomcat关闭，然后替换war，启动。当然之前的war包也进行了按日期备份：stages: - build - deploy variables: MAVEN_CLI_OPTS: "--batch-mode --errors --fail-at-end --show-version -Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository" # === 路径与部署参数（按需修改） === POM_DIR: "code/service" # ← pom.xml 所在目录 APP_NAME: "tjzc-service" # 部署到 webapps 后的名字：${APP_NAME}.war LINUX_SERVER: "10.0.5.166" LINUX_USER: "deploy" LINUX_TOMCAT: "/data/soft/tomcat" # Tomcat 根目录（含 webapps/） TOMCAT_SERVICE: "tomcat" # systemctl 的服务名，例如 tomcat/tomcat9 BACKUP_BASE: "/backup/tomcat" # 备份根路径 BACKUP_RETENTION_DAYS: "30" # 备份保留天数 # 1) 构建（Runner 自带 Maven/JDK） build: stage: build tags: [deploy] script: - set -e - test -f "$POM_DIR/pom.xml" || (echo "找不到 $POM_DIR/pom.xml，请检查 POM_DIR" && exit 1) - cd "$POM_DIR" - mvn $MAVEN_CLI_OPTS clean package -DskipTests artifacts: paths: - $POM_DIR/target/*.war expire_in: 7 days # 2) 部署（停服 → 备份(若有) → 上传 → 启动） deploy-linux: stage: deploy tags: [deploy] needs: ["build"] script: - set -euo pipefail # 取构建产物（如有多包，取第一个；可按需改名匹配） - WAR_FILE=$(ls "$POM_DIR"/target/*.war | head -1) - REMOTE_WAR="${LINUX_TOMCAT}/webapps/${APP_NAME}.war" # 停止 Tomcat - ssh ${LINUX_USER}@${LINUX_SERVER} "${LINUX_TOMCAT}/bin/shutdown.sh || true" # 若存在旧 war/目录则备份至带时间戳目录，并清理超期备份 - | ssh ${LINUX_USER}@${LINUX_SERVER} " set -e TIMESTAMP=\$(date +%F_%H%M%S) BACKUP_DIR='${BACKUP_BASE}/${APP_NAME}/'\"\$TIMESTAMP\" mkdir -p \"\$BACKUP_DIR\" # 备份 war（如果存在） if [ -f '${LINUX_TOMCAT}/webapps/${APP_NAME}.war' ]; then mv '${LINUX_TOMCAT}/webapps/${APP_NAME}.war' \"\$BACKUP_DIR/\" fi # 备份解压目录（如果存在） if [ -d '${LINUX_TOMCAT}/webapps/${APP_NAME}' ]; then tar -C '${LINUX_TOMCAT}/webapps' -czf \"\$BACKUP_DIR/${APP_NAME}.dir.tgz\" '${APP_NAME}' rm -rf '${LINUX_TOMCAT}/webapps/${APP_NAME}' fi # 清理超过 ${BACKUP_RETENTION_DAYS} 天的备份 find '${BACKUP_BASE}/${APP_NAME}' -mindepth 1 -maxdepth 1 -type d -mtime +${BACKUP_RETENTION_DAYS} -exec rm -rf {} + " # 上传新 war（重命名为 ${APP_NAME}.war） - scp "$WAR_FILE" ${LINUX_USER}@${LINUX_SERVER}:"$REMOTE_WAR" # 启动 Tomcat (使用 startup.sh 脚本) - ssh ${LINUX_USER}@${LINUX_SERVER} "${LINUX_TOMCAT}/bin/startup.sh" - | ssh ${LINUX_USER}@${LINUX_SERVER} " for i in \$(seq 1 60); do pgrep -f 'org.apache.catalina.startup.Bootstrap' >/dev/null && exit 0 sleep 1 done echo 'Tomcat 进程未在 60 秒内启动' >&2; exit 1 " environment: name: production我这里分了两个阶段：build：用 Maven 打包deploy：传到服务器，重启 Tomcat第一次跑的时候卡在 scp，因为免密配置没生效，还要输密码。修好后就顺畅多了。三、一些小感受整个流程跑通后，心里还是挺有成就感的。以前一套手动操作下来动不动 20 分钟，现在基本就是点一下，等 Runner 自己跑完，省时省力。当然这只是一个入门级的 CI/CD 流程，真正的生产环境可能还需要加灰度发布、回滚机制之类的，但对目前场景来说已经够用了。四、常见问题和踩坑记录最后总结下我遇到的一些坑：SSH 免密失败常见原因：防火墙没放端口、.ssh 权限不对。权限不足deploy 用户没加 sudo 权限，Runner 执行 systemctl 时报错。Runner tag 没对上.gitlab-ci.yml 写了 tags: deploy，但 Runner 注册时没加这个 tag，结果任务一直 pending。环境变量缺失Runner 机器没装 JDK、Maven，打包直接失败。要记得补充 JAVA_HOME 和 MAVEN_HOME。小提醒不要用 root 直接跑 Runner，风险太大。.gitlab-ci.yml 多加点 echo，方便排查问题。结语这次把 GitLab CI/CD 跑通之后，算是把部署这件事自动化了，手工操作少了，心里更踏实。后面有机会的话，我还想继续探索下分环境部署、自动回滚这些更高级的玩法。
Gemini CLI 使用指南:将 AI 助手集成到你的终端（含网络问题与常见错误解决）
Gemini CLI 使用指南：将 AI 助手集成到你的终端（含网络问题与常见错误解决）对于开发者而言，命令行终端是我们最常打交道的工具之一。如果在终端中能直接调用强大的 AI 模型，无疑会极大地提升工作效率。Google 推出的 Gemini CLI 就是这样一款工具，它能将 Gemini 的能力无缝集成到你的命令行工作流中。本文将详细介绍 Gemini CLI 的安装、配置过程，并分享我在使用中遇到的关键问题及其解决方案。一、准备工作：Node.js 环境在安装 Gemini CLI 之前，请确保你的系统中已经安装了 Node.js。这是其运行的基础环境。你可以通过在终端执行以下命令来检查：node -v如果未安装，请前往 Node.js 官网 下载并安装 LTS (长期支持) 版本。二、安装 Gemini CLI安装 Gemini CLI 有两种方式：临时使用 (npx)：这种方式无需全局安装，适合快速体验。每次使用时都会从网络获取最新版本。npx @google/gemini-cli全局安装 (npm)：（推荐） 对于长期使用者，建议采用全局安装。这样，你就可以在系统的任何路径下通过 gemini 命令直接启动它。npm install -g @google/gemini-cli三、关键配置：网络代理重要提示： Gemini CLI 在首次启动和后续使用中需要验证 Google 账户，相关服务在国内无法直接访问。因此，必须为你的终端配置代理。这是初次配置时最容易遇到的障碍。我的解决方案如下：使用 Clash 等代理工具，并开启 TUN 模式。该模式可以接管系统级的网络请求，从而覆盖到终端环境。启动 TUN 模式后，在终端（如 Windows Terminal 内的 PowerShell）中执行后续命令。当 gemini 命令成功启动并弹出浏览器进行 Google 账户授权时，说明网络配置正确。四、初始化设置首次成功授权后，Gemini CLI 会引导你完成几个简单的初始化配置。选择主题：根据个人偏好选择一个命令行界面的颜色主题。选择认证方式：Sign in with your Google Account: 我选择此项。通过浏览器登录 Google 账户完成认证，流程简单直观。Use an API Key: 你也可以前往 Google AI Studio 生成一个 API 密钥，并在此处粘贴。这种方式更适合自动化脚本或无浏览器环境。完成这些步骤后，你就可以正式开始使用了。五、核心用法在终端输入 gemini 并回车，即可进入交互模式。其输入方式非常灵活：直接输入问题：进行常规的问答对话。@ 符号：分析文件与目录：这是 Gemini CLI 的一个核心功能，可以直接引用本地文件或文件夹作为上下文。示例：@./src/utils.js 解释这段代码的功能。示例：@./project_docs 总结这个目录下的所有文档。/ 命令：使用内置功能：输入 / 可以查看并使用内置命令，如：/theme: 切换主题。/editor: 开启多行编辑模式，方便粘贴和编辑大段代码或文本。/help: 查看帮助文档。! 符号：结合 Shell 命令：将标准 Shell 命令的输出结果作为输入，直接传递给 Gemini 进行处理。示例：!git diff | gemini 为这些变更生成一个合适的 commit message。示例：!ls -l | gemini 解释这些文件权限的含义。六、常见问题排查：解决自动退出在使用过程中，你可能会遇到 Gemini CLI 运行一段时间后自动退出的问题。原因分析：该问题是由于 Gemini CLI 默认会定时发送匿名的使用情况统计（Telemetry）到 Google 服务器。在网络不稳定的情况下，该请求失败会导致程序异常终止。解决方案：关闭该统计功能即可。找到配置文件 settings.json。Windows: C:\Users\<你的用户名>\.gemini\settings.jsonmacOS/Linux: ~/.gemini/settings.json使用文本编辑器打开该文件，添加以下字段并保存："usageStatisticsEnabled": false修改配置后，重新启动 gemini，程序即可稳定运行。总结通过以上步骤，你就可以将 Gemini CLI 顺利地集成到日常的开发工作中。它能够有效减少在不同应用间切换的频率，无论是代码解读、文档生成还是命令查询，都能在终端内一站式完成。希望这篇指南能帮助你顺利地使用 Gemini CLI。如果你有其他使用技巧，也欢迎交流。
向量数据库迁移实录：从 FAISS 到 Weaviate 的那些坑最近因为需要支持多用户知识库检索，我开始琢磨向量数据库的替换方案。原来系统里用的是本地的 FAISS，搭配 LangChain 用起来还挺顺，但随着需求复杂起来，比如想加 hit\_count 记录、分页、精确查某个文件的所有块，FAISS 显然就有点捉襟见肘了。🥊 为什么考虑换掉 FAISS？简单说两点：FAISS 不支持原生 filter 查询，像我要查某个 kb_id 的内容，或模糊查某个 filename 的所有内容，只能自己手动过滤；没有内置分页。每次都得查全量，再手动分页，这在数据量大时非常不友好。于是我决定换掉 FAISS，试试 Weaviate。🔁 切换 Weaviate 的过程✅ 基本查询Weaviate 的向量检索写法和 FAISS 有点类似，只是接口风格更“声明式”一点。最开始试了下这样一段代码：results = collection.query.near_vector( near_vector=query_vector, limit=5, filters=Filter.by_property("kb_id").equal(kb_id) )这一段是查询向量最相近的内容，且只筛选 kb_id 为指定值的记录。比起 FAISS 自己 filter 一堆，Weaviate 这点真的太方便了。📈 查询命中的内容并计数接下来加了一个需求：一旦某个块被召回了，就把它的 hit_count 字段 +1。这其实也很方便：for item in results.objects: collection.data.update( uuid=item.uuid, properties={"hit_count": item.properties.get("hit_count", 0) + 1} )注意这里 item.uuid 是默认带的，不需要你在 return_metadata 里特地加上（我一开始就踩了这个坑）。🧩 模糊查询 + 分页后来又要实现：用户选择一个知识库、一个文件名，分页查看里面所有块，还能模糊查内容。一开始我尝试构造 filters：filters = { "operator": "And", "operands": [ {"path": ["kb_id"], "operator": "Equal", "valueText": kb_id}, {"path": ["filename"], "operator": "Equal", "valueText": filename}, {"path": ["text"], "operator": "Like", "valueText": f"*{query}*"} # 模糊 ] }但后来我发现 Weaviate 新版 SDK 提供了更优雅的 Filter 方式：from weaviate.classes.query import Filter filters = ( Filter.by_property("kb_id").equal(kb_id) & Filter.by_property("filename").equal(filename) & Filter.by_property("text").like(f"*{query}*") # 支持模糊 )这个组合可以直接传给 collection.query.fetch_objects(...) 方法，分页也只需设置 limit 和 offset：offset = (page - 1) * pageSize result = collection.query.fetch_objects( filters=filters, limit=pageSize, offset=offset, return_properties=["text", "hit_count", "upload_time", "filename", "kb_id"] )❌ 踩坑记录不能插入 id 和 vector 到 properties批量插入时我傻傻地把 id 和 vector 放进了 properties 字典里，Weaviate 直接报错：It is forbidden to insert id or vector inside properties...后来我改成使用 DataObject 的方式解决了。return\_metadata=["uuid"] 会报错我以为想取 UUID 要写 return_metadata=["uuid"]，结果报了个特别诡异的错误：Extra inputs are not permitted [type=extra\_forbidden, input\_value=True, input\_type=bool]实际上根本不需要那一行，.uuid 默认就能取。Filter 的链式调用坑一开始我写了个 Filter.all(...) 组合多个 filter，结果提示 Filter 根本没这个方法。正确的写法是用 & 链接多个 Filter.by_property(...)，就像上面示例。💡 写在最后整体迁移下来，Weaviate 在功能和可扩展性方面明显优于 FAISS，尤其是在过滤器、属性更新、分页这些业务场景上更灵活。如果你正好也面临类似的向量检索多条件查询、知识块统计更新的需求，不妨试试换到 Weaviate。
从 Selenium 到 Playwright：我的浏览器控制切换之旅最近在折腾一个远程浏览器自动化的项目，最开始是用 Selenium 控制浏览器执行一系列开锁流程，后来因为种种问题，最终还是换成了 Playwright。这里简单记录一下整个过程中遇到的一些坑、思考和切换过程。初始方案：Selenium 控制浏览器一开始整个系统的架构是：多线程管理浏览器池（初始化多个浏览器放进浏览器池）每个浏览器执行任务：打开页面 → 登录 → 操作页面 DOM控制流程主要使用 find_element, click, send_keys 等操作Selenium 用着也没啥大问题，配合 Chrome + chromedriver（chromedriver版本很重要），流程基本能跑通。但随着任务量增加、并发变多、页面交互越来越复杂，问题也逐渐暴露出来：有时候页面切换 iframe 很慢，Selenium 报错找不到元素弹窗不稳定，处理逻辑需要各种 try/except多线程下的浏览器状态管理很混乱，有时线程没结束浏览器还开着于是我开始思考，有没有更现代、更轻量的替代方案？Playwright 更香？Playwright 这个名字其实早听说过，一直没仔细研究。这次真碰到实际问题了，就开始试着改一下逻辑，把 Selenium 整体替换成 Playwright。我用了它的 同步模式（sync_playwright()），主要是因为现有代码结构基于同步逻辑，异步改起来太麻烦。改造的重点包括：启动浏览器流程：从 webdriver.Chrome() 换成 p.chromium.launch(...)页面控制逻辑：换成 Playwright 的 locator, click, fill, frame() 等 API最关键：不再直接返回 page 对象，而是封装成 LockIndex.enter() 返回 browser，保持浏览器常驻最大的坑：Playwright 不能跨进程传 browser 对象这点一开始没注意。在 Selenium 模式下，浏览器 driver 可以传来传去（虽然也不推荐）。但 Playwright 的 browser 对象不能跨进程传递，哪怕你 pickle 了也会报错。我原本在主进程调度任务，然后把 browser 对象传过去执行操作，结果直接报错。于是我重构了架构：新方案：多进程 + Playwright 控制最终的架构大致如下：主进程 管理多个任务队列（一个队列对应一个浏览器进程）每个子进程 启动自己的浏览器，进入锁控页面，接收任务 → 执行 → 回传结果调度器 轮询分发任务，比如当前是第 0 个浏览器空闲，就发给它def dispatch_task(task): task_queues[current_index].put(task) current_index = (current_index + 1) % len(task_queues)任务本质上是“给某个 code 和 id 做一次 DOM 操作”。浏览器常驻，执行完一个任务继续待命。这个结构的好处：每个浏览器独立管理，状态更清晰多进程隔离，互不干扰Playwright 控制页面的效率、容错能力都明显强于 Selenium最后一点感慨其实如果不是这次遇到实际需求，我也不会下定决心从 Selenium 迁移。现在的架构逻辑更清晰了，浏览器也更稳定了，维护起来也更方便。小结如果你还在用 Selenium 控制浏览器，建议试试 Playwright。使用多进程时，不要跨进程传 page、browser 对象。合理设计任务调度和进程架构，能让系统更稳定。希望这篇文章能帮到也在做浏览器自动化的朋友们。如果你也在做类似项目，欢迎留言交流。
Ubuntu 上使用 Docker 安装 Weaviate 教程在搭建自己的 RAG 知识库系统时，最初我是用 FAISS 来做向量检索的。虽然运行起来很快，但数据是存在本地磁盘文件中，维护和迁移都不太方便。特别是当系统稍微复杂一些，比如要做远程调用、版本升级或者支持多端访问时，这种方式就显得有些力不从心了。后来我了解到了 Weaviate，一个功能比较完善的开源向量数据库，支持 REST 和 gRPC 接口，数据结构更像真正的数据库，有 schema 管理，也适合做多类数据的组织，于是就决定切换过来。这篇文章就是记录一下我在 Ubuntu 上用 Docker 安装 Weaviate 的过程，并用 Python 客户端初始化 schema、插入数据做了基本验证。希望对你有帮助。一、安装 Docker首先确保你的系统中安装了最新版本的 Docker。1. 清理旧的 Docker 源（如有）sudo rm /etc/apt/sources.list.d/docker.list添加新的 GPG 密钥和阿里云源curl -fsSL https://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker.gpg echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker.gpg] https://mirrors.aliyun.com/docker-ce/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list安装 Docker 组件sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io验证安装是否成功sudo docker version二、配置并运行 WeaviateWeaviate 可以直接通过 Docker 启动运行。你可以选择使用 docker run 命令或编写 docker-compose.yml 文件进行管理。这里先介绍更灵活的方式：直接运行容器。拉取镜像（或在有网络环境下打包传输）docker pull semitechnologies/weaviate:1.27.15如服务器无外网，可在本地执行：docker save -o weaviate.tar semitechnologies/weaviate:1.27.15然后将 weaviate.tar 文件复制到目标服务器，执行：docker load -i weaviate.tar启动容器docker run -d --name weaviate \ -p 8003:8080 -p 50052:50052 \ -e QUERY_DEFAULTS_LIMIT=25 \ -e AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED=true \ -e PERSISTENCE_DATA_PATH=/var/lib/weaviate \ -e DEFAULT_VECTORIZER_MODULE=none \ -e ENABLE_MODULES= \ -e GRPC_ENABLED=true \ -e GRPC_PORT=50052 \ semitechnologies/weaviate:1.27.15✅ 提示：端口可根据需求自行调整。启动成功后，可通过浏览器访问 http://localhost:8003/v1/meta 查看服务是否正常。三、创建 Schema（重点）Weaviate 的 Schema（类定义）是系统的核心，直接关系到你后续向量数据的组织与查询方式。⚠️ 注意：Weaviate 服务端版本与 Python 客户端版本必须匹配，否则可能报错或方法不可用。当前版本：Weaviate Server：1.27.15Python SDK：weaviate-client==4.15.0安装 Python 客户端pip install weaviate-client示例代码：连接 Weaviate 并创建 Schemaimport weaviate from weaviate.classes.config import Configure, Property, DataType from datetime import datetime连接本地 Weaviate 实例client = weaviate.connect_to_local( port=8003, grpc_port=50052, ) print("✅ 成功连接 Weaviate v4 本地实例")删除旧类（如果存在）for class_name in ["Document", "KnowledgeChunk"]: if client.collections.exists(class_name): client.collections.delete(class_name) print(f"🗑️ 已删除旧类 {class_name}") # 创建新类 KnowledgeChunk client.collections.create( name="KnowledgeChunk", properties=[ Property(name="text", data_type=DataType.TEXT), Property(name="hit_count", data_type=DataType.INT), Property(name="upload_time", data_type=DataType.TEXT), Property(name="filename", data_type=DataType.TEXT), Property(name="user_id", data_type=DataType.TEXT), Property(name="kb_name", data_type=DataType.TEXT), Property(name="file_url", data_type=DataType.TEXT), Property(name="kb_id", data_type=DataType.TEXT), ], vectorizer_config=Configure.Vectorizer.none(), vector_index_config=Configure.VectorIndex.hnsw(), ) print("✅ 已成功创建 KnowledgeChunk 类") # 插入一条测试数据 collection = client.collections.get("KnowledgeChunk") collection.data.insert({ "text": "这里是测试文档内容", "hit_count": 0, "upload_time": datetime.now().isoformat(), "filename": "test.txt", "user_id": "user_001", "kb_name": "测试知识库", "file_url": "http://localhost/test.txt", "kb_id": "kb_001" }) print("✅ 成功插入测试数据") # 关闭连接 client.close() print("✅ 已关闭连接")四、小结通过本文的步骤，你已经完成了以下内容：在 Ubuntu 上安装 Docker 并配置国内源成功部署 Weaviate 并验证服务启动使用 Python 客户端连接服务、创建 schema 并写入测试数据💡 后续如果你希望接入向量模型（如 OpenAI、Cohere、Hugging Face Transformers 等），只需适当配置 DEFAULT_VECTORIZER_MODULE 与 ENABLE_MODULES 环境变量即可。如果你也在使用 Weaviate，欢迎交流你的使用心得或遇到的问题。希望这篇教程对你有所帮助！
🤖AI摘要此内容根据文章生成，不代表个人观点，仅用于文章内容的解释与总结在公司中，我负责管理前后端代码的部署。随着项目数量的增加，我发现手动部署过程不仅耗时，而且容易出错。为了解决这个问题，我决定寻找一个自动化部署工具。经过一番搜索，我发现了 deploy-cli-service，但它主要支持Linux服务器。由于我们公司使用的是Windows服务器，我决定自行修改源码以适应Windows环境。以下是我修改和部署的详细步骤：步骤1：评估现有工具我发现 deploy-cli-service 工具不支持Windows服务器，因此决定对其进行修改。步骤2：修改源码主要问题集中在SFTP操作和文件路径处理上，因为Windows和Linux在这些方面存在差异。步骤3：编写备份和部署脚本我编写了一个函数 backupRemoteFile 来处理备份、删除和解压的过程。const backupRemoteFile = async (config, index) => { try { const { windowsPath, bakDir } = config; const dirName = 'dist'; const zipFileName = `${dirName}_${dayjs().format('YYYY-MM-DD_HH-mm-ss')}.zip`; console.log(`(${index}) 备份远程文件 ${underline(windowsPath)}`); const path = 'D:\\soft\\nginx-1.23.4\\nginx-1.23.4\\html'; const destination = `${bakDir}\\${zipFileName}`; await ssh.execCommand(`cmd /c "cd /d ${windowsPath} && powershell -command "Compress-Archive -Path '${windowsPath}\\*' -DestinationPath '${destination}'; Get-ChildItem -Path . -Exclude 'dist.zip' | Remove-Item -Recurse -Force; Expand-Archive -Path 'dist.zip' -DestinationPath .; Remove-Item -Path 'dist.zip' -Force""`); succeed('备份成功'); console.log(`备份成功，备份至 ${underline(`${bakDir}\\${zipFileName}`)}，且原文件已被删除`); } catch (e) { console.error(e); process.exit(1); } };步骤4：测试修改在本地环境和测试服务器上测试修改后的脚本，确保其按预期工作。步骤5：打包和上传项目将整个项目打包，并上传到npm，以便其他开发者可以使用。资源链接npm包地址：deploy-cli-win-serviceGitHub仓库：lovisnd/deploy-cli-win-service结语通过这些步骤，我不仅提高了部署效率，还学习到了如何跨平台修改和优化工具。我希望我的经验能够帮助其他面临类似挑战的开发者。如果你对这个过程有任何疑问或建议，请随时联系我。
最近，客户要求将项目部署在Docker上，因此我开始学习并了解Docker的部署流程。下面是我整理的部署步骤：1. 安装Docker首先，在Ubuntu系统上安装Docker：sudo snap install docker2. 创建Dockerfile新建一个Dockerfile文件，并添加以下内容：# 使用官方Ubuntu镜像作为基础镜像 FROM ubuntu # 设置时区 RUN echo "Asia/Shanghai" > /etc/timezone ENV LANG C.UTF-8 ENV LC_ALL C.UTF-8 # 更新软件包列表并安装所需软件包 RUN apt-get update \ && apt-get install -y --no-install-recommends \ openjdk-8-jdk \ python3 \ python3-pip \ && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y libgomp1 # 使用pip3安装所需的Python库，并指定镜像源为阿里云 RUN pip3 install -i https://mirrors.aliyun.com/pypi/simple/ numpy==1.21.2 pandas==1.3.3 joblib==1.1.1 lightgbm==2.2.3 # 设置Java版本环境变量 ENV JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 # 设置工作目录 WORKDIR /app # 将Java可执行Jar文件复制到容器中 COPY test.jar app.jar COPY code/ /app # 设置容器启动时执行的命令 CMD ["java", "-jar", "app.jar"]3. 传输文件将打包好的jar文件和Dockerfile一起传输到Ubuntu系统上。4. 创建Docker镜像进入包含Dockerfile的文件夹，并执行以下命令来创建Docker镜像：docker build -t nhgl .5. 查看镜像使用以下命令查看是否成功创建了镜像：docker images6. 运行镜像运行镜像，并将内部端口9900映射到宿主机的10000端口上：docker run -d -p 10000:9900 test7. 查看运行情况查看镜像运行情况：docker ps -a8. 进入镜像使用以下命令进入镜像：docker exec -it [容器ID或容器名] /bin/bash9. 镜像保存根据ID将镜像保存成一个文件：docker save 镜像ID > test.tar以上就是使用Docker部署项目的详细步骤。希望对同样在学习Docker部署的你有所帮助。