Files
laoyang 40e1bc5e56 feat: 完善电影记录系统源码与项目文档
功能模块:
- 观影记录:列表(瀑布流)/ 添加 / 编辑 / 详情 / 删除,删除时同步清理图片文件
- 首页快捷搜索 + 高级搜索(片名、影院、观影人、日期区间组合筛选)
- 影院管理:三段式信息 + 50 种预设配色,删除已引用影院时二次确认
- 观影人管理:头像裁剪上传 + 专属配色,已引用者禁止删除
- 图片处理:Cropper.js 裁剪票根/海报/观影照片/头像,多图上传,首字母 SVG 头像兜底
- OMDb 自动获取影片信息(fetch_movie.php 代理,返回 JSON)
- CSV 批量导入:UTF-8/GBK 自动识别,影院去重,事务保护,逐行错误报告
- 统计仪表板:总览 + 按影院/观影人/年份/近 12 个月排行
- 年度报告:按账号关联的观影人视角生成,含 Chart.js 月度与星期分布图
- 多用户:账号增删改、账号与观影人绑定、修改自己的用户名与密码
- 登录鉴权:Session + password_hash,全站页面登录校验

文档:
- 重写 README:功能特性、技术栈、目录结构、数据库结构、部署与使用指南、接口说明、CSV 格式、安全注意事项、已知限制
- 新增 .gitignore,排除 db/ 与 uploads/ 等运行时数据
2026-09-21 08:20:56 +08:00

582 lines
21 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">
<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">
<!-- 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: #e67e22;
--report-secondary: #f39c12;
}
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 = $movie['poster_path'] ? '/' . $movie['poster_path'] : '/assets/placeholder-poster.jpg';
?>
<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): ?>
// 月度图表
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(230, 126, 34, 0.7)',
borderColor: '#e67e22',
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(243, 156, 18, 0.7)',
borderColor: '#f39c12',
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>
</body>
</html>