Files
UserCard/Plugin.php
2026-02-23 21:07:09 +08:00

738 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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));
}
}