从零开始开发 Typecho 微语插件 - Whisper

📘 开发记录 · 02-04 · 687 人浏览
从零开始开发 Typecho 微语插件 - Whisper
🤖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');
?>

<div class="jasmine-container grid grid-cols-12">
    <!-- 左侧边栏 -->
    <?php $this->need('component/sidebar-left.php'); ?>
    
    <!-- 主内容区 -->
    <div class="flex col-span-12 lg:col-span-8 flex-col lg:border-x-2 border-stone-100 dark:border-neutral-600 lg:pt-0 lg:px-6 pb-10 px-3">
        <?php $this->need('component/menu.php'); ?>
        
        <!-- 微语内容 -->
        <div class="whisper-container">
            <!-- 发表框、微语列表等 -->
        </div>
    </div>
    
    <!-- 右侧边栏 -->
    <?php $this->need('component/sidebar.php'); ?>
</div>

<?php $this->need('footer.php'); ?>

这样微语页面就和博客的其他页面保持了一致的布局,看起来就像是主题自带的功能一样。

发表框设计

发表框是用户最常接触的部分,我希望它简洁但功能完整:

<div class="whisper-publish">
    <textarea id="whisper-content" class="whisper-textarea" 
              placeholder="发表您的新鲜事儿..."></textarea>
    
    <div class="whisper-actions">
        <div style="display: flex; gap: 12px;">
            <!-- 表情按钮 -->
            <button class="whisper-emoji-btn" onclick="toggleEmojiPicker(event)">
                😊 表情
            </button>
            
            <!-- 图片上传 -->
            <label class="whisper-image-btn">
                📷 图片
                <input type="file" id="image-upload" accept="image/*" 
                       multiple style="display: none;" 
                       onchange="handleImageUpload(event)">
            </label>
        </div>
        
        <button class="whisper-submit-btn" onclick="publishWhisper()">
            立即发表
        </button>
    </div>
    
    <!-- 图片预览区 -->
    <div id="image-preview" class="image-preview"></div>
</div>

这里有个小技巧:图片上传按钮使用 <label> 包裹隐藏的 <input>,这样可以自定义按钮样式,同时保持原生的文件选择功能。

第五步:样式优化

样式是最考验耐心的部分,我反复调整了很多次才达到满意的效果。

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。

排查过程:

  1. 检查 Action.php 的代码逻辑,没有问题
  2. 检查路由注册,也正常
  3. 最后发现是数据库查询报错

原因: 原来是我后来才加的点赞功能,数据库表中没有 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 让它们分居两端。结果发现在移动端显得很拥挤,而且视觉上不够清晰。

迭代过程:

  1. 第一版:点赞按钮和设备信息在同一行 → 太挤
  2. 第二版:改为垂直布局,但间距太大 → 不够紧凑
  3. 第三版:调整间距为 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(包括版本号)
  • macOS
  • Linux
  • Android
  • iOS

虽然不是核心功能,但这种小细节能让产品更有温度。


技术栈与架构

在开始开发之前,先来看看整个项目的技术选型和架构设计。

技术选型

  • 后端框架: Typecho 1.0+(PHP 5.4+)
  • 数据库: MySQL 5.5+
  • 前端技术: 原生 JavaScript(无依赖)
  • 样式方案: CSS3(Flexbox + Grid)
  • 主题适配: Jasmine 主题

选择原生 JavaScript 而不是 jQuery 或其他框架,主要是考虑到:

  1. 现代浏览器对原生 API 的支持已经很好
  2. 减少依赖,降低插件体积
  3. 提高加载速度

架构设计

插件采用了经典的 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] 响应式设计
  • [ ] 评论功能(计划中)
  • [ ] 标签分类(计划中)
  • [ ] 视频上传(计划中)

如何使用

  1. 下载插件

    cd /usr/plugins/
    git clone https://github.com/lovisnd/Whisper.git
  2. 启用插件

    • 后台 → 插件 → 启用"微语"
  3. 创建页面

    • whisper.php 复制到主题目录
    • 创建独立页面,选择"微语"模板
  4. 开始使用

    • 登录后访问微语页面
    • 发表你的第一条微语!

总结与展望

开发感悟

这次开发微语插件的过程很有趣,从最初看到流情博客的创意,到自己动手实现,再到不断优化细节,整个过程充满了成就感。

特别是在调整样式的时候,虽然有时候为了几个像素的间距反复调整,但看到最终效果时,会觉得一切都值得。这就是前端开发的魅力吧——每一个细节都能影响用户体验。

通过这个项目,我也更深入地理解了 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
  • 在博客留言
  • 通过邮件联系我

让我们一起把这个插件做得更好!


取消回复
  1. sink 03-14

    我用的是1.3的typecho,好像不行?提示500错误?

    1. 执迷 (作者)  03-17
      @sink

      好的 我的版本是1.21是正常。可能是1.3最新版有些兼容问题,我来升级测试下。

  2. sink 03-13

    大神看看那里出问题了。1.3版本提示无法启用插件

  3. 厉害,后续要用用,然后反馈一下

  4. 流情 02-05

    厉害,我也是用的别人的插件微调后拿来用的,你这是从零纯手动,尽善尽美了

    1. 执迷 (作者)  02-06
      @流情

      嘿嘿 我也是各种学习+问AI 捣鼓出来的

Under CC BY NC-SA License.
Powered by Typecho | Theme by Jasmine
您是第 69801 位访客