[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. David Kim David Kim

      The frontend JS integration is smooth. I just added data attributes and it worked like a charm. Great job!

    2. 李婷婷 李婷婷

      置顶功能终于不用再依赖其他插件了,而且支持多篇置顶,太实用了!

    3. Alex Rivera Alex Rivera

      I appreciate the anti-cheat mechanism with IP and cookie tracking. Makes the view count actually mean something.

    4. Mike Chen Mike Chen

      文章写得很详细,特别是数据库设计和路由那部分,对我这种刚接触Typecho插件开发的新手非常友好。

    5. Sarah Johnson Sarah Johnson

      This is exactly what I’ve been looking for! The custom field click tracking is a game-changer for my download-heavy site.