[Lounge] (Sensual Erotic)- Sensual Downtempo Music:一场绵长而深沉的感官漂流
[神游舞曲 Trip Hop] 莫奇葩(Morcheeba)-专辑《Charango》:南美风情与嘻哈碰撞的异色 cocktail
[福音爵士 Gospel Jazz] Velvet Ladies Records - The Best of Gospel Jazz – Vol. I:灵魂深处的圣洁律动
[爵士 Jazz] Velvet Ladies Records - The Best of Lounge Jazz Divas, Vol. 1:都会夜色中的优雅独白
[爵士&流行] 手嶌葵 - La Vie En Rose & Love Cinemas~:吟唱光影与玫瑰的精灵诗篇

Typecho插件开发方案 Statistician(统计师)

这个插件将实现文章置顶、浏览量统计、自定义字段点击统计三大功能,并

📦 插件名称:Statistician(统计师)

一、插件功能概述

功能模块具体实现技术特点
文章置顶后台文章编辑页添加置顶开关,支持多篇文章置顶基于Sticky插件思路,扩展字段标记
文章浏览量自动记录文章被浏览次数,支持防刷机制独立数据表存储,Cookie防刷
自定义字段统计统计任意自定义字段(如下载链接)的点击次数动态路由+异步请求,可扩展性强

二、数据库设计

创建独立数据表 typecho_statistician_stats

