895 lines
31 KiB
PHP
895 lines
31 KiB
PHP
|
|
<?php
|
|||
|
|
/**
|
|||
|
|
* 谁读了本文
|
|||
|
|
*
|
|||
|
|
* @package WhoReadThis
|
|||
|
|
* @author 石头厝
|
|||
|
|
* @version 1.0.8
|
|||
|
|
* @link https://www.shitoucuo.com
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
if (!defined('__TYPECHO_ROOT_DIR__')) exit;
|
|||
|
|
|
|||
|
|
class WhoReadThis_Plugin implements Typecho_Plugin_Interface
|
|||
|
|
{
|
|||
|
|
// 数据库表名
|
|||
|
|
private static $_table = 'whoreadthis';
|
|||
|
|
|
|||
|
|
// 是否已输出资源
|
|||
|
|
private static $_assetsOutput = false;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 激活插件
|
|||
|
|
*/
|
|||
|
|
public static function activate()
|
|||
|
|
{
|
|||
|
|
// 创建数据表记录阅读者
|
|||
|
|
$db = Typecho_Db::get();
|
|||
|
|
$prefix = $db->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 '<!-- WhoReadThis Plugin Assets -->';
|
|||
|
|
echo '<style>';
|
|||
|
|
echo self::getPluginStyles();
|
|||
|
|
echo '</style>';
|
|||
|
|
|
|||
|
|
echo '<script>';
|
|||
|
|
echo self::getPluginScripts();
|
|||
|
|
echo '</script>';
|
|||
|
|
|
|||
|
|
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 .= '<li class="whoreadthis-user">
|
|||
|
|
<a href="' . htmlspecialchars($link) . '" target="_blank" class="whoreadthis-avatar-wrapper"
|
|||
|
|
title="' . $username . '" rel="noopener noreferrer">
|
|||
|
|
<div class="whoreadthis-avatar">
|
|||
|
|
<img src="' . htmlspecialchars($avatar) . '" alt="' . $username . '" loading="lazy">
|
|||
|
|
</div>
|
|||
|
|
</a>
|
|||
|
|
</li>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$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 = '<div class="whoreadthis-container" id="' . $containerId . '" data-avatar-size="' . $avatarSize . '">
|
|||
|
|
<div class="whoreadthis-header-section">
|
|||
|
|
<div class="whoreadthis-header">
|
|||
|
|
<h3>' . htmlspecialchars($title) . '<span class="whoreadthis-count-badge">' . $count . '人</span></h3>
|
|||
|
|
<button class="whoreadthis-toggle-btn' . ($isOpen ? ' expanded' : ' collapsed') . '">
|
|||
|
|
<span class="toggle-arrow">↓</span>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="whoreadthis-content-section">
|
|||
|
|
<div class="whoreadthis-items-wrapper' . $isOpen . '">
|
|||
|
|
<ul class="whoreadthis-users">' . $usersHtml . '</ul>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>';
|
|||
|
|
|
|||
|
|
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();
|
|||
|
|
}
|