Pārlūkot izejas kodu

User profile page update.

ryp 3 nedēļas atpakaļ
vecāks
revīzija
200b8b5e2c
2 mainītis faili ar 480 papildinājumiem un 327 dzēšanām
  1. 17 0
      app/controllers/certs_manage.php
  2. 463 327
      app/controllers/user_info.php

+ 17 - 0
app/controllers/certs_manage.php

@@ -41,6 +41,21 @@
 		becomeMsgPage("<div><h4>成功切换!</h4></div>");
 	}
 
+	$bg_form = new UOJForm('bg_form');
+	$bg_form->addInput('bgurl', 'text', '背景地址 URL', '',
+		function ($x) {
+			return '';
+		}, null);
+
+	$bg_form->handle = function () {
+		global $myUser;
+		$url = DB::escape($_POST['bgurl']);
+		error_log($url);
+		DB::update("update user_info set bgurl = '$url' where username = '{$myUser['username']}'");
+	};
+
+	$bg_form->runAtServer();
+
 	$aboutme_form = new UOJForm('aboutme_form');
 	$aboutme_form->addInput('blogid', 'text', '博客 ID', '',
 		function ($x) {
@@ -142,6 +157,8 @@
 			<div>
 				<h4>设置自定义页面</h4>
 				<?php $aboutme_form->printHTML(); ?>
+				<h4>设置背景</h4>
+				<?php $bg_form->printHTML(); ?>
 			</div>
 		<?php elseif ($cur_tab == 'songs'): ?>
 			<div>

+ 463 - 327
app/controllers/user_info.php

@@ -1,327 +1,463 @@
-<?php
-	$username = $_GET['username'];
-	
-	requireLib('flot');
-	$REQUIRE_LIB['mathjax'] = '';
-	$REQUIRE_LIB['hljs'] = '';
-
-?>
-<?php if (validateUsername($username) && ($user = queryUser($username))): ?>
-	<?php echoUOJPageHeader($user['username'] . ' - ' . UOJLocale::get('user profile')) ?>
-	<?php
-		$esc_email = HTML::escape($user['email']);
-		$esc_qq = HTML::escape($user['qq'] != 0 ? $user['qq'] : 'Unfilled');
-		$esc_sex = HTML::escape($user['sex']);
-		$col_sex="color:blue";
-		if ($esc_sex == "M") {
-			$esc_sex="♂";
-			$col_sex="color:blue";
-		} elseif ($esc_sex == "F") {
-			$esc_sex="♀";
-			$col_sex="color:red";
-		} else {
-			$esc_sex="";
-			$col_sex="color:black";
-		}
-		$esc_motto = HTML::escape($user['motto']);
-		$extgroups = $user['extgroups'];
-	?>
-	<div class="card border-info">
-		<h5 class="card-header bg-info"><?= UOJLocale::get('user profile') ?></h5>
-		<div class="card-body">
-			<div class="row mb-4">
-				<div class="col-md-4 order-md-9">
-					<img class="media-object img-thumbnail d-block mx-auto" alt="<?= $user['username'] ?> Avatar" height="250" width="250" src="<?= HTML::avatar_addr($user, 256) ?>" />
-				</div>
-				<div class="col-md-8 order-md-1">
-					<h2><span class="uoj-honor" data-rating="<?= $user['rating'] ?>" data-certs="<?= $user['certs'] ?>"><?= $user['username'] ?></span> <span><strong style="<?= $col_sex ?>"><?= $esc_sex ?></strong></span></h2>
-					<div class="list-group">
-						<div class="list-group-item">
-							<h4 class="list-group-item-heading"><?= UOJLocale::get('rating') ?></h4>
-							<p class="list-group-item-text"><strong style="color:red"><?= $user['rating'] ?> (±<?= 2 * round($user['rating_rd']) ?>), vol <?= round($user['rating_vol'] * 100) / 100.0 ?></strong></p>
-						</div>
-						<div class="list-group-item">
-							<h4 class="list-group-item-heading"><?= UOJLocale::get('email') ?></h4>
-							<p class="list-group-item-text"><?= $esc_email ?></p>
-						</div>
-						<div class="list-group-item">
-							<h4 class="list-group-item-heading"><?= UOJLocale::get('QQ') ?></h4>
-							<p class="list-group-item-text"><?= $esc_qq ?></p>
-						</div>
-						<div class="list-group-item">
-							<h4 class="list-group-item-heading"><?= UOJLocale::get('motto') ?></h4>
-							<p class="list-group-item-text"><?= $esc_motto ?></p>
-						</div>
-						<div class="list-group-item">
-							<h4 class="list-group-item-heading">用户组信息</h4>
-							<p class="list-group-item-text"><?= $extgroups ?></p>
-						</div>
-						
-						<?php if (isSuperUser($myUser)): ?>
-						<div class="list-group-item">
-							<h4 class="list-group-item-heading">register time</h4>
-							<p class="list-group-item-text"><?= $user['register_time'] ?></p>
-						</div>
-						<div class="list-group-item">
-							<h4 class="list-group-item-heading">remote_addr</h4>
-							<p class="list-group-item-text"><?= $user['remote_addr'] ?></p>
-						</div>
-						<div class="list-group-item">
-							<h4 class="list-group-item-heading">http_x_forwarded_for</h4>
-							<p class="list-group-item-text"><?= $user['http_x_forwarded_for'] ?></p>
-						</div>
-						<?php endif ?>
-					</div>
-				</div>
-			</div>
-			<?php if (Auth::check()): ?>
-			<?php if (Auth::id() != $user['username']): ?>
-			<a type="button" class="btn btn-info btn-sm" href="/user/msg?enter=<?= $user['username'] ?>"><span class="glyphicon glyphicon-envelope"></span> <?= UOJLocale::get('send private message') ?></a>
-			<?php else: ?>
-			<a type="button" class="btn btn-info btn-sm" href="/user/modify-profile"><span class="glyphicon glyphicon-pencil"></span> <?= UOJLocale::get('modify my profile') ?></a>
-			<?php endif ?>
-			<?php endif ?>
-			
-			<a type="button" class="btn btn-success btn-sm" href="<?= HTML::blog_url($user['username'], '/') ?>"><span class="glyphicon glyphicon-arrow-right"></span> <?= UOJLocale::get('visit his blog', $username) ?></a>
-			
-			<div class="top-buffer-lg"></div>
-			<div class="list-group">
-				<?php
-					$blogid = $user['aboutme_blogid'];
-					if ($blogid) {
-						echo '<div class="list-group-item">';
-						echoBlog(queryBlog($blogid), array('show_content_only' => true, 'outside_blog_domain' => true));
-						echo '</div>';
-					}
-				?>
-				<div class="list-group-item">
-					<h4 class="list-group-item-heading"><?= UOJLocale::get('rating changes') ?></h4>
-					<div class="list-group-item-text" id="rating-plot" style="height:500px;"></div>
-				</div>
-				<div class="list-group-item">
-					<h4 class="list-group-item-heading">Rating 分布</h4>
-					<div class="list-group-item-text" id="rating-dist-plot" style="height:500px;"></div>
-				</div>
-
-				<div class="list-group-item">
-					<?php
-						$ac_problems = DB::selectAll("select problem_id from best_ac_submissions where submitter = '{$user['username']}'");
-					?>
-					<h4 class="list-group-item-heading"><?= UOJLocale::get('accepted problems').':'.UOJLocale::get('n problems in total', count($ac_problems))?> </h4>
-					<p class="list-group-item-text">
-					<?php
-						foreach ($ac_problems as $problem) {
-							echo '<a href="/problem/', $problem['problem_id'], '" style="display:inline-block; width:4em;">', $problem['problem_id'], '</a>';
-						}
-						if (empty($ac_problems)) {
-							echo UOJLocale::get('none');
-						}
-					?>
-					</p>
-				</div>
-			</div>
-		</div>
-	</div>
-<script type="text/javascript">
-var rating_data = [[
-<?php
-	$user_rating_min = $user_rating_max = 1500;
-	$result = DB::query("select contest_id, `rank`, user_rating from contests_registrants join contests on id = contest_id where username = '{$user['username']}' and has_participated = 1 order by contests.start_time");
-	$is_first_row = true;
-	$last_rating = 1500;
-	while ($row = DB::fetch($result)) {
-		$contest = queryContest($row['contest_id']);
-		$rating_delta = $row['user_rating'] - $last_rating;
-		if (!$is_first_row) {
-			echo "[$last_contest_time, {$row['user_rating']}, $last_contest_id, $last_contest_name, $last_rank, $rating_delta],";
-		} else {
-			$is_first_row = false;
-		}
-		$contest_start_time = new DateTime($contest['start_time']);
-		$last_contest_time = ($contest_start_time->getTimestamp() + $contest_start_time->getOffset()) * 1000;
-		$last_contest_name = json_encode($contest['name']);
-		$last_contest_id = $contest['id'];
-		$last_rank = $row['rank'];
-		$last_rating = $row['user_rating'];
-		
-		if ($row['user_rating'] < $user_rating_min) {
-			$user_rating_min = $row['user_rating'];
-		}
-		if ($row['user_rating'] > $user_rating_max) {
-			$user_rating_max = $row['user_rating'];
-		}
-	}
-	if ($is_first_row) {
-		$time_now_stamp = (UOJTime::$time_now->getTimestamp() + UOJTime::$time_now->getOffset()) * 1000;
-		echo "[{$time_now_stamp}, {$user['rating']}, 0]";
-	} else {
-		$rating_delta = $user['rating'] - $last_rating;
-		echo "[$last_contest_time, {$user['rating']}, $last_contest_id, $last_contest_name, $last_rank, $rating_delta]";
-	}
-	if ($user['rating'] < $user_rating_min) {
-		$user_rating_min = $user['rating'];
-	}
-	if ($user['rating'] > $user_rating_max) {
-		$user_rating_max = $user['rating'];
-	}
-		
-	$user_rating_min -= 400;
-	$user_rating_max += 400;
-?>
-]];
-var rating_plot = $.plot($("#rating-plot"), [{
-	color: "#3850eb",
-	label: "<?= $user['username'] ?>",
-	data: rating_data[0]
-}], {
-	series: {
-		lines: {
-			show: true
-		},
-		points: {
-			show: true
-		}
-	},
-	xaxis: {
-		mode: "time"
-	},
-	yaxis: {
-		min: <?= $user_rating_min ?>,
-		max: <?= $user_rating_max ?>
-	},
-	legend: {
-		labelFormatter: function(username) {
-			return getUserLink(username, <?= $user['rating'] ?>, false);
-		}
-	},
-	grid: {
-		clickable: true,
-		hoverable: true
-	},
-	hooks: {
-		drawBackground: [
-			function(plot, ctx) {
-				var plotOffset = plot.getPlotOffset();
-				for (var y = 0; y < plot.height(); y++) {
-					var rating = <?= $user_rating_max ?> - <?= $user_rating_max - $user_rating_min ?> * y / plot.height();
-					ctx.fillStyle = getColOfRating(rating);
-					ctx.fillRect(plotOffset.left, plotOffset.top + y, plot.width(), Math.min(5, plot.height() - y));
-				}
-			}
-		]
-	}
-});
-
-function showTooltip(x, y, contents) {
-    $('<div id="rating-tooltip">' + contents + '</div>').css({
-        position: 'absolute',
-        display: 'none',
-        top: y - 20,
-        left: x + 10,
-        border: '1px solid #fdd',
-        padding: '2px',
-        'font-size' : '11px',
-        'background-color': '#fee',
-        opacity: 0.80
-    }).appendTo("body").fadeIn(200);
-}
-
-var prev = -1;
-function onHoverRating(event, pos, item) {
-	if (prev != item.dataIndex) {
-		$("#rating-tooltip").remove();
-		var params = rating_data[item.seriesIndex][item.dataIndex];
-
-		var total = params[1];
-		var contestId = params[2];
-		if (contestId != 0) {
-			var change = params[5] > 0 ? "+" + params[5] : params[5];
-			var contestName = params[3];
-			var rank = params[4];
-			var html = "= " + total + " (" + change + "), <br/>"
-			+ "Rank: " + rank + "<br/>"
-            + '<a href="' + '/contest/' + contestId + '">' + contestName + '</a>';
-		} else {
-			var html = "= " + total + "<br/>"
-			+ "Unrated";
-		}
-		showTooltip(item.pageX, item.pageY, html);
-		prev = item.dataIndex;
-	}
-}
-$("#rating-plot").bind("plothover", function (event, pos, item) {
-    if (item) {
-    	onHoverRating(event, pos, item);
-    }
-});
-$("#rating-plot").bind("plotclick", function (event, pos, item) {
-    if (item && prev == -1) {
-    	onHoverRating(event, pos, item);
-    } else {
-		$("#rating-tooltip").fadeOut(200);
-		prev = -1;
-	}
-});
-
-(() => {
-	var E = <?php echo $user['rating']; ?>, sigma = <?php echo $user['rating_rd']; ?>;
-	var data = [], step = 0.1;
-	for (var x = E - 6 * sigma; x <= E + 6 * sigma; x += step) {
-		var e = -Math.pow(x - E, 2) / (2 * Math.pow(sigma, 2));
-		var pdf = (1 / (sigma * Math.sqrt(2 * Math.PI))) * Math.exp(e);
-		data.push([x, pdf]);
-	}
-
-	var realratings = [
-		<?php 	
-		$result = DB::query("select contest_id, user_rating from contests_registrants where username = '{$user['username']}' and has_participated = 1");
-		foreach ($result as $r) echo "{$r['user_rating']}, ";
-		echo '0';
-		?>
-	];
-	
-		console.log(realratings);
-
-	var realdata = [];
-	realratings.forEach((x) => {
-		if (x >= E - 6 * sigma && x <= E + 6 * sigma) {
-			var e = -Math.pow(x - E, 2) / (2 * Math.pow(sigma, 2));
-			var pdf = (1 / sigma / Math.sqrt(2 * Math.PI)) * Math.exp(e);
-			realdata.push([x, pdf]);
-		}
-	});
-
-	console.log(realdata);
-
-	$.plot('#rating-dist-plot', [{
-		data: data,
-		color: '#FF0000',
-		lines: { show: true, lineWidth: 2 },
-		points: { show: false }
-	}, {
-		data: realdata,
-		color: '#0000FF',
-		lines: { show: false },
-		points: {
-			show: true,
-			radius: 3,
-			fillColor: '#FFFFFF',
-			symbol: 'circle'
-		}
-	}], {
-		grid: { hoverable: true, borderWidth: 1 },
-		legend: { show: true, position: 'ne' }
-	});
-}) ();
-</script>
-<?php else: ?>
-	<?php echoUOJPageHeader('不存在该用户' . ' - 用户信息') ?>
-	<div class="card border-danger">
-		<div class="card-header bg-danger">用户信息</div>
-		<div class="card-body">
-		<h4>不存在该用户</h4>
-		</div>
-	</div>
-<?php endif ?>
-
-<?php echoUOJPageFooter() ?>
+<?php
+    $username = $_GET['username'];
+
+    requireLib('flot');
+    $REQUIRE_LIB['mathjax'] = '';
+    $REQUIRE_LIB['hljs'] = '';
+?>
+<?php if (validateUsername($username) && ($user = queryUser($username))): ?>
+    <?php echoUOJPageHeader($user['username'] . ' - ' . UOJLocale::get('user profile')) ?>
+    <?php
+        $esc_motto = HTML::escape($user['motto']);
+        $esc_qq    = HTML::escape($user['qq'] != 0 ? $user['qq'] : 'Unfilled');
+	$groups = explode(',', $user['extgroups']);
+	$extgroups = '';
+	foreach ($groups as $grp) {
+		$grpname = queryUser($grp)['email'];
+		if ($grpname == '') $grpname = '无昵称';
+		$extgroups .= "<a href=\"/user/profile/{$grp}\">$grpname ($grp)</a>";
+		$extgroups .= '<br/>';
+	}
+
+        $bgurl  = isset($user['bgurl']) ? trim($user['bgurl']) : '';
+        $has_bg = $bgurl !== '' && preg_match('#^https?://#i', $bgurl);
+        $bg_style = $has_bg ? 'background-image: url(\'' . HTML::escape($bgurl) . '\');' : '';
+
+        // rating 排名:rating 严格小于他的人数 + 1
+        $rating_rank = DB::selectCount("select count(*) from user_info where rating > {$user['rating']}") + 1;
+    ?>
+
+    <div class="wyoj-profile">
+
+        <!-- Banner -->
+        <div class="wyoj-banner" style="<?= $bg_style ?>">
+            <div class="wyoj-banner-overlay"></div>
+	    <div class="wyoj-banner-content">
+		<div class="wyoj-identity">
+	                <div class="wyoj-username"><?= $user['username'] ?></div>
+			<div class="wyoj-motto"><?= $esc_motto ?></div>
+		</div>
+                <img class="wyoj-avatar"
+                     src="<?= HTML::avatar_addr($user, 256) ?>"
+                     alt="<?= $user['username'] ?> Avatar">
+            </div>
+        </div>
+
+        <!-- 下方:左 aboutme,右信息卡 -->
+        <div class="row wyoj-profile-body">
+            <div class="col-12 col-lg-8">
+                <div class="wyoj-about">
+                    <?php
+                        $blogid = $user['aboutme_blogid'];
+                        if ($blogid) {
+                            echoBlog(queryBlog($blogid), array(
+                                'show_content_only'    => true,
+                                'outside_blog_domain'  => true
+                            ));
+                        } else {
+                            echo '<p class="wyoj-empty">这个人很懒,什么都没有写。</p>';
+                        }
+                    ?>
+                </div>
+            </div>
+            <div class="col-12 col-lg-4">
+                <div class="wyoj-info-card">
+                    <div class="wyoj-info-item">
+                        <div class="wyoj-info-label">Rating</div>
+                        <div class="wyoj-info-value">
+                            <strong style="color:#dc3545;"><?= $user['rating'] ?></strong>
+                            <small>(±<?= round($user['rating_rd']) * 2 ?>)</small>
+                        </div>
+                    </div>
+                    <div class="wyoj-info-item">
+                        <div class="wyoj-info-label">Rating 排名</div>
+                        <div class="wyoj-info-value">
+                            <strong style="color:#dc3545;">#<?= $rating_rank ?></strong>
+                        </div>
+                    </div>
+                    <div class="wyoj-info-item">
+                        <div class="wyoj-info-label">QQ</div>
+                        <div class="wyoj-info-value"><?= $esc_qq ?></div>
+                    </div>
+                    <div class="wyoj-info-item">
+                        <div class="wyoj-info-label">用户组</div>
+                        <div class="wyoj-info-value"><?= $extgroups ?></div>
+                    </div>
+                    <div class="wyoj-info-item">
+                        <div class="wyoj-info-label">注册时间</div>
+                        <div class="wyoj-info-value"><?= $user['register_time'] ?></div>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- rating 曲线 + 分布 -->
+        <div class="row wyoj-rating-row">
+            <div class="col-12 col-lg-8">
+                <div class="wyoj-chart-card">
+                    <h5 class="wyoj-chart-title"><?= UOJLocale::get('rating changes') ?></h5>
+                    <div id="rating-plot" class="wyoj-chart"></div>
+                </div>
+            </div>
+            <div class="col-12 col-lg-4">
+                <div class="wyoj-chart-card">
+                    <h5 class="wyoj-chart-title">Rating 分布</h5>
+                    <div id="rating-dist-plot" class="wyoj-chart"></div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <style>
+    .wyoj-profile { color: #212529; }
+
+    /* ---------- Banner ---------- */
+    .wyoj-banner {
+        position: relative;
+        height: 220px;
+        border-radius: 8px;
+        overflow: hidden;
+        background-color: #f8f9fa;
+        background-size: cover;
+        background-position: center;
+        background-repeat: no-repeat;
+        background-image: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
+        border: 1px solid #dee2e6;
+    }
+    .wyoj-banner-overlay {
+        position: absolute; inset: 0;
+        background: linear-gradient(180deg,
+                    rgba(255,255,255,0.10) 0%,
+                    rgba(255,255,255,0.55) 100%);
+        pointer-events: none;
+    }
+    .wyoj-banner-content {
+        position: absolute; inset: 0;
+        display: flex; align-items: flex-end; justify-content: flex-end;
+        padding: 20px 28px; gap: 20px; z-index: 1;
+    }
+
+    .wyoj-identity {
+        text-align: right;
+        max-width: 420px;
+        padding-bottom: 6px;
+    }
+    .wyoj-username {
+        color: #212529;
+        font-size: 1.4rem;
+        font-weight: 700;
+        line-height: 1.2;
+        text-shadow: 0 1px 2px rgba(255,255,255,0.9);
+    }
+    .wyoj-motto {
+        color: #212529;
+        font-size: 1rem;
+	line-height: 1.5;
+	text-shadow: 0 1px 2px rgba(255,255,255,0.9);
+    }
+    
+
+    .wyoj-avatar {
+        width: 96px; height: 96px; object-fit: cover;
+        border-radius: 50%;
+        border: 3px solid #fff;
+        box-shadow: 0 2px 10px rgba(0,0,0,0.15);
+        flex-shrink: 0;
+    }
+
+    /* ---------- 下方内容 ---------- */
+    .wyoj-profile-body { margin-top: 24px; }
+
+    .wyoj-about { min-height: 1px; }
+    .wyoj-about a { color: #dc3545; }
+    .wyoj-empty {
+        color: #6c757d;
+        margin: 0;
+        padding: 24px;
+        background: #fff;
+        border: 1px solid #dee2e6;
+        border-radius: 8px;
+    }
+
+    .wyoj-info-card {
+        background: #fff;
+        border: 1px solid #dee2e6;
+        border-radius: 8px;
+        padding: 8px 20px;
+    }
+    .wyoj-info-item {
+        padding: 14px 0;
+        border-bottom: 1px solid #e9ecef;
+    }
+    .wyoj-info-item:last-child { border-bottom: none; }
+    .wyoj-info-label {
+        color: #6c757d;
+        font-size: 0.78rem;
+        letter-spacing: 0.04em;
+        margin-bottom: 4px;
+    }
+    .wyoj-info-value {
+        color: #212529;
+        font-size: 0.95rem;
+        word-break: break-all;
+    }
+
+    /* ---------- rating 图表 ---------- */
+    .wyoj-rating-row { margin-top: 24px; }
+    .wyoj-chart-card {
+        background: #fff;
+        border: 1px solid #dee2e6;
+        border-radius: 8px;
+        padding: 20px;
+        height: 100%;
+    }
+    .wyoj-chart-title {
+        color: #212529;
+        font-size: 0.95rem;
+        font-weight: 600;
+        margin-bottom: 14px;
+    }
+    .wyoj-chart { width: 100%; height: 320px; }
+
+    .wyoj-chart .flot-tick-label {
+        color: #6c757d !important;
+        font-size: 0.75rem;
+    }
+    .wyoj-chart .flot-text { color: #6c757d; }
+    .wyoj-chart canvas { background: transparent; }
+
+    #rating-tooltip {
+        position: absolute; display: none;
+        background: #fff !important;
+        color: #212529 !important;
+        border: 1px solid #dc3545 !important;
+        border-radius: 6px;
+        padding: 6px 10px !important;
+        font-size: 12px !important;
+        opacity: 0.98 !important;
+        box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+        z-index: 9999;
+        pointer-events: none;
+    }
+    #rating-tooltip a { color: #dc3545 !important; }
+
+    /* ---------- 小屏适配 ---------- */
+    @media (max-width: 991.98px) {
+        .wyoj-banner { height: 180px; }
+        .wyoj-banner-content { padding: 16px 18px; gap: 14px; }
+	.wyoj-identity { max-width: 60%; }
+	.wyoj-username { font-size: 1.15rem; }
+	.wyoj-motto { font-size: 0.9rem; }
+        .wyoj-avatar { width: 72px; height: 72px; }
+        .wyoj-info-card { margin-top: 20px; }
+        .wyoj-chart { height: 260px; }
+        .wyoj-chart-card + .wyoj-chart-card { margin-top: 20px; }
+    }
+    </style>
+
+    <script type="text/javascript">
+    var rating_data = [[
+    <?php
+        $user_rating_min = $user_rating_max = 1500;
+        $result = DB::query("select contest_id, `rank`, user_rating from contests_registrants join contests on id = contest_id where username = '{$user['username']}' and has_participated = 1 order by contests.start_time");
+        $is_first_row = true;
+        $last_rating = 1500;
+        while ($row = DB::fetch($result)) {
+            $contest = queryContest($row['contest_id']);
+            $rating_delta = $row['user_rating'] - $last_rating;
+            if (!$is_first_row) {
+                echo "[$last_contest_time, {$row['user_rating']}, $last_contest_id, $last_contest_name, $last_rank, $rating_delta],";
+            } else {
+                $is_first_row = false;
+            }
+            $contest_start_time = new DateTime($contest['start_time']);
+            $last_contest_time = ($contest_start_time->getTimestamp() + $contest_start_time->getOffset()) * 1000;
+            $last_contest_name = json_encode($contest['name']);
+            $last_contest_id = $contest['id'];
+            $last_rank = $row['rank'];
+            $last_rating = $row['user_rating'];
+
+            if ($row['user_rating'] < $user_rating_min) $user_rating_min = $row['user_rating'];
+            if ($row['user_rating'] > $user_rating_max) $user_rating_max = $row['user_rating'];
+        }
+        if ($is_first_row) {
+            $time_now_stamp = (UOJTime::$time_now->getTimestamp() + UOJTime::$time_now->getOffset()) * 1000;
+            echo "[{$time_now_stamp}, {$user['rating']}, 0]";
+        } else {
+            $rating_delta = $user['rating'] - $last_rating;
+            echo "[$last_contest_time, {$user['rating']}, $last_contest_id, $last_contest_name, $last_rank, $rating_delta]";
+        }
+        if ($user['rating'] < $user_rating_min) $user_rating_min = $user['rating'];
+        if ($user['rating'] > $user_rating_max) $user_rating_max = $user['rating'];
+
+        $user_rating_min -= 400;
+        $user_rating_max += 400;
+    ?>
+    ]];
+
+    var rating_plot = $.plot($("#rating-plot"), [{
+        color: "#dc3545",
+        label: "<?= $user['username'] ?>",
+        data: rating_data[0]
+    }], {
+        series: {
+            lines: { show: true, lineWidth: 2 },
+            points: { show: true, radius: 3, fillColor: "#fff" }
+        },
+        xaxis: { mode: "time", color: "#6c757d" },
+        yaxis: {
+            min: <?= $user_rating_min ?>,
+            max: <?= $user_rating_max ?>,
+            color: "#6c757d"
+        },
+        legend: {
+            labelFormatter: function(username) {
+                return getUserLink(username, <?= $user['rating'] ?>, false);
+            }
+        },
+        grid: {
+            clickable: true,
+            hoverable: true,
+            borderWidth: 1,
+            borderColor: "#e9ecef",
+            color: "#6c757d",
+            backgroundColor: null
+        },
+        hooks: {
+            drawBackground: [
+                function(plot, ctx) {
+                    var plotOffset = plot.getPlotOffset();
+                    for (var y = 0; y < plot.height(); y++) {
+                        var rating = <?= $user_rating_max ?> - <?= $user_rating_max - $user_rating_min ?> * y / plot.height();
+                        ctx.fillStyle = getColOfRating(rating);
+                        ctx.globalAlpha = 0.10;
+                        ctx.fillRect(plotOffset.left, plotOffset.top + y, plot.width(), Math.min(5, plot.height() - y));
+                    }
+                    ctx.globalAlpha = 1;
+                }
+            ]
+        }
+    });
+
+    function showTooltip(x, y, contents) {
+        $('<div id="rating-tooltip">' + contents + '</div>').css({
+            position: 'absolute',
+            display: 'none',
+            top: y - 20,
+            left: x + 10
+        }).appendTo("body").fadeIn(200);
+    }
+
+    var prev = -1;
+    function onHoverRating(event, pos, item) {
+        if (prev != item.dataIndex) {
+            $("#rating-tooltip").remove();
+            var params = rating_data[item.seriesIndex][item.dataIndex];
+
+            var total = params[1];
+            var contestId = params[2];
+            if (contestId != 0) {
+                var change = params[5] > 0 ? "+" + params[5] : params[5];
+                var contestName = params[3];
+                var rank = params[4];
+                var html = "= " + total + " (" + change + "), <br/>"
+                    + "Rank: " + rank + "<br/>"
+                    + '<a href="' + '/contest/' + contestId + '">' + contestName + '</a>';
+            } else {
+                var html = "= " + total + "<br/>Unrated";
+            }
+            showTooltip(item.pageX, item.pageY, html);
+            prev = item.dataIndex;
+        }
+    }
+    $("#rating-plot").bind("plothover", function (event, pos, item) {
+        if (item) onHoverRating(event, pos, item);
+    });
+    $("#rating-plot").bind("plotclick", function (event, pos, item) {
+        if (item && prev == -1) {
+            onHoverRating(event, pos, item);
+        } else {
+            $("#rating-tooltip").fadeOut(200);
+            prev = -1;
+        }
+    });
+
+    (() => {
+        var E = <?php echo $user['rating']; ?>, sigma = <?php echo $user['rating_rd']; ?>;
+        var data = [], step = 0.1;
+        for (var x = E - 6 * sigma; x <= E + 6 * sigma; x += step) {
+            var e = -Math.pow(x - E, 2) / (2 * Math.pow(sigma, 2));
+            var pdf = (1 / (sigma * Math.sqrt(2 * Math.PI))) * Math.exp(e);
+            data.push([x, pdf]);
+        }
+
+        var realratings = [
+            <?php
+            $result = DB::query("select contest_id, user_rating from contests_registrants where username = '{$user['username']}' and has_participated = 1");
+            foreach ($result as $r) echo "{$r['user_rating']}, ";
+            echo '0';
+            ?>
+        ];
+
+        var realdata = [];
+        realratings.forEach((x) => {
+            if (x >= E - 6 * sigma && x <= E + 6 * sigma) {
+                var e = -Math.pow(x - E, 2) / (2 * Math.pow(sigma, 2));
+                var pdf = (1 / sigma / Math.sqrt(2 * Math.PI)) * Math.exp(e);
+                realdata.push([x, pdf]);
+            }
+        });
+
+        var maxY = 0;
+        data.forEach(p => { if (p[1] > maxY) maxY = p[1]; });
+
+        $.plot('#rating-dist-plot', [{
+            data: data,
+            color: '#dc3545',
+            lines: { show: true, lineWidth: 2 },
+            points: { show: false }
+        }, {
+            data: realdata,
+            color: '#6c757d',
+            lines: { show: false },
+            points: {
+                show: true,
+                radius: 3,
+                fillColor: '#fff',
+                symbol: 'circle'
+            }
+        }, {
+            data: [[E, 0], [E, maxY]],
+            color: '#dc3545',
+            lines: { show: true, lineWidth: 1 },
+            dashes: { show: true, lineWidth: 1, dashLength: 4 }
+        }], {
+            grid: {
+                hoverable: true,
+                borderWidth: 1,
+                borderColor: "#e9ecef",
+                color: "#6c757d",
+                backgroundColor: null,
+                markings: [{
+                    xaxis: { from: E, to: E },
+                    color: "rgba(220,53,69,0.10)"
+                }]
+            },
+            xaxis: { color: "#6c757d" },
+            yaxis: { color: "#6c757d", show: false },
+            legend: { show: false }
+        });
+    })();
+    </script>
+
+<?php else: ?>
+    <?php echoUOJPageHeader('不存在该用户' . ' - 用户信息') ?>
+    <div class="card border-danger">
+        <div class="card-header bg-danger">用户信息</div>
+        <div class="card-body">
+            <h4>不存在该用户</h4>
+        </div>
+    </div>
+<?php endif ?>
+
+<?php echoUOJPageFooter() ?>