CREATE TABLE `typecho_statistician_stats` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `cid` int(10) unsigned NOT NULL COMMENT '文章ID',
  `type` varchar(50) NOT NULL COMMENT '统计类型:views(浏览量)/field_字段名(如field_download)',
  `value` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '统计数值',
  `last_ip` varchar(45) DEFAULT NULL COMMENT '最后访问IP',
  `last_time` int(10) unsigned DEFAULT NULL COMMENT '最后访问时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `cid_type` (`cid`,`type`),
  KEY `type` (`type`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

三、插件目录结构

usr/plugins/Statistician/
├── Plugin.php              # 主插件文件
├── Action.php              # 路由动作处理
├── Widget.php              # 数据展示Widget
├── install.php             # 安装脚本(建表)
├── uninstall.php           # 卸载脚本(删表)
├── assets/
│   ├── css/
│   │   └── admin.css       # 后台样式
│   └── js/
│       ├── admin.js        # 后台交互
│       └── frontend.js     # 前端统计脚本
├── languages/              # 国际化(可选)
└── README.md               # 说明文档

四、核心代码实现

1. 插件主文件 Plugin.php

<?php
namespace TypechoPlugin\Statistician;

use Typecho\Plugin\PluginInterface;
use Typecho\Widget\Helper\Form;
use Typecho\Widget\Helper\Form\Element\Text;
use Typecho\Widget\Helper\Form\Element\Radio;
use Widget\Options;

if (!defined('__TYPECHO_ROOT_DIR__')) {
    exit;
}

/**
 * 全能统计插件 - 支持文章置顶、浏览量、自定义字段点击统计
 * 
 * @package Statistician
 * @author YourName
 * @version 1.0.0
 * @link https://yourblog.com
 */
class Plugin implements PluginInterface
{
    /**
     * 激活插件
     */
    public static function activate()
    {
        // 1. 注册钩子:文章编辑页添加置顶选项
        \Typecho\Plugin::factory('admin/write-post.php')->bottom = __CLASS__ . '::renderStickyField';
        \Typecho\Plugin::factory('admin/write-page.php')->bottom = __CLASS__ . '::renderStickyField';
        
        // 2. 注册钩子:保存文章时处理置顶标记
        \Typecho\Plugin::factory('Widget_Contents_Post_Edit')->finishPublish = __CLASS__ . '::saveStickyFlag';
        
        // 3. 注册钩子:文章展示时增加浏览量
        \Typecho\Plugin::factory('Widget_Archive')->beforeRender = __CLASS__ . '::recordView';
        
        // 4. 添加路由:处理点击统计请求
        \Typecho\Router::addRoute(
            'statistician-click',
            '/statistician/click/[cid]/[field]',
            'TypechoPlugin\Statistician\Action',
            'recordClick'
        );
        
        // 5. 安装数据库
        self::installDb();
        
        return _t('插件已激活,请配置统计选项');
    }
    
    /**
     * 禁用插件
     */
    public static function deactivate()
    {
        // 移除路由
        \Typecho\Router::removeRoute('statistician-click');
        
        return _t('插件已禁用');
    }
    
    /**
     * 插件配置面板
     */
    public static function config(Form $form)
    {
        // 统计开关
        $enableView = new Radio(
            'enable_view',
            ['1' => '开启', '0' => '关闭'],
            '1',
            _t('浏览量统计'),
            _t('是否开启文章浏览量统计')
        );
        $form->addInput($enableView);
        
        // 防刷时间间隔(分钟)
        $viewInterval = new Text(
            'view_interval',
            null,
            '60',
            _t('浏览量防刷间隔'),
            _t('同一IP在多少分钟内多次访问只统计一次,0表示不限制')
        );
        $form->addInput($viewInterval->addRule('isInteger', _t('请填写整数')));
        
        // 自定义字段统计配置
        $statFields = new Text(
            'stat_fields',
            null,
            'download,baidu_down,lanzoub_down,alist_down',
            _t('要统计的自定义字段'),
            _t('填写字段名,多个用英文逗号分隔,如:download,baidu_down,lanzoub_down')
        );
        $form->addInput($statFields);
    }
    
    /**
     * 安装数据库
     */
    private static function installDb()
    {
        $db = \Typecho\Db::get();
        $scripts = file_get_contents(__DIR__ . '/install.php');
        $scripts = str_replace('typecho_', $db->getPrefix(), $scripts);
        
        try {
            foreach (explode(';', $scripts) as $script) {
                $script = trim($script);
                if ($script) {
                    $db->query($script, \Typecho\Db::WRITE);
                }
            }
        } catch (\Exception $e) {
            // 表可能已存在,忽略错误
        }
    }
    
    /**
     * 文章编辑页添加置顶选项
     */
    public static function renderStickyField()
    {
        $cid = $_REQUEST['cid'] ?? 0;
        $isSticky = self::isSticky($cid);
        ?>
        <section class="typecho-post-option">
            <label class="typecho-label">置顶设置</label>
            <ul>
                <li>
                    <input id="sticky" name="sticky" type="checkbox" value="1" <?php echo $isSticky ? 'checked' : ''; ?> />
                    <label for="sticky">将这篇文章置顶</label>
                </li>
            </ul>
        </section>
        <script src="<?php echo \Typecho\Common::url('Statistician/assets/js/admin.js', Options::alloc()->pluginUrl); ?>"></script>
        <?php
    }
    
    /**
     * 保存文章时处理置顶标记
     */
    public static function saveStickyFlag($post)
    {
        $cid = $post->cid;
        $sticky = $_POST['sticky'] ?? 0;
        
        $db = \Typecho\Db::get();
        
        if ($sticky) {
            // 写入置顶标记到自定义字段
            $db->query($db->insert('table.fields')
                ->rows([
                    'cid' => $cid,
                    'name' => 'sticky',
                    'type' => 'str',
                    'str_value' => '1'
                ]));
        } else {
            // 删除置顶标记
            $db->query($db->delete('table.fields')
                ->where('cid = ? AND name = ?', $cid, 'sticky'));
        }
    }
    
    /**
     * 判断文章是否置顶
     */
    public static function isSticky($cid)
    {
        $db = \Typecho\Db::get();
        $sticky = $db->fetchRow($db->select('str_value')
            ->from('table.fields')
            ->where('cid = ? AND name = ?', $cid, 'sticky'));
        return !empty($sticky);
    }
    
    /**
     * 记录文章浏览量
     */
    public static function recordView($archive)
    {
        // 只统计文章页面
        if ($archive->is('single')) {
            $cid = $archive->cid;
            $db = \Typecho\Db::get();
            
            // 获取配置
            $options = Options::alloc()->plugin('Statistician');
            $enableView = $options->enable_view ?? '1';
            
            if (!$enableView) {
                return;
            }
            
            $interval = intval($options->view_interval ?? 60);
            
            // 防刷检查
            $ip = $_SERVER['REMOTE_ADDR'];
            $cookieKey = 'statistician_view_' . $cid;
            
            if ($interval > 0) {
                // 检查Cookie
                if (isset($_COOKIE[$cookieKey])) {
                    return;
                }
                
                // 检查数据库记录
                $last = $db->fetchRow($db->select('last_time, last_ip')
                    ->from('table.statistician_stats')
                    ->where('cid = ? AND type = ?', $cid, 'views'));
                
                if ($last && $last['last_ip'] == $ip) {
                    $lastTime = $last['last_time'];
                    if (time() - $lastTime < $interval * 60) {
                        return; // 间隔内不统计
                    }
                }
            }
            
            // 更新统计
            $db->query($db->insert('table.statistician_stats')
                ->rows([
                    'cid' => $cid,
                    'type' => 'views',
                    'value' => 1,
                    'last_ip' => $ip,
                    'last_time' => time()
                ]) . ' ON DUPLICATE KEY UPDATE 
                    value = value + 1,
                    last_ip = VALUES(last_ip),
                    last_time = VALUES(last_time)');
            
            // 设置Cookie
            if ($interval > 0) {
                setcookie($cookieKey, '1', time() + $interval * 60, '/');
            }
        }
    }
    
    /**
     * 获取文章浏览量
     */
    public static function getViews($cid)
    {
        $db = \Typecho\Db::get();
        $row = $db->fetchRow($db->select('value')
            ->from('table.statistician_stats')
            ->where('cid = ? AND type = ?', $cid, 'views'));
        return $row ? intval($row['value']) : 0;
    }
    
    /**
     * 获取字段点击量
     */
    public static function getFieldClicks($cid, $field)
    {
        $db = \Typecho\Db::get();
        $row = $db->fetchRow($db->select('value')
            ->from('table.statistician_stats')
            ->where('cid = ? AND type = ?', $cid, 'field_' . $field));
        return $row ? intval($row['value']) : 0;
    }
}

2. 路由动作处理 Action.php

<?php
namespace TypechoPlugin\Statistician;

use Typecho\Widget\Exception;

class Action extends \Typecho\Widget
{
    /**
     * 记录字段点击
     */
    public function recordClick()
    {
        $cid = $this->request->get('cid');
        $field = $this->request->get('field');
        
        // 参数验证
        if (!$cid || !$field) {
            $this->response->throwJson(['code' => 400, 'msg' => '参数错误']);
            return;
        }
        
        // 检查该字段是否配置为需要统计
        $options = \Widget\Options::alloc()->plugin('Statistician');
        $statFields = explode(',', $options->stat_fields ?? '');
        $statFields = array_map('trim', $statFields);
        
        if (!in_array($field, $statFields)) {
            $this->response->throwJson(['code' => 403, 'msg' => '该字段未启用统计']);
            return;
        }
        
        $db = \Typecho\Db::get();
        $ip = $_SERVER['REMOTE_ADDR'];
        $type = 'field_' . $field;
        
        // 可选:添加防刷限制
        $interval = 10; // 10秒内不重复统计同个字段
        $last = $db->fetchRow($db->select('last_time, last_ip')
            ->from('table.statistician_stats')
            ->where('cid = ? AND type = ?', $cid, $type));
        
        if ($last && $last['last_ip'] == $ip) {
            if (time() - $last['last_time'] < $interval) {
                // 防刷,但不返回错误
                $this->response->throwJson(['code' => 200, 'msg' => '统计成功(防刷)']);
                return;
            }
        }
        
        // 更新统计
        $db->query($db->insert('table.statistician_stats')
            ->rows([
                'cid' => $cid,
                'type' => $type,
                'value' => 1,
                'last_ip' => $ip,
                'last_time' => time()
            ]) . ' ON DUPLICATE KEY UPDATE 
                value = value + 1,
                last_ip = VALUES(last_ip),
                last_time = VALUES(last_time)');
        
        $this->response->throwJson(['code' => 200, 'msg' => '统计成功']);
    }
}

3. 数据展示 Widget.php

<?php
namespace TypechoPlugin\Statistician;

class Widget extends \Typecho\Widget
{
    /**
     * 显示文章浏览量
     */
    public static function views($cid, $echo = true)
    {
        $views = Plugin::getViews($cid);
        $text = $views . ' 次阅读';
        
        if ($echo) {
            echo $text;
        } else {
            return $text;
        }
    }
    
    /**
     * 显示字段点击量
     */
    public static function fieldClicks($cid, $field, $echo = true)
    {
        $clicks = Plugin::getFieldClicks($cid, $field);
        $text = $clicks . ' 次下载';
        
        if ($echo) {
            echo $text;
        } else {
            return $text;
        }
    }
    
    /**
     * 获取置顶文章列表
     */
    public static function getStickyPosts($limit = 5)
    {
        $db = \Typecho\Db::get();
        
        // 查询有置顶标记的文章
        $sticky = $db->fetchAll($db->select('cid')
            ->from('table.fields')
            ->where('name = ? AND str_value = ?', 'sticky', '1'));
        
        if (empty($sticky)) {
            return [];
        }
        
        $cids = array_column($sticky, 'cid');
        
        $select = $db->select('cid, title, slug, created')
            ->from('table.contents')
            ->where('status = ? AND type = ?', 'publish', 'post')
            ->where('cid IN (' . implode(',', $cids) . ')')
            ->order('created', \Typecho\Db::SORT_DESC);
        
        if ($limit > 0) {
            $select->limit($limit);
        }
        
        return $db->fetchAll($select);
    }
}

4. 前端统计脚本 assets/js/frontend.js

/**
 * Statistician 前端统计脚本
 * 自动绑定自定义字段的点击统计
 */
(function() {
    // 获取插件配置(通过PHP在页面中输出)
    var config = window.StatisticianConfig || {
        apiBase: '/statistician/click/',
        fields: [] // 需要统计的字段名数组
    };
    
    // 初始化
    function init() {
        if (!config.fields || config.fields.length === 0) return;
        
        // 遍历所有需要统计的字段
        config.fields.forEach(function(field) {
            bindFieldClicks(field);
        });
    }
    
    // 绑定字段点击事件
    function bindFieldClicks(field) {
        // 查找包含该字段数据的元素
        // 支持两种方式:
        // 1. 元素上有 data-field="{字段名}" 属性
        // 2. 元素上有 data-field-{字段名} 属性
        var selectors = [
            '[data-field="' + field + '"]',
            '[data-field-' + field + ']'
        ];
        
        selectors.forEach(function(selector) {
            var elements = document.querySelectorAll(selector);
            elements.forEach(function(el) {
                el.addEventListener('click', function(e) {
                    // 获取文章CID
                    var cid = el.getAttribute('data-cid') || 
                              getCidFromUrl() ||
                              document.querySelector('meta[name="cid"]')?.content;
                    
                    if (!cid) {
                        console.warn('Statistician: 未找到文章CID');
                        return;
                    }
                    
                    // 发送统计请求
                    recordClick(cid, field);
                });
            });
        });
    }
    
    // 发送点击统计
    function recordClick(cid, field) {
        var url = config.apiBase + cid + '/' + field;
        
        // 使用fetch发送请求
        fetch(url, {
            method: 'GET',
            credentials: 'same-origin',
            headers: {
                'X-Requested-With': 'XMLHttpRequest'
            }
        })
        .then(response => response.json())
        .then(data => {
            if (data.code === 200) {
                console.log('Statistician: 统计成功', field);
            }
        })
        .catch(err => {
            console.warn('Statistician: 统计请求失败', err);
        });
    }
    
    // 从URL中获取文章CID(适用于单页文章)
    function getCidFromUrl() {
        var match = window.location.pathname.match(/\/(\d+)\.html$/);
        return match ? match[1] : null;
    }
    
    // 页面加载完成后初始化
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();

5. 安装脚本 install.php

CREATE TABLE IF NOT EXISTS `typecho_statistician_stats` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `cid` int(10) unsigned NOT NULL COMMENT '文章ID',
  `type` varchar(50) NOT NULL COMMENT '统计类型',
  `value` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '统计数值',
  `last_ip` varchar(45) DEFAULT NULL COMMENT '最后访问IP',
  `last_time` int(10) unsigned DEFAULT NULL COMMENT '最后访问时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `cid_type` (`cid`,`type`),
  KEY `type` (`type`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

五、前端调用示例

在你的主题模板中,可以这样使用:

1. 显示浏览量

<?php while($this->next()): ?>
    <article>
        <h2><a href="<?php $this->permalink() ?>"><?php $this->title() ?></a></h2>
        
        <!-- 显示浏览量 -->
        <span class="post-views">
            <?php \TypechoPlugin\Statistician\Widget::views($this->cid); ?>
        </span>
        
        <!-- 显示下载统计 -->
        <?php if ($this->fields->download): ?>
            <a href="<?php $this->fields->download() ?>" 
               class="download-btn"
               data-field="download"
               data-cid="<?php $this->cid() ?>"
               target="_blank">
                下载文件
            </a>
            <span class="download-count">
                (<?php \TypechoPlugin\Statistician\Widget::fieldClicks($this->cid, 'download'); ?>)
            </span>
        <?php endif; ?>
    </article>
<?php endwhile; ?>

2. 在页面头部引入配置

在主题的 header.php 中添加:

<?php
// 获取需要统计的字段配置
$statFields = explode(',', \Widget\Options::alloc()->plugin('Statistician')->stat_fields ?? '');
$statFields = array_map('trim', $statFields);
?>
<script>
window.StatisticianConfig = {
    apiBase: '<?php echo \Typecho\Common::url('statistician/click/', \Helper::options()->index); ?>',
    fields: <?php echo json_encode($statFields); ?>
};
</script>
<script src="<?php echo \Typecho\Common::url('Statistician/assets/js/frontend.js', \Helper::options()->pluginUrl); ?>"></script>

六、插件特点总结

  1. 一体化设计:集成了你需要的所有统计功能,无需多个插件组合
  2. 扩展性强:支持任意自定义字段统计,只需在配置中添加字段名
  3. 防刷机制:内置IP和Cookie双重防刷,统计数据更真实
  4. 使用简单:前端通过data-field属性自动绑定,无需额外JS代码
  5. 数据独立:使用独立数据表存储,不影响系统原有表结构
  6. 兼容性好:基于Typecho官方钩子机制开发,符合规范

七、后续可扩展功能

  • 数据可视化:后台增加统计图表展示
  • 导出功能:支持导出统计数据为CSV
  • 排行榜:自动生成热门文章、热门下载排行榜
  • 定时清零:支持按周期重置统计数据

已有 25 条评论

    1. 静静 静静

      用上了,非常稳定!提个小建议:置顶功能如果能支持按置顶时间排序就更好了。

    2. Noah Wilson Noah Wilson

      I wonder if this could be extended to track outbound links as well? That would make it a complete analytics solution for Typecho.

    3. 刘东 刘东

      建议增加一个按时间段(比如本周、本月)的统计功能,能看到文章的近期热度变化。

    4. Isabella Garcia Isabella Garcia

      The installation and uninstallation scripts are a nice touch. Clean plugin management is always appreciated.

    5. 陈小霞 陈小霞

      作为一个经常分享下载链接的博主,这个自定义字段统计简直就是救星,能清楚知道哪些资源受欢迎。