Files
UserCard/Plugin.php

738 lines
24 KiB
PHP
Raw Normal View History

2026-02-23 21:07:09 +08:00
<?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));
}
}