Files
YearlyData/Panel.php

1430 lines
47 KiB
PHP
Raw Permalink Normal View History

2026-02-23 21:11:30 +08:00
<?php
include 'common.php';
include 'header.php';
include 'menu.php';
// 添加 Helper 类的使用声明
use Utils\Helper;
$user = \Typecho\Widget::widget('Widget_User');
if (!$user->pass('administrator', true)) {
die('无权限访问');
}
$options = \Widget\Options::alloc();
// 从数据库读取路由设置
$db = \Typecho\Db::get();
$settingQuery = $db->select()->from('table.options')->where('name = ?', 'routingTable');
$settingRow = $db->fetchRow($settingQuery);
$routingTable = @unserialize($settingRow['value']);
// post 路由的 key 可能是 'post' 或 'archives'
$postUrlRule = null;
if (isset($routingTable[0]['post']['url'])) {
$postUrlRule = $routingTable[0]['post']['url'];
} elseif (isset($routingTable['post']['url'])) {
$postUrlRule = $routingTable['post']['url'];
} elseif (isset($routingTable[0]['archives']['url'])) {
$postUrlRule = $routingTable[0]['archives']['url'];
} elseif (isset($routingTable['archives']['url'])) {
$postUrlRule = $routingTable['archives']['url'];
}
if (empty($postUrlRule)) {
$postUrlRule = '/archives/{cid}/';
}
// 检测是否包含 .html 后缀(兼容 [cid:digital] 和 {cid} 两种格式)
if (strpos($postUrlRule, '.html') !== false) {
$postUrlFormat = $options->siteUrl . '%s.html';
} else {
$postUrlFormat = $options->siteUrl . '%s/';
}
// 标签地址格式 - 修正为 tag-slug.html
$tagUrlFormat = $options->siteUrl . 'tag-%s.html';
try {
$pluginConfig = $options->plugin('YearlyData');
$topLimit = isset($pluginConfig->topLimit) ? intval($pluginConfig->topLimit) : 10;
$includeDraft = isset($pluginConfig->includeDraft) ? $pluginConfig->includeDraft : '0';
$chartColor = isset($pluginConfig->chartColor) ? $pluginConfig->chartColor : '#667eea';
// 从数据库读取当前年份的目标设置
// 使用独立的配置存储键名为yearly_target_2026
$currentYear = isset($_GET['year']) ? intval($_GET['year']) : intval(date('Y'));
$targetsQuery = $db->select('value')->from('table.options')->where('name = ?', 'yearly_target_' . $currentYear);
$targetsRow = $db->fetchRow($targetsQuery);
if ($targetsRow && $targetsRow['value']) {
$targets = @unserialize($targetsRow['value']);
$targetPosts = isset($targets['posts']) ? intval($targets['posts']) : 0;
$targetComments = isset($targets['comments']) ? intval($targets['comments']) : 0;
$targetImages = isset($targets['images']) ? intval($targets['images']) : 0;
$targetWords = isset($targets['words']) ? intval($targets['words']) : 0;
} else {
// 如果没有找到该年份的目标设置,检查是否是当前年份且插件配置中有设置
if ($currentYear == date('Y')) {
$targetPosts = isset($pluginConfig->targetPosts) ? intval($pluginConfig->targetPosts) : 0;
$targetComments = isset($pluginConfig->targetComments) ? intval($pluginConfig->targetComments) : 0;
$targetImages = isset($pluginConfig->targetImages) ? intval($pluginConfig->targetImages) : 0;
$targetWords = isset($pluginConfig->targetWords) ? intval($pluginConfig->targetWords) : 0;
// 如果是当前年份且有设置目标,保存到数据库
if ($targetPosts > 0 || $targetComments > 0 || $targetImages > 0 || $targetWords > 0) {
$targetsData = serialize([
'posts' => $targetPosts,
'comments' => $targetComments,
'images' => $targetImages,
'words' => $targetWords
]);
// 检查是否已存在
$checkQuery = $db->select('value')->from('table.options')->where('name = ?', 'yearly_target_' . $currentYear);
$checkRow = $db->fetchRow($checkQuery);
if ($checkRow) {
// 更新
$db->query($db->update('table.options')->rows(['value' => $targetsData])->where('name = ?', 'yearly_target_' . $currentYear));
} else {
// 插入
$db->query($db->insert('table.options')->rows([
'name' => 'yearly_target_' . $currentYear,
'user' => 0,
'value' => $targetsData
]));
}
}
} else {
$targetPosts = 0;
$targetComments = 0;
$targetImages = 0;
$targetWords = 0;
}
}
} catch (Exception $e) {
$topLimit = 10;
$includeDraft = '0';
$chartColor = '#667eea';
$targetPosts = 0;
$targetComments = 0;
$targetImages = 0;
$targetWords = 0;
}
$currentYear = isset($_GET['year']) ? intval($_GET['year']) : intval(date('Y'));
// 获取可用年份
$yearsQuery = $db->select('DISTINCT FROM_UNIXTIME(created, "%Y") as year')
->from('table.contents')
->where('type = ?', 'post')
->order('year', \Typecho\Db::SORT_DESC);
$yearsRows = $db->fetchAll($yearsQuery);
$availableYears = [];
foreach ($yearsRows as $row) {
if (!empty($row['year'])) {
$availableYears[] = $row['year'];
}
}
if (empty($availableYears)) {
$availableYears = [date('Y')];
}
// 时间范围
$startTime = mktime(0, 0, 0, 1, 1, $currentYear);
$endTime = mktime(23, 59, 59, 12, 31, $currentYear);
$statusCondition = ($includeDraft === '1') ? "status IN ('publish', 'draft')" : "status = 'publish'";
// 文章总数
$totalPostsQuery = $db->select('COUNT(*) as total')
->from('table.contents')
->where('type = ?', 'post')
->where('created >= ?', $startTime)
->where('created <= ?', $endTime)
->where($statusCondition);
$totalPosts = intval($db->fetchRow($totalPostsQuery)['total']);
// 评论总数
$commentsQuery = $db->select('COUNT(*) as total')
->from('table.comments')
->where('created >= ?', $startTime)
->where('created <= ?', $endTime)
->where('status = ?', 'approved');
$totalComments = intval($db->fetchRow($commentsQuery)['total']);
// 图片总数(新增)
$imagesQuery = $db->select('COUNT(*) as total')
->from('table.contents')
->where('type = ?', 'attachment')
->where('created >= ?', $startTime)
->where('created <= ?', $endTime)
->where('text LIKE ?', '%.jpg%');
$imagesResult = $db->fetchRow($imagesQuery);
$totalImages = intval($imagesResult['total']);
// 总字数(同时获取 cid, title, slug 供后续最长/最短文章使用)
$textsQuery = $db->select('cid, title, slug, text')
->from('table.contents')
->where('type = ?', 'post')
->where('created >= ?', $startTime)
->where('created <= ?', $endTime)
->where($statusCondition);
$textsRows = $db->fetchAll($textsQuery);
$totalWords = 0;
foreach ($textsRows as $row) {
$text = strip_tags($row['text']);
$text = preg_replace('/\s+/', '', $text);
$totalWords += mb_strlen($text, 'UTF-8');
}
$averageWords = $totalPosts > 0 ? intval($totalWords / $totalPosts) : 0;
$averageComments = $totalPosts > 0 ? round($totalComments / $totalPosts, 2) : 0;
// 计算目标完成百分比
function calculatePercentage($current, $target) {
if ($target <= 0) return 0;
return min(100, round(($current / $target) * 100, 1));
}
$postsPercentage = calculatePercentage($totalPosts, $targetPosts);
$commentsPercentage = calculatePercentage($totalComments, $targetComments);
$imagesPercentage = calculatePercentage($totalImages, $targetImages);
$wordsPercentage = calculatePercentage($totalWords, $targetWords);
// 浏览量
$totalViews = 0;
try {
$viewsQuery = $db->select('SUM(views) as total')
->from('table.contents')
->where('type = ?', 'post')
->where('created >= ?', $startTime)
->where('created <= ?', $endTime)
->where($statusCondition);
$viewsResult = $db->fetchRow($viewsQuery);
$totalViews = intval($viewsResult['total']);
} catch (Exception $e) {}
// 按月统计文章
$monthlyQuery = $db->select('FROM_UNIXTIME(created, "%m") as month, COUNT(*) as count')
->from('table.contents')
->where('type = ?', 'post')
->where('created >= ?', $startTime)
->where('created <= ?', $endTime)
->where($statusCondition)
->group('month')
->order('month', \Typecho\Db::SORT_ASC);
$monthlyRows = $db->fetchAll($monthlyQuery);
$monthlyData = [];
for ($i = 1; $i <= 12; $i++) {
$monthlyData[str_pad($i, 2, '0', STR_PAD_LEFT)] = 0;
}
foreach ($monthlyRows as $row) {
$monthlyData[$row['month']] = intval($row['count']);
}
// 按月统计图片(新增)
$monthlyImagesQuery = $db->select('FROM_UNIXTIME(created, "%m") as month, COUNT(*) as count')
->from('table.contents')
->where('type = ?', 'attachment')
->where('created >= ?', $startTime)
->where('created <= ?', $endTime)
->where('text LIKE ?', '%.jpg%')
->group('month')
->order('month', \Typecho\Db::SORT_ASC);
$monthlyImagesRows = $db->fetchAll($monthlyImagesQuery);
$monthlyImagesData = [];
for ($i = 1; $i <= 12; $i++) {
$monthlyImagesData[str_pad($i, 2, '0', STR_PAD_LEFT)] = 0;
}
foreach ($monthlyImagesRows as $row) {
$monthlyImagesData[$row['month']] = intval($row['count']);
}
// 按时段统计
$hourlyQuery = $db->select('FROM_UNIXTIME(created, "%H") as hour, COUNT(*) as count')
->from('table.contents')
->where('type = ?', 'post')
->where('created >= ?', $startTime)
->where('created <= ?', $endTime)
->where($statusCondition)
->group('hour');
$hourlyRows = $db->fetchAll($hourlyQuery);
$hourlyData = ['凌晨 (0-6点)' => 0, '上午 (6-12点)' => 0, '下午 (12-18点)' => 0, '晚上 (18-24点)' => 0];
foreach ($hourlyRows as $row) {
$hour = intval($row['hour']);
$count = intval($row['count']);
if ($hour < 6) $hourlyData['凌晨 (0-6点)'] += $count;
elseif ($hour < 12) $hourlyData['上午 (6-12点)'] += $count;
elseif ($hour < 18) $hourlyData['下午 (12-18点)'] += $count;
else $hourlyData['晚上 (18-24点)'] += $count;
}
// 评论排行
$commentRankQuery = $db->select('cid, title, commentsNum')
->from('table.contents')
->where('type = ?', 'post')
->where('created >= ?', $startTime)
->where('created <= ?', $endTime)
->where($statusCondition)
->order('commentsNum', \Typecho\Db::SORT_DESC)
->limit($topLimit);
$topByComments = $db->fetchAll($commentRankQuery);
// 浏览排行
$topByViews = [];
try {
$viewRankQuery = $db->select('cid, title, views')
->from('table.contents')
->where('type = ?', 'post')
->where('created >= ?', $startTime)
->where('created <= ?', $endTime)
->where($statusCondition)
->order('views', \Typecho\Db::SORT_DESC)
->limit($topLimit);
$topByViews = $db->fetchAll($viewRankQuery);
} catch (Exception $e) {}
// 活跃评论者(同时获取评论者网址)- 修改这里:添加排除管理员的条件
$commenterQuery = $db->select('author, mail, url, COUNT(*) as count')
->from('table.comments')
->where('created >= ?', $startTime)
->where('created <= ?', $endTime)
->where('status = ?', 'approved')
->where('authorId != ?', $user->uid) // 排除当前登录的管理员
->group('mail')
->order('count', \Typecho\Db::SORT_DESC)
->limit($topLimit);
$topCommenters = $db->fetchAll($commenterQuery);
// 获取最近四年的数据(按年份升序排列,从左到右:最老 -> 最新)
$fourYearData = [];
$yearsToShow = [];
for ($i = 3; $i >= 0; $i--) {
$year = $currentYear - $i;
if (in_array($year, $availableYears) || $i == 0) {
$yearsToShow[] = $year;
$yearStart = mktime(0, 0, 0, 1, 1, $year);
$yearEnd = mktime(23, 59, 59, 12, 31, $year);
// 文章数
$postsQuery = $db->select('COUNT(*) as total')->from('table.contents')
->where('type = ?', 'post')->where('created >= ?', $yearStart)
->where('created <= ?', $yearEnd)->where($statusCondition);
$posts = intval($db->fetchRow($postsQuery)['total']);
// 评论数
$commsQuery = $db->select('COUNT(*) as total')->from('table.comments')
->where('created >= ?', $yearStart)->where('created <= ?', $yearEnd)
->where('status = ?', 'approved');
$comments = intval($db->fetchRow($commsQuery)['total']);
// 图片附件数(新增)
$imagesQuery = $db->select('COUNT(*) as total')->from('table.contents')
->where('type = ?', 'attachment')
->where('created >= ?', $yearStart)
->where('created <= ?', $yearEnd)
->where('text LIKE ?', '%.jpg%');
$imagesResult = $db->fetchRow($imagesQuery);
$images = intval($imagesResult['total']);
// 总字数
$wordsQuery = $db->select('text')->from('table.contents')
->where('type = ?', 'post')->where('created >= ?', $yearStart)
->where('created <= ?', $yearEnd)->where($statusCondition);
$wordsRows = $db->fetchAll($wordsQuery);
$words = 0;
foreach ($wordsRows as $row) {
$text = strip_tags($row['text']);
$text = preg_replace('/\s+/', '', $text);
$words += mb_strlen($text, 'UTF-8');
}
$fourYearData[$year] = [
'posts' => $posts,
'comments' => $comments,
'images' => $images, // 新增图片数
'words' => $words
];
}
}
// 如果不够4年补全显示最近4年
if (count($yearsToShow) < 4) {
$yearsToShow = [];
for ($i = 3; $i >= 0; $i--) {
$year = $currentYear - $i;
if (!in_array($year, $yearsToShow)) {
$yearsToShow[] = $year;
}
}
}
// 确保数组包含所有年份的数据
foreach ($yearsToShow as $year) {
if (!isset($fourYearData[$year])) {
$fourYearData[$year] = [
'posts' => 0,
'comments' => 0,
'images' => 0, // 新增图片数
'words' => 0
];
}
}
// 排序确保显示顺序正确
sort($yearsToShow);
// 按周统计
$weeklyQuery = $db->select('WEEK(FROM_UNIXTIME(created), 1) as week, COUNT(*) as count')
->from('table.contents')
->where('type = ?', 'post')
->where('created >= ?', $startTime)
->where('created <= ?', $endTime)
->where($statusCondition)
->group('week')
->order('week', \Typecho\Db::SORT_ASC);
$weeklyRows = $db->fetchAll($weeklyQuery);
$weeklyData = [];
foreach ($weeklyRows as $row) {
$weeklyData['第' . intval($row['week']) . '周'] = intval($row['count']);
}
// 最长文章
$longestPost = null;
$maxWords = 0;
foreach ($textsRows as $row) {
$text = strip_tags($row['text']);
$text = preg_replace('/\s+/', '', $text);
$words = mb_strlen($text, 'UTF-8');
if ($words > $maxWords) {
$maxWords = $words;
$longestPost = [
'cid' => $row['cid'],
'title' => $row['title'],
'slug' => $row['slug'],
'words' => $words
];
}
}
// 最短文章
$shortestPost = null;
$minWords = PHP_INT_MAX;
foreach ($textsRows as $row) {
$text = strip_tags($row['text']);
$text = preg_replace('/\s+/', '', $text);
$words = mb_strlen($text, 'UTF-8');
if ($words > 0 && $words < $minWords) {
$minWords = $words;
$shortestPost = [
'cid' => $row['cid'],
'title' => $row['title'],
'slug' => $row['slug'],
'words' => $words
];
}
}
// 先获取符合条件的文章CID列表
$cidsQuery = $db->select('cid')->from('table.contents')
->where('type = ?', 'post')
->where('created >= ?', $startTime)
->where('created <= ?', $endTime)
->where($statusCondition);
$cidsRows = $db->fetchAll($cidsQuery);
$cids = array_column($cidsRows, 'cid');
if (empty($cids)) {
$cids = [0];
}
// 获取标签分布用两步查询避免JOIN问题
// 第一步获取本年度文章的所有标签mid
$midQuery = $db->select(' DISTINCT mid')->from('table.relationships')
->where('cid IN ?', $cids);
$midRows = $db->fetchAll($midQuery);
$mids = array_column($midRows, 'mid');
if (empty($mids)) {
$tagDistribution = [];
} else {
// 第二步获取每个标签的文章数只保留type=tag的
$tagDistribution = [];
foreach ($mids as $mid) {
$count = $db->fetchRow($db->select('COUNT(*) as count')->from('table.relationships')
->where('mid = ?', $mid)->where('cid IN ?', $cids));
$meta = $db->fetchRow($db->select('name', 'slug')->from('table.metas')->where('mid = ?', $mid)->where('type = ?', 'tag'));
if ($meta && $count['count'] > 0) {
$tagDistribution[] = [
'name' => $meta['name'],
'slug' => $meta['slug'],
'count' => intval($count['count'])
];
}
}
// 按数量排序
usort($tagDistribution, function($a, $b) { return $b['count'] - $a['count']; });
$tagDistribution = array_slice($tagDistribution, 0, $topLimit);
}
// 分类分布用两步查询避免JOIN问题
// 第一步获取本年度文章的所有分类mid
$catMidQuery = $db->select(' DISTINCT mid')->from('table.relationships')
->where('cid IN ?', $cids);
$catMidRows = $db->fetchAll($catMidQuery);
$catMids = array_column($catMidRows, 'mid');
if (empty($catMids)) {
$categoryDistribution = [];
} else {
// 第二步获取每个分类的文章数只保留type=category的
$categoryDistribution = [];
foreach ($catMids as $mid) {
$count = $db->fetchRow($db->select('COUNT(*) as count')->from('table.relationships')
->where('mid = ?', $mid)->where('cid IN ?', $cids));
$meta = $db->fetchRow($db->select('name', 'slug')->from('table.metas')->where('mid = ?', $mid)->where('type = ?', 'category'));
if ($meta && $count['count'] > 0) {
$categoryDistribution[] = [
'name' => $meta['name'],
'slug' => $meta['slug'],
'count' => intval($count['count'])
];
}
}
// 按数量排序
usort($categoryDistribution, function($a, $b) { return $b['count'] - $a['count']; });
}
?>
<style>
.ys-toolbar {
display: flex;
justify-content: flex-start;
align-items: center;
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
}
.ys-toolbar-left {
display: flex;
align-items: center;
gap: 10px;
margin-left: 0;
}
.ys-toolbar-left label {
font-weight: 500;
color: #333;
}
.ys-toolbar select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 120px;
height: auto !important;
line-height: normal !important;
min-height: 36px;
}
.ys-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 15px;
margin-bottom: 20px;
text-align: center;
}
.ys-card {
background: #fff;
border-radius: 10px;
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid #f0f0f0;
}
.ys-card-icon {
width: 45px;
height: 45px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin-right: 12px;
color: #fff;
}
.ys-card-value {
font-size: 24px;
font-weight: 700;
color: #333;
}
.ys-card-label {
font-size: 12px;
color: #888;
margin-top: 2px;
}
.ys-section {
background: #fff;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.ys-section .typecho-list-table th{
color:#000!important;
}
.ys-section .typecho-list-table td{
color:#444!important;
}
.ys-section .typecho-list-table tr:hover{
background-color: #FFF!important;
color: #FFF!important;
}
.ys-section .typecho-list-table td:hover{
background-color: #FFF!important;
color: #FFF;
}
.ys-section-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
color:#000!important;
}
.ys-chart-container {
position: relative;
height: 280px;
}
.ys-chart-small {
height: 220px;
}
.ys-row {
display: flex;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.ys-col-6 {
flex: 1;
min-width: 300px;
}
.ys-col-6 .ys-section {
margin-bottom: 0;
}
.ys-growth-up {
color: #52c41a;
font-weight: 600;
}
.ys-growth-down {
color: #ff4d4f;
font-weight: 600;
}
.ys-empty {
padding: 30px;
text-align: center;
color: #999;
}
.ys-footer {
text-align: center;
padding: 20px;
color: #999;
font-size: 12px;
}
.ys-footer a {
color: #667eea;
text-decoration: none;
}
.ys-link {
color: #667eea;
text-decoration: none;
}
.ys-link:hover {
text-decoration: underline;
}
/* 评论和评论者容器统一样式 */
.ys-comments-container {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 300px;
}
.ys-comment-item,
.ys-commenter-item {
display: flex;
align-items: center;
padding: 12px 15px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 6px;
margin: 0;
height: 48px;
box-sizing: border-box;
transition: all 0.2s ease;
}
.ys-comment-item:hover,
.ys-commenter-item:hover {
border-color: #667eea;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.1);
}
.ys-comment-rank {
width: 30px;
text-align: center;
font-weight: bold;
color: #667eea;
flex-shrink: 0;
}
.ys-comment-content {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
min-width: 0;
height: 100%;
}
.ys-comment-title {
font-size: 14px;
color: #333;
flex: 1;
margin-right: 15px;
min-width: 0;
height: 100%;
display: flex;
align-items: center;
}
.ys-comment-title a {
color: #333;
text-decoration: none;
}
.ys-comment-title a:hover {
color: #667eea;
text-decoration: underline;
}
.ys-commenter-name {
font-size: 14px;
color: #333;
flex: 1;
min-width: 0;
height: 100%;
display: flex;
align-items: center;
}
.ys-commenter-name a {
color: #333;
text-decoration: none;
}
.ys-commenter-name a:hover {
color: #667eea;
text-decoration: underline;
}
.ys-comment-count {
font-weight: bold;
color: #667eea;
flex-shrink: 0;
padding-left: 10px;
}
.ys-title-truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
display: inline-block;
vertical-align: middle;
}
/* 标签样式 */
.ys-tag-item {
display: inline-block;
padding: 5px 12px;
margin: 0 8px 8px 0;
background: #f0f0f0;
border-radius: 15px;
font-size: 13px;
color: #333;
text-decoration: none;
transition: all 0.2s ease;
}
.ys-tag-item:hover {
background: #667eea;
color: #fff;
transform: translateY(-2px);
}
/* 年度对比模块样式 */
.ys-year-compare {
width: 100%;
overflow-x: auto;
margin-bottom: 20px;
}
.ys-year-header {
display: flex;
background: #f8f9fa;
border-radius: 6px 6px 0 0;
border: 1px solid #e8e8e8;
border-bottom: none;
}
.ys-year-header-cell {
flex: 1;
padding: 12px 15px;
text-align: center;
font-weight: 600;
color: #333;
min-width: 120px;
}
.ys-year-header-cell:first-child {
flex: 0.6;
min-width: 100px;
text-align: left;
background: #f1f3f5;
border-right: 1px solid #e8e8e8;
}
.ys-year-row {
display: flex;
border-left: 1px solid #e8e8e8;
border-right: 1px solid #e8e8e8;
border-bottom: 1px solid #e8e8e8;
}
.ys-year-row:last-child {
border-radius: 0 0 6px 6px;
}
.ys-year-row-cell {
flex: 1;
padding: 12px 15px;
text-align: center;
color: #555;
min-width: 120px;
border-right: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: center;
}
.ys-year-row-cell:first-child {
flex: 0.6;
min-width: 100px;
text-align: left;
justify-content: flex-start;
background: #f8f9fa;
font-weight: 500;
color: #333;
}
.ys-year-row-cell:last-child {
border-right: none;
}
.ys-year-value {
font-weight: 600;
color: #000;
}
/* 年度目标进度条样式(新增) */
.ys-target-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.ys-target-item {
background: #fff;
border-radius: 10px;
padding: 20px;
border: 1px solid #f0f0f0;
}
.ys-target-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.ys-target-title {
font-size: 15px;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
gap: 8px;
}
.ys-target-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #fff;
}
.ys-target-count {
font-size: 13px;
font-weight: 600;
color: #667eea;
}
.ys-target-progress {
height: 10px;
background: #f0f0f0;
border-radius: 5px;
overflow: hidden;
margin-bottom: 8px;
}
.ys-target-progress-bar {
height: 100%;
border-radius: 5px;
transition: width 0.3s ease;
}
.ys-target-percentage {
font-size: 12px;
color: #888;
text-align: center;
}
.ys-target-completed {
color: #52c41a;
font-weight: 600;
}
.ys-target-incomplete {
color: #ff4d4f;
font-weight: 600;
}
@media (max-width: 768px) {
.ys-row {
flex-direction: column;
}
.ys-cards {
grid-template-columns: repeat(2, 1fr);
}
.ys-title-truncate {
max-width: 200px;
}
.ys-toolbar-left {
justify-content: flex-start;
}
.ys-year-header-cell,
.ys-year-row-cell {
min-width: 90px;
}
.ys-year-header-cell:first-child,
.ys-year-row-cell:first-child {
min-width: 80px;
}
.ys-target-container {
grid-template-columns: 1fr;
}
}
</style>
<div class="main">
<div class="body container">
<div class="ys-toolbar" style="margin-left:0px;text-align:center;justify-content:center;">
<div class="ys-toolbar-left">
<label></label>
<select id="year-select" onchange="location.href='<?php echo $options->adminUrl; ?>extending.php?panel=YearlyData%2FPanel.php&year='+this.value">
<?php foreach ($availableYears as $year): ?>
<option value="<?php echo $year; ?>" <?php echo $year == $currentYear ? 'selected' : ''; ?>><?php echo $year; ?>年</option>
<?php endforeach; ?>
</select>
</div>
</div>
<!-- 年度目标进度条模块 -->
<?php if ($targetPosts > 0 || $targetComments > 0 || $targetImages > 0 || $targetWords > 0): ?>
<div class="ys-section">
<h3 class="ys-section-title"><!--<?php echo $currentYear; ?>-->年度目标</h3>
<div class="ys-target-container">
<?php if ($targetPosts > 0): ?>
<div class="ys-target-item">
<div class="ys-target-header">
<div class="ys-target-title">
文章数
</div>
<div class="ys-target-count"><?php echo number_format($totalPosts); ?> / <?php echo number_format($targetPosts); ?></div>
</div>
<div class="ys-target-progress">
<div class="ys-target-progress-bar" style="width: <?php echo $postsPercentage; ?>%; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);"></div>
</div>
<div class="ys-target-percentage <?php echo $postsPercentage >= 100 ? 'ys-target-completed' : 'ys-target-incomplete'; ?>">
<?php echo $postsPercentage; ?>%
</div>
</div>
<?php endif; ?>
<?php if ($targetComments > 0): ?>
<div class="ys-target-item">
<div class="ys-target-header">
<div class="ys-target-title">
评论数
</div>
<div class="ys-target-count"><?php echo number_format($totalComments); ?> / <?php echo number_format($targetComments); ?></div>
</div>
<div class="ys-target-progress">
<div class="ys-target-progress-bar" style="width: <?php echo $commentsPercentage; ?>%; background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);"></div>
</div>
<div class="ys-target-percentage <?php echo $commentsPercentage >= 100 ? 'ys-target-completed' : 'ys-target-incomplete'; ?>">
<?php echo $commentsPercentage; ?>%
</div>
</div>
<?php endif; ?>
<?php if ($targetImages > 0): ?>
<div class="ys-target-item">
<div class="ys-target-header">
<div class="ys-target-title">
图片数
</div>
<div class="ys-target-count"><?php echo number_format($totalImages); ?> / <?php echo number_format($targetImages); ?></div>
</div>
<div class="ys-target-progress">
<div class="ys-target-progress-bar" style="width: <?php echo $imagesPercentage; ?>%; background: linear-gradient(90deg, #f093fb 0%, #f5576c 100%);"></div>
</div>
<div class="ys-target-percentage <?php echo $imagesPercentage >= 100 ? 'ys-target-completed' : 'ys-target-incomplete'; ?>">
<?php echo $imagesPercentage; ?>%
</div>
</div>
<?php endif; ?>
<?php if ($targetWords > 0): ?>
<div class="ys-target-item">
<div class="ys-target-header">
<div class="ys-target-title">
总字数
</div>
<div class="ys-target-count"><?php echo number_format($totalWords); ?> / <?php echo number_format($targetWords); ?></div>
</div>
<div class="ys-target-progress">
<div class="ys-target-progress-bar" style="width: <?php echo $wordsPercentage; ?>%; background: linear-gradient(90deg, #43e97b 0%, #38f9d7 100%);"></div>
</div>
<div class="ys-target-percentage <?php echo $wordsPercentage >= 100 ? 'ys-target-completed' : 'ys-target-incomplete'; ?>">
<?php echo $wordsPercentage; ?>%
</div>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<div class="ys-section">
<h3 class="ys-section-title">年度概览</h3>
<div class="ys-cards">
<div class="ys-card">
<div><div class="ys-card-value"><?php echo number_format($totalPosts); ?></div><div class="ys-card-label">总文章</div></div>
</div>
<div class="ys-card">
<div><div class="ys-card-value"><?php echo number_format($totalWords); ?></div><div class="ys-card-label">总字数</div></div>
</div>
<div class="ys-card">
<div><div class="ys-card-value"><?php echo number_format($totalComments); ?></div><div class="ys-card-label">总评论</div></div>
</div>
<div class="ys-card">
<div><div class="ys-card-value"><?php echo number_format($averageWords); ?></div><div class="ys-card-label">平均字数</div></div>
</div>
<div class="ys-card">
<div><div class="ys-card-value"><?php echo $averageComments; ?></div><div class="ys-card-label">平均评论</div></div>
</div>
</div>
</div>
<div class="ys-section">
<h3 class="ys-section-title">年度对比</h3>
<div class="ys-year-compare">
<div class="ys-year-header">
<div class="ys-year-header-cell">统计年份</div>
<?php foreach ($yearsToShow as $year): ?>
<div class="ys-year-header-cell"><?php echo $year; ?>年</div>
<?php endforeach; ?>
</div>
<div class="ys-year-row">
<div class="ys-year-row-cell">文章数</div>
<?php foreach ($yearsToShow as $year): ?>
<div class="ys-year-row-cell">
<span class="ys-year-value">
<?php echo isset($fourYearData[$year]) ? number_format($fourYearData[$year]['posts']) : '0'; ?>
</span>
</div>
<?php endforeach; ?>
</div>
<div class="ys-year-row">
<div class="ys-year-row-cell">评论数</div>
<?php foreach ($yearsToShow as $year): ?>
<div class="ys-year-row-cell">
<span class="ys-year-value">
<?php echo isset($fourYearData[$year]) ? number_format($fourYearData[$year]['comments']) : '0'; ?>
</span>
</div>
<?php endforeach; ?>
</div>
<div class="ys-year-row">
<div class="ys-year-row-cell">图片数</div>
<?php foreach ($yearsToShow as $year): ?>
<div class="ys-year-row-cell">
<span class="ys-year-value">
<?php echo isset($fourYearData[$year]) ? number_format($fourYearData[$year]['images']) : '0'; ?>
</span>
</div>
<?php endforeach; ?>
</div>
<div class="ys-year-row">
<div class="ys-year-row-cell">总字数</div>
<?php foreach ($yearsToShow as $year): ?>
<div class="ys-year-row-cell">
<span class="ys-year-value">
<?php echo isset($fourYearData[$year]) ? number_format($fourYearData[$year]['words']) : '0'; ?>
</span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<div class="ys-section">
<h3 class="ys-section-title">月度数据(文章)</h3>
<div class="ys-chart-container"><canvas id="monthlyChart"></canvas></div>
</div>
<div class="ys-section">
<h3 class="ys-section-title">月度数据(图片)</h3>
<div class="ys-chart-container"><canvas id="monthlyImagesChart"></canvas></div>
</div>
<div class="ys-row">
<div class="ys-col-6">
<div class="ys-section">
<h3 class="ys-section-title">时段数据</h3>
<div class="ys-chart-container ys-chart-small"><canvas id="hourlyChart"></canvas></div>
</div>
</div>
<div class="ys-col-6">
<div class="ys-section">
<h3 class="ys-section-title">分类数据</h3>
<?php if (!empty($categoryDistribution)): ?>
<div class="ys-chart-container ys-chart-small"><canvas id="categoryChart"></canvas></div>
<?php else: ?><div class="ys-empty">暂无分类</div><?php endif; ?>
</div>
</div>
</div>
<div class="ys-row">
<div class="ys-col-6">
<div class="ys-section">
<h3 class="ys-section-title">标签数据</h3>
<?php if (!empty($tagDistribution)): ?>
<div style="display: flex; flex-wrap: wrap;">
<?php foreach ($tagDistribution as $tag): ?>
<a href="<?php echo sprintf($tagUrlFormat, $tag['slug']); ?>" class="ys-tag-item" target="_blank">
<?php echo htmlspecialchars($tag['name']); ?> (<?php echo $tag['count']; ?>)
</a>
<?php endforeach; ?>
</div>
<?php else: ?><div class="ys-empty">暂无标签</div><?php endif; ?>
</div>
</div>
</div>
<div class="ys-row">
<div class="ys-col-6">
<div class="ys-section">
<h3 class="ys-section-title">文章评论</h3>
<?php if (!empty($topByComments)): ?>
<div class="ys-comments-container">
<?php foreach ($topByComments as $i => $post):
// 使用 Typecho 内置方法生成文章链接
$postUrl = Helper::widgetById('contents', $post['cid'])->permalink;
?>
<div class="ys-comment-item">
<div class="ys-comment-rank"><?php echo $i + 1; ?></div>
<div class="ys-comment-content">
<div class="ys-comment-title">
<span class="ys-title-truncate">
<a href="<?php echo $postUrl; ?>" class="ys-link" target="_blank"><?php echo htmlspecialchars($post['title']); ?></a>
</span>
</div>
<div class="ys-comment-count"><?php echo number_format($post['commentsNum']); ?></div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?><div class="ys-empty">暂无数据</div><?php endif; ?>
</div>
</div>
<div class="ys-col-6">
<div class="ys-section">
<h3 class="ys-section-title">活跃用户</h3>
<?php if (!empty($topCommenters)): ?>
<div class="ys-comments-container">
<?php foreach ($topCommenters as $i => $c): ?>
<div class="ys-commenter-item">
<div class="ys-comment-rank"><?php echo $i + 1; ?></div>
<div class="ys-comment-content">
<div class="ys-commenter-name">
<?php if (!empty($c['url'])): ?>
<a href="<?php echo htmlspecialchars($c['url']); ?>" class="ys-link" target="_blank"><?php echo htmlspecialchars($c['author']); ?></a>
<?php else: ?>
<?php echo htmlspecialchars($c['author']); ?>
<?php endif; ?>
</div>
<div class="ys-comment-count"><?php echo $c['count']; ?></div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?><div class="ys-empty">暂无数据</div><?php endif; ?>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
const chartColor = '<?php echo $chartColor; ?>';
const monthlyData = <?php echo json_encode(array_values($monthlyData)); ?>;
const monthlyImagesData = <?php echo json_encode(array_values($monthlyImagesData)); ?>;
const hourlyData = <?php echo json_encode(array_values($hourlyData)); ?>;
const hourlyLabels = <?php echo json_encode(array_keys($hourlyData)); ?>;
const weeklyData = <?php echo json_encode(array_values($weeklyData)); ?>;
const weeklyLabels = <?php echo json_encode(array_keys($weeklyData)); ?>;
const categoryLabels = <?php echo json_encode(array_column($categoryDistribution, 'name')); ?>;
const categoryData = <?php echo json_encode(array_column($categoryDistribution, 'count')); ?>;
const categoryColors = ['#667eea','#f093fb','#4facfe','#43e97b','#fa709a','#fee140','#a8edea','#fed6e3'];
// 准备饼图工具提示回调函数
function createTooltipCallback() {
return {
callbacks: {
label: function(context) {
const value = context.parsed;
const data = context.dataset.data;
const total = data.reduce((sum, val) => sum + val, 0);
const percentage = total > 0 ? Math.round((value / total) * 100) + '%' : '0%';
return `${value} (${percentage})`;
}
}
};
}
// 月度文章发布图表
new Chart(document.getElementById('monthlyChart'), {
type: 'line',
data: {
labels: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
datasets: [{
label: '文章数',
data: monthlyData,
borderColor: chartColor,
backgroundColor: chartColor + '20',
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: function(tooltipItems) {
const monthIndex = tooltipItems[0].dataIndex;
const count = monthlyData[monthIndex];
return (monthIndex + 1) + '月 (' + count + ')';
},
label: function(context) {
return '文章数: ' + context.parsed.y;
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
callback: function(value) {
return Number.isInteger(value) ? value : '';
}
}
},
x: {
ticks: {
callback: function(value, index) {
// 在X轴标签上显示文章数量
return (index + 1) + '月 (' + monthlyData[index] + ')';
}
}
}
}
}
});
// 月度图片上传图表
new Chart(document.getElementById('monthlyImagesChart'), {
type: 'bar',
data: {
labels: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
datasets: [{
label: '图片数',
data: monthlyImagesData,
backgroundColor: '#f093fb',
borderColor: '#f093fb',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: function(tooltipItems) {
const monthIndex = tooltipItems[0].dataIndex;
const count = monthlyImagesData[monthIndex];
return (monthIndex + 1) + '月 (' + count + ')';
},
label: function(context) {
return '图片数: ' + context.parsed.y;
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
callback: function(value) {
return Number.isInteger(value) ? value : '';
}
}
},
x: {
ticks: {
callback: function(value, index) {
// 在X轴标签上显示图片数量
return (index + 1) + '月 (' + monthlyImagesData[index] + ')';
}
}
}
}
}
});
// 发布时段图表
new Chart(document.getElementById('hourlyChart'), {
type: 'doughnut',
data: {
labels: hourlyLabels,
datasets: [{
data: hourlyData,
backgroundColor: ['#667eea','#f093fb','#4facfe','#43e97b']
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20
}
},
tooltip: createTooltipCallback()
},
cutout: '60%'
}
});
<?php if (!empty($weeklyData)): ?>
new Chart(document.getElementById('weeklyChart'), {
type: 'bar',
data: {
labels: weeklyLabels,
datasets: [{
label: '文章数',
data: weeklyData,
backgroundColor: chartColor + '80',
borderColor: chartColor,
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
callback: function(value) {
return Number.isInteger(value) ? value : '';
}
}
}
}
}
});
<?php endif; ?>
<?php if (!empty($categoryDistribution)): ?>
new Chart(document.getElementById('categoryChart'), {
type: 'pie',
data: {
labels: categoryLabels,
datasets: [{
data: categoryData,
backgroundColor: categoryColors
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20
}
},
tooltip: createTooltipCallback()
}
}
});
<?php endif; ?>
</script>
<?php
include 'copyright.php';
include 'common-js.php';
include 'footer.php';
?>