فهرست منبع

Blog Huge UPDATEgit status

ryp 2 هفته پیش
والد
کامیت
4b366ab9c0

+ 4 - 2
db/app_uoj233.sql

@@ -69,7 +69,8 @@ CREATE TABLE `blogs` (
   `is_hidden` tinyint(1) NOT NULL,
   `type` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'B',
   `is_draft` tinyint(1) NOT NULL DEFAULT '0',
-  PRIMARY KEY (`id`)
+  PRIMARY KEY (`id`),
+  KEY `poster` (`poster`,`post_time`)
 ) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
 /*!40101 SET character_set_client = @saved_cs_client */;
 
@@ -677,7 +678,8 @@ CREATE TABLE `submissions` (
   `is_hidden` tinyint(1) NOT NULL,
   `status_details` varchar(100) NOT NULL,
   PRIMARY KEY (`id`),
-  KEY `is_hidden` (`is_hidden`,`problem_id`)
+  KEY `is_hidden` (`is_hidden`,`problem_id`),
+  KEY `submitter` (`submitter`,`submit_time`)
 ) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4;
 /*!40101 SET character_set_client = @saved_cs_client */;
 

+ 95 - 91
web/app/controllers/subdomain/blog/archive.php

@@ -34,27 +34,8 @@
 <?php echoUOJPageHeader('日志') ?>
 
 <div class="row">
-	<div class="col-md-3">
-		<?php if (UOJContext::hasBlogPermission()): ?>
-		<div class="btn-group d-flex">
-			<a href="<?=HTML::blog_url(UOJContext::userid(), '/post/new/write')?>" class="btn btn-primary"><span class="glyphicon glyphicon-edit"></span> 写新博客</a>
-			<a href="<?=HTML::blog_url(UOJContext::userid(), '/slide/new/write')?>" class="btn btn-primary"><span class="glyphicon glyphicon-edit"></span> 写新幻灯片</a>
-		</div>
-		<?php endif ?>
-		<div class="card top-buffer-sm">
-			<div class="card-header">标签</div>
-			<div class="card-body">
-			<?php if ($all_tags): ?>
-			<?php foreach ($all_tags as $tag): ?>
-				<?php echoBlogTag($tag['tag']) ?>
-			<?php endforeach ?>
-			<?php else: ?>
-				<div class="text-muted">暂无</div>
-			<?php endif ?>
-			</div>
-		</div>
-	</div>
-	<div class="col-md-9 blog-list">
+	<div class="col-12 col-lg-9">
+		<div class="blog-list">
 		<?php if (!$blog_tag_required): ?>
 			<?php if ($blogs_pag->isEmpty()): ?>
 			<div class="text-muted">此人很懒,什么博客也没留下。</div>
@@ -62,7 +43,6 @@
 			<?php foreach ($blogs_pag->get() as $blog): ?>
 				<?php echoBlog($blog, array('is_preview' => true)) ?>
 			<?php endforeach ?>
-<div class="text-right text-muted" style="display: none;">共 <?= $blogs_pag->n_rows ?> 篇博客</div>
 			<?php endif ?>
 		<?php else: ?>
 			<?php if ($blogs_pag->isEmpty()): ?>
@@ -78,87 +58,111 @@
 			<?php endforeach ?>
 			<?php endif ?>
 		<?php endif ?>
+		</div>
+		<div id="blog-feed-loading" class="wyoj-blog-feed-loading text-center text-muted top-buffer-sm" style="display: none;">
+			<span class="spinner-border spinner-border-sm align-middle" role="status" aria-hidden="true"></span>
+			<span class="align-middle ml-2">正在加载…</span>
+		</div>
+		<div id="blog-feed-end" class="text-center text-muted top-buffer-sm" style="display: none;">没有更多了</div>
+		<div id="blog-feed-error" class="text-center top-buffer-sm" style="display: none;">
+			<span class="text-danger">加载失败</span>
+			<a href="#" id="blog-feed-retry" class="ml-2">重试</a>
+		</div>
 	</div>
