Files
YearlyData/Panel.php
2026-02-23 21:11:30 +08:00

1430 lines
47 KiB
PHP
Raw Permalink 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
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';
?>