getPrefix(); $table = $prefix . self::$_table; $sql = "CREATE TABLE IF NOT EXISTS `{$table}` ( `id` int(11) NOT NULL AUTO_INCREMENT, `cid` int(11) NOT NULL COMMENT '文章ID', `uid` int(11) DEFAULT 0 COMMENT '用户ID', `username` varchar(200) DEFAULT NULL COMMENT '用户名', `email` varchar(200) DEFAULT NULL COMMENT '用户邮箱', `website` varchar(500) DEFAULT NULL COMMENT '用户网站', `avatar` varchar(500) DEFAULT NULL COMMENT '头像URL', `ip` varchar(45) DEFAULT NULL COMMENT 'IP地址', `created` int(10) NOT NULL COMMENT '阅读时间', PRIMARY KEY (`id`), KEY `cid` (`cid`), KEY `uid` (`uid`), KEY `ip` (`ip`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;"; try { $db->query($sql); } catch (Exception $e) { // 表可能已经存在 } // 挂载文章页 Typecho_Plugin::factory('Widget_Archive')->footer = array(__CLASS__, 'trackVisitor'); return _t('插件已激活,请在后台设置中开启功能'); } /** * 禁用插件 */ public static function deactivate() { return _t('插件已禁用'); } /** * 插件配置方法 */ public static function config(Typecho_Widget_Helper_Form $form) { // 是否开启功能 $enable = new Typecho_Widget_Helper_Form_Element_Radio( 'enable', array('1' => _t('开启'), '0' => _t('关闭')), '1', _t('是否开启功能'), _t('关闭后前端将不会显示谁读了本文') ); $form->addInput($enable); // 是否屏蔽管理员记录和显示 $hideAdmin = new Typecho_Widget_Helper_Form_Element_Radio( 'hide_admin', array( '1' => _t('是(不记录也不显示)'), '0' => _t('否(正常记录和显示)') ), '0', _t('是否屏蔽管理员'), _t('开启后管理员阅读本文不会被记录,也不会在列表中显示') ); $form->addInput($hideAdmin); // 显示模式 $mode = new Typecho_Widget_Helper_Form_Element_Radio( 'mode', array( 'all' => _t('显示所有阅读者'), 'login' => _t('仅显示登录用户'), 'recent' => _t('仅显示近期阅读者(24小时内)') ), 'all', _t('显示模式'), _t('选择要显示的用户类型') ); $form->addInput($mode); // 显示数量限制 $limit = new Typecho_Widget_Helper_Form_Element_Text( 'limit', NULL, '20', _t('最大显示数量'), _t('最多显示多少个阅读者(0表示不限制)') ); $limit->input->setAttribute('class', 'mini'); $limit->addRule('isInteger', _t('请输入数字')); $form->addInput($limit); // IP去重时间(小时) $ip_interval = new Typecho_Widget_Helper_Form_Element_Text( 'ip_interval', NULL, '24', _t('IP去重时间(小时)'), _t('同一IP多少小时内不重复记录') ); $ip_interval->input->setAttribute('class', 'mini'); $ip_interval->addRule('isInteger', _t('请输入数字')); $form->addInput($ip_interval); // 自定义标题 $title = new Typecho_Widget_Helper_Form_Element_Text( 'title', NULL, '谁读了本文', _t('显示标题'), _t('前端显示的标题文字') ); $form->addInput($title); // 展开默认状态 $default_open = new Typecho_Widget_Helper_Form_Element_Radio( 'default_open', array('0' => _t('收起'), '1' => _t('展开')), '0', _t('默认状态'), _t('初始显示时是展开还是收起状态') ); $form->addInput($default_open); // 头像大小 $avatar_size = new Typecho_Widget_Helper_Form_Element_Text( 'avatar_size', NULL, '40', _t('头像尺寸(像素)'), _t('头像显示的大小,单位:像素') ); $avatar_size->input->setAttribute('class', 'mini'); $avatar_size->addRule('isInteger', _t('请输入数字')); $form->addInput($avatar_size); } /** * 配置保存处理 */ public static function configHandle($settings, $isInit) { // 确保所有设置都有值 $defaults = array( 'enable' => '1', 'hide_admin' => '0', 'mode' => 'all', 'limit' => '20', 'ip_interval' => '24', 'title' => '谁读了本文', 'default_open' => '0', 'avatar_size' => '40' ); foreach ($defaults as $key => $value) { if (!isset($settings[$key])) { $settings[$key] = $value; } } Helper::configPlugin('WhoReadThis', $settings); } /** * 个人用户配置面板 */ public static function personalConfig(Typecho_Widget_Helper_Form $form) {} /** * 追踪访问者并输出资源 */ public static function trackVisitor() { $archive = Typecho_Widget::widget('Widget_Archive'); if (!$archive->is('single')) { return; } $options = Helper::options()->plugin('WhoReadThis'); if (!$options || $options->enable == '0') { return; } $cid = $archive->cid; $ip = self::getClientIP(); // 检查是否屏蔽管理员 $hideAdmin = isset($options->hide_admin) ? $options->hide_admin : '0'; $user = Typecho_Widget::widget('Widget_User'); $isAdmin = $user->hasLogin() && $user->pass('administrator', true); if ($hideAdmin == '1' && $isAdmin) { return; // 如果是管理员且开启屏蔽,则不记录 } // 检查IP是否在指定时间内已记录 $db = Typecho_Db::get(); $table = $db->getPrefix() . self::$_table; $ipInterval = isset($options->ip_interval) ? intval($options->ip_interval) : 24; $checkSql = $db->select('id') ->from($table) ->where('cid = ?', $cid) ->where('ip = ?', $ip) ->where('created > ?', time() - ($ipInterval * 3600)) ->limit(1); $check = $db->fetchRow($checkSql); if (!$check) { // 获取当前用户信息 $uid = $user->hasLogin() ? $user->uid : 0; $username = $user->hasLogin() ? $user->screenName : '访客'; $email = $user->hasLogin() ? $user->mail : ''; $website = $user->hasLogin() ? $user->url : ''; // 获取头像 $avatar = ''; if ($email) { $avatar = 'https://cravatar.cn/avatar/' . md5(strtolower(trim($email))) . '?s=200&d=identicon'; } else { // 为访客生成随机头像 $avatar = 'https://cravatar.cn/avatar/' . md5($ip) . '?s=200&d=identicon'; } // 记录阅读者 $insert = $db->insert($table)->rows(array( 'cid' => $cid, 'uid' => $uid, 'username' => $username, 'email' => $email, 'website' => $website, 'avatar' => $avatar, 'ip' => $ip, 'created' => time() )); $db->query($insert); } // 输出资源文件(只输出一次) if (!self::$_assetsOutput) { echo ''; echo ''; echo ''; self::$_assetsOutput = true; } } /** * 前端调用函数 */ public static function show() { $archive = Typecho_Widget::widget('Widget_Archive'); if (!$archive->is('single')) { return ''; } $options = Helper::options()->plugin('WhoReadThis'); if (!$options || $options->enable == '0') { return ''; } $cid = $archive->cid; $db = Typecho_Db::get(); $table = $db->getPrefix() . self::$_table; // 构建查询 $select = $db->select()->from($table)->where('cid = ?', $cid); // 如果开启了屏蔽管理员,排除管理员记录 $hideAdmin = isset($options->hide_admin) ? $options->hide_admin : '0'; if ($hideAdmin == '1') { // 获取所有管理员ID $adminUsers = $db->fetchAll($db->select('uid')->from('table.users')->where('group = ?', 'administrator')); if ($adminUsers) { $adminIds = array_column($adminUsers, 'uid'); if (!empty($adminIds)) { $select->where('uid NOT IN ?', $adminIds); } } } $mode = isset($options->mode) ? $options->mode : 'all'; switch ($mode) { case 'login': $select->where('uid > 0'); break; case 'recent': $select->where('created > ?', time() - 86400); // 24小时内 break; } $select->order('id', Typecho_Db::SORT_DESC); $limit = isset($options->limit) ? intval($options->limit) : 20; if ($limit > 0) { $select->limit($limit); } try { $readers = $db->fetchAll($select); } catch (Exception $e) { return ''; } // 如果没有阅读者数据,返回空字符串(不显示任何内容) if (empty($readers)) { return ''; } $usersHtml = ''; $uniqueReaders = array(); foreach ($readers as $reader) { // 去重,同个用户只显示一次 $key = $reader['uid'] ?: $reader['ip']; if (isset($uniqueReaders[$key])) { continue; } $uniqueReaders[$key] = true; $avatar = $reader['avatar'] ?: 'https://cravatar.cn/avatar/0?s=200&d=identicon'; $username = htmlspecialchars($reader['username']); $link = !empty($reader['website']) ? $reader['website'] : '#'; $usersHtml .= '
  • ' . $username . '
  • '; } $count = count($uniqueReaders); $defaultOpen = isset($options->default_open) ? $options->default_open : '0'; $isOpen = $defaultOpen == '1' ? ' expanded' : ' collapsed'; $title = isset($options->title) ? $options->title : '谁读了本文'; $avatarSize = isset($options->avatar_size) ? intval($options->avatar_size) : 40; // 生成唯一的容器ID $containerId = 'whoreadthis-' . $cid; $html = '

    ' . htmlspecialchars($title) . '' . $count . '人

      ' . $usersHtml . '
    '; return $html; } /** * 获取插件CSS样式(包含深色模式) */ private static function getPluginStyles() { return ' /* WhoReadThis 插件专用样式 - 包含深色模式 */ .whoreadthis-container { border-radius: 10px; overflow: hidden; position: relative; z-index: 10; margin: 0 0 30px 0; clear: both; box-sizing: border-box; } /* 标题区域 */ .whoreadthis-container .whoreadthis-header-section { padding: 18px 24px; background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); position: relative; z-index: 20; box-sizing: border-box; } /* 深色模式标题区域 */ body.dark .whoreadthis-container .whoreadthis-header-section, body.dark-mode .whoreadthis-container .whoreadthis-header-section, body.theme-dark .whoreadthis-container .whoreadthis-header-section, .dark .whoreadthis-container .whoreadthis-header-section, .dark-mode .whoreadthis-container .whoreadthis-header-section { background: rgb(10 12 25 / 1); color:rgb(156 163 175 / var(--tw-text-opacity))!important; } /* 标题文字 */ .whoreadthis-container .whoreadthis-header { margin: 0; font-size: 18px; color: #fff; display: flex; justify-content: space-between; align-items: center; cursor: pointer; user-select: none; font-weight: 600; position: relative; z-index: 25; box-sizing: border-box; } .dark .whoreadthis-container .whoreadthis-toggle-btn {color: rgb(156 163 175 / var(--tw-text-opacity))!important;} .dark .whoreadthis-count-badge {color: rgb(156 163 175 / var(--tw-text-opacity))!important;} /* 深色模式标题文字 */ body.dark .whoreadthis-container .whoreadthis-header h3, body.dark-mode .whoreadthis-container .whoreadthis-header h3, body.theme-dark .whoreadthis-container .whoreadthis-header h3, .dark .whoreadthis-container .whoreadthis-header h3, .dark-mode .whoreadthis-container .whoreadthis-header h3 { color: rgb(156 163 175 / var(--tw-text-opacity))!important; } /* 计数徽章 */ .whoreadthis-container .whoreadthis-count-badge { background-color: rgba(255, 255, 255, 0.2); color: white; padding: 4px 8px; border-radius: 12px; font-size: 13px; margin-left: 8px; display: inline-block; vertical-align: middle; } /* 深色模式计数徽章 */ body.dark .whoreadthis-container .whoreadthis-count-badge, body.dark-mode .whoreadthis-container .whoreadthis-count-badge, body.theme-dark .whoreadthis-container .whoreadthis-count-badge, .dark .whoreadthis-container .whoreadthis-count-badge, .dark-mode .whoreadthis-container .whoreadthis-count-badge { background-color: rgba(255, 255, 255, 0.1); color: #e2e8f0; } /* 切换按钮 */ .whoreadthis-container .whoreadthis-toggle-btn { font-size: 0.8em; color: #fff; display: flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 4px; transition: all 0.3s ease; font-weight: normal; border: none; cursor: pointer; position: relative; z-index: 30; background: transparent; box-sizing: border-box; } .whoreadthis-container .whoreadthis-toggle-btn:hover { background: rgba(255, 255, 255, 0.2); } .whoreadthis-container .whoreadthis-toggle-btn .toggle-arrow { font-size: 14px; line-height: 1; transition: transform 0.3s ease; display: inline-block; } .whoreadthis-container .whoreadthis-toggle-btn.expanded .toggle-arrow { transform: rotate(180deg); } /* 内容区域 */ .whoreadthis-container .whoreadthis-content-section { padding: 0 24px; background: #f9fafb; border-top: none; border-radius: 0 0 10px 10px; position: relative; z-index: 15; box-sizing: border-box; } /* 深色模式内容区域 */ body.dark .whoreadthis-container .whoreadthis-content-section, body.dark-mode .whoreadthis-container .whoreadthis-content-section, body.theme-dark .whoreadthis-container .whoreadthis-content-section, .dark .whoreadthis-container .whoreadthis-content-section, .dark-mode .whoreadthis-container .whoreadthis-content-section { background: rgb(15 23 42 / 1); } /* 项目包装器 */ .whoreadthis-container .whoreadthis-items-wrapper { transition: all 0.3s ease; overflow: hidden; position: relative; z-index: 15; box-sizing: border-box; } .whoreadthis-container .whoreadthis-items-wrapper.collapsed { max-height: 0; opacity: 0; visibility: hidden; padding: 0; } .whoreadthis-container .whoreadthis-items-wrapper.expanded { max-height: 5000px; opacity: 1; visibility: visible; padding: 20px 0; } /* 阅读者列表 */ .whoreadthis-container .whoreadthis-users { display: flex; flex-wrap: wrap; gap: 12px; margin: 0; padding: 0; list-style: none; position: relative; z-index: 40; box-sizing: border-box; } .whoreadthis-container .whoreadthis-user { position: relative; z-index: 45; box-sizing: border-box; } /* 头像包装器 */ .whoreadthis-container .whoreadthis-avatar-wrapper { position: relative; display: block; z-index: 50; text-decoration: none; box-sizing: border-box; } /* 头像样式 */ .whoreadthis-container .whoreadthis-avatar { width: 40px; height: 40px; border-radius: 50%; overflow: hidden; cursor: pointer; display: block; position: relative; z-index: 55; transition: transform 0.3s ease; box-sizing: border-box; } .whoreadthis-container .whoreadthis-avatar:hover { transform: scale(1.05); } .whoreadthis-container .whoreadthis-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; padding: 2px; border: 2px solid #f15a22; display: block; transition: transform 0.4s ease-out; box-sizing: border-box; } .whoreadthis-container .whoreadthis-avatar img:hover { transform: rotateZ(360deg); } /* 空状态 */ .whoreadthis-container .whoreadthis-empty { text-align: center; color: #95a5a6; font-style: italic; padding: 20px; } /* 响应式设计 */ @media (max-width: 768px) { .whoreadthis-container .whoreadthis-header-section, .whoreadthis-container .whoreadthis-content-section { padding: 14px 18px; } .whoreadthis-container .whoreadthis-header { font-size: 16px; } .whoreadthis-container .whoreadthis-users { gap: 8px; } } @media (max-width: 480px) { .whoreadthis-container .whoreadthis-header-section, .whoreadthis-container .whoreadthis-content-section { padding: 12px 15px; } .whoreadthis-container .whoreadthis-header { font-size: 14px; } .whoreadthis-container .whoreadthis-count-badge { font-size: 11px; padding: 3px 6px; } .whoreadthis-container .whoreadthis-users { gap: 6px; justify-content: center; } } /* 动画效果 */ @keyframes whoreadthisFadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .whoreadthis-container .whoreadthis-items-wrapper.expanded .whoreadthis-user { animation: whoreadthisFadeIn 0.3s ease forwards; } /* 为每个头像添加延迟动画 */ .whoreadthis-container .whoreadthis-items-wrapper.expanded .whoreadthis-user:nth-child(1) { animation-delay: 0.05s; } .whoreadthis-container .whoreadthis-items-wrapper.expanded .whoreadthis-user:nth-child(2) { animation-delay: 0.1s; } .whoreadthis-container .whoreadthis-items-wrapper.expanded .whoreadthis-user:nth-child(3) { animation-delay: 0.15s; } .whoreadthis-container .whoreadthis-items-wrapper.expanded .whoreadthis-user:nth-child(4) { animation-delay: 0.2s; } .whoreadthis-container .whoreadthis-items-wrapper.expanded .whoreadthis-user:nth-child(5) { animation-delay: 0.25s; } .whoreadthis-container .whoreadthis-items-wrapper.expanded .whoreadthis-user:nth-child(6) { animation-delay: 0.3s; } .whoreadthis-container .whoreadthis-items-wrapper.expanded .whoreadthis-user:nth-child(7) { animation-delay: 0.35s; } .whoreadthis-container .whoreadthis-items-wrapper.expanded .whoreadthis-user:nth-child(8) { animation-delay: 0.4s; } .whoreadthis-container .whoreadthis-items-wrapper.expanded .whoreadthis-user:nth-child(9) { animation-delay: 0.45s; } .whoreadthis-container .whoreadthis-items-wrapper.expanded .whoreadthis-user:nth-child(10) { animation-delay: 0.5s; } /* 头像大小动态设置 */ .whoreadthis-container[data-avatar-size] .whoreadthis-avatar { width: var(--whoreadthis-avatar-size, 40px); height: var(--whoreadthis-avatar-size, 40px); } .whoreadthis-container[data-avatar-size] .whoreadthis-avatar img { width: var(--whoreadthis-avatar-size, 40px); height: var(--whoreadthis-avatar-size, 40px); } '; } /** * 获取插件JavaScript */ private static function getPluginScripts() { return ' (function() { function initWhoReadThis() { const containers = document.querySelectorAll(".whoreadthis-container"); containers.forEach(container => { if (container.dataset.whoReadThisInitialized === "true") { return; } container.dataset.whoReadThisInitialized = "true"; // 设置头像大小 const avatarSize = container.getAttribute("data-avatar-size"); if (avatarSize && avatarSize !== "40") { container.style.setProperty("--whoreadthis-avatar-size", avatarSize + "px"); } const header = container.querySelector(".whoreadthis-header"); const itemsWrapper = container.querySelector(".whoreadthis-items-wrapper"); const toggleBtn = container.querySelector(".whoreadthis-toggle-btn"); if (!header || !itemsWrapper || !toggleBtn) return; // 绑定标题点击事件 header.addEventListener("click", function(e) { if (e.target.closest(".whoreadthis-toggle-btn")) { return; } toggleWhoReadThis(container); }); // 绑定按钮点击事件 toggleBtn.addEventListener("click", function(e) { e.stopPropagation(); toggleWhoReadThis(container); }); // 处理头像链接点击 const avatarWrappers = container.querySelectorAll(".whoreadthis-avatar-wrapper"); avatarWrappers.forEach(wrapper => { const link = wrapper.getAttribute("href"); if (link && link !== "#") { wrapper.addEventListener("click", function(e) { e.stopPropagation(); window.open(link, "_blank"); }); } }); // 锚点跳转支持 const containerId = container.id; if (window.location.hash === "#whoreadthis" || window.location.hash === "#" + containerId) { setTimeout(function() { container.scrollIntoView({ behavior: "smooth" }); expandWhoReadThis(container); }, 300); } }); } function toggleWhoReadThis(container) { const itemsWrapper = container.querySelector(".whoreadthis-items-wrapper"); const toggleBtn = container.querySelector(".whoreadthis-toggle-btn"); const arrow = toggleBtn.querySelector(".toggle-arrow"); if (!itemsWrapper || !toggleBtn || !arrow) return; if (itemsWrapper.classList.contains("collapsed")) { expandWhoReadThis(container); } else { collapseWhoReadThis(container); } } function expandWhoReadThis(container) { const itemsWrapper = container.querySelector(".whoreadthis-items-wrapper"); const toggleBtn = container.querySelector(".whoreadthis-toggle-btn"); const arrow = toggleBtn.querySelector(".toggle-arrow"); if (itemsWrapper && toggleBtn && arrow) { itemsWrapper.className = "whoreadthis-items-wrapper expanded"; toggleBtn.className = "whoreadthis-toggle-btn expanded"; arrow.textContent = "↑"; } } function collapseWhoReadThis(container) { const itemsWrapper = container.querySelector(".whoreadthis-items-wrapper"); const toggleBtn = container.querySelector(".whoreadthis-toggle-btn"); const arrow = toggleBtn.querySelector(".toggle-arrow"); if (itemsWrapper && toggleBtn && arrow) { itemsWrapper.className = "whoreadthis-items-wrapper collapsed"; toggleBtn.className = "whoreadthis-toggle-btn collapsed"; arrow.textContent = "↓"; } } // 初始化 if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", function() { setTimeout(initWhoReadThis, 100); }); } else { setTimeout(initWhoReadThis, 100); } // 暴露公共API window.WhoReadThis = { init: initWhoReadThis, expand: function(containerId) { const container = document.getElementById(containerId); if (container) { expandWhoReadThis(container); } }, collapse: function(containerId) { const container = document.getElementById(containerId); if (container) { collapseWhoReadThis(container); } }, toggle: function(containerId) { const container = document.getElementById(containerId); if (container) { toggleWhoReadThis(container); } } }; })(); '; } /** * 获取客户端IP */ private static function getClientIP() { $ip = ''; // 检查HTTP头 $headers = array( 'HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR' ); foreach ($headers as $header) { if (isset($_SERVER[$header]) && !empty($_SERVER[$header]) && strcasecmp($_SERVER[$header], 'unknown')) { $ip = $_SERVER[$header]; break; } } // 处理多个IP的情况 if (strpos($ip, ',') !== false) { $ips = explode(',', $ip); $ip = trim($ips[0]); } // 验证IP格式 if (!filter_var($ip, FILTER_VALIDATE_IP)) { $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } return $ip; } } /** * 前端调用助手函数 */ function showWhoReadThis() { echo WhoReadThis_Plugin::show(); }