738 lines
24 KiB
PHP
738 lines
24 KiB
PHP
<?php
|
||
/**
|
||
* 前端评论区用户信息卡片
|
||
*
|
||
* @package UserCard
|
||
* @author 石头厝
|
||
* @version 3.2.0
|
||
* @link https://www.shitoucuo.com
|
||
*/
|
||
|
||
if (!defined('__TYPECHO_ROOT_DIR__')) exit;
|
||
|
||
class UserCard_Plugin implements Typecho_Plugin_Interface
|
||
{
|
||
/**
|
||
* 激活插件
|
||
*/
|
||
public static function activate()
|
||
{
|
||
// 添加数据库字段
|
||
$db = Typecho_Db::get();
|
||
$prefix = $db->getPrefix();
|
||
|
||
try {
|
||
$db->query("ALTER TABLE `{$prefix}users` ADD `user_feed` VARCHAR(255) DEFAULT NULL");
|
||
} catch (Exception $e) {
|
||
// 字段可能已存在
|
||
}
|
||
|
||
// 添加管理菜单
|
||
Helper::addPanel(3, 'UserCard/manage-users.php', 'RSS卡片', 'RSS卡片', 'administrator');
|
||
|
||
// 前端资源
|
||
Typecho_Plugin::factory('Widget_Archive')->header = array('UserCard_Plugin', 'addHeader');
|
||
|
||
return _t('用户卡片插件已激活');
|
||
}
|
||
|
||
/**
|
||
* 禁用插件
|
||
*/
|
||
public static function deactivate()
|
||
{
|
||
// 移除管理菜单
|
||
Helper::removePanel(3, 'UserCard/manage-users.php');
|
||
return _t('用户卡片插件已禁用');
|
||
}
|
||
|
||
/**
|
||
* 插件配置
|
||
*/
|
||
public static function config(Typecho_Widget_Helper_Form $form)
|
||
{
|
||
// 管理员评论是否显示卡片
|
||
$show_admin_card = new Typecho_Widget_Helper_Form_Element_Radio(
|
||
'show_admin_card',
|
||
array(
|
||
'1' => _t('显示'),
|
||
'0' => _t('不显示')
|
||
),
|
||
'1',
|
||
_t('管理员评论卡片'),
|
||
_t('是否在管理员发表的评论上显示用户卡片(普通用户不受此设置影响)')
|
||
);
|
||
$form->addInput($show_admin_card);
|
||
|
||
// RSS缓存时间
|
||
$cache_time = new Typecho_Widget_Helper_Form_Element_Text(
|
||
'cache_time',
|
||
NULL,
|
||
'3600',
|
||
_t('RSS缓存时间(秒)'),
|
||
_t('RSS数据缓存时间,建议设置为3600秒(1小时)')
|
||
);
|
||
$form->addInput($cache_time->addRule('required', _t('必须填写缓存时间'))->addRule('isInteger', _t('请输入整数')));
|
||
|
||
// 最多显示条数
|
||
$max_items = new Typecho_Widget_Helper_Form_Element_Text(
|
||
'max_items',
|
||
NULL,
|
||
'5',
|
||
_t('最多显示文章数'),
|
||
_t('用户卡片中最多显示的RSS文章数量')
|
||
);
|
||
$form->addInput($max_items->addRule('required', _t('必须填写显示条数'))->addRule('isInteger', _t('请输入整数')));
|
||
|
||
// RSS超时时间
|
||
$timeout = new Typecho_Widget_Helper_Form_Element_Text(
|
||
'timeout',
|
||
NULL,
|
||
'10',
|
||
_t('RSS请求超时时间(秒)'),
|
||
_t('获取RSS数据时的超时时间')
|
||
);
|
||
$form->addInput($timeout->addRule('required', _t('必须填写超时时间'))->addRule('isInteger', _t('请输入整数')));
|
||
|
||
// 添加卡片显示延迟
|
||
$hover_delay = new Typecho_Widget_Helper_Form_Element_Text(
|
||
'hover_delay',
|
||
NULL,
|
||
'200',
|
||
_t('卡片显示延迟(毫秒)'),
|
||
_t('鼠标悬停后显示卡片的延迟时间,防止误触发')
|
||
);
|
||
$form->addInput($hover_delay->addRule('required', _t('必须填写延迟时间'))->addRule('isInteger', _t('请输入整数')));
|
||
}
|
||
|
||
/**
|
||
* 个人配置 - 保持为空
|
||
*/
|
||
public static function personalConfig(Typecho_Widget_Helper_Form $form)
|
||
{
|
||
// 不在个人页面显示RSS地址字段
|
||
}
|
||
|
||
/**
|
||
* 添加头部资源
|
||
*/
|
||
public static function addHeader()
|
||
{
|
||
// 只在文章页面加载
|
||
if (!Typecho_Widget::widget('Widget_Archive')->is('single')) {
|
||
return;
|
||
}
|
||
|
||
// 获取配置
|
||
$options = Typecho_Widget::widget('Widget_Options')->plugin('UserCard');
|
||
$hover_delay = isset($options->hover_delay) ? intval($options->hover_delay) : 200;
|
||
|
||
echo '<style>
|
||
/* 用户卡片样式 */
|
||
.comment-author.usercard-wrapper {
|
||
position: relative;
|
||
display: inline-block;
|
||
}
|
||
|
||
.comment-author.usercard-wrapper a {
|
||
color: #4a90e2;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.comment-author.usercard-wrapper a:hover {
|
||
color: #2c6db5;
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.usercard-popup {
|
||
display: none;
|
||
position: absolute;
|
||
z-index: 9999000;
|
||
width: 250px;
|
||
background: white;
|
||
border-radius: 10px;
|
||
box-shadow: 0 5px 20px rgba(0,0,0,0.15);
|
||
left: 0;
|
||
top: 100%;
|
||
margin-top: 5px;
|
||
opacity: 0;
|
||
transform: translateY(-10px);
|
||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||
}
|
||
|
||
.usercard-popup.active {
|
||
display: block;
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.usercard-header {
|
||
padding: 8px 15px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border-radius: 8px 8px 0 0;
|
||
text-align:center;
|
||
overflow:hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.dark .usercard-header h3{
|
||
|
||
}
|
||
}
|
||
.usercard-header h3 {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.usercard-body {
|
||
padding: 15px;
|
||
}
|
||
|
||
.usercard-info p {
|
||
margin: 5px 0;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.usercard-info strong {
|
||
color: #666;
|
||
min-width: 80px;
|
||
display: inline-block;
|
||
}
|
||
|
||
.usercard-info a {
|
||
color: #4a90e2;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.usercard-info a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.usercard-rss h4 {
|
||
margin: 5px 0 5px 0;
|
||
color: #333;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
padding-bottom: 8px;
|
||
border-bottom: 2px solid #4a90e2;
|
||
}
|
||
|
||
.usercard-rss-list {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
}
|
||
|
||
.usercard-rss-list li {
|
||
padding: 5px 0;
|
||
}
|
||
|
||
.usercard-rss-list li:last-child {
|
||
border-bottom: none;
|
||
padding-bottom:none;
|
||
}
|
||
|
||
.usercard-rss-list a {
|
||
color: #2c3e50;
|
||
text-decoration: none;
|
||
font-size: 13px;
|
||
display: block;
|
||
width: 230px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
transition: color 0.3s ease;
|
||
}
|
||
|
||
.usercard-rss-list a:hover {
|
||
color: #4a90e2;
|
||
}
|
||
|
||
.usercard-rss-date {
|
||
font-size: 12px;
|
||
color: #999;
|
||
display: block;
|
||
margin-top: 3px;
|
||
}
|
||
|
||
/* 黑暗模式样式 */
|
||
.dark .usercard-popup {
|
||
background: rgb(10 12 25 / 1);
|
||
border: 1px solid #333;
|
||
box-shadow: 0 5px 20px rgba(0,0,0,0.3);
|
||
}
|
||
|
||
.dark .usercard-header {
|
||
background: #1d1d1e;
|
||
border-bottom: 1px solid #333;
|
||
}
|
||
|
||
.dark .usercard-info p {
|
||
color: rgb(156 163 175 / var(--tw-text-opacity));
|
||
}
|
||
|
||
.dark .usercard-info strong {
|
||
color: #a0aec0;
|
||
}
|
||
|
||
.dark .usercard-info a {
|
||
color: #90cdf4;
|
||
}
|
||
|
||
.dark .usercard-info a:hover {
|
||
color: #63b3ed;
|
||
}
|
||
|
||
.dark .usercard-rss h4 {
|
||
color: rgb(156 163 175 / var(--tw-text-opacity));
|
||
border-bottom-color: #333;
|
||
}
|
||
|
||
.dark .usercard-rss-list a {
|
||
color: #cbd5e0;
|
||
}
|
||
|
||
.dark .usercard-rss-list a:hover {
|
||
color: #90cdf4;
|
||
}
|
||
|
||
.dark .usercard-rss-date {
|
||
color: #a0aec0;
|
||
}
|
||
|
||
/* 响应式设计 */
|
||
@media (max-width: 768px) {
|
||
.usercard-popup {
|
||
width: 220px;
|
||
}
|
||
|
||
.usercard-body {
|
||
padding: 12px;
|
||
}
|
||
|
||
.usercard-rss-list a {
|
||
width: 200px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.usercard-popup {
|
||
width: 200px;
|
||
}
|
||
|
||
.usercard-body {
|
||
padding: 10px;
|
||
}
|
||
|
||
.usercard-info p {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.usercard-rss-list a {
|
||
width: 180px;
|
||
}
|
||
|
||
.usercard-rss h4 {
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
/* 卡片内部边框 */
|
||
.usercard-rss h4 {
|
||
position: relative;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
/* RSS文章项悬停效果 */
|
||
.usercard-rss-list li {
|
||
transition: transform 0.2s ease;
|
||
}
|
||
|
||
.usercard-rss-list li:hover {
|
||
transform: translateX(5px);
|
||
}
|
||
</style>';
|
||
|
||
echo '<script>
|
||
document.addEventListener("DOMContentLoaded", function() {
|
||
var hoverDelay = ' . $hover_delay . ';
|
||
var hideDelay = 150;
|
||
var showTimer = null;
|
||
var hideTimer = null;
|
||
var activeCard = null;
|
||
var isMouseOverCard = false;
|
||
|
||
// 初始化所有卡片
|
||
var initCards = function() {
|
||
var wrappers = document.querySelectorAll(".usercard-wrapper");
|
||
|
||
wrappers.forEach(function(wrapper) {
|
||
var popup = wrapper.querySelector(".usercard-popup");
|
||
if (!popup) return;
|
||
|
||
var userLink = wrapper.querySelector("a");
|
||
|
||
// 鼠标进入用户链接
|
||
userLink.addEventListener("mouseenter", function(e) {
|
||
clearTimeout(hideTimer);
|
||
clearTimeout(showTimer);
|
||
|
||
// 如果已经有激活的卡片且不是当前卡片,先隐藏
|
||
if (activeCard && activeCard !== wrapper) {
|
||
var otherPopup = activeCard.querySelector(".usercard-popup");
|
||
if (otherPopup) {
|
||
otherPopup.classList.remove("active");
|
||
isMouseOverCard = false;
|
||
}
|
||
}
|
||
|
||
showTimer = setTimeout(function() {
|
||
popup.classList.add("active");
|
||
activeCard = wrapper;
|
||
|
||
// 检查卡片位置,防止溢出屏幕
|
||
adjustPopupPosition(popup);
|
||
}, hoverDelay);
|
||
});
|
||
|
||
// 鼠标离开用户链接
|
||
userLink.addEventListener("mouseleave", function(e) {
|
||
clearTimeout(showTimer);
|
||
|
||
// 设置一个小的延迟,检查鼠标是否移动到了卡片上
|
||
setTimeout(function() {
|
||
if (!isMouseOverCard) {
|
||
hideTimer = setTimeout(function() {
|
||
if (!isMouseOverCard) {
|
||
popup.classList.remove("active");
|
||
if (activeCard === wrapper) {
|
||
activeCard = null;
|
||
}
|
||
}
|
||
}, hideDelay);
|
||
}
|
||
}, 50);
|
||
});
|
||
|
||
// 鼠标进入卡片
|
||
popup.addEventListener("mouseenter", function(e) {
|
||
isMouseOverCard = true;
|
||
clearTimeout(hideTimer);
|
||
});
|
||
|
||
// 鼠标离开卡片
|
||
popup.addEventListener("mouseleave", function(e) {
|
||
isMouseOverCard = false;
|
||
hideTimer = setTimeout(function() {
|
||
popup.classList.remove("active");
|
||
if (activeCard === wrapper) {
|
||
activeCard = null;
|
||
}
|
||
}, hideDelay);
|
||
});
|
||
|
||
// 卡片内的链接点击
|
||
popup.addEventListener("click", function(e) {
|
||
if (e.target.tagName === "A") {
|
||
// 让链接正常跳转
|
||
e.stopPropagation();
|
||
}
|
||
});
|
||
});
|
||
};
|
||
|
||
// 调整卡片位置,防止溢出屏幕
|
||
function adjustPopupPosition(popup) {
|
||
var rect = popup.getBoundingClientRect();
|
||
var viewportWidth = window.innerWidth;
|
||
|
||
// 如果卡片右侧超出屏幕,向左移动
|
||
if (rect.right > viewportWidth) {
|
||
var overflow = rect.right - viewportWidth;
|
||
popup.style.left = -overflow + "px";
|
||
} else {
|
||
popup.style.left = "0";
|
||
}
|
||
}
|
||
|
||
// 窗口调整大小时重新定位卡片
|
||
window.addEventListener("resize", function() {
|
||
var activePopup = document.querySelector(".usercard-popup.active");
|
||
if (activePopup) {
|
||
adjustPopupPosition(activePopup);
|
||
}
|
||
});
|
||
|
||
// 初始加载
|
||
setTimeout(initCards, 100);
|
||
|
||
// 监听动态加载的评论
|
||
var observer = new MutationObserver(function(mutations) {
|
||
for (var mutation of mutations) {
|
||
if (mutation.addedNodes.length > 0) {
|
||
setTimeout(initCards, 100);
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
observer.observe(document.body, {
|
||
childList: true,
|
||
subtree: true
|
||
});
|
||
|
||
// 点击页面其他地方关闭卡片
|
||
document.addEventListener("click", function(e) {
|
||
if (!e.target.closest(".usercard-wrapper")) {
|
||
var cards = document.querySelectorAll(".usercard-popup.active");
|
||
cards.forEach(function(card) {
|
||
card.classList.remove("active");
|
||
});
|
||
activeCard = null;
|
||
isMouseOverCard = false;
|
||
}
|
||
});
|
||
});
|
||
</script>';
|
||
}
|
||
|
||
/**
|
||
* 生成用户卡片(供主题调用)
|
||
*/
|
||
public static function render($comments)
|
||
{
|
||
if (!$comments || $comments->authorId == 0) {
|
||
// 游客
|
||
if ($comments->url) {
|
||
return '<a href="' . $comments->url . '" rel="external nofollow" target="_blank">' . $comments->author . '</a>';
|
||
} else {
|
||
return $comments->author;
|
||
}
|
||
}
|
||
|
||
try {
|
||
$db = Typecho_Db::get();
|
||
$user = $db->fetchRow($db->select()
|
||
->from('table.users')
|
||
->where('uid = ?', $comments->authorId));
|
||
|
||
if (!$user) {
|
||
return $comments->author;
|
||
}
|
||
|
||
// 获取插件配置
|
||
$options = Typecho_Widget::widget('Widget_Options')->plugin('UserCard');
|
||
$show_admin_card = isset($options->show_admin_card) ? intval($options->show_admin_card) : 1;
|
||
|
||
// 检查用户是否为管理员
|
||
$is_admin = ($user['group'] == 'administrator');
|
||
|
||
// 如果是管理员且配置为不显示卡片,则返回普通链接
|
||
if ($is_admin && !$show_admin_card) {
|
||
if (!empty($user['url'])) {
|
||
return '<a href="' . $user['url'] . '" target="_blank" rel="nofollow">' . $comments->author . '</a>';
|
||
} else {
|
||
return $comments->author;
|
||
}
|
||
}
|
||
|
||
// 评论数
|
||
$commentCount = $db->fetchObject($db->select('COUNT(*) as cnt')
|
||
->from('table.comments')
|
||
->where('authorId = ?', $user['uid'])
|
||
->where('status = ?', 'approved'))->cnt;
|
||
|
||
// RSS文章
|
||
$rssItems = array();
|
||
if (!empty($user['user_feed'])) {
|
||
$rssItems = self::getRssItems($user['user_feed']);
|
||
}
|
||
|
||
// 基本信息
|
||
$displayName = !empty($user['screenName']) ? $user['screenName'] : $user['name'];
|
||
$userUrl = !empty($user['url']) ? $user['url'] : '#';
|
||
|
||
// 构建HTML
|
||
$html = '<span class="comment-author usercard-wrapper">';
|
||
$html .= '<a href="' . $userUrl . '" target="_blank" rel="nofollow">' . $displayName . '</a>';
|
||
|
||
// 卡片
|
||
$html .= '<div class="usercard-popup">';
|
||
$html .= '<div class="usercard-header">';
|
||
$html .= '<h3>' . $displayName . '</h3>';
|
||
$html .= '</div>';
|
||
|
||
$html .= '<div class="usercard-body">';
|
||
$html .= '<div class="usercard-info">';
|
||
|
||
// 注册时间
|
||
$html .= '<p><strong>' . _t('注册时间:') . '</strong>' . date('Y-m-d', $user['created']) . '</p>';
|
||
|
||
// 评论数
|
||
$html .= '<p><strong>' . _t('已评论数:') . '</strong>' . $commentCount . _t('条') . '</p>';
|
||
|
||
// 最后登录
|
||
if ($user['logged']) {
|
||
$html .= '<p><strong>' . _t('最后登录:') . '</strong>' . date('m-d H:i', $user['logged']) . '</p>';
|
||
}
|
||
|
||
$html .= '</div>';
|
||
|
||
// RSS文章
|
||
if (!empty($rssItems) && is_array($rssItems) && count($rssItems) > 0) {
|
||
// 获取配置中的显示条数
|
||
$max_display_items = isset($options->max_items) ? intval($options->max_items) : 5;
|
||
|
||
// 限制显示的条数
|
||
$displayItems = array_slice($rssItems, 0, $max_display_items);
|
||
|
||
$html .= '<div class="usercard-rss">';
|
||
$html .= '<h4>' . _t('') . '</h4>';
|
||
$html .= '<ul class="usercard-rss-list">';
|
||
|
||
foreach ($displayItems as $item) {
|
||
$title = htmlspecialchars($item['title']);
|
||
if (mb_strlen($title, 'UTF-8') > 40) {
|
||
$title = mb_substr($title, 0, 40, 'UTF-8') . '...';
|
||
}
|
||
|
||
$html .= '<li>';
|
||
$html .= '<a href="' . $item['link'] . '" target="_blank" title="' . htmlspecialchars($item['title']) . '">' . $title . '</a>';
|
||
$html .= '<span class="usercard-rss-date">' . date('Y-m-d', $item['date']) . '</span>';
|
||
$html .= '</li>';
|
||
}
|
||
|
||
$html .= '</ul>';
|
||
$html .= '</div>';
|
||
}
|
||
|
||
$html .= '</div>';
|
||
$html .= '</div>';
|
||
$html .= '</span>';
|
||
|
||
return $html;
|
||
|
||
} catch (Exception $e) {
|
||
// 出错时返回简单链接
|
||
if ($comments->url) {
|
||
return '<a href="' . $comments->url . '" rel="external nofollow" target="_blank">' . $comments->author . '</a>';
|
||
} else {
|
||
return $comments->author;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取RSS文章
|
||
*/
|
||
private static function getRssItems($url)
|
||
{
|
||
if (empty($url)) {
|
||
return array();
|
||
}
|
||
|
||
// 检查缓存
|
||
$cacheKey = 'usercard_rss_' . md5($url);
|
||
$cache = self::getCache($cacheKey);
|
||
|
||
if ($cache !== false) {
|
||
return $cache;
|
||
}
|
||
|
||
// 获取配置
|
||
$options = Typecho_Widget::widget('Widget_Options')->plugin('UserCard');
|
||
$timeout = isset($options->timeout) ? intval($options->timeout) : 10;
|
||
|
||
// 固定从RSS获取最多10条数据保存到缓存
|
||
$fetch_max_items = 10;
|
||
|
||
// 获取RSS
|
||
$context = stream_context_create(array(
|
||
'http' => array('timeout' => $timeout),
|
||
'ssl' => array('verify_peer' => false)
|
||
));
|
||
|
||
$content = @file_get_contents($url, false, $context);
|
||
|
||
if (empty($content)) {
|
||
return array();
|
||
}
|
||
|
||
$xml = @simplexml_load_string($content);
|
||
if (!$xml) {
|
||
return array();
|
||
}
|
||
|
||
$items = array();
|
||
$counter = 0;
|
||
|
||
// RSS格式
|
||
if (isset($xml->channel->item)) {
|
||
foreach ($xml->channel->item as $item) {
|
||
if ($counter >= $fetch_max_items) break;
|
||
|
||
$title = trim((string)$item->title);
|
||
$link = trim((string)$item->link);
|
||
|
||
if ($title && $link) {
|
||
$pubDate = isset($item->pubDate) ? strtotime((string)$item->pubDate) : time();
|
||
|
||
$items[] = array(
|
||
'title' => $title,
|
||
'link' => $link,
|
||
'date' => $pubDate
|
||
);
|
||
$counter++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 设置缓存
|
||
$cacheTime = isset($options->cache_time) ? intval($options->cache_time) : 3600;
|
||
self::setCache($cacheKey, $items, $cacheTime);
|
||
|
||
return $items;
|
||
}
|
||
|
||
/**
|
||
* 获取缓存
|
||
*/
|
||
private static function getCache($key)
|
||
{
|
||
$cacheFile = dirname(__FILE__) . '/cache/' . $key . '.json';
|
||
|
||
if (file_exists($cacheFile)) {
|
||
$data = json_decode(file_get_contents($cacheFile), true);
|
||
if ($data && isset($data['expire']) && $data['expire'] > time()) {
|
||
return $data['data'];
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 设置缓存
|
||
*/
|
||
private static function setCache($key, $data, $expire = 3600)
|
||
{
|
||
$cacheDir = dirname(__FILE__) . '/cache';
|
||
|
||
if (!is_dir($cacheDir)) {
|
||
@mkdir($cacheDir, 0777, true);
|
||
}
|
||
|
||
$cacheFile = $cacheDir . '/' . $key . '.json';
|
||
$cacheData = array(
|
||
'data' => $data,
|
||
'expire' => time() + $expire
|
||
);
|
||
|
||
@file_put_contents($cacheFile, json_encode($cacheData, JSON_UNESCAPED_UNICODE));
|
||
}
|
||
} |