-	<script>
-		(function () {
-			let currentPage = 1, isLoading = false, hasMore = true;
-			const blogList = document.querySelector('.blog-list');
-			const itemSelector = '.blog-item';
-			const tag = (new URLSearchParams(window.location.search)).get('tag');
-
-			if (blogList.querySelectorAll('.blog-item').length < 10)
-				hasMore = false;
-
-			function extractSummary(html) {
-				const div = document.createElement('div');
-				div.innerHTML = html;
-				let text = div.textContent || div.innerText || '';
-				text = text.replace(/\s+/g, ' ').trim();
-				return text;
-			}
-
-			function collapseArticle(post) {
-				const content = post.querySelector('.blog-content');
-				const divorig = post.querySelector('.blog-content-orig');
-				const divsummary = post.querySelector('.blog-content-summary');
-
-				let summary = extractSummary(divorig.innerHTML);
-				const maxLen = 150;
-
-				if (summary.length < maxLen) return;
-				summary = summary.slice(0, maxLen) + '……';
-				divsummary.innerHTML = summary;
+	<div class="col-12 col-lg-3">
+		<?php uojIncludeView('blog-sidebar', array(
+			'owner' => UOJContext::userid(),
+			'show_write_buttons' => true,
+			'show_tags' => true,
+			'all_tags' => $all_tags
+		)) ?>
+	</div>
+</div>
+<script>
+	(function () {
+		let currentPage = 1, isLoading = false, hasMore = true, failed = false;
+		const blogList = document.querySelector('.blog-list');
+		const itemSelector = '.blog-item';
+		const tag = (new URLSearchParams(window.location.search)).get('tag');
+		const feedBase = window.location.pathname;
+		const loadingEl = document.getElementById('blog-feed-loading');
+		const endEl = document.getElementById('blog-feed-end');
+		const errorEl = document.getElementById('blog-feed-error');
+		const retryEl = document.getElementById('blog-feed-retry');
 
-				divsummary.style.display = 'block';
-				divorig.style.display = 'none';
-				content.classList.add('collapsed');
+		const initialItems = blogList.querySelectorAll(itemSelector).length;
+		if (initialItems < 10)
+			hasMore = false;
+		if (!hasMore && initialItems > 0)
+			endEl.style.display = 'block';
 
-				content.addEventListener('click', () => {
-					divsummary.style.display = 'none';
-					divorig.style.display = 'block';
-					content.classList.remove('collapsed');
-					content.classList.add('expand');
-				});
-			}
+		function bindExpand(item) {
+			const content = item.querySelector('.blog-content');
+			if (!content) return;
+			content.addEventListener('click', (e) => {
+				if (e.target && e.target.closest && e.target.closest('a')) return;
+				content.classList.add('uoj-clamp-open');
+			});
+		}
 
-			document.querySelectorAll('.blog-item').forEach(collapseArticle);
+		document.querySelectorAll(itemSelector).forEach(bindExpand);
 
-			async function loadPage(page) {
-				if (isLoading || !hasMore) return;
-				isLoading = true;
+		async function loadPage(page) {
+			if (isLoading || !hasMore || failed) return;
+			isLoading = true;
+			errorEl.style.display = 'none';
+			loadingEl.style.display = 'block';
 
-				const url = !tag ? `/?page=${page}` : `/?page=${page}&tag=${tag}`;
-				try {
-					const resp = await fetch(url);
-					const html = await resp.text();
-					const parser = new DOMParser();
-					const doc = parser.parseFromString(html, 'text/html');
-					const items = doc.querySelectorAll(itemSelector);
-					if (items.length == 0) {
-						hasMore = false;
-						return;
-					}
-					items.forEach(item => { blogList.appendChild(item); collapseArticle(item); });
+			const url = !tag ? `${feedBase}?page=${page}` : `${feedBase}?page=${page}&tag=${encodeURIComponent(tag)}`;
+			try {
+				const resp = await fetch(url);
+				if (!resp.ok) throw new Error('HTTP ' + resp.status);
+				const html = await resp.text();
+				const parser = new DOMParser();
+				const doc = parser.parseFromString(html, 'text/html');
+				const items = doc.querySelectorAll(itemSelector);
+				if (items.length == 0) {
+					hasMore = false;
+				} else {
+					items.forEach(item => { blogList.appendChild(item); bindExpand(item); });
+					currentPage = page;
 					if (items.length < 10)
 						hasMore = false;
 
 					MathJax.Hub.Queue(["Typeset", MathJax.Hub]);
 					hljs.highlightAll();
 					$('body').uoj_highlight();
-				} catch (err) {
-					console.log('加载失败', err);
-				} finally {
-					isLoading = false;		
+				}
+			} catch (err) {
+				failed = true;
+				console.log('加载失败', err);
+			} finally {
+				isLoading = false;
+				loadingEl.style.display = 'none';
+				if (failed) {
+					errorEl.style.display = 'block';
+				} else if (!hasMore) {
+					endEl.style.display = 'block';
 				}
 			}
-			window.addEventListener('scroll', () => {
-				const scrollTop = document.documentElement.scrollTop;
-				const windowHeight = window.innerHeight;
-				const docHeight = document.documentElement.scrollHeight;
-				if (scrollTop + windowHeight >= docHeight - 200 && !isLoading && hasMore)
-					loadPage(++currentPage);
-			});
-		})();
-	</script>
-</div>
+		}
+
+		retryEl.addEventListener('click', (e) => {
+			e.preventDefault();
+			if (isLoading) return;
+			failed = false;
+			errorEl.style.display = 'none';
+			loadPage(currentPage + 1);
+		});
+
+		window.addEventListener('scroll', () => {
+			if (isLoading || !hasMore || failed) return;
+			const scrollTop = document.documentElement.scrollTop;
+			const windowHeight = window.innerHeight;
+			const docHeight = document.documentElement.scrollHeight;
+			if (scrollTop + windowHeight >= docHeight - 200)
+				loadPage(currentPage + 1);
+		});
+	})();
+</script>
 <?php echoUOJPageFooter() ?>

+ 11 - 0
web/app/controllers/subdomain/blog/blog.php

@@ -142,6 +142,8 @@
 	$REQUIRE_LIB['hljs'] = '';
 ?>
 <?php echoUOJPageHeader(HTML::stripTags($blog['title']) . ' - 博客') ?>
+<div class="row">
+	<div class="col-12 col-lg-9">
 <?php echoBlog($blog, array('show_title_only' => isset($_GET['page']) && $_GET['page'] != 1)) ?>
 <h2>评论 <span class="glyphicon glyphicon-comment"></span></h2>
 <div class="list-group">
@@ -192,5 +194,14 @@
 <div id="div-form-reply" style="display:none">
 	<?php $reply_form->printHTML() ?>
 </div>
+	</div>
+	<div class="col-12 col-lg-3">
+		<?php uojIncludeView('blog-sidebar', array(
+			'owner' => $blog['poster'],
+			'show_write_buttons' => false,
+			'show_tags' => false
+		)) ?>
+	</div>
+</div>
 
 <?php echoUOJPageFooter() ?>

+ 17 - 8
web/app/controllers/subdomain/blog/route.php

@@ -10,14 +10,23 @@ call_user_func(function() { // to prevent variable scope leak
 			$domain = '{blog_username}.'.UOJConfig::$data['web']['main']['host'];
 			$prefix = '';
 			break;
-#		case 2:
-#			$domain = UOJConfig::$data['web']['blog']['host'];
-#			$prefix = '/{blog_username}';
-#			break;
-#		case 3:
-#			$domain = UOJConfig::$data['web']['main']['host'];
-#			$prefix = '/blog/{blog_username}';
-#			break;
+		case 2:
+			$domain = UOJConfig::$data['web']['blog']['host'];
+			$prefix = '/{blog_username}';
+			break;
+		case 3:
+			$domain = UOJConfig::$data['web']['main']['host'];
+			$prefix = '/blog/{blog_username}';
+			break;
+		default:
+			// An unknown or missing mode used to leave $domain and $prefix
+			// undefined, which registered these routes with an empty prefix and
+			// no domain check: they leaked onto the main domain while every real
+			// blog URL (the ones HTML::blog_url() builds) 404'd.  Degrade to the
+			// path-prefix form instead of breaking silently.
+			$domain = UOJConfig::$data['web']['main']['host'];
+			$prefix = '/blog/{blog_username}';
+			break;
 	}
 
 	Route::group([

+ 26 - 0
web/app/libs/uoj-query-lib.php

@@ -279,6 +279,32 @@ function queryBlogComment($id) {
 	return DB::selectFirst("select * from blogs_comments where id='$id'", MYSQLI_ASSOC);
 }
 
+// Per-day activity map ("Y-m-d" => count) for the last $days days, counting
+// blogs posted plus submissions made.  The DB session timezone is +08:00, the
+// same as PHP's configured timezone, so DATE(col) needs no conversion.
+function queryUserActivityHeatmap($username, $days = 371) {
+	if (!validateUsername($username)) {
+		return array();
+	}
+	$esc_username = DB::escape($username);
+	$start_date = date('Y-m-d', strtotime('-'.($days - 1).' days'));
+	$activity = array();
+	$result = DB::query("
+		select date(t.dt) as d, sum(t.c) as cnt from (
+			select post_time as dt, 1 as c from blogs
+				where poster = '$esc_username' and post_time >= '$start_date'
+			union all
+			select submit_time as dt, 1 as c from submissions
+				where submitter = '$esc_username' and submit_time >= '$start_date'
+		) as t
+		group by date(t.dt)
+	");
+	while ($row = DB::fetch($result, MYSQLI_NUM)) {
+		$activity[$row[0]] = (int)$row[1];
+	}
+	return $activity;
+}
+
 function isProblemVisibleToUser($problem, $user) {
 	return !$problem['is_hidden'] || hasProblemViewPermission($user, $problem);
 }

+ 35 - 25
web/app/views/blog-preview.php

@@ -6,48 +6,56 @@
 		} else {
 			$content = $blog['content'];
 		}
+		$blog_href = HTML::blog_url(UOJContext::userid(), '/post/'.$blog['id']);
 	} else {
 		$content = $blog['content'];
+		$blog_href = null;
 	}
 	
 	$extra_text = $blog['is_hidden'] ? '<span class="text-muted">[已隐藏]</span> ' : '';
 	
 	$blog_type = $blog['type'] == 'B' ? 'post' : 'slide';
 	$poster = queryUser($blog['poster']);
+	$avatar_addr = HTML::avatar_addr($poster, 100);
+	$profile_href = HTML::url('/user/profile/'.$blog['poster']);
 ?>
 
 <div class="blog-item">
-<?php if (!$is_preview && !$show_content_only): ?>
+<?php // Paginated post view (?page=N): the title stands alone, there is no body to merge it into. ?>
+<?php if (!$is_preview && !$show_content_only && $show_title_only): ?>
 <div class="card mb-4 shadow">
 	<div class="card-body">
-		<h3 class="blog-title font-weight-bold mb-3"><?= $blog['title'] ?></h3>
-		<div class="d-flex align-items-stretch">
-			<div class="d-flex align-items-center mr-3">
-				<img class="uoj-avatar mx-auto mb-2 rounded" src=<?= "https://q.qlogo.cn/g?b=qq&nk=" . $poster['qq'] . "&s=100" ?> alt="...">
-			</div>
-			<div class="col d-flex flex-column justify-content-between flex-grow-1">
-				<div class="d-flex align-items-end">
-					<?= getUserLink($blog['poster']) ?>
-				</div>
-
-				<div class="d-flex align-items-start">
-					<span class="text-muted"><?= $blog['post_time'] ?></span>
-				</div>
-				<div class="d-flex align-items-end">
-					<span><?= HTML::escape($poster['motto']) ?></span>
-				</div>
-			</div>
+		<h3 class="blog-title wyoj-blog-detail-title mb-3"><?= $blog['title'] ?></h3>
+		<div class="wyoj-blog-meta d-flex align-items-center flex-wrap">
+			<a href="<?= $profile_href ?>" class="d-inline-flex align-items-center mr-2">
+				<img class="wyoj-blog-meta-avatar rounded-circle" src="<?= $avatar_addr ?>" alt="avatar" />
+			</a>
+			<?= getUserLink($blog['poster']) ?>
+			<span class="text-muted ml-3"><?= $blog['post_time'] ?></span>
 		</div>
 	</div>
 </div>
-
-<?php elseif (!$show_content_only): ?>
-<h3 class="blog-title font-weight-bold mb-3"><?= $blog['title'] ?></h3>
 <?php endif ?>
 
 <?php if (!$show_title_only): ?>
 <div class="card shadow mb-4">
 	<div class="card-body blog-content">
+		<?php // The title and meta live inside the body card on every page, so a
+		      // post renders as one card instead of a title card stacked on a
+		      // content card.  Only the size differs: list vs. detail. ?>
+		<?php if (!$show_content_only): ?>
+		<h3 class="blog-title <?= $is_preview ? 'wyoj-blog-list-title' : 'wyoj-blog-detail-title' ?> mb-2"><?php if ($is_preview): ?><a href="<?= $blog_href ?>"><?= $blog['title'] ?></a><?php else: ?><?= $blog['title'] ?><?php endif ?></h3>
+		<div class="wyoj-blog-meta d-flex align-items-center flex-wrap mb-3">
+			<a href="<?= $profile_href ?>" class="d-inline-flex align-items-center mr-2">
+				<img class="wyoj-blog-meta-avatar rounded-circle" src="<?= $avatar_addr ?>" alt="avatar" />
+			</a>
+			<?= getUserLink($blog['poster']) ?>
+			<span class="text-muted ml-3"><?= $blog['post_time'] ?></span>
+			<?php if ($is_preview): ?>
+			<span class="ml-auto"><?= getClickZanBlock('B', $blog['id'], $blog['zan']) ?></span>
+			<?php endif ?>
+		</div>
+		<?php endif ?>
 		<?php if ($blog_type == 'post'): ?>
 		<article class="blog-content-orig"><?= $content ?></article>
 		<article class="blog-content-summary"></article>
@@ -70,13 +78,15 @@
 			<?php endforeach ?>
 			</li>
 			<?php if ($is_preview): ?>
-  			<li class="list-inline-item"><a href="<?= HTML::blog_url(UOJContext::userid(), '/post/'.$blog['id']) ?>">阅读全文</a></li>
-  			<?php endif ?>
-  			<?php if (!$outside_blog_domain && Auth::check() && (isSuperUser(Auth::user()) || Auth::id() == $blog['poster'])): ?>
+			<li class="list-inline-item"><a href="<?= HTML::blog_url(UOJContext::userid(), '/post/'.$blog['id']) ?>">阅读全文</a></li>
+			<?php endif ?>
+			<?php if (!$outside_blog_domain && Auth::check() && (isSuperUser(Auth::user()) || Auth::id() == $blog['poster'])): ?>
 			<li class="list-inline-item"><a href="<?=HTML::blog_url(UOJContext::userid(), '/'.$blog_type.'/'.$blog['id'].'/write')?>">修改</a></li>
 			<li class="list-inline-item"><a href="<?=HTML::blog_url(UOJContext::userid(), '/post/'.$blog['id'].'/delete')?>">删除</a></li>
 			<?php endif ?>
-  			<li class="list-inline-item"><?= getClickZanBlock('B', $blog['id'], $blog['zan']) ?></li>
+			<?php if (!$is_preview): ?>
+			<li class="list-inline-item"><?= getClickZanBlock('B', $blog['id'], $blog['zan']) ?></li>
+			<?php endif ?>
 		</ul>
 	</div>
 </div></div>

+ 182 - 0
web/app/views/blog-sidebar.php

@@ -0,0 +1,182 @@
+<?php
+	// Shared sidebar for the blog archive (/blog/<user>) and the blog post page
+	// (/blog/<user>/post/<id>).
+	//
+	// Expects:
+	//   $owner               blog owner's username
+	//   $show_write_buttons  render the "写新博客 / 写新幻灯片" buttons (owner only)
+	//   $show_tags           render the 标签 card (archive page only)
+	//   $all_tags            tags for the 标签 card (rows with a 'tag' key)
+	if (!isset($owner)) {
+		$owner = UOJContext::userid();
+	}
+	$show_write_buttons = !empty($show_write_buttons);
+	$show_tags = !empty($show_tags);
+	if (!isset($all_tags)) {
+		$all_tags = array();
+	}
+	$sidebar_user = queryUser($owner);
+	// Queried exactly once per page view; the mini preview and the enlarged
+	// modal both render from this single map.
+	$activity = queryUserActivityHeatmap($owner);
+
+	$heatmap_today_ts = strtotime(date('Y-m-d'));
+	// Sunday of the current week (weeks are Monday-first), then back up to the
+	// Monday 52 weeks before it: 53 columns x 7 rows ending at today.
+	$heatmap_start_ts = $heatmap_today_ts + (7 - (int)date('N', $heatmap_today_ts)) * 86400 - 370 * 86400;
+
+	// Month labels are only shown in the enlarged modal, spaced at least four
+	// columns apart so short month names never overlap; a partial leading month
+	// yields to the fuller month that starts right after it.
+	$heatmap_month_labels = array();
+	$heatmap_candidates = array();
+	$heatmap_prev_month = null;
+	for ($col = 0; $col < 53; $col++) {
+		$month = (int)date('n', $heatmap_start_ts + $col * 7 * 86400);
+		if ($month !== $heatmap_prev_month) {
+			$heatmap_candidates[] = array($col, $month.'月');
+			$heatmap_prev_month = $month;
+		}
+	}
+	$heatmap_last_col = -100;
+	foreach ($heatmap_candidates as $candidate) {
+		if ($candidate[0] - $heatmap_last_col < 4) {
+			array_pop($heatmap_month_labels);
+		}
+		$heatmap_month_labels[$candidate[0]] = $candidate[1];
+		$heatmap_last_col = $candidate[0];
+	}
+	ksort($heatmap_month_labels);
+
+	// Build the 371 cells once and reuse the same markup in both grids.
+	$heatmap_cells = '';
+	for ($col = 0; $col < 53; $col++) {
+		for ($row = 0; $row < 7; $row++) {
+			$ts = $heatmap_start_ts + ($col * 7 + $row) * 86400;
+			$date = date('Y-m-d', $ts);
+			$count = isset($activity[$date]) ? $activity[$date] : 0;
+			if ($count <= 0) {
+				$level = 0;
+			} elseif ($count == 1) {
+				$level = 1;
+			} elseif ($count == 2) {
+				$level = 2;
+			} elseif ($count == 3) {
+				$level = 3;
+			} else {
+				$level = 4;
+			}
+			if ($ts > $heatmap_today_ts) {
+				$heatmap_cells .= '<span class="wyoj-heatmap-cell wyoj-heatmap-cell-future" data-level="0" data-date="'.$date.'" data-count="0"></span>';
+			} else {
+				$heatmap_cells .= '<span class="wyoj-heatmap-cell" data-level="'.$level.'" data-date="'.$date.'" data-count="'.$count.'" title="'.$count.' 次 · '.$date.'"></span>';
+			}
+		}
+	}
+	$heatmap_total = array_sum($activity);
+?>
+<div class="wyoj-blog-sidebar">
+	<?php if ($show_write_buttons && UOJContext::hasBlogPermission()): ?>
+	<div class="btn-group d-flex">
+		<a href="<?= HTML::blog_url($owner, '/post/new/write') ?>" class="btn btn-primary"><span class="glyphicon glyphicon-edit"></span> 写新博客</a>
+		<a href="<?= HTML::blog_url($owner, '/slide/new/write') ?>" class="btn btn-primary"><span class="glyphicon glyphicon-edit"></span> 写新幻灯片</a>
+	</div>
+	<?php endif ?>
+
+	<div class="card top-buffer-sm wyoj-blog-owner-card">
+		<div class="card-body">
+			<img class="wyoj-blog-owner-avatar" src="<?= HTML::avatar_addr($sidebar_user, 256) ?>" alt="<?= HTML::escape($owner) ?> Avatar" />
+			<div class="wyoj-blog-owner-name"><a href="<?= HTML::url('/user/profile/'.$owner) ?>"><?= HTML::escape($owner) ?></a></div>
+			<div class="wyoj-blog-owner-motto text-muted"><?= HTML::escape($sidebar_user['motto']) ?></div>
+		</div>
+	</div>
+
+	<?php if ($show_tags): ?>
+	<div class="card top-buffer-sm">
+		<div class="card-header">标签</div>
+		<div class="card-body">
+		<?php if ($all_tags): ?>
+		<?php foreach ($all_tags as $tag): ?>
+			<?php echoBlogTag($tag['tag']) ?>
+		<?php endforeach ?>
+		<?php else: ?>
+			<div class="text-muted">暂无</div>
+		<?php endif ?>
+		</div>
+	</div>
+	<?php endif ?>
+
+	<div class="card top-buffer-sm wyoj-heatmap-card" id="wyoj-heatmap-preview" role="button" tabindex="0" aria-label="查看全年活跃度">
+		<div class="card-header d-flex justify-content-between align-items-center">
+			<span>活跃度</span>
+			<small class="text-muted">近 53 周</small>
+		</div>
+		<div class="card-body">
+			<div class="wyoj-heatmap-mini">
+				<div class="wyoj-heatmap-grid wyoj-heatmap-grid-mini"><?= $heatmap_cells ?></div>
+			</div>
+			<div class="wyoj-heatmap-hint text-muted"><span class="glyphicon glyphicon-zoom-in"></span> 查看全年</div>
+		</div>
+	</div>
+</div>
+
+<div class="uoj-modal wyoj-heatmap-modal" id="wyoj-heatmap-modal" hidden>
+	<div class="uoj-modal-backdrop" data-uoj-modal-close></div>
+	<div class="uoj-modal-window" role="dialog" aria-modal="true" aria-label="活跃度">
+		<button type="button" class="uoj-modal-close" data-uoj-modal-close aria-label="关闭">×</button>
+		<div class="uoj-modal-body">
+			<div class="wyoj-heatmap-modal-head">
+				<span class="wyoj-heatmap-modal-title">活跃度</span>
+				<small class="text-muted">近 53 周<?= $heatmap_total > 0 ? ' · 共 '.$heatmap_total.' 次' : '' ?></small>
+			</div>
+			<?php if ($heatmap_total <= 0): ?>
+			<div class="text-muted top-buffer-sm">暂无活跃记录</div>
+			<?php endif ?>
+			<div class="wyoj-heatmap-lg-wrap">
+				<div class="wyoj-heatmap-lg">
+					<div class="wyoj-heatmap-lg-corner"></div>
+					<div class="wyoj-heatmap-months wyoj-heatmap-months-lg">
+						<?php foreach ($heatmap_month_labels as $col => $label): ?>
+						<span style="grid-column: <?= $col + 1 ?>"><?= $label ?></span>
+						<?php endforeach ?>
+					</div>
+					<div class="wyoj-heatmap-lg-days">
+						<span style="grid-row: 1">一</span>
+						<span style="grid-row: 3">三</span>
+						<span style="grid-row: 5">五</span>
+					</div>
+					<div class="wyoj-heatmap-grid wyoj-heatmap-grid-lg"><?= $heatmap_cells ?></div>
+				</div>
+			</div>
+			<div class="wyoj-heatmap-legend wyoj-heatmap-legend-lg">
+				<span class="wyoj-heatmap-cell" data-level="0"></span>
+				<span class="wyoj-heatmap-cell" data-level="1"></span>
+				<span class="wyoj-heatmap-cell" data-level="2"></span>
+				<span class="wyoj-heatmap-cell" data-level="3"></span>
+				<span class="wyoj-heatmap-cell" data-level="4"></span>
+				<span class="wyoj-heatmap-legend-label">活跃程度</span>
+			</div>
+		</div>
+	</div>
+</div>
+<?php echoUOJModalHelper(); ?>
+<script type="text/javascript">
+(function () {
+	var card = document.getElementById('wyoj-heatmap-preview');
+	var modal = document.getElementById('wyoj-heatmap-modal');
+	if (!card || !modal || !window.uojModalInit) {
+		return;
+	}
+	var api = window.uojModalInit(modal);
+	function openModal(e) {
+		e.preventDefault();
+		api.open();
+	}
+	card.addEventListener('click', openModal);
+	card.addEventListener('keydown', function (e) {
+		if (e.key === 'Enter' || e.key === ' ' || e.keyCode === 13 || e.keyCode === 32) {
+			openModal(e);
+		}
+	});
+})();
+</script>

+ 1 - 1
web/app/views/page-header.php

@@ -47,7 +47,7 @@
 		<?= HTML::css_link('/css/bootstrap-glyphicons.min.css?v=2019.5.31') ?>
 
 		<!-- Custom styles for this template -->
-		<?= HTML::css_link('/css/uoj-theme.css?v=3.2226') ?>
+		<?= HTML::css_link('/css/uoj-theme.css?v=3.2227') ?>
 		
 		<!-- jQuery (necessary for Bootstrap\'s JavaScript plugins) -->
 		<?= HTML::js_src('/js/jquery.min.js') ?>

+ 263 - 3
web/css/uoj-theme.css

@@ -607,9 +607,13 @@ pre {
 	white-space: nowrap;
 }
 
+/* #00ee00 was a maximum-saturation neon green; a field of 96px squares of it
+   was hard to look at.  #28a745 is the theme's own success green (btn-success,
+   text-success), so AC also stops being an arbitrary one-off colour.
+   WA stays Vivid on purpose: failures should be the thing that stands out. */
 .uoj-test-rect-ac {
-	background-color: #00ee00;
-	border-color: #2bff4f;
+	background-color: #28a745;
+	border-color: #1e7e34;
 	color: #fff;
 }
 
@@ -640,7 +644,7 @@ pre {
 }
 
 .uoj-test-rect-ac.is-selected {
-	outline-color: #00ee00;
+	outline-color: #28a745;
 }
 
 .uoj-test-rect-wa.is-selected {
@@ -804,3 +808,259 @@ body.uoj-modal-open {
 .card-header > small {
 	font-weight: 400;
 }
+
+/* ==========================================================================
+   Blog pages: list titles/cards, shared sidebar, activity heatmap.
+   Self-contained; nothing here restyles global typography, buttons or cards.
+   ========================================================================== */
+
+/* -- list titles: a list headline, not an article headline ---------------- */
+.blog-title.wyoj-blog-list-title {
+	font-size: 1.25rem;
+	font-weight: 600;
+	color: #1d1d1f;
+	line-height: 1.4;
+	margin: 0 0 0.75rem;
+}
+.blog-title.wyoj-blog-list-title a {
+	color: inherit;
+	text-decoration: none;
+}
+.blog-title.wyoj-blog-list-title a:hover {
+	color: #007bff;
+}
+
+/* -- post detail title: clearly larger than the list headline ------------ */
+.blog-title.wyoj-blog-detail-title {
+	font-size: 1.5rem;
+	font-weight: 600;
+	line-height: 1.3;
+	/* .blog-title carries `margin: 1rem 0 1.5rem`.  Inside a card body that 1rem
+	   stacked on the card's own 20px padding and pushed the title 37px off the
+	   top edge; the card padding is inset enough on its own. */
+	margin-top: 0;
+}
+
+/* -- compact author/date/zan meta line ----------------------------------- */
+.wyoj-blog-meta {
+	font-size: 0.85rem;
+	line-height: 1.2;
+}
+.wyoj-blog-meta-avatar {
+	width: 24px;
+	height: 24px;
+	object-fit: cover;
+}
+
+/* -- list summary: clamp the real rendered HTML; click to expand ---------- */
+.blog-list .blog-content-orig {
+	display: -webkit-box;
+	-webkit-line-clamp: 6;
+	-webkit-box-orient: vertical;
+	overflow: hidden;
+	/* safety cap: -webkit-line-clamp treats an embedded table/JSON block as a
+	   single box, so bound the height as well (~6 lines + paragraph gaps). */
+	max-height: 14rem;
+}
+.blog-list .blog-content:not(.uoj-clamp-open) {
+	cursor: pointer;
+}
+.blog-list .blog-content.uoj-clamp-open .blog-content-orig {
+	display: block;
+	overflow: visible;
+	max-height: none;
+}
+
+/* -- infinite feed status ------------------------------------------------ */
+.wyoj-blog-feed-loading {
+	font-size: 0.9rem;
+}
+.wyoj-blog-feed-loading .spinner-border {
+	width: 1rem;
+	height: 1rem;
+	border-width: 0.15em;
+}
+
+/* -- shared sidebar: sticky on wide screens only ------------------------- */
+.wyoj-blog-owner-card .card-body {
+	text-align: center;
+}
+.wyoj-blog-owner-avatar {
+	width: 80px;
+	height: 80px;
+	object-fit: cover;
+	border-radius: 8px;
+}
+.wyoj-blog-owner-name {
+	margin-top: 0.5rem;
+	font-weight: 600;
+}
+.wyoj-blog-owner-motto {
+	margin-top: 0.25rem;
+	font-size: 0.85rem;
+	word-break: break-word;
+}
+@media (min-width: 992px) {
+	.wyoj-blog-sidebar {
+		position: sticky;
+		top: 12px;
+		max-height: calc(100vh - 24px);
+		overflow-y: auto;
+	}
+}
+
+/* -- GitHub-style activity heatmap --------------------------------------- */
+/* Mini preview in the sidebar: full 53 weeks at 3px, no labels, no legend and
+   no horizontal scroll.  minmax(2px,3px) lets the tracks shrink instead of
+   overflowing when col-lg-3 is at its narrowest (992-1200px container). */
+.wyoj-heatmap-card {
+	cursor: pointer;
+	transition: box-shadow 0.15s ease;
+}
+.wyoj-heatmap-card:hover {
+	box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+}
+/* Keep the header's 20px inset so the grid lines up with the 活跃度 label, but
+   pull the bottom in: the default 20px left the card ending in a band of empty
+   space under the 查看全年 hint. */
+.wyoj-heatmap-card .card-body {
+	padding-bottom: 10px;
+}
+.wyoj-heatmap-mini {
+	width: 100%;
+}
+.wyoj-heatmap-grid-mini {
+	display: grid;
+	grid-auto-flow: column;
+	grid-template-columns: repeat(53, minmax(2px, 3px));
+	grid-template-rows: repeat(7, 3px);
+	gap: 1px;
+	justify-content: space-between;
+}
+.wyoj-heatmap-hint {
+	margin-top: 8px;
+	font-size: 12px;
+	text-align: center;
+}
+.wyoj-heatmap-card:hover .wyoj-heatmap-hint {
+	color: #007bff !important;
+}
+
+/* Shared cells. The grid tracks own the size; the base rule must not impose
+   width/height or the cells would not shrink to fit. */
+.wyoj-heatmap-grid .wyoj-heatmap-cell {
+	display: block;
+	width: auto;
+	height: auto;
+	border-radius: 1px;
+}
+.wyoj-heatmap-cell[data-level="0"] { background-color: #ebedf0; }
+.wyoj-heatmap-cell[data-level="1"] { background-color: #9be9a8; }
+.wyoj-heatmap-cell[data-level="2"] { background-color: #40c463; }
+.wyoj-heatmap-cell[data-level="3"] { background-color: #30a14e; }
+.wyoj-heatmap-cell[data-level="4"] { background-color: #216e39; }
+.wyoj-heatmap-cell-future {
+	visibility: hidden;
+}
+
+/* Enlarged grid inside the reusable .uoj-modal component. */
+.wyoj-heatmap-modal .uoj-modal-window {
+	height: auto;
+	width: min(1040px, 94vw);
+}
+.wyoj-heatmap-modal-head {
+	padding-right: 28px;
+	margin-bottom: 10px;
+}
+.wyoj-heatmap-modal-title {
+	font-size: 1.1rem;
+	font-weight: 700;
+	margin-right: 8px;
+}
+.wyoj-heatmap-lg-wrap {
+	overflow-x: auto;
+	padding-bottom: 4px;
+}
+.wyoj-heatmap-lg {
+	display: grid;
+	grid-template-columns: auto auto;
+	grid-template-rows: auto auto;
+	column-gap: 6px;
+	row-gap: 4px;
+	width: max-content;
+}
+.wyoj-heatmap-lg-corner {
+	grid-column: 1;
+	grid-row: 1;
+}
+.wyoj-heatmap-lg .wyoj-heatmap-months {
+	grid-column: 2;
+	grid-row: 1;
+}
+.wyoj-heatmap-lg-days {
+	grid-column: 1;
+	grid-row: 2;
+	display: grid;
+	grid-template-rows: repeat(7, 14px);
+	gap: 3px;
+	font-size: 11px;
+	line-height: 14px;
+	color: #586069;
+	text-align: right;
+}
+.wyoj-heatmap-lg .wyoj-heatmap-grid {
+	grid-column: 2;
+	grid-row: 2;
+}
+.wyoj-heatmap-months {
+	display: grid;
+	height: 14px;
+	font-size: 10px;
+	line-height: 1;
+	color: #586069;
+}
+.wyoj-heatmap-months > span {
+	white-space: nowrap;
+}
+.wyoj-heatmap-months-lg {
+	grid-template-columns: repeat(53, 14px);
+	column-gap: 3px;
+	height: 16px;
+	font-size: 11px;
+}
+.wyoj-heatmap-grid-lg {
+	display: grid;
+	grid-auto-flow: column;
+	grid-template-columns: repeat(53, 14px);
+	grid-template-rows: repeat(7, 14px);
+	gap: 3px;
+}
+.wyoj-heatmap-grid-lg .wyoj-heatmap-cell {
+	border-radius: 3px;
+}
+.wyoj-heatmap-legend {
+	display: flex;
+	align-items: center;
+	margin-top: 10px;
+	font-size: 11px;
+	color: #586069;
+}
+.wyoj-heatmap-legend > span {
+	margin-right: 4px;
+}
+.wyoj-heatmap-legend-label {
+	white-space: nowrap;
+}
+.wyoj-heatmap-legend .wyoj-heatmap-cell {
+	display: inline-block;
+	width: 5px;
+	height: 5px;
+	border-radius: 1px;
+	margin-right: 3px;
+	vertical-align: middle;
+}
+.wyoj-heatmap-legend-lg .wyoj-heatmap-cell {
+	width: 14px;
+	height: 14px;
+	border-radius: 2px;
+}