Files
laoyang ee256adc4b 界面主题:5 套经典配色,按账号记住
系统设置里多一块「🎨 我的界面主题」,点卡片即时预览、保存后全站生效,不用重新登录。

- lib/theme.php(新增):主题注册表 ticket/indigo/forest/wine/midnight,
  分别是「复古票根(暖橙,默认)/ 靛蓝 / 松林 / 绛红 / 暗夜(深色)」。
  currentThemeKey() 以 session 为准,session 里没有就从 admins.theme 补读一次
  —— 升级前就已经登录着的会话也能立刻用上自己账号存的主题。库里是脏值时回落默认主题。
- css/themes.css(新增):5 套调色板,全部以 [data-theme="<key>"] 规则块给出,
  每套 16 个变量一个不少。**必须排在 css/style.css 之后加载**:两者优先级相同
  (都是 0,1,0),靠后出现者胜。另加 --primary-rgb("R, G, B" 三元组),
  页面里的淡色底/光晕一律 rgba(var(--primary-rgb), α),所以加一套主题只需动 CSS,
  页面与 JS 一行都不用改;每套还要声明 color-scheme,否则暗色主题下原生控件
  (日期选择器、下拉、滚动条)仍是刺眼的白底。
- css/style.css::root 保留默认主题那一份作兜底;导航底色改走 --nav-bg,
  滚动条改走变量,按钮光晕改走 --primary-rgb。
- lib/db.php / lib/auth.php:admins 增列 theme(幂等 ALTER,老库自动补,空值 = 默认主题);
  setAdminTheme() 校验 key、写库、同步 session。
- settings.php:主题卡片区。每张预览卡自己套一层 data-theme,用的就是那套主题真实的
  变量,因此「预览所见」与「换上去之后」必然一致,不必再抄一份色值;另有一小段 JS
  在点选时改 <html data-theme> 做即时预览(没脚本时选中后提交同样生效)。
- 14 个页面:<html> 上输出 data-theme,并在 style.css 之后引入 themes.css;
  内联样式里写死的橙色 rgba(230,126,34,…) 全部改走 rgba(var(--primary-rgb),…);
  影院/观影人色板选中态的描边由 #333 改为 var(--text-primary)(暗色主题下才看得见)。
- annual_report.php:报告本身仍是深色「海报」版式,但点缀色跟随主题;
  Chart.js 画在 canvas 上读不到 CSS 变量,改为运行时从 <html> 的计算样式取色。

顺手修的两个坑:
1. 注释里**不能**出现 PHP 起止标签。PHP 的结束标签在 // 注释里同样会关闭 PHP 模式,
   之后若再出现短标签就会被真的执行 —— lib/theme.php 的用法说明里写了完整标签示例,
   导致该文件被 require 时直接 Fatal error、整站白屏,而 php -l 查不出来(只看语法)。
   现在部署脚本里加了一道静态断言专门挡这个。
2. themesCssLink() 的返回值末尾补一个换行:PHP 会吃掉结束标签后面那一个换行,
   不补的话渲染结果里这一行会和下一行黏成一行。

回归:theme_e2e 111/111(含老库自动补列、脏值回落、5 个选项渲染、切换后落库+落 session+
立刻生效、跨账号隔离、非法 key 与注入被拒、themes.css 完整性、:root 与 ticket 一致、
无残留写死主色、无悬空 CSS 变量);reviews_e2e 84/84、fetch_movie 76/76、storage 196/196。
A/B 48 个响应:把「新增 head 行 / data-theme 属性 / 橙色→变量」三类等价替换归一化、
并把设置页新增区块整块删掉之后,**其余部分逐字节一致,零残留差异**。
线上 PHP 7.4.30 / 8.1.7 双版本冒烟全通过(迁移、5 套主题切换、跨账号隔离均验证,
写测试后已把 admin #1 的主题原样还原、其它账号一字未动)。
2026-09-22 20:33:47 +08:00

604 lines
22 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
// ============================================
// 文件: annual_report.php (年度报告)
// 说明: 修复标题居中问题
// ============================================
require_once 'config.php';
checkLogin();
$admin = getCurrentAdmin();
// 获取可选年份
$years = getYearsWithData();
$current_year = date('Y');
// 默认选择最新有数据的年份,否则当前年份
$selected_year = isset($_GET['year']) ? (int)$_GET['year'] : ( !empty($years) ? $years[0] : $current_year );
// 如果所选年份无数据,回退到最新有数据的年份
if (!in_array($selected_year, $years) && !empty($years)) {
$selected_year = $years[0];
}
$stats = getAnnualStats($selected_year);
$has_data = $stats['total'] > 0;
// 计算数据用于图表
$month_labels = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
$month_values = array_values($stats['month_distribution']);
$weekday_labels = ['周日','周一','周二','周三','周四','周五','周六'];
$weekday_values = array_values($stats['weekday_distribution']);
?>
<!DOCTYPE html>
<html lang="zh-CN"<?= themeAttr() ?>>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $selected_year ?> 年度观影报告 - 电影日志</title>
<link rel="stylesheet" href="css/style.css">
<?= themesCssLink() ?>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {
--report-bg: #0f0e17;
--report-card: #1a1a2e;
--report-text: #fffffe;
--report-muted: #a7a9be;
/* 年度报告本身是一套深色「海报」版式,底色固定不动;
点缀色跟随用户选的主题,这样蓝色主题下报告也是蓝的。 */
--report-primary: var(--primary);
--report-secondary: var(--primary-light);
}
body {
background: var(--report-bg);
color: var(--report-text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.report-container {
max-width: 1100px;
margin: 0 auto;
padding: 20px 20px 60px;
}
.report-header {
text-align: center;
padding: 30px 0 20px;
}
.report-header h1 {
font-size: 42px;
font-weight: 800;
background: linear-gradient(135deg, var(--report-primary), var(--report-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin: 0;
}
.report-header .sub {
color: var(--report-muted);
font-size: 18px;
margin-top: 8px;
}
.year-selector {
display: inline-block;
margin: 16px 0;
background: var(--report-card);
padding: 8px 16px;
border-radius: 30px;
border: 1px solid rgba(255,255,255,0.1);
}
.year-selector select {
background: transparent;
color: var(--report-text);
border: none;
font-size: 18px;
font-weight: 600;
padding: 8px 12px;
outline: none;
cursor: pointer;
}
.year-selector select option {
background: var(--report-card);
color: var(--report-text);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 16px;
margin: 30px 0;
}
.summary-card {
background: var(--report-card);
border-radius: 16px;
padding: 20px 16px;
text-align: center;
border: 1px solid rgba(255,255,255,0.05);
transition: transform 0.2s;
}
.summary-card:hover {
transform: translateY(-4px);
}
.summary-card .number {
font-size: 32px;
font-weight: 700;
color: var(--report-primary);
}
.summary-card .label {
font-size: 14px;
color: var(--report-muted);
margin-top: 4px;
}
.highlight-card {
background: var(--report-card);
border-radius: 16px;
padding: 24px 28px;
margin-bottom: 24px;
border: 1px solid rgba(255,255,255,0.05);
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
}
.highlight-card .label {
color: var(--report-muted);
font-size: 14px;
}
.highlight-card .value {
font-size: 24px;
font-weight: 700;
color: var(--report-text);
}
.highlight-card .value .highlight {
color: var(--report-primary);
}
.highlight-card .exclude-hint {
font-size: 12px;
color: var(--report-muted);
margin-top: 4px;
}
.chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-top: 30px;
}
.chart-box {
background: var(--report-card);
border-radius: 16px;
padding: 20px;
border: 1px solid rgba(255,255,255,0.05);
}
.chart-box h3 {
text-align: center;
color: var(--report-muted);
font-size: 16px;
font-weight: 400;
margin: 0 0 16px;
}
.chart-box canvas {
width: 100% !important;
height: auto !important;
max-height: 220px;
}
.keyword-section {
margin-top: 30px;
text-align: center;
}
.keyword-section h3 {
text-align: center;
color: var(--report-muted);
font-weight: 400;
}
.recent-movies {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
margin-top: 12px;
}
.recent-movies .movie-tag {
background: var(--report-card);
padding: 6px 16px;
border-radius: 20px;
font-size: 14px;
color: var(--report-text);
border: 1px solid rgba(255,255,255,0.06);
}
.no-data {
text-align: center;
padding: 60px 20px;
color: var(--report-muted);
}
.no-data .big-icon {
font-size: 64px;
display: block;
margin-bottom: 16px;
}
/* 我的观影记录 - 无滚动条 */
.my-movies-section {
margin-top: 30px;
}
.my-movies-section h3 {
color: var(--report-muted);
font-weight: 400;
text-align: center;
}
.movie-entry {
display: flex;
align-items: center;
gap: 20px;
background: var(--report-card);
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 12px;
border: 1px solid rgba(255,255,255,0.05);
}
.movie-entry .poster {
width: 80px;
height: 112px;
border-radius: 6px;
object-fit: cover;
background: #333;
flex-shrink: 0;
}
.movie-entry .info {
flex: 1;
}
.movie-entry .info .title {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
}
.movie-entry .info .date {
font-size: 14px;
color: var(--report-muted);
}
.movie-entry .viewers {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.movie-entry .viewers .viewer {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--report-text);
background: rgba(255,255,255,0.06);
padding: 2px 8px 2px 4px;
border-radius: 20px;
}
.movie-entry .viewers .viewer img {
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
}
.movie-entry .viewers .viewer.alone {
color: var(--report-muted);
background: transparent;
padding-left: 0;
}
/* 共同观影人列表 */
.common-viewers {
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: center;
margin-top: 12px;
}
.common-viewer-item {
display: flex;
flex-direction: column;
align-items: center;
background: var(--report-card);
padding: 12px 16px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.05);
min-width: 80px;
}
.common-viewer-item img {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
margin-bottom: 4px;
}
.common-viewer-item .name {
font-size: 14px;
font-weight: 500;
}
.common-viewer-item .count {
font-size: 12px;
color: var(--report-muted);
}
@media (max-width: 768px) {
.chart-grid {
grid-template-columns: 1fr;
}
.report-header h1 {
font-size: 28px;
}
.summary-grid {
grid-template-columns: repeat(2, 1fr);
}
.highlight-card {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.highlight-card .value {
font-size: 20px;
}
.movie-entry {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.movie-entry .poster {
width: 100%;
height: auto;
aspect-ratio: 2/3;
max-height: 200px;
}
}
@media (max-width: 480px) {
.summary-grid {
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.summary-card .number {
font-size: 24px;
}
}
</style>
</head>
<body>
<?php include 'nav.php'; ?>
<main>
<div class="report-container">
<div class="report-header">
<h1>🎬 <?= $selected_year ?> 观影年度报告</h1>
<p class="sub">回顾你这一年的光影旅程</p>
<div class="year-selector">
<form method="GET" action="">
<select name="year" onchange="this.form.submit()">
<?php foreach ($years as $y): ?>
<option value="<?= $y ?>" <?= $y == $selected_year ? 'selected' : '' ?>><?= $y ?> 年</option>
<?php endforeach; ?>
<?php if (!in_array($current_year, $years)): ?>
<option value="<?= $current_year ?>" <?= $current_year == $selected_year ? 'selected' : '' ?>><?= $current_year ?> 年</option>
<?php endif; ?>
</select>
</form>
</div>
</div>
<?php if (!$has_data): ?>
<div class="no-data">
<span class="big-icon">📭</span>
<h2>这一年还没有观影记录</h2>
<p style="color: var(--report-muted);">快去添加电影,开启你的光影之旅吧</p>
<a href="add.php" class="btn btn-primary" style="margin-top: 20px;">➕ 添加电影</a>
</div>
<?php else: ?>
<!-- 总览卡片 -->
<div class="summary-grid">
<div class="summary-card">
<div class="number"><?= $stats['total'] ?></div>
<div class="label">🎬 观影总数</div>
</div>
<div class="summary-card">
<div class="number"><?= $stats['days'] ?></div>
<div class="label">📅 观影天数</div>
</div>
<div class="summary-card">
<div class="number"><?= $stats['avg_per_month'] ?></div>
<div class="label">📊 月均观影</div>
</div>
<div class="summary-card">
<div class="number"><?= $stats['avg_days_between'] ?></div>
<div class="label">📆 平均每<?= $stats['avg_days_between'] > 0 ? $stats['avg_days_between'] : '-' ?>天一部</div>
</div>
</div>
<!-- 亮点 -->
<div class="highlight-card">
<div>
<div class="label">🏛️ 最常去的影院</div>
<div class="value"><span class="highlight"><?= h($stats['top_cinema']) ?></span> (<?= $stats['top_cinema_count'] ?> 次)</div>
</div>
<div>
<div class="label">👥 最常一起观影的人</div>
<div class="value"><span class="highlight"><?= h($stats['top_person']) ?></span> (<?= $stats['top_person_count'] ?> 次)</div>
<?php if ($stats['self_person_name']): ?>
<div class="exclude-hint">✅ 已排除 "<?= h($stats['self_person_name']) ?>" 自己</div>
<?php else: ?>
<div class="exclude-hint">⚠️ 未设置自己的观影人身份,未排除自己</div>
<?php endif; ?>
</div>
<div>
<div class="label">🎫 单次最多人</div>
<div class="value"><span class="highlight"><?= h($stats['most_viewers_movie']) ?></span> (<?= $stats['most_viewers_count'] ?> 人)</div>
</div>
</div>
<!-- 图表 -->
<div class="chart-grid">
<div class="chart-box">
<h3>📈 月度观影分布</h3>
<canvas id="monthChart"></canvas>
</div>
<div class="chart-box">
<h3>📆 星期观影偏好</h3>
<canvas id="weekdayChart"></canvas>
</div>
</div>
<!-- 今年共同观影人 -->
<?php if (!empty($stats['common_viewers'])): ?>
<div class="keyword-section">
<h3>👥 今年共同观影人</h3>
<div class="common-viewers">
<?php foreach ($stats['common_viewers'] as $viewer): ?>
<div class="common-viewer-item">
<img src="<?= getAvatarUrl($viewer['avatar'], $viewer['name']) ?>" alt="">
<span class="name"><?= h($viewer['name']) ?></span>
<span class="count">共同观影 <?= $viewer['count'] ?> 次</span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- 年末最近观影 -->
<?php if (!empty($stats['recent_movies'])): ?>
<div class="keyword-section">
<h3>🎥 年末最近观影</h3>
<div class="recent-movies">
<?php foreach ($stats['recent_movies'] as $title): ?>
<span class="movie-tag"><?= h($title) ?></span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- 我的观影记录(含海报和观影人头像) -->
<?php if (!empty($stats['my_movies'])): ?>
<div class="my-movies-section">
<h3>🎞️ 我的观影记录 (<?= count($stats['my_movies']) ?> 部)</h3>
<?php foreach ($stats['my_movies'] as $movie): ?>
<div class="movie-entry">
<?php
$poster = posterUrl($movie['poster_path']); // 本地/对象存储自动解析,无海报时回退占位图
?>
<img class="poster" src="<?= $poster ?>" alt="<?= h($movie['title']) ?>">
<div class="info">
<div class="title"><?= h($movie['title']) ?></div>
<div class="date">📅 <?= date('Y-m-d', strtotime($movie['watch_date'])) ?></div>
<div class="viewers">
<?php if (!empty($movie['viewers'])): ?>
<?php foreach ($movie['viewers'] as $viewer): ?>
<span class="viewer">
<img src="<?= getAvatarUrl($viewer['avatar'], $viewer['name']) ?>" alt="">
<?= h($viewer['name']) ?>
</span>
<?php endforeach; ?>
<?php else: ?>
<span class="viewer alone">独自观影</span>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- 年份跨度 -->
<div style="text-align: center; margin-top: 30px; color: var(--report-muted); font-size: 14px;">
首部观影:<?= $stats['first_date'] ? date('Y年m月d日', strtotime($stats['first_date'])) : '-' ?>
&nbsp;·&nbsp;
最后一部:<?= $stats['last_date'] ? date('Y年m月d日', strtotime($stats['last_date'])) : '-' ?>
</div>
<?php endif; ?>
</div>
</main>
<script>
<?php if ($has_data): ?>
// Chart.js 画在 <canvas> 上,读不到 CSS 变量,必须先把当前主题的颜色值取出来。
// 从 <html> 的计算样式里读,用户在设置里换了主题,图表颜色就跟着变,
// 不用在 PHP 里再抄一份调色板。
function themeColor(name, fallback) {
var v = getComputedStyle(document.documentElement).getPropertyValue(name);
return (v || '').trim() || fallback;
}
function rgbTriplet(hex) {
var h = (hex || '').replace('#', '');
if (h.length === 3) { h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; }
if (h.length !== 6) { return '230, 126, 34'; }
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)].join(', ');
}
var PRIMARY_HEX = themeColor('--primary', '#e67e22');
var PRIMARY_RGB = rgbTriplet(PRIMARY_HEX);
var LIGHT_HEX = themeColor('--primary-light', '#f39c12');
var LIGHT_RGB = rgbTriplet(LIGHT_HEX);
// 月度图表
const monthCtx = document.getElementById('monthChart').getContext('2d');
new Chart(monthCtx, {
type: 'bar',
data: {
labels: <?= json_encode($month_labels) ?>,
datasets: [{
label: '观影数量',
data: <?= json_encode($month_values) ?>,
backgroundColor: 'rgba(' + PRIMARY_RGB + ', 0.7)',
borderColor: PRIMARY_HEX,
borderWidth: 1,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
ticks: { stepSize: 1, color: '#a7a9be' },
grid: { color: 'rgba(255,255,255,0.05)' }
},
x: {
ticks: { color: '#a7a9be' },
grid: { display: false }
}
}
}
});
// 星期图表
const weekdayCtx = document.getElementById('weekdayChart').getContext('2d');
new Chart(weekdayCtx, {
type: 'bar',
data: {
labels: <?= json_encode($weekday_labels) ?>,
datasets: [{
label: '观影数量',
data: <?= json_encode($weekday_values) ?>,
backgroundColor: 'rgba(' + LIGHT_RGB + ', 0.7)',
borderColor: LIGHT_HEX,
borderWidth: 1,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
ticks: { stepSize: 1, color: '#a7a9be' },
grid: { color: 'rgba(255,255,255,0.05)' }
},
x: {
ticks: { color: '#a7a9be' },
grid: { display: false }
}
}
}
});
<?php endif; ?>
</script>
<script src="js/main.js"></script>
</body>
</html>