diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e6a87f5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,23 @@
+# ===== 运行时数据(不入库)=====
+db/
+uploads/
+*.db
+*.db-journal
+*.db-wal
+*.db-shm
+*.sqlite
+
+# ===== 日志与临时文件 =====
+*.log
+*.tmp
+*.bak
+*.swp
+*~
+
+# ===== 系统与编辑器 =====
+Thumbs.db
+Desktop.ini
+.DS_Store
+.idea/
+.vscode/
+.attachments/
diff --git a/README.md b/README.md
index a0af4de..ee910a7 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,288 @@
-# MovieLog_Server
+# MovieLog_Server · 电影全纪录
-电影记录系统服务器端
\ No newline at end of file
+一个轻量级的个人/家庭观影记录系统。纯 PHP 编写,使用 SQLite 作为数据库,**无需任何框架、无需 Composer、无需配置数据库服务**,上传到支持 PHP 的空间即可运行。
+
+> 记录每一帧感动 —— 观影记录、影院、同行人、票根与照片,以及属于你的年度观影报告。
+
+---
+
+## 功能特性
+
+### 观影记录管理
+- **瀑布流首页**:按观影日期倒序展示所有电影,卡片显示海报、日期、影院(影院专属配色)、同行人头像。
+- **添加电影**:支持电影名、剧情简介、观影日期、电影院、同行人、海报、票根、多张观影照片。
+- **编辑 / 删除**:编辑页可替换或裁剪已有图片;删除电影时同步清理关联的图片文件。
+- **电影详情**:海报大图、基本信息、备注、同行人列表(含头像)、影院信息、影像记录画廊(票根 + 观影照片,点击可查看原图)。
+
+### 图片处理
+- **内置裁剪器**:基于 Cropper.js,票根 / 海报 / 观影照片 / 观影人头像均可在浏览器内裁剪后再保存,服务端接收 Base64 图片落盘。
+- **多图上传**:观影照片支持一次选择多张。
+- **头像兜底**:未上传头像的观影人自动生成"首字母 + 配色"的 SVG 头像,配色由姓名哈希决定,同名同色。
+- 上传目录按类型分离:`uploads/posters/`、`uploads/tickets/`、`uploads/photos/`、`uploads/avatars/`。
+
+### 信息辅助
+- **OMDb 自动获取**:在添加页输入片名后点击「📡 自动获取」,自动填充年份、类型、片长、导演、主演、IMDb 评分与剧情简介,并预览海报(海报仅供参考,仍需本地上传)。
+- **CSV 批量导入**:一次性导入历史数据,自动识别 UTF-8 / GBK / GB2312 编码,自动创建不存在的影院,影院名去重,全程事务保护(出错自动回滚),并输出逐行错误报告。
+
+### 检索与统计
+- **首页快捷搜索**:按片名模糊搜索。
+- **高级搜索**:片名 + 影院 + 观影人 + 日期区间,多条件组合筛选。
+- **统计仪表板**:总览卡片(电影数 / 影院数 / 观影人数 / 观影人次 / 时间跨度),按影院、按观影人、按年份、最近 12 个月的条形图排行。
+
+### 年度报告
+- **个性化年度报告**:以「当前登录账号所关联的观影人」为视角生成,而非全站数据。
+- **报告内容**:年度观影总数、观影天数、最常去的影院、最常一起观影的人、单场最多人的电影、首末场日期、月均频率、平均间隔天数、备注关键词云、今年共同观影人排行、年末最近观看、年度观影记录墙(含海报与同行人头像)。
+- **可视化**:月度观影分布、星期观影偏好(Chart.js 双图),整体为深色"影院感"主题。
+
+### 多用户与权限
+- **Session 登录**:`password_hash` / `password_verify` 加盐哈希存储口令,全站页面均需登录。
+- **用户管理**:可新增、编辑、删除管理员账号,可修改用户名与密码。
+- **账号 ↔ 观影人绑定**:每个管理员账号可关联一个观影人,绑定后即可查看个人年度报告。
+- **安全约束**:不允许删除最后一个管理员,也不允许删除自己。
+- **个人资料**:修改自己的用户名与密码(需验证旧密码)。
+
+### 基础数据维护
+- **影院管理**:增删改查,支持「所在地方 / 影院名称 / 详细地址」三段式信息,内置 50 种预设配色,每个影院可用专属颜色区分;删除已被引用的影院时会给出醒目提示并要求二次确认(关联电影的影院字段将被置空)。
+- **观影人管理**:增删改查,支持头像裁剪上传与专属配色;已被电影引用的观影人不允许直接删除。
+
+### 界面与体验
+- 复古票根色系(主色 `#e67e22` 橙)的响应式设计,移动端断点适配(600 / 768px)。
+- 导航为下拉菜单,自动高亮当前页;顶部始终显示当前用户头像与用户名。
+- 登录页为深色渐变 + 毛玻璃风格。
+
+---
+
+## 技术栈
+
+| 项目 | 说明 |
+| --- | --- |
+| 语言 | PHP(过程式 + PDO) |
+| 数据库 | SQLite(`db/movies.db`,首次运行自动建表) |
+| 前端 | 原生 HTML / CSS / JavaScript,无构建步骤 |
+| 第三方库 | [Cropper.js 1.5.13](https://github.com/fengyuanchen/cropperjs)(图片裁剪)、[Chart.js 4.4.0](https://www.chartjs.org/)(图表),均由 CDN 引入 |
+| 外部 API | [OMDb API](http://www.omdbapi.com/)(电影信息查询,需申请 API Key) |
+
+---
+
+## 目录结构
+
+```
+MovieLog_Server/
+├── config.php # 核心:常量、DB 连接、建表与迁移、公共函数、统计与年度报告查询
+├── index.php # 首页:观影列表(瀑布流)+ 快捷搜索
+├── add.php # 添加电影
+├── edit.php # 编辑电影
+├── detail.php # 电影详情
+├── delete.php # 删除电影(连带清理图片文件)
+├── search.php # 高级搜索
+├── stats.php # 统计仪表板
+├── annual_report.php # 年度报告
+├── cinemas.php # 影院管理
+├── persons.php # 观影人管理
+├── import.php # CSV 批量导入
+├── users.php # 用户(管理员)管理
+├── change_password.php # 修改自己的用户名/密码
+├── login.php # 登录
+├── logout.php # 退出
+├── nav.php # 导航栏组件(被各页面 include)
+├── fetch_movie.php # OMDb 代理接口,返回 JSON
+├── test_crop.php # 开发调试页:验证 Base64 图片落盘是否正常
+├── css/style.css # 全局样式
+├── js/main.js # 少量公共脚本
+├── assets/.gitkeep # 静态占位资源目录
+├── db/movies.db # SQLite 数据库(运行时自动生成,不纳入版本控制)
+└── uploads/ # 上传图片(运行时自动生成,不纳入版本控制)
+ ├── posters/ # 海报
+ ├── tickets/ # 票根
+ ├── photos/ # 观影照片
+ └── avatars/ # 观影人头像
+```
+
+---
+
+## 数据库结构
+
+建表逻辑集中在 `config.php` 的 `initDB()`,每次请求都会执行 `CREATE TABLE IF NOT EXISTS` 与幂等的 `ALTER TABLE` 迁移,因此**升级代码无需手动改库**。
+
+| 表 | 作用 | 关键字段 |
+| --- | --- | --- |
+| `admins` | 管理员账号 | `username`(唯一)、`password`(哈希)、`avatar`、`person_id`(关联观影人) |
+| `cinemas` | 电影院 | `place`(所在地方)、`name`、`address`、`color`(专属配色) |
+| `movies` | 电影记录 | `title`、`info`、`remark`、`poster_path`、`ticket_path`、`photo_path`(历史遗留单图)、`cinema_id`、`watch_date`、`created_at` |
+| `persons` | 观影人 | `name`(唯一)、`color`、`avatar` |
+| `viewers` | 电影 ↔ 观影人关联 | `movie_id`、`person_id`、`name`(历史遗留字段) |
+| `movie_photos` | 电影多图 | `movie_id`、`photo_path`、`created_at` |
+
+外键约束:`movies.cinema_id → cinemas.id`(`ON DELETE SET NULL`)、`viewers.movie_id → movies.id`(`ON DELETE CASCADE`)、`movie_photos.movie_id → movies.id`(`ON DELETE CASCADE`)。
+
+**历史数据迁移**:`initDB()` 会自动把早期版本存在 `viewers.name` 里的姓名去重后写入 `persons`,并回填 `viewers.person_id`,老库可直接升级。
+
+---
+
+## 安装部署
+
+### 环境要求
+
+- PHP 7.4+(推荐 8.x),需启用扩展:`pdo_sqlite`、`fileinfo`、`mbstring`、`curl`
+- Web 服务器:Apache / Nginx / PHP 内置服务器均可
+
+### 步骤
+
+1. **放置代码**:将项目文件放到 Web 根目录或其子目录。
+
+2. **确保可写**:保证项目根目录可写,程序会自动创建 `db/` 与 `uploads/` 及其子目录;或手动创建并授权:
+ ```bash
+ mkdir -p db uploads/posters uploads/tickets uploads/photos uploads/avatars
+ chmod -R 755 db uploads
+ ```
+
+3. **配置 OMDb API Key**(可选,仅影响「自动获取」功能):编辑 `fetch_movie.php`,将 `OMDB_API_KEY` 换成你在 [omdbapi.com](http://www.omdbapi.com/apikey.aspx) 申请的免费 Key。
+
+4. **访问站点**:浏览器打开 `login.php`。
+
+5. **首次登录**:数据库为空时会自动创建默认管理员:
+ - 用户名 `admin`
+ - 密码 `123456`
+ > **登录后请立即在「修改密码」中更换。**
+
+### 本地快速运行
+
+```bash
+php -S localhost:8000
+# 浏览器访问 http://localhost:8000/login.php
+```
+
+> 用 PHP 内置服务器时,`config.php` 中 `posterUrl()` 等函数返回的是以 `/` 开头的根路径,建议直接从项目根目录启动,或通过站点根目录访问。
+
+### Nginx 参考配置
+
+```nginx
+server {
+ listen 80;
+ server_name movielog.local;
+ root /path/to/MovieLog_Server;
+ index index.php;
+
+ location / {
+ try_files $uri $uri/ =404;
+ }
+
+ location ~ \.php$ {
+ fastcgi_pass 127.0.0.1:9000;
+ fastcgi_index index.php;
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+ include fastcgi_params;
+ }
+}
+```
+
+---
+
+## 使用说明
+
+### 添加一部电影
+
+1. 顶部导航点击「➕ 添加电影」。
+2. 输入片名后点「📡 自动获取」可自动填充影片信息(可选)。
+3. 填写观影日期(必填)、选择或新建电影院。
+4. 「一起看的人」逐行添加:选择已有观影人,或在下拉框中选「➕ 新增」并输入姓名,新姓名会自动进入观影人库。
+5. 上传海报(推荐 2:3);上传票根会弹出裁剪框,裁剪结果以 Base64 提交。
+6. 观影照片支持一次多选。
+7. 保存后自动跳转到详情页。
+
+### 生成年度报告
+
+年度报告是**按人**生成的,需要先建立账号与观影人的绑定:
+
+1. 进入「用户管理」,找到自己的账号,在「关联观影人」中选择对应的观影人并保存。
+2. 进入「年度报告」,页面顶部切换年份即可查看。
+3. 未绑定观影人时报告为空并给出提示。
+
+### CSV 批量导入
+
+CSV **共 9 列,顺序固定**:
+
+```
+电影名称, 地点(忽略), 电影院名称, 观影日期, 观影人(空格分隔), 电影票图片路径, 海报图片路径, 备注, 合影图片路径
+```
+
+- **观影人**:多个姓名用空格分隔,例如 `张三 李四 王五`。
+- **图片路径**:可带目录,系统只取最后一个 `/` 后的文件名;若整段被括号包裹(如 `(电影票/阿甘正传.jpg)`),会先取括号内内容再取文件名。
+- **观影日期可为空**,空日期的记录会排在列表最后。
+- **导入前**需先把图片文件放入 `uploads/tickets/`、`uploads/posters/`、`uploads/photos/`,文件名与 CSV 中提取出的保持一致。
+- 编码支持 UTF-8 / GBK / GB2312,自动识别转换。
+- 导入在事务中执行,失败会整体回滚;成功后显示「导入 N 条 / 跳过 M 条」及逐行错误原因。
+
+示例行:
+
+```csv
+阿甘正传, , 万达影城, 2024-01-15, 张三 李四, (电影票/阿甘正传.jpg), (海报/阿甘正传.jpg), 经典励志片, (合影/阿甘正传.jpg)
+```
+
+### 接口:`fetch_movie.php`
+
+供添加页前端调用,服务端代请求 OMDb 避免浏览器跨域。
+
+- **请求**:`GET fetch_movie.php?q=电影名称`
+- **响应**:`Content-Type: application/json`
+- **成功**:
+ ```json
+ {
+ "title": "Forrest Gump",
+ "year": "1994",
+ "rated": "PG-13",
+ "released": "06 Jul 1994",
+ "runtime": "142 min",
+ "genre": "Drama, Romance",
+ "director": "Robert Zemeckis",
+ "actors": "Tom Hanks, Robin Wright, Gary Sinise",
+ "plot": "...",
+ "poster": "https://...",
+ "imdbRating": "8.8"
+ }
+ ```
+- **失败**:`{"error": "错误说明"}`(如「未找到相关电影,请尝试更准确的关键词」)
+
+---
+
+## 安全注意事项
+
+部署到公网前请务必处理以下事项:
+
+1. **修改默认口令**:默认账号 `admin / 123456` 必须在首次登录后立即更换。
+2. **更换 OMDb API Key**:`fetch_movie.php` 中当前硬编码的 Key 为开发期临时使用,请替换为你自己的 Key;若仓库需要公开,建议将该值改为从环境变量读取。
+3. **删除或保护 `test_crop.php`**:该文件是开发调试页,**没有登录校验**,仅用于验证 Base64 图片能否落盘,生产环境应直接删除。
+4. **关闭调试输出**:`config.php` 顶部当前为 `error_reporting(E_ALL)` + `display_errors=1`,上线前请改为关闭或仅写日志,避免泄露路径与数据库信息。
+5. **启用 HTTPS 并校正证书校验**:`fetch_movie.php` 中 `CURLOPT_SSL_VERIFYPEER` 被设为 `false`,生产环境应改为 `true`。
+6. **保护数据目录**:`db/`(含 `movies.db`)与 `uploads/` 应禁止直接目录列举;SQLite 文件更应通过 Web 服务器规则**拒绝任何 HTTP 访问**。Nginx 示例:
+ ```nginx
+ location ~ ^/(db|uploads/.*\.(db|sqlite)) { deny all; }
+ ```
+7. **限制上传目录执行权限**:确保 `uploads/` 下不允许执行 PHP。
+
+---
+
+## 已知限制与待改进
+
+- `movies.photo_path` 与 `viewers.name` 是历史遗留字段,保留用于兼容旧数据与 CSV 导入,新代码统一使用 `movie_photos` 与 `person_id`。
+- 除 `delete.php` 通过 GET 触发(带前端 `confirm`)外,其余写操作均为 POST;删除类接口未做 CSRF Token 校验。
+- 暂无分页,数据量增大后首页与统计页需要补充分页或懒加载。
+- `config.php` 的 `initDB()` 在每次请求都会跑一遍建表与迁移,数据量或并发上升后可改为一次性迁移脚本。
+- 无「回收站」,删除电影与其图片文件不可恢复。
+- `assets/placeholder-poster.jpg`、`assets/default-avatar.png` 为代码中的兜底引用,当前仓库未包含这两个文件;由于页面在检测到 placeholder 时会改用 CSS 占位块渲染,正常使用不受影响。
+
+---
+
+## 版本控制说明
+
+以下内容为运行时产物或敏感数据,**不纳入版本控制**(见 `.gitignore`):
+
+- `db/`(SQLite 数据库文件)
+- `uploads/`(用户上传的图片)
+- `*.log`、编辑器与系统临时文件
+
+---
+
+## 许可
+
+个人项目,未指定开源许可。
diff --git a/add.php b/add.php
new file mode 100644
index 0000000..b02d102
--- /dev/null
+++ b/add.php
@@ -0,0 +1,739 @@
+query("SELECT * FROM persons ORDER BY name");
+$all_persons = $stmt->fetchAll();
+
+$error = '';
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $title = trim($_POST['title'] ?? '');
+ $info = trim($_POST['info'] ?? '');
+ $watch_date = $_POST['watch_date'] ?? '';
+ $cinema_id = $_POST['cinema_id'] ?? '';
+ $viewer_selects = $_POST['viewer_select'] ?? [];
+ $viewer_news = $_POST['viewers_new'] ?? [];
+
+ // 票根裁剪数据
+ $ticket_crop = $_POST['ticket_crop'] ?? '';
+
+ // 新建电影院
+ $new_cinema_place = trim($_POST['new_cinema_place'] ?? '');
+ $new_cinema_name = trim($_POST['new_cinema_name'] ?? '');
+ $new_cinema_address = trim($_POST['new_cinema_address'] ?? '');
+
+ if ($cinema_id === 'new' && !empty($new_cinema_name)) {
+ $stmt = $db->prepare("INSERT INTO cinemas (place, name, address) VALUES (?, ?, ?)");
+ $stmt->execute([$new_cinema_place, $new_cinema_name, $new_cinema_address]);
+ $cinema_id = $db->lastInsertId();
+ } elseif ($cinema_id === 'new') {
+ $cinema_id = null;
+ } elseif (!empty($cinema_id) && is_numeric($cinema_id)) {
+ $cinema_id = (int)$cinema_id;
+ } else {
+ $cinema_id = null;
+ }
+
+ if (empty($title)) {
+ $error = '请输入电影名称';
+ } elseif (empty($watch_date)) {
+ $error = '请选择观影日期';
+ } else {
+ // ----- 处理海报(直接上传) -----
+ $poster_path = '';
+ if (isset($_FILES['poster']) && $_FILES['poster']['error'] === UPLOAD_ERR_OK) {
+ $ext = strtolower(pathinfo($_FILES['poster']['name'], PATHINFO_EXTENSION));
+ if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'])) {
+ $filename = uniqueFilename($_FILES['poster']['name']);
+ $target = POSTER_DIR . $filename;
+ if (move_uploaded_file($_FILES['poster']['tmp_name'], $target)) {
+ $poster_path = 'uploads/posters/' . $filename;
+ }
+ }
+ }
+
+ // ----- 处理票根(裁剪优先) -----
+ $ticket_path = '';
+ if (!empty($ticket_crop)) {
+ $new_path = saveBase64Image($ticket_crop, TICKET_DIR, 'ticket');
+ if ($new_path) {
+ $ticket_path = $new_path;
+ } else {
+ $error = '票根裁剪保存失败,请重试';
+ }
+ } elseif (isset($_FILES['ticket']) && $_FILES['ticket']['error'] === UPLOAD_ERR_OK) {
+ $ext = strtolower(pathinfo($_FILES['ticket']['name'], PATHINFO_EXTENSION));
+ if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'])) {
+ $filename = uniqueFilename($_FILES['ticket']['name']);
+ $target = TICKET_DIR . $filename;
+ if (move_uploaded_file($_FILES['ticket']['tmp_name'], $target)) {
+ $ticket_path = 'uploads/tickets/' . $filename;
+ }
+ }
+ }
+
+ // 如果没有错误,继续
+ if (empty($error)) {
+ // 插入电影
+ $stmt = $db->prepare("
+ INSERT INTO movies (title, info, poster_path, ticket_path, cinema_id, watch_date)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ");
+ $stmt->execute([$title, $info, $poster_path, $ticket_path, $cinema_id, $watch_date]);
+ $movie_id = $db->lastInsertId();
+
+ // ---- 处理合影照片(多张) ----
+ if (isset($_FILES['photos']) && is_array($_FILES['photos']['tmp_name'])) {
+ $photo_files = $_FILES['photos'];
+ for ($i = 0; $i < count($photo_files['tmp_name']); $i++) {
+ if ($photo_files['error'][$i] === UPLOAD_ERR_OK) {
+ $ext = strtolower(pathinfo($photo_files['name'][$i], PATHINFO_EXTENSION));
+ if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'])) {
+ $filename = uniqueFilename($photo_files['name'][$i]);
+ $target = PHOTO_DIR . $filename;
+ if (move_uploaded_file($photo_files['tmp_name'][$i], $target)) {
+ $photo_path = 'uploads/photos/' . $filename;
+ $stmt2 = $db->prepare("INSERT INTO movie_photos (movie_id, photo_path) VALUES (?, ?)");
+ $stmt2->execute([$movie_id, $photo_path]);
+ }
+ }
+ }
+ }
+ }
+
+ // ---- 处理观影人 ----
+ $person_ids = [];
+ foreach ($viewer_selects as $idx => $val) {
+ if ($val === 'new') {
+ $new_name = trim($viewer_news[$idx] ?? '');
+ if (!empty($new_name)) {
+ $chk = $db->prepare("SELECT id FROM persons WHERE name = ?");
+ $chk->execute([$new_name]);
+ $existing = $chk->fetch();
+ if ($existing) {
+ $person_ids[] = $existing['id'];
+ } else {
+ $ins = $db->prepare("INSERT INTO persons (name) VALUES (?)");
+ $ins->execute([$new_name]);
+ $person_ids[] = $db->lastInsertId();
+ }
+ }
+ } elseif (is_numeric($val) && $val > 0) {
+ $person_ids[] = (int)$val;
+ }
+ }
+ $person_ids = array_unique($person_ids);
+ if (!empty($person_ids)) {
+ $stmt = $db->prepare("INSERT INTO viewers (movie_id, person_id, name) VALUES (?, ?, ?)");
+ foreach ($person_ids as $pid) {
+ $stmt->execute([$movie_id, $pid, '']);
+ }
+ }
+
+ header('Location: detail.php?id=' . $movie_id);
+ exit;
+ }
+ }
+}
+?>
+
+
+
+
+
+ 添加电影 - 电影日志
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/annual_report.php b/annual_report.php
new file mode 100644
index 0000000..af21593
--- /dev/null
+++ b/annual_report.php
@@ -0,0 +1,582 @@
+ 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']);
+?>
+
+
+
+
+
+ = $selected_year ?> 年度观影报告 - 电影日志
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📭
+
这一年还没有观影记录
+
快去添加电影,开启你的光影之旅吧
+
➕ 添加电影
+
+
+
+
+
+
+
= $stats['total'] ?>
+
🎬 观影总数
+
+
+
= $stats['days'] ?>
+
📅 观影天数
+
+
+
= $stats['avg_per_month'] ?>
+
📊 月均观影
+
+
+
= $stats['avg_days_between'] ?>
+
📆 平均每= $stats['avg_days_between'] > 0 ? $stats['avg_days_between'] : '-' ?>天一部
+
+
+
+
+
+
+
🏛️ 最常去的影院
+
= h($stats['top_cinema']) ?> (= $stats['top_cinema_count'] ?> 次)
+
+
+
👥 最常一起观影的人
+
= h($stats['top_person']) ?> (= $stats['top_person_count'] ?> 次)
+
+
✅ 已排除 "= h($stats['self_person_name']) ?>" 自己
+
+
⚠️ 未设置自己的观影人身份,未排除自己
+
+
+
+
🎫 单次最多人
+
= h($stats['most_viewers_movie']) ?> (= $stats['most_viewers_count'] ?> 人)
+
+
+
+
+
+
+
📈 月度观影分布
+
+
+
+
📆 星期观影偏好
+
+
+
+
+
+
+
+
👥 今年共同观影人
+
+
+
+
 ?>)
+
= h($viewer['name']) ?>
+
共同观影 = $viewer['count'] ?> 次
+
+
+
+
+
+
+
+
+
+
🎥 年末最近观影
+
+
+ = h($title) ?>
+
+
+
+
+
+
+
+
+
🎞️ 我的观影记录 (= count($stats['my_movies']) ?> 部)
+
+
+
+
![<?= h($movie['title']) ?>](<?= $poster ?>)
+
+
= h($movie['title']) ?>
+
📅 = date('Y-m-d', strtotime($movie['watch_date'])) ?>
+
+
+
+
+
+ = h($viewer['name']) ?>
+
+
+
+
独自观影
+
+
+
+
+
+
+
+
+
+
+ 首部观影:= $stats['first_date'] ? date('Y年m月d日', strtotime($stats['first_date'])) : '-' ?>
+ ·
+ 最后一部:= $stats['last_date'] ? date('Y年m月d日', strtotime($stats['last_date'])) : '-' ?>
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/assets/.gitkeep b/assets/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/change_password.php b/change_password.php
new file mode 100644
index 0000000..7eee00e
--- /dev/null
+++ b/change_password.php
@@ -0,0 +1,204 @@
+prepare("SELECT password FROM admins WHERE id = ?");
+ $stmt->execute([$admin['id']]);
+ $row = $stmt->fetch();
+ if (!password_verify($old_password, $row['password'])) {
+ $error = '旧密码错误';
+ } elseif (empty($new_username)) {
+ $error = '用户名不能为空';
+ } elseif (strlen($new_password) < 6) {
+ $error = '新密码长度至少6位';
+ } elseif ($new_password !== $confirm_password) {
+ $error = '两次输入的密码不一致';
+ } else {
+ // 检查用户名是否已被占用(排除自己)
+ $stmt = $db->prepare("SELECT COUNT(*) FROM admins WHERE username = ? AND id != ?");
+ $stmt->execute([$new_username, $admin['id']]);
+ if ($stmt->fetchColumn() > 0) {
+ $error = '用户名已被占用';
+ } else {
+ // 更新
+ $hashed = password_hash($new_password, PASSWORD_DEFAULT);
+ $stmt = $db->prepare("UPDATE admins SET username = ?, password = ? WHERE id = ?");
+ $stmt->execute([$new_username, $hashed, $admin['id']]);
+
+ // 更新session中的用户名
+ $_SESSION['admin_username'] = $new_username;
+ $success = '用户名和密码已更新,请重新登录';
+ // 可选:强制退出重新登录
+ // session_destroy();
+ // header('Location: login.php');
+ // exit;
+ }
+ }
+}
+?>
+
+
+
+
+
+ 修改密码 - 电影全纪录
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cinemas.php b/cinemas.php
new file mode 100644
index 0000000..b144d79
--- /dev/null
+++ b/cinemas.php
@@ -0,0 +1,474 @@
+prepare("INSERT INTO cinemas (place, name, address, color) VALUES (?, ?, ?, ?)");
+ $stmt->execute([$place, $name, $address, $color]);
+ $message = '影院添加成功';
+ }
+}
+
+// ---------- 处理编辑 ----------
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'edit') {
+ $id = (int)$_POST['id'];
+ $place = trim($_POST['place'] ?? '');
+ $name = trim($_POST['name'] ?? '');
+ $address = trim($_POST['address'] ?? '');
+ $color = trim($_POST['color'] ?? '#888888');
+ if (!preg_match('/^#[a-fA-F0-9]{6}$/', $color)) {
+ $color = '#888888';
+ }
+
+ if (empty($name)) {
+ $error = '影院名称不能为空';
+ } else {
+ $stmt = $db->prepare("UPDATE cinemas SET place = ?, name = ?, address = ?, color = ? WHERE id = ?");
+ $stmt->execute([$place, $name, $address, $color, $id]);
+ $message = '影院信息已更新';
+ }
+}
+
+// ---------- 处理删除 ----------
+if (isset($_GET['action']) && $_GET['action'] === 'delete' && isset($_GET['id'])) {
+ $id = (int)$_GET['id'];
+ // 检查是否有电影关联
+ $stmt = $db->prepare("SELECT COUNT(*) FROM movies WHERE cinema_id = ?");
+ $stmt->execute([$id]);
+ $count = $stmt->fetchColumn();
+ if ($count > 0) {
+ $confirm = isset($_GET['confirm']) ? $_GET['confirm'] : '';
+ if ($confirm !== 'yes') {
+ $error = "该影院关联了 {$count} 部电影,删除后这些电影的影院信息将置空。确定删除?请 点击确认删除";
+ } else {
+ $stmt = $db->prepare("DELETE FROM cinemas WHERE id = ?");
+ $stmt->execute([$id]);
+ $message = '影院已删除,关联电影已置空影院信息';
+ }
+ } else {
+ $stmt = $db->prepare("DELETE FROM cinemas WHERE id = ?");
+ $stmt->execute([$id]);
+ $message = '影院已删除';
+ }
+}
+
+// ---------- 获取列表 ----------
+$cinemas = getCinemas();
+
+// ---------- 获取编辑数据 ----------
+$edit_data = null;
+if (isset($_GET['action']) && $_GET['action'] === 'edit' && isset($_GET['id'])) {
+ $id = (int)$_GET['id'];
+ $stmt = $db->prepare("SELECT * FROM cinemas WHERE id = ?");
+ $stmt->execute([$id]);
+ $edit_data = $stmt->fetch();
+ if (!$edit_data) {
+ header('Location: cinemas.php');
+ exit;
+ }
+}
+
+// ---------- 预设50色 ----------
+$preset_colors = [
+ // 红色系 (5)
+ '#e74c3c', '#c0392b', '#e67e22', '#d35400', '#f39c12',
+ // 橙色系 (3)
+ '#f1c40f', '#f7dc6f', '#f5b041',
+ // 黄色系 (3)
+ '#f4d03f', '#f9e79f', '#f8c471',
+ // 绿色系 (5)
+ '#2ecc71', '#27ae60', '#1abc9c', '#16a085', '#58d68d',
+ // 青色系 (4)
+ '#48c9b0', '#45b39d', '#82e0aa', '#a3e4d7',
+ // 蓝色系 (7)
+ '#3498db', '#2980b9', '#2e86c1', '#5dade2', '#85c1e9', '#5499c7', '#aed6f1',
+ // 紫色系 (5)
+ '#9b59b6', '#8e44ad', '#af7ac5', '#bb8fce', '#d2b4de',
+ // 粉色系 (4)
+ '#e84393', '#fd79a8', '#e91e63', '#f06292',
+ // 灰色系 (6)
+ '#95a5a6', '#7f8c8d', '#bdc3c7', '#d5dbdb', '#aab7b8', '#99a3a4',
+ // 棕色/米色系 (4)
+ '#a67c52', '#8d6e63', '#d4a574', '#c9a87c',
+ // 其他 (4)
+ '#00b894', '#0984e3', '#6c5ce7', '#fd79a8'
+];
+// 只取前50个,如果有重复或不足可调整,但这里正好约50个(实际46个,再补几个)
+$extra = ['#2d3436', '#636e72', '#b2bec3', '#dfe6e9', '#fab1a0', '#e17055', '#00cec9', '#a29bfe'];
+$preset_colors = array_merge($preset_colors, $extra);
+$preset_colors = array_slice($preset_colors, 0, 50); // 确保正好50个
+?>
+
+
+
+
+
+ 影院管理 - 电影全纪录
+
+
+
+
+
+
+
+
+
= h($message) ?>
+
+
+
= $error ?>
+
+
+
+
+
= $edit_data ? '✏️ 编辑影院' : '➕ 添加影院' ?>
+
= $edit_data ? '修改影院信息' : '录入新的电影院' ?>
+
+
+
+
+
+
🏛️ 影院列表
+
共 = count($cinemas) ?> 家影院
+
+ 0): ?>
+
+
+
+ | 名称 |
+ 所在地方 |
+ 详细地址 |
+ 操作 |
+
+
+
+
+
+ |
+
+ = h($c['name']) ?>
+ |
+ = h($c['place']) ?: '-' ?> |
+ = h($c['address']) ?: '-' ?> |
+
+
+ |
+
+
+
+
+
+
暂无影院,请添加
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/config.php b/config.php
new file mode 100644
index 0000000..64304ec
--- /dev/null
+++ b/config.php
@@ -0,0 +1,740 @@
+setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
+ return $db;
+ } catch (PDOException $e) {
+ die('数据库连接失败: ' . $e->getMessage());
+ }
+}
+
+// 初始化数据库
+function initDB() {
+ $db = getDB();
+
+ // 管理员表
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS admins (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE NOT NULL,
+ password TEXT NOT NULL,
+ avatar TEXT
+ )
+ ");
+
+ // 电影院表
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS cinemas (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ place TEXT NOT NULL,
+ name TEXT NOT NULL,
+ address TEXT,
+ color TEXT DEFAULT '#888888'
+ )
+ ");
+
+ // 电影表
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS movies (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ info TEXT,
+ remark TEXT,
+ poster_path TEXT,
+ ticket_path TEXT,
+ photo_path TEXT,
+ cinema_id INTEGER,
+ watch_date DATE,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (cinema_id) REFERENCES cinemas(id) ON DELETE SET NULL
+ )
+ ");
+
+ // 观影人表
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS persons (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ color TEXT DEFAULT '#888888',
+ avatar TEXT
+ )
+ ");
+
+ // 观影关联表
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS viewers (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ movie_id INTEGER NOT NULL,
+ name TEXT,
+ person_id INTEGER,
+ FOREIGN KEY (movie_id) REFERENCES movies(id) ON DELETE CASCADE,
+ FOREIGN KEY (person_id) REFERENCES persons(id) ON DELETE SET NULL
+ )
+ ");
+
+ // 多照片表
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS movie_photos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ movie_id INTEGER NOT NULL,
+ photo_path TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (movie_id) REFERENCES movies(id) ON DELETE CASCADE
+ )
+ ");
+
+ // ===== 迁移字段 =====
+ try { $db->exec("ALTER TABLE cinemas ADD COLUMN color TEXT DEFAULT '#888888'"); } catch (PDOException $e) {}
+ try { $db->exec("ALTER TABLE admins ADD COLUMN avatar TEXT"); } catch (PDOException $e) {}
+ try { $db->exec("ALTER TABLE persons ADD COLUMN avatar TEXT"); } catch (PDOException $e) {}
+ try { $db->exec("ALTER TABLE movies ADD COLUMN remark TEXT"); } catch (PDOException $e) {}
+ try { $db->exec("ALTER TABLE viewers ADD COLUMN person_id INTEGER REFERENCES persons(id) ON DELETE SET NULL"); } catch (PDOException $e) {}
+ // 新增:管理员关联观影人
+ try { $db->exec("ALTER TABLE admins ADD COLUMN person_id INTEGER REFERENCES persons(id) ON DELETE SET NULL"); } catch (PDOException $e) {}
+
+ // ===== 迁移旧数据 =====
+ // 将 viewers.name 迁移到 persons
+ $stmt = $db->query("SELECT DISTINCT name FROM viewers WHERE name != '' AND name IS NOT NULL");
+ $names = $stmt->fetchAll(PDO::FETCH_COLUMN);
+ foreach ($names as $name) {
+ $chk = $db->prepare("SELECT id FROM persons WHERE name = ?");
+ $chk->execute([$name]);
+ if (!$chk->fetch()) {
+ $ins = $db->prepare("INSERT INTO persons (name) VALUES (?)");
+ $ins->execute([$name]);
+ }
+ }
+ $update = $db->prepare("UPDATE viewers SET person_id = (SELECT id FROM persons WHERE persons.name = viewers.name) WHERE person_id IS NULL AND name IS NOT NULL");
+ $update->execute();
+
+ // 默认管理员
+ $stmt = $db->prepare("SELECT COUNT(*) FROM admins");
+ $stmt->execute();
+ if ($stmt->fetchColumn() == 0) {
+ $hashed = password_hash('123456', PASSWORD_DEFAULT);
+ $stmt = $db->prepare("INSERT INTO admins (username, password) VALUES (?, ?)");
+ $stmt->execute(['admin', $hashed]);
+ }
+}
+
+// 登录检查
+function checkLogin() {
+ if (!isset($_SESSION['admin_id'])) {
+ header('Location: login.php');
+ exit;
+ }
+}
+
+// 获取当前管理员(含关联观影人信息)
+function getCurrentAdmin() {
+ if (isset($_SESSION['admin_id'])) {
+ $db = getDB();
+ $stmt = $db->prepare("
+ SELECT a.*, p.id as person_id, p.name as person_name, p.color as person_color, p.avatar as person_avatar
+ FROM admins a
+ LEFT JOIN persons p ON a.person_id = p.id
+ WHERE a.id = ?
+ ");
+ $stmt->execute([$_SESSION['admin_id']]);
+ return $stmt->fetch();
+ }
+ return null;
+}
+
+// 获取所有管理员(含关联观影人)
+function getAllAdmins() {
+ $db = getDB();
+ $stmt = $db->query("
+ SELECT a.id, a.username, a.avatar, a.person_id, p.name as person_name
+ FROM admins a
+ LEFT JOIN persons p ON a.person_id = p.id
+ ORDER BY a.id
+ ");
+ return $stmt->fetchAll();
+}
+
+// 添加管理员(支持关联观影人)
+function addAdmin($username, $password, $avatar = null, $person_id = null) {
+ $db = getDB();
+ $stmt = $db->prepare("SELECT COUNT(*) FROM admins WHERE username = ?");
+ $stmt->execute([$username]);
+ if ($stmt->fetchColumn() > 0) return false;
+ $hashed = password_hash($password, PASSWORD_DEFAULT);
+ $stmt = $db->prepare("INSERT INTO admins (username, password, avatar, person_id) VALUES (?, ?, ?, ?)");
+ return $stmt->execute([$username, $hashed, $avatar, $person_id]);
+}
+
+// 删除管理员
+function deleteAdmin($id) {
+ $db = getDB();
+ $stmt = $db->query("SELECT COUNT(*) FROM admins");
+ if ($stmt->fetchColumn() <= 1) return false;
+ if ($id == $_SESSION['admin_id']) return false;
+ $stmt = $db->prepare("DELETE FROM admins WHERE id = ?");
+ return $stmt->execute([$id]);
+}
+
+// 更新管理员的观影人关联
+function updateAdminPersonId($admin_id, $person_id) {
+ $db = getDB();
+ $stmt = $db->prepare("UPDATE admins SET person_id = ? WHERE id = ?");
+ return $stmt->execute([$person_id, $admin_id]);
+}
+
+// 生成唯一文件名
+function uniqueFilename($original) {
+ $ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
+ $allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
+ if (!in_array($ext, $allowed)) {
+ $ext = 'jpg';
+ }
+ return date('YmdHis') . '_' . uniqid() . '.' . $ext;
+}
+
+// 安全输出
+function h($str) {
+ return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
+}
+
+// 海报 URL
+function posterUrl($path) {
+ if ($path && file_exists(__DIR__ . '/' . $path)) {
+ return '/' . $path;
+ }
+ return '/assets/placeholder-poster.jpg';
+}
+
+// 图片 URL
+function imageUrl($path) {
+ if ($path && file_exists(__DIR__ . '/' . $path)) {
+ return '/' . $path;
+ }
+ return null;
+}
+
+// ===== 头像处理 =====
+function getAvatarUrl($avatar_path, $name = '') {
+ if ($avatar_path && file_exists(__DIR__ . '/' . $avatar_path)) {
+ return '/' . $avatar_path;
+ }
+ if (!empty($name)) {
+ $letter = mb_substr($name, 0, 1, 'UTF-8');
+ $colors = ['#e74c3c','#e67e22','#f1c40f','#2ecc71','#3498db','#9b59b6','#1abc9c','#e84393','#00b894','#0984e3'];
+ $color = $colors[abs(crc32($name)) % count($colors)];
+ return "data:image/svg+xml," . urlencode("");
+ }
+ return '/assets/default-avatar.png';
+}
+
+// ===== 保存 Base64 图片 =====
+function saveBase64Image($base64_data, $save_dir, $prefix = 'avatar') {
+ if (strpos($base64_data, 'base64,') !== false) {
+ $base64_data = substr($base64_data, strpos($base64_data, 'base64,') + 7);
+ }
+ $binary = base64_decode($base64_data);
+ if ($binary === false) return false;
+
+ $finfo = finfo_open(FILEINFO_MIME_TYPE);
+ $mime = finfo_buffer($finfo, $binary);
+ finfo_close($finfo);
+
+ $ext_map = [
+ 'image/jpeg' => 'jpg',
+ 'image/png' => 'png',
+ 'image/gif' => 'gif',
+ 'image/webp' => 'webp'
+ ];
+ if (!isset($ext_map[$mime])) return false;
+ $ext = $ext_map[$mime];
+
+ $filename = date('YmdHis') . '_' . uniqid() . '_' . $prefix . '.' . $ext;
+ $filepath = $save_dir . $filename;
+ if (file_put_contents($filepath, $binary) === false) return false;
+
+ return str_replace(__DIR__ . '/', '', $filepath);
+}
+
+// ===== 电影列表 =====
+function getMovies() {
+ $db = getDB();
+ $stmt = $db->query("
+ SELECT m.*, c.place, c.name as cinema_name, c.address as cinema_address, c.color as cinema_color
+ FROM movies m
+ LEFT JOIN cinemas c ON m.cinema_id = c.id
+ ORDER BY m.watch_date DESC, m.created_at DESC
+ ");
+ $movies = $stmt->fetchAll();
+ attachViewersToMovies($movies);
+ return $movies;
+}
+
+function getMovie($id) {
+ $db = getDB();
+ $stmt = $db->prepare("
+ SELECT m.*, c.place, c.name as cinema_name, c.address as cinema_address, c.color as cinema_color
+ FROM movies m
+ LEFT JOIN cinemas c ON m.cinema_id = c.id
+ WHERE m.id = ?
+ ");
+ $stmt->execute([$id]);
+ $movie = $stmt->fetch();
+ if ($movie) {
+ $stmt2 = $db->prepare("
+ SELECT v.id, v.movie_id, v.person_id,
+ COALESCE(p.name, v.name) as name,
+ p.color as person_color,
+ p.avatar as person_avatar
+ FROM viewers v
+ LEFT JOIN persons p ON v.person_id = p.id
+ WHERE v.movie_id = ?
+ ORDER BY v.id
+ ");
+ $stmt2->execute([$id]);
+ $movie['viewers'] = $stmt2->fetchAll();
+
+ $stmt3 = $db->prepare("SELECT * FROM movie_photos WHERE movie_id = ? ORDER BY id");
+ $stmt3->execute([$id]);
+ $movie['photos'] = $stmt3->fetchAll();
+ }
+ return $movie;
+}
+
+function attachViewersToMovies(&$movies) {
+ if (empty($movies)) return;
+ $db = getDB();
+ $movie_ids = array_column($movies, 'id');
+ if (empty($movie_ids)) return;
+ $placeholders = implode(',', array_fill(0, count($movie_ids), '?'));
+ $stmt = $db->prepare("
+ SELECT v.movie_id, p.id as person_id, p.name, p.color, p.avatar
+ FROM viewers v
+ LEFT JOIN persons p ON v.person_id = p.id
+ WHERE v.movie_id IN ($placeholders)
+ ORDER BY v.id
+ ");
+ $stmt->execute($movie_ids);
+ $rows = $stmt->fetchAll();
+ $viewers_map = [];
+ foreach ($rows as $row) {
+ $movie_id = $row['movie_id'];
+ if (!isset($viewers_map[$movie_id])) $viewers_map[$movie_id] = [];
+ $viewers_map[$movie_id][] = [
+ 'person_id' => $row['person_id'],
+ 'name' => $row['name'] ?? '未知',
+ 'color' => $row['color'] ?? '#888888',
+ 'avatar' => $row['avatar']
+ ];
+ }
+ foreach ($movies as &$movie) {
+ $movie['viewers'] = $viewers_map[$movie['id']] ?? [];
+ }
+}
+
+// ===== 影院 =====
+function getCinemas() {
+ $db = getDB();
+ $stmt = $db->query("SELECT * FROM cinemas ORDER BY place, name");
+ return $stmt->fetchAll();
+}
+
+// ===== 搜索 =====
+function searchMovies($keyword) {
+ return advancedSearch(['title' => $keyword]);
+}
+
+function advancedSearch($params) {
+ $db = getDB();
+ $sql = "
+ SELECT DISTINCT m.*,
+ c.place, c.name as cinema_name, c.address as cinema_address, c.color as cinema_color
+ FROM movies m
+ LEFT JOIN cinemas c ON m.cinema_id = c.id
+ LEFT JOIN viewers v ON m.id = v.movie_id
+ LEFT JOIN persons p ON v.person_id = p.id
+ WHERE 1=1
+ ";
+ $bind = [];
+ if (!empty($params['title'])) {
+ $sql .= " AND m.title LIKE :title";
+ $bind[':title'] = '%' . $params['title'] . '%';
+ }
+ if (!empty($params['cinema_id']) && is_numeric($params['cinema_id'])) {
+ $sql .= " AND m.cinema_id = :cinema_id";
+ $bind[':cinema_id'] = (int)$params['cinema_id'];
+ }
+ if (!empty($params['person_id']) && is_numeric($params['person_id'])) {
+ $sql .= " AND v.person_id = :person_id";
+ $bind[':person_id'] = (int)$params['person_id'];
+ }
+ if (!empty($params['date_from'])) {
+ $sql .= " AND m.watch_date >= :date_from";
+ $bind[':date_from'] = $params['date_from'];
+ }
+ if (!empty($params['date_to'])) {
+ $sql .= " AND m.watch_date <= :date_to";
+ $bind[':date_to'] = $params['date_to'];
+ }
+ $sql .= " ORDER BY m.watch_date DESC, m.created_at DESC";
+ $stmt = $db->prepare($sql);
+ $stmt->execute($bind);
+ $movies = $stmt->fetchAll();
+ attachViewersToMovies($movies);
+ return $movies;
+}
+
+// ===== 统计 =====
+function getStatsOverview() {
+ $db = getDB();
+ $stats = [];
+ $stmt = $db->query("SELECT COUNT(*) FROM movies");
+ $stats['total_movies'] = $stmt->fetchColumn();
+ $stmt = $db->query("SELECT COUNT(*) FROM cinemas");
+ $stats['total_cinemas'] = $stmt->fetchColumn();
+ $stmt = $db->query("SELECT COUNT(*) FROM persons");
+ $stats['total_persons'] = $stmt->fetchColumn();
+ $stmt = $db->query("SELECT COUNT(*) FROM viewers");
+ $stats['total_viewership'] = $stmt->fetchColumn();
+ $stmt = $db->query("SELECT MIN(watch_date) as first, MAX(watch_date) as last FROM movies WHERE watch_date IS NOT NULL");
+ $row = $stmt->fetch();
+ $stats['first_date'] = $row['first'];
+ $stats['last_date'] = $row['last'];
+ return $stats;
+}
+
+function statsByCinema() {
+ $db = getDB();
+ $stmt = $db->query("
+ SELECT c.id, c.name, c.color, COUNT(m.id) as count
+ FROM cinemas c
+ LEFT JOIN movies m ON c.id = m.cinema_id
+ GROUP BY c.id
+ ORDER BY count DESC
+ ");
+ return $stmt->fetchAll();
+}
+
+function statsByPerson() {
+ $db = getDB();
+ $stmt = $db->query("
+ SELECT p.id, p.name, p.color, p.avatar, COUNT(v.movie_id) as count
+ FROM persons p
+ LEFT JOIN viewers v ON p.id = v.person_id
+ GROUP BY p.id
+ ORDER BY count DESC
+ ");
+ return $stmt->fetchAll();
+}
+
+function statsByMonth($limit = 12) {
+ $db = getDB();
+ $stmt = $db->prepare("
+ SELECT strftime('%Y-%m', watch_date) as month, COUNT(*) as count
+ FROM movies
+ WHERE watch_date IS NOT NULL
+ GROUP BY month
+ ORDER BY month DESC
+ LIMIT ?
+ ");
+ $stmt->execute([$limit]);
+ return $stmt->fetchAll();
+}
+
+function statsByYear() {
+ $db = getDB();
+ $stmt = $db->query("
+ SELECT strftime('%Y', watch_date) as year, COUNT(*) as count
+ FROM movies
+ WHERE watch_date IS NOT NULL
+ GROUP BY year
+ ORDER BY year DESC
+ ");
+ return $stmt->fetchAll();
+}
+
+// ===== 年度报告 =====
+function getAnnualStats($year) {
+ $db = getDB();
+ $stats = [];
+
+ // 获取当前管理员
+ $admin = getCurrentAdmin();
+ $self_person_id = $admin['person_id'] ?? null;
+ $stats['self_person_name'] = $admin['person_name'] ?? null;
+ $stats['self_person_avatar'] = $admin['person_avatar'] ?? null;
+
+ if (!$self_person_id) {
+ // 未关联观影人,返回空数据
+ $stats['total'] = 0;
+ $stats['days'] = 0;
+ $stats['top_cinema'] = '无';
+ $stats['top_cinema_count'] = 0;
+ $stats['top_person'] = '无';
+ $stats['top_person_count'] = 0;
+ $stats['first_date'] = null;
+ $stats['last_date'] = null;
+ $stats['most_viewers_movie'] = '无';
+ $stats['most_viewers_count'] = 0;
+ $stats['month_distribution'] = array_fill(1, 12, 0);
+ $stats['weekday_distribution'] = ['日'=>0,'一'=>0,'二'=>0,'三'=>0,'四'=>0,'五'=>0,'六'=>0];
+ $stats['avg_per_month'] = 0;
+ $stats['avg_days_between'] = 0;
+ $stats['keywords'] = [];
+ $stats['recent_movies'] = [];
+ $stats['my_movies'] = [];
+ $stats['common_viewers'] = [];
+ return $stats;
+ }
+
+ // 1. 总观影数(当前用户参与的电影)
+ $stmt = $db->prepare("
+ SELECT COUNT(DISTINCT m.id)
+ FROM movies m
+ JOIN viewers v ON m.id = v.movie_id
+ WHERE strftime('%Y', m.watch_date) = ?
+ AND v.person_id = ?
+ ");
+ $stmt->execute([$year, $self_person_id]);
+ $stats['total'] = (int)$stmt->fetchColumn();
+
+ // 2. 观影天数
+ $stmt = $db->prepare("
+ SELECT COUNT(DISTINCT m.watch_date)
+ FROM movies m
+ JOIN viewers v ON m.id = v.movie_id
+ WHERE strftime('%Y', m.watch_date) = ?
+ AND v.person_id = ?
+ AND m.watch_date IS NOT NULL
+ ");
+ $stmt->execute([$year, $self_person_id]);
+ $stats['days'] = (int)$stmt->fetchColumn();
+
+ // 3. 最常去的影院
+ $stmt = $db->prepare("
+ SELECT c.name, COUNT(m.id) as count
+ FROM movies m
+ JOIN viewers v ON m.id = v.movie_id
+ LEFT JOIN cinemas c ON m.cinema_id = c.id
+ WHERE strftime('%Y', m.watch_date) = ?
+ AND v.person_id = ?
+ AND m.cinema_id IS NOT NULL
+ GROUP BY m.cinema_id
+ ORDER BY count DESC LIMIT 1
+ ");
+ $stmt->execute([$year, $self_person_id]);
+ $top_cinema = $stmt->fetch();
+ $stats['top_cinema'] = $top_cinema ? $top_cinema['name'] : '无';
+ $stats['top_cinema_count'] = $top_cinema ? (int)$top_cinema['count'] : 0;
+
+ // 4. 最常一起观影的人(与当前用户同场最多的人)
+ // 先获取当前用户看过的所有电影ID
+ $stmt = $db->prepare("
+ SELECT DISTINCT m.id
+ FROM movies m
+ JOIN viewers v ON m.id = v.movie_id
+ WHERE strftime('%Y', m.watch_date) = ?
+ AND v.person_id = ?
+ ");
+ $stmt->execute([$year, $self_person_id]);
+ $movie_ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
+
+ // 共同观影人统计
+ $common_viewers = [];
+ if (!empty($movie_ids)) {
+ $placeholders = implode(',', array_fill(0, count($movie_ids), '?'));
+ $params = array_merge($movie_ids, [$self_person_id]);
+ $stmt = $db->prepare("
+ SELECT p.id, p.name, p.avatar, COUNT(*) as count
+ FROM viewers v
+ JOIN persons p ON v.person_id = p.id
+ WHERE v.movie_id IN ($placeholders)
+ AND v.person_id != ?
+ GROUP BY v.person_id
+ ORDER BY count DESC
+ ");
+ $stmt->execute($params);
+ $common_viewers = $stmt->fetchAll();
+ }
+ $stats['common_viewers'] = $common_viewers;
+
+ // 最常一起观影的人(取第一个)
+ $stats['top_person'] = !empty($common_viewers) ? $common_viewers[0]['name'] : '无';
+ $stats['top_person_count'] = !empty($common_viewers) ? (int)$common_viewers[0]['count'] : 0;
+
+ // 5. 最早和最晚日期
+ $stmt = $db->prepare("
+ SELECT MIN(m.watch_date) as first, MAX(m.watch_date) as last
+ FROM movies m
+ JOIN viewers v ON m.id = v.movie_id
+ WHERE strftime('%Y', m.watch_date) = ?
+ AND v.person_id = ?
+ AND m.watch_date IS NOT NULL
+ ");
+ $stmt->execute([$year, $self_person_id]);
+ $row = $stmt->fetch();
+ $stats['first_date'] = $row['first'];
+ $stats['last_date'] = $row['last'];
+
+ // 6. 单次最多人的电影(当前用户参与的电影中)
+ $stmt = $db->prepare("
+ SELECT m.id, m.title, COUNT(v.id) as viewer_count
+ FROM movies m
+ JOIN viewers v ON m.id = v.movie_id
+ WHERE strftime('%Y', m.watch_date) = ?
+ AND m.id IN (SELECT movie_id FROM viewers WHERE person_id = ?)
+ GROUP BY m.id
+ ORDER BY viewer_count DESC LIMIT 1
+ ");
+ $stmt->execute([$year, $self_person_id]);
+ $most_viewers = $stmt->fetch();
+ $stats['most_viewers_movie'] = $most_viewers ? $most_viewers['title'] : '无';
+ $stats['most_viewers_count'] = $most_viewers ? (int)$most_viewers['viewer_count'] : 0;
+
+ // 7. 月份分布
+ $stmt = $db->prepare("
+ SELECT strftime('%m', m.watch_date) as month, COUNT(*) as count
+ FROM movies m
+ JOIN viewers v ON m.id = v.movie_id
+ WHERE strftime('%Y', m.watch_date) = ?
+ AND v.person_id = ?
+ AND m.watch_date IS NOT NULL
+ GROUP BY month
+ ORDER BY month
+ ");
+ $stmt->execute([$year, $self_person_id]);
+ $month_data = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
+ $stats['month_distribution'] = [];
+ for ($m=1; $m<=12; $m++) {
+ $key = str_pad($m, 2, '0', STR_PAD_LEFT);
+ $stats['month_distribution'][$m] = isset($month_data[$key]) ? (int)$month_data[$key] : 0;
+ }
+
+ // 8. 星期分布
+ $stmt = $db->prepare("
+ SELECT strftime('%w', m.watch_date) as wday, COUNT(*) as count
+ FROM movies m
+ JOIN viewers v ON m.id = v.movie_id
+ WHERE strftime('%Y', m.watch_date) = ?
+ AND v.person_id = ?
+ AND m.watch_date IS NOT NULL
+ GROUP BY wday
+ ORDER BY wday
+ ");
+ $stmt->execute([$year, $self_person_id]);
+ $wday_data = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
+ $weekdays = ['日','一','二','三','四','五','六'];
+ $stats['weekday_distribution'] = [];
+ foreach ($weekdays as $idx => $name) {
+ $stats['weekday_distribution'][$name] = isset($wday_data[$idx]) ? (int)$wday_data[$idx] : 0;
+ }
+
+ // 9. 月均和频率
+ $stats['avg_per_month'] = $stats['total'] > 0 ? round($stats['total'] / 12, 1) : 0;
+ $stats['avg_days_between'] = $stats['total'] > 0 ? round(365 / $stats['total'], 1) : 0;
+
+ // 10. 关键词(基于当前用户电影的备注)
+ $stmt = $db->prepare("
+ SELECT m.remark
+ FROM movies m
+ JOIN viewers v ON m.id = v.movie_id
+ WHERE strftime('%Y', m.watch_date) = ?
+ AND v.person_id = ?
+ AND m.remark IS NOT NULL
+ AND m.remark != ''
+ ");
+ $stmt->execute([$year, $self_person_id]);
+ $remarks = $stmt->fetchAll(PDO::FETCH_COLUMN);
+ $word_count = [];
+ foreach ($remarks as $text) {
+ $words = preg_split('/[\s,,。.!!??、;;::]+/', $text, -1, PREG_SPLIT_NO_EMPTY);
+ foreach ($words as $w) {
+ $w = trim($w);
+ if (mb_strlen($w) >= 2) {
+ if (!isset($word_count[$w])) $word_count[$w] = 0;
+ $word_count[$w]++;
+ }
+ }
+ }
+ arsort($word_count);
+ $stats['keywords'] = array_slice(array_keys($word_count), 0, 5);
+
+ // 11. 最近5部(当前用户参与的)
+ $stmt = $db->prepare("
+ SELECT DISTINCT m.title
+ FROM movies m
+ JOIN viewers v ON m.id = v.movie_id
+ WHERE strftime('%Y', m.watch_date) = ?
+ AND v.person_id = ?
+ ORDER BY m.watch_date DESC
+ LIMIT 5
+ ");
+ $stmt->execute([$year, $self_person_id]);
+ $stats['recent_movies'] = $stmt->fetchAll(PDO::FETCH_COLUMN);
+
+ // 12. 我的观影记录(含海报)
+ $stmt = $db->prepare("
+ SELECT m.id, m.title, m.watch_date, m.poster_path
+ FROM movies m
+ JOIN viewers v ON m.id = v.movie_id
+ WHERE strftime('%Y', m.watch_date) = ?
+ AND v.person_id = ?
+ GROUP BY m.id
+ ORDER BY m.watch_date DESC
+ ");
+ $stmt->execute([$year, $self_person_id]);
+ $movie_list = $stmt->fetchAll();
+
+ // 对每个电影查询一起观影的人(含头像)
+ foreach ($movie_list as &$movie) {
+ $stmt2 = $db->prepare("
+ SELECT p.id, p.name, p.avatar
+ FROM viewers v
+ LEFT JOIN persons p ON v.person_id = p.id
+ WHERE v.movie_id = ?
+ AND v.person_id != ?
+ ");
+ $stmt2->execute([$movie['id'], $self_person_id]);
+ $movie['viewers'] = $stmt2->fetchAll();
+ }
+ $stats['my_movies'] = $movie_list;
+
+ return $stats;
+}
+
+function getYearsWithData() {
+ $db = getDB();
+ $stmt = $db->query("SELECT DISTINCT strftime('%Y', watch_date) as year FROM movies WHERE watch_date IS NOT NULL ORDER BY year DESC");
+ return $stmt->fetchAll(PDO::FETCH_COLUMN);
+}
+
+// 初始化数据库
+initDB();
\ No newline at end of file
diff --git a/css/style.css b/css/style.css
new file mode 100644
index 0000000..d8d8a9e
--- /dev/null
+++ b/css/style.css
@@ -0,0 +1,483 @@
+/* ============================================
+ 文件: css/style.css
+ 说明: 全局样式 - 支持 Grid 网格瀑布流
+ ============================================ */
+
+:root {
+ --primary: #e67e22;
+ --primary-dark: #d35400;
+ --primary-light: #f39c12;
+ --bg-primary: #f0f2f5;
+ --bg-secondary: #e8eaed;
+ --card-bg: #ffffff;
+ --text-primary: #1a1a2e;
+ --text-secondary: #6b7280;
+ --border-color: #e5e7eb;
+ --shadow: 0 4px 24px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04);
+ --shadow-hover: 0 12px 48px rgba(0, 0, 0, 0.12);
+ --radius: 12px;
+ --radius-lg: 16px;
+ --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: var(--font);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ line-height: 1.6;
+ min-height: 100vh;
+}
+
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: #d1d5db;
+ border-radius: 8px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: #b0b4ba;
+}
+
+/* ===== 导航栏 ===== */
+.navbar {
+ background: var(--card-bg);
+ border-bottom: 1px solid var(--border-color);
+ padding: 0 24px;
+ height: 64px;
+ display: flex;
+ align-items: center;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ backdrop-filter: blur(12px);
+ background: rgba(255, 255, 255, 0.92);
+}
+
+.nav-container {
+ max-width: 1280px;
+ width: 100%;
+ margin: 0 auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.nav-brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--text-primary);
+ text-decoration: none;
+}
+.nav-brand .brand-icon {
+ font-size: 26px;
+}
+.nav-brand .brand-name {
+ background: linear-gradient(135deg, var(--primary), var(--primary-light));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.nav-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.nav-user {
+ font-size: 14px;
+ color: var(--text-secondary);
+ padding: 4px 12px;
+ background: var(--bg-secondary);
+ border-radius: 20px;
+}
+
+/* ===== 按钮 ===== */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 10px 20px;
+ border-radius: 10px;
+ font-size: 14px;
+ font-weight: 600;
+ border: none;
+ cursor: pointer;
+ text-decoration: none;
+ transition: var(--transition);
+ font-family: inherit;
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, var(--primary), var(--primary-light));
+ color: #fff;
+ box-shadow: 0 4px 14px rgba(230, 126, 34, 0.25);
+}
+.btn-primary:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 28px rgba(230, 126, 34, 0.35);
+}
+.btn-primary:active {
+ transform: translateY(0);
+}
+
+.btn-outline {
+ background: transparent;
+ color: var(--text-secondary);
+ border: 1px solid var(--border-color);
+}
+.btn-outline:hover {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+}
+
+.btn-sm {
+ padding: 6px 14px;
+ font-size: 13px;
+ border-radius: 8px;
+}
+
+.btn-danger {
+ background: #e74c3c;
+ color: #fff;
+}
+.btn-danger:hover {
+ background: #c0392b;
+}
+
+/* ===== 主内容 ===== */
+.main-content {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 24px 20px 60px;
+}
+
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ margin-bottom: 28px;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+.page-header h2 {
+ font-size: 26px;
+ font-weight: 700;
+ margin: 0;
+}
+.page-header .subtitle {
+ color: var(--text-secondary);
+ font-size: 15px;
+ margin: 4px 0 0;
+}
+
+/* ===== 网格瀑布流(按行排列) ===== */
+.waterfall {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 20px;
+ padding: 4px 0;
+}
+
+.movie-card {
+ background: var(--card-bg);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ box-shadow: var(--shadow);
+ cursor: pointer;
+ transition: var(--transition);
+ border: 1px solid var(--border-color);
+ display: flex;
+ flex-direction: column;
+}
+.movie-card:hover {
+ transform: translateY(-6px);
+ box-shadow: var(--shadow-hover);
+ border-color: var(--primary-light);
+}
+
+.card-image {
+ width: 100%;
+ aspect-ratio: 2/3;
+ background: var(--bg-secondary);
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ flex-shrink: 0;
+}
+.card-image img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform 0.4s ease;
+}
+.movie-card:hover .card-image img {
+ transform: scale(1.03);
+}
+
+.placeholder-poster {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, #2d2d44, #1a1a2e);
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 42px;
+ gap: 6px;
+}
+.placeholder-poster .placeholder-title {
+ font-size: 14px;
+ text-align: center;
+ padding: 0 12px;
+ color: rgba(255, 255, 255, 0.7);
+ font-weight: 500;
+}
+
+.card-body {
+ padding: 14px 16px 16px;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+}
+.card-title {
+ font-size: 16px;
+ font-weight: 600;
+ margin: 0 0 4px;
+ color: var(--text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.card-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 14px;
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+.card-meta span {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+}
+
+/* ===== 空状态 ===== */
+.empty-state {
+ text-align: center;
+ padding: 80px 20px;
+ background: var(--card-bg);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow);
+ border: 1px solid var(--border-color);
+}
+.empty-state .empty-icon {
+ font-size: 64px;
+ display: block;
+ margin-bottom: 16px;
+}
+.empty-state h3 {
+ font-size: 24px;
+ margin: 0 0 8px;
+}
+.empty-state p {
+ color: var(--text-secondary);
+ margin-bottom: 24px;
+}
+
+/* ===== 响应式调整 ===== */
+@media (max-width: 900px) {
+ .main-content {
+ padding: 16px 14px 40px;
+ }
+ .navbar {
+ padding: 0 16px;
+ height: 58px;
+ }
+ .nav-brand .brand-name {
+ font-size: 18px;
+ }
+}
+
+@media (max-width: 600px) {
+ .waterfall {
+ gap: 12px;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ }
+ .movie-card {
+ border-radius: 10px;
+ }
+ .card-body {
+ padding: 10px 12px 12px;
+ }
+ .card-title {
+ font-size: 14px;
+ }
+ .card-meta {
+ font-size: 11px;
+ gap: 4px 10px;
+ }
+ .page-header h2 {
+ font-size: 20px;
+ }
+ .nav-actions .btn-sm {
+ font-size: 12px;
+ padding: 4px 10px;
+ }
+ .nav-user {
+ font-size: 12px;
+ padding: 2px 10px;
+ }
+ .navbar {
+ padding: 0 12px;
+ height: 52px;
+ }
+ .nav-brand .brand-name {
+ font-size: 16px;
+ }
+}
+
+@media (max-width: 420px) {
+ .waterfall {
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 10px;
+ }
+ .nav-actions .btn-sm {
+ font-size: 11px;
+ padding: 3px 8px;
+ }
+ .nav-user {
+ font-size: 11px;
+ padding: 2px 8px;
+ }
+}
+/* ===== 下拉菜单 ===== */
+.nav-dropdown {
+ position: relative;
+ display: inline-block;
+}
+.nav-dropdown .dropbtn {
+ background: transparent;
+ color: var(--text-secondary);
+ border: 1px solid var(--border-color);
+ padding: 6px 14px;
+ border-radius: 8px;
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.2s;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.nav-dropdown .dropbtn:hover {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+}
+.nav-dropdown .dropbtn .arrow {
+ font-size: 10px;
+ margin-left: 2px;
+ transition: transform 0.2s;
+}
+.nav-dropdown:hover .dropbtn .arrow {
+ transform: rotate(180deg);
+}
+
+.nav-dropdown .dropdown-content {
+ display: none;
+ position: absolute;
+ right: 0;
+ top: calc(100% + 6px);
+ background: var(--card-bg);
+ min-width: 180px;
+ border-radius: 12px;
+ box-shadow: 0 12px 48px rgba(0, 0, 0, 0.15);
+ border: 1px solid var(--border-color);
+ padding: 6px 0;
+ z-index: 200;
+ overflow: hidden;
+ animation: dropdownFade 0.2s ease;
+}
+.nav-dropdown:hover .dropdown-content,
+.nav-dropdown .dropdown-content.show {
+ display: block;
+}
+
+@keyframes dropdownFade {
+ from {
+ opacity: 0;
+ transform: translateY(-8px) scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+.nav-dropdown .dropdown-content a {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 18px;
+ color: var(--text-primary);
+ text-decoration: none;
+ font-size: 14px;
+ transition: background 0.15s;
+ border-left: 3px solid transparent;
+}
+.nav-dropdown .dropdown-content a:hover {
+ background: var(--bg-secondary);
+ border-left-color: var(--primary);
+}
+.nav-dropdown .dropdown-content a .icon {
+ font-size: 16px;
+ width: 24px;
+ text-align: center;
+}
+.nav-dropdown .dropdown-content .divider {
+ height: 1px;
+ background: var(--border-color);
+ margin: 4px 12px;
+}
+
+/* 移动端适配 */
+@media (max-width: 600px) {
+ .nav-dropdown .dropdown-content {
+ right: -8px;
+ min-width: 160px;
+ }
+ .nav-dropdown .dropbtn {
+ padding: 4px 10px;
+ font-size: 13px;
+ }
+}
+/* 下拉菜单 - 高亮当前页面 */
+.nav-dropdown .dropdown-content a.active {
+ background: var(--bg-secondary);
+ border-left-color: var(--primary);
+ font-weight: 600;
+}
+.avatar-circle {
+ border-radius: 50%;
+ object-fit: cover;
+ vertical-align: middle;
+}
\ No newline at end of file
diff --git a/delete.php b/delete.php
new file mode 100644
index 0000000..ed7cc37
--- /dev/null
+++ b/delete.php
@@ -0,0 +1,42 @@
+prepare("SELECT poster_path, ticket_path, photo_path FROM movies WHERE id = ?");
+$stmt->execute([$id]);
+$movie = $stmt->fetch();
+
+if ($movie) {
+ // 删除海报、票根、旧单张照片
+ foreach (['poster_path', 'ticket_path', 'photo_path'] as $field) {
+ if (!empty($movie[$field]) && file_exists(__DIR__ . '/' . $movie[$field])) {
+ unlink(__DIR__ . '/' . $movie[$field]);
+ }
+ }
+
+ // 删除多张照片(新表)
+ $stmt2 = $db->prepare("SELECT photo_path FROM movie_photos WHERE movie_id = ?");
+ $stmt2->execute([$id]);
+ $photos = $stmt2->fetchAll();
+ foreach ($photos as $p) {
+ if (!empty($p['photo_path']) && file_exists(__DIR__ . '/' . $p['photo_path'])) {
+ unlink(__DIR__ . '/' . $p['photo_path']);
+ }
+ }
+
+ // 删除电影(viewers 和 movie_photos 会自动级联删除)
+ $stmt = $db->prepare("DELETE FROM movies WHERE id = ?");
+ $stmt->execute([$id]);
+}
+
+header('Location: index.php');
+exit;
\ No newline at end of file
diff --git a/detail.php b/detail.php
new file mode 100644
index 0000000..759a702
--- /dev/null
+++ b/detail.php
@@ -0,0 +1,383 @@
+
+
+
+
+
+
+ = h($movie['title']) ?> - 电影日志
+
+
+
+
+
+
+
+
+
← 返回列表
+
+
+
+
+
+
+ 🎬
+ = h($movie['title']) ?>
+
+
+
![<?= h($movie['title']) ?>](<?= $poster ?>)
+
+
+
+
= h($movie['title']) ?>
+
+ 📅 = $movie['watch_date'] ? date('Y年m月d日', strtotime($movie['watch_date'])) : '日期待补' ?>
+
+ 🏛️ = h($movie['cinema_name']) ?>
+
+ 🎫 观影人: = count($movie['viewers']) ?> 人
+
+
+
= h($movie['info']) ?>
+
+
+
+
+
+
+
+
+
+
+
👥 一起看的人 = count($movie['viewers']) ?> 人
+ 0): ?>
+
+
+
+
+ = h($viewer['name']) ?>
+
+
+
+
+
暂无记录
+
+
+
+
+
+
🏛️ 电影院信息
+
+
+ 所在地方
+ = h($movie['place'] ?? '-') ?>
+ 影院名称
+
+ = h($movie['cinema_name']) ?>
+
+ 详细地址
+ = h($movie['cinema_address'] ?? '-') ?>
+
+
+
未记录电影院信息
+
+
+
+
+ $movie['photo_path'], 'label' => '📸 旧照片'];
+ }
+ foreach ($movie['photos'] as $p) {
+ if (file_exists(__DIR__ . '/' . $p['photo_path'])) {
+ $all_photos[] = ['path' => $p['photo_path'], 'label' => '📸 观影照片'];
+ }
+ }
+ ?>
+ 0): ?>
+
+
🖼️ 影像记录 = count($all_photos) + ($hasTicket ? 1 : 0) ?> 张
+
+
+
+

+
🎫 电影票
+
+
+
+
+

+
= $photo['label'] ?>
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/edit.php b/edit.php
new file mode 100644
index 0000000..1c8b13f
--- /dev/null
+++ b/edit.php
@@ -0,0 +1,634 @@
+query("SELECT * FROM persons ORDER BY name");
+$all_persons = $stmt->fetchAll();
+
+$error = '';
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $title = trim($_POST['title'] ?? '');
+ $info = trim($_POST['info'] ?? '');
+ $watch_date = $_POST['watch_date'] ?? '';
+ $cinema_id = $_POST['cinema_id'] ?? '';
+ $viewer_selects = $_POST['viewer_select'] ?? [];
+ $viewer_news = $_POST['viewers_new'] ?? [];
+
+ $poster_crop = $_POST['poster_crop'] ?? '';
+ $ticket_crop = $_POST['ticket_crop'] ?? '';
+ $photo_crops = $_POST['photo_crop'] ?? [];
+
+ // 新建电影院
+ $new_cinema_place = trim($_POST['new_cinema_place'] ?? '');
+ $new_cinema_name = trim($_POST['new_cinema_name'] ?? '');
+ $new_cinema_address = trim($_POST['new_cinema_address'] ?? '');
+
+ if ($cinema_id === 'new' && !empty($new_cinema_name)) {
+ $stmt = $db->prepare("INSERT INTO cinemas (place, name, address) VALUES (?, ?, ?)");
+ $stmt->execute([$new_cinema_place, $new_cinema_name, $new_cinema_address]);
+ $cinema_id = $db->lastInsertId();
+ } elseif ($cinema_id === 'new') {
+ $cinema_id = null;
+ } elseif (!empty($cinema_id) && is_numeric($cinema_id)) {
+ $cinema_id = (int)$cinema_id;
+ } else {
+ $cinema_id = null;
+ }
+
+ if (empty($title)) {
+ $error = '请输入电影名称';
+ } elseif (empty($watch_date)) {
+ $error = '请选择观影日期';
+ } else {
+ // ----- 处理海报裁剪/上传 -----
+ $poster_path = $movie['poster_path'];
+ if (!empty($poster_crop)) {
+ $new_path = saveBase64Image($poster_crop, POSTER_DIR, 'poster');
+ if ($new_path) {
+ if ($poster_path && file_exists(__DIR__ . '/' . $poster_path)) {
+ unlink(__DIR__ . '/' . $poster_path);
+ }
+ $poster_path = $new_path;
+ } else {
+ $error = '海报裁剪保存失败,请重试';
+ }
+ } elseif (isset($_FILES['poster']) && $_FILES['poster']['error'] === UPLOAD_ERR_OK) {
+ // 上传新海报替换
+ $ext = strtolower(pathinfo($_FILES['poster']['name'], PATHINFO_EXTENSION));
+ if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'])) {
+ $filename = uniqueFilename($_FILES['poster']['name']);
+ $target = POSTER_DIR . $filename;
+ if (move_uploaded_file($_FILES['poster']['tmp_name'], $target)) {
+ if ($poster_path && file_exists(__DIR__ . '/' . $poster_path)) {
+ unlink(__DIR__ . '/' . $poster_path);
+ }
+ $poster_path = 'uploads/posters/' . $filename;
+ }
+ }
+ }
+
+ // ----- 处理票根裁剪/上传 -----
+ $ticket_path = $movie['ticket_path'];
+ if (!empty($ticket_crop)) {
+ $new_path = saveBase64Image($ticket_crop, TICKET_DIR, 'ticket');
+ if ($new_path) {
+ if ($ticket_path && file_exists(__DIR__ . '/' . $ticket_path)) {
+ unlink(__DIR__ . '/' . $ticket_path);
+ }
+ $ticket_path = $new_path;
+ } else {
+ $error = '票根裁剪保存失败,请重试';
+ }
+ } elseif (isset($_FILES['ticket']) && $_FILES['ticket']['error'] === UPLOAD_ERR_OK) {
+ $ext = strtolower(pathinfo($_FILES['ticket']['name'], PATHINFO_EXTENSION));
+ if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'])) {
+ $filename = uniqueFilename($_FILES['ticket']['name']);
+ $target = TICKET_DIR . $filename;
+ if (move_uploaded_file($_FILES['ticket']['tmp_name'], $target)) {
+ if ($ticket_path && file_exists(__DIR__ . '/' . $ticket_path)) {
+ unlink(__DIR__ . '/' . $ticket_path);
+ }
+ $ticket_path = 'uploads/tickets/' . $filename;
+ }
+ }
+ }
+
+ // 如果前面处理没有错误,继续更新数据库
+ if (empty($error)) {
+ // 更新电影基本信息
+ $stmt = $db->prepare("
+ UPDATE movies
+ SET title = ?, info = ?, poster_path = ?, ticket_path = ?, cinema_id = ?, watch_date = ?
+ WHERE id = ?
+ ");
+ $stmt->execute([$title, $info, $poster_path, $ticket_path, $cinema_id, $watch_date, $id]);
+
+ // ----- 处理合影裁剪 -----
+ if (!empty($photo_crops)) {
+ foreach ($photo_crops as $photo_id => $base64_data) {
+ if (empty($base64_data)) continue;
+ $stmt = $db->prepare("SELECT photo_path FROM movie_photos WHERE id = ? AND movie_id = ?");
+ $stmt->execute([$photo_id, $id]);
+ $old = $stmt->fetch();
+ if ($old) {
+ $new_path = saveBase64Image($base64_data, PHOTO_DIR, 'photo');
+ if ($new_path) {
+ if ($old['photo_path'] && file_exists(__DIR__ . '/' . $old['photo_path'])) {
+ unlink(__DIR__ . '/' . $old['photo_path']);
+ }
+ $stmt = $db->prepare("UPDATE movie_photos SET photo_path = ? WHERE id = ?");
+ $stmt->execute([$new_path, $photo_id]);
+ }
+ }
+ }
+ }
+
+ // ----- 新增合影照片 -----
+ if (isset($_FILES['new_photos']) && is_array($_FILES['new_photos']['tmp_name'])) {
+ $photo_files = $_FILES['new_photos'];
+ for ($i = 0; $i < count($photo_files['tmp_name']); $i++) {
+ if ($photo_files['error'][$i] === UPLOAD_ERR_OK) {
+ $ext = strtolower(pathinfo($photo_files['name'][$i], PATHINFO_EXTENSION));
+ if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'])) {
+ $filename = uniqueFilename($photo_files['name'][$i]);
+ $target = PHOTO_DIR . $filename;
+ if (move_uploaded_file($photo_files['tmp_name'][$i], $target)) {
+ $photo_path = 'uploads/photos/' . $filename;
+ $stmt = $db->prepare("INSERT INTO movie_photos (movie_id, photo_path) VALUES (?, ?)");
+ $stmt->execute([$id, $photo_path]);
+ }
+ }
+ }
+ }
+ }
+
+ // ----- 处理观影人 -----
+ $stmt = $db->prepare("DELETE FROM viewers WHERE movie_id = ?");
+ $stmt->execute([$id]);
+ $person_ids = [];
+ foreach ($viewer_selects as $idx => $val) {
+ if ($val === 'new') {
+ $new_name = trim($viewer_news[$idx] ?? '');
+ if (!empty($new_name)) {
+ $chk = $db->prepare("SELECT id FROM persons WHERE name = ?");
+ $chk->execute([$new_name]);
+ $existing = $chk->fetch();
+ if ($existing) {
+ $person_ids[] = $existing['id'];
+ } else {
+ $ins = $db->prepare("INSERT INTO persons (name) VALUES (?)");
+ $ins->execute([$new_name]);
+ $person_ids[] = $db->lastInsertId();
+ }
+ }
+ } elseif (is_numeric($val) && $val > 0) {
+ $person_ids[] = (int)$val;
+ }
+ }
+ $person_ids = array_unique($person_ids);
+ if (!empty($person_ids)) {
+ $stmt = $db->prepare("INSERT INTO viewers (movie_id, person_id, name) VALUES (?, ?, ?)");
+ foreach ($person_ids as $pid) {
+ $stmt->execute([$id, $pid, '']);
+ }
+ }
+
+ header('Location: detail.php?id=' . $id);
+ exit;
+ }
+ }
+}
+
+// 准备观影人数据
+$current_person_ids = array_column($movie['viewers'], 'person_id');
+$current_person_ids = array_filter($current_person_ids, function($v) { return $v > 0; });
+if (empty($current_person_ids)) {
+ $current_person_ids = [0];
+}
+?>
+
+
+
+
+
+ 编辑电影 - 电影日志
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/fetch_movie.php b/fetch_movie.php
new file mode 100644
index 0000000..c56c67b
--- /dev/null
+++ b/fetch_movie.php
@@ -0,0 +1,76 @@
+ '请输入电影名称']);
+ exit;
+}
+
+// OMDb API 请求 URL(按标题搜索,返回完整信息)
+$url = 'http://www.omdbapi.com/?i=tt3896198&apikey=' . OMDB_API_KEY . '&t=' . urlencode($q) . '&plot=full';
+
+// 发起请求
+$ch = curl_init();
+curl_setopt($ch, CURLOPT_URL, $url);
+curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+curl_setopt($ch, CURLOPT_TIMEOUT, 10);
+curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+curl_setopt($ch, CURLOPT_USERAGENT, 'MovieLog/1.0');
+$response = curl_exec($ch);
+$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+$curlError = curl_error($ch);
+curl_close($ch);
+
+if ($curlError) {
+ echo json_encode(['error' => '网络请求失败: ' . $curlError]);
+ exit;
+}
+
+if ($httpCode != 200) {
+ echo json_encode(['error' => 'API 返回 HTTP ' . $httpCode]);
+ exit;
+}
+
+$data = json_decode($response, true);
+if (!$data) {
+ echo json_encode(['error' => '解析数据失败']);
+ exit;
+}
+
+// 检查是否找到电影
+if (isset($data['Response']) && $data['Response'] === 'False') {
+ echo json_encode(['error' => '未找到相关电影,请尝试更准确的关键词']);
+ exit;
+}
+
+// 提取需要的字段
+$result = [
+ 'title' => $data['Title'] ?? '',
+ 'year' => $data['Year'] ?? '',
+ 'rated' => $data['Rated'] ?? '',
+ 'released'=> $data['Released'] ?? '',
+ 'runtime' => $data['Runtime'] ?? '',
+ 'genre' => $data['Genre'] ?? '',
+ 'director'=> $data['Director'] ?? '',
+ 'actors' => $data['Actors'] ?? '',
+ 'plot' => $data['Plot'] ?? '',
+ 'poster' => $data['Poster'] ?? '',
+ 'imdbRating' => $data['imdbRating'] ?? '',
+];
+
+// 如果海报是 'N/A',置空
+if ($result['poster'] === 'N/A') {
+ $result['poster'] = '';
+}
+
+echo json_encode($result);
\ No newline at end of file
diff --git a/import.php b/import.php
new file mode 100644
index 0000000..ff28310
--- /dev/null
+++ b/import.php
@@ -0,0 +1,333 @@
+beginTransaction();
+ try {
+ foreach ($lines as $line_num => $line) {
+ $line = trim($line);
+ if (empty($line)) continue;
+
+ $row = str_getcsv($line);
+ if (count($row) < 9) {
+ $errors[] = "第 " . ($line_num + 2) . " 行列数不足,跳过";
+ $skip_count++;
+ continue;
+ }
+
+ $title = trim($row[0]);
+ $cinema_name = trim($row[2]);
+ $watch_date = trim($row[3]);
+ $viewer_names = trim($row[4]);
+ $ticket_file = trim($row[5]);
+ $poster_file = trim($row[6]);
+ $remark = trim($row[7]);
+ $photo_file = trim($row[8]);
+
+ if (empty($title)) {
+ $errors[] = "第 " . ($line_num + 2) . " 行缺少电影名称,跳过";
+ $skip_count++;
+ continue;
+ }
+
+ // 处理日期(允许为空)
+ $formatted_date = null;
+ if (!empty($watch_date)) {
+ $timestamp = strtotime($watch_date);
+ if ($timestamp === false) {
+ $errors[] = "第 " . ($line_num + 2) . " 行日期格式无效: $watch_date,跳过";
+ $skip_count++;
+ continue;
+ }
+ $formatted_date = date('Y-m-d', $timestamp);
+ }
+
+ // 电影院去重
+ $cinema_id = null;
+ if (!empty($cinema_name)) {
+ if (!isset($cinema_cache[$cinema_name])) {
+ $stmt = $db->prepare("SELECT id FROM cinemas WHERE name = ?");
+ $stmt->execute([$cinema_name]);
+ $existing = $stmt->fetch();
+ if ($existing) {
+ $cinema_cache[$cinema_name] = $existing['id'];
+ } else {
+ $stmt = $db->prepare("INSERT INTO cinemas (place, name, address) VALUES (?, ?, ?)");
+ $stmt->execute(['', $cinema_name, '']);
+ $cinema_id = $db->lastInsertId();
+ $cinema_cache[$cinema_name] = $cinema_id;
+ }
+ }
+ $cinema_id = $cinema_cache[$cinema_name];
+ }
+
+ $ticket_filename = extractFileName($ticket_file);
+ $poster_filename = extractFileName($poster_file);
+ $photo_filename = extractFileName($photo_file);
+
+ $ticket_path = !empty($ticket_filename) ? 'uploads/tickets/' . $ticket_filename : null;
+ $poster_path = !empty($poster_filename) ? 'uploads/posters/' . $poster_filename : null;
+ $photo_path = !empty($photo_filename) ? 'uploads/photos/' . $photo_filename : null;
+
+ $stmt = $db->prepare("
+ INSERT INTO movies (title, info, remark, poster_path, ticket_path, photo_path, cinema_id, watch_date)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ");
+ $stmt->execute([
+ $title,
+ '',
+ $remark,
+ $poster_path,
+ $ticket_path,
+ $photo_path,
+ $cinema_id,
+ $formatted_date
+ ]);
+ $movie_id = $db->lastInsertId();
+
+ // 处理观影人:按空白字符分割(空格、制表符等)
+ if (!empty($viewer_names)) {
+ $viewers = preg_split('/\s+/', trim($viewer_names));
+ $viewers = array_filter($viewers, function($v) { return !empty($v); });
+ if (!empty($viewers)) {
+ $stmt = $db->prepare("INSERT INTO viewers (movie_id, name) VALUES (?, ?)");
+ foreach ($viewers as $name) {
+ $stmt->execute([$movie_id, $name]);
+ }
+ }
+ }
+
+ $import_count++;
+ }
+
+ $db->commit();
+ $result = [
+ 'success' => true,
+ 'imported' => $import_count,
+ 'skipped' => $skip_count,
+ 'errors' => $errors
+ ];
+ } catch (Exception $e) {
+ $db->rollBack();
+ $error = '导入过程中发生错误:' . $e->getMessage();
+ }
+ }
+}
+?>
+
+
+
+
+
+ CSV导入 - 电影日志
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..ea2a8e0
--- /dev/null
+++ b/index.php
@@ -0,0 +1,200 @@
+
+
+
+
+
+
+ 电影日志 - 我的电影
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🎥
+
= empty($keyword) ? '还没有电影记录' : '没有找到匹配的电影' ?>
+
= empty($keyword) ? '点击「添加电影」开始记录你的观影之旅吧' : '尝试其他关键词' ?>
+
+
➕ 添加第一部电影
+
+
查看全部电影
+
+
+
+
+
+
+
+
+
+ 🎬
+ = h($movie['title']) ?>
+
+
+
![<?= h($movie['title']) ?>](<?= $poster ?>)
+
+
+
+
= h($movie['title']) ?>
+
+ 📅 = $movie['watch_date'] ? date('Y年m月d日', strtotime($movie['watch_date'])) : '日期待补' ?>
+
+
+ 🏛️ = h($movie['cinema_name']) ?>
+
+
+
+
+
+
+
+
+
+ = h($viewer['name']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/js/main.js b/js/main.js
new file mode 100644
index 0000000..3f68117
--- /dev/null
+++ b/js/main.js
@@ -0,0 +1,11 @@
+document.addEventListener('DOMContentLoaded', function() {
+ console.log('🎬 电影日志系统已加载');
+});
+
+function formatDate(dateStr) {
+ if (!dateStr) return '';
+ const d = new Date(dateStr);
+ return d.getFullYear() + '年' +
+ String(d.getMonth() + 1).padStart(2, '0') + '月' +
+ String(d.getDate()).padStart(2, '0') + '日';
+}
\ No newline at end of file
diff --git a/login.php b/login.php
new file mode 100644
index 0000000..778eba4
--- /dev/null
+++ b/login.php
@@ -0,0 +1,199 @@
+prepare("SELECT * FROM admins WHERE username = ?");
+ $stmt->execute([$username]);
+ $admin = $stmt->fetch();
+
+ if ($admin && password_verify($password, $admin['password'])) {
+ $_SESSION['admin_id'] = $admin['id'];
+ $_SESSION['admin_username'] = $admin['username'];
+ header('Location: index.php');
+ exit;
+ } else {
+ $error = '用户名或密码错误';
+ }
+ }
+}
+?>
+
+
+
+
+
+ 电影全纪录 - 登录
+
+
+
+
+
+
+
+
+
= h($error) ?>
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/logout.php b/logout.php
new file mode 100644
index 0000000..ee222ca
--- /dev/null
+++ b/logout.php
@@ -0,0 +1,5 @@
+ '未登录'];
+ }
+}
+
+$current_page = basename($_SERVER['PHP_SELF']);
+?>
+
+
+
\ No newline at end of file
diff --git a/persons.php b/persons.php
new file mode 100644
index 0000000..ccc3466
--- /dev/null
+++ b/persons.php
@@ -0,0 +1,484 @@
+prepare("SELECT id FROM persons WHERE name = ?");
+ $stmt->execute([$name]);
+ if ($stmt->fetch()) {
+ $error = '该姓名已存在';
+ } else {
+ // 保存头像
+ $avatar_path = null;
+ if (!empty($avatar_base64)) {
+ $avatar_path = saveBase64Image($avatar_base64, AVATAR_DIR, 'avatar');
+ }
+
+ $stmt = $db->prepare("INSERT INTO persons (name, color, avatar) VALUES (?, ?, ?)");
+ $stmt->execute([$name, $color, $avatar_path]);
+ $message = '观影人添加成功';
+ }
+ }
+}
+
+// ---------- 处理编辑 ----------
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'edit') {
+ $id = (int)$_POST['id'];
+ $name = trim($_POST['name'] ?? '');
+ $color = trim($_POST['color'] ?? '#888888');
+ if (!preg_match('/^#[a-fA-F0-9]{6}$/', $color)) $color = '#888888';
+
+ $avatar_base64 = $_POST['avatar_base64'] ?? '';
+
+ if (empty($name)) {
+ $error = '姓名不能为空';
+ } else {
+ // 检查重复
+ $stmt = $db->prepare("SELECT id FROM persons WHERE name = ? AND id != ?");
+ $stmt->execute([$name, $id]);
+ if ($stmt->fetch()) {
+ $error = '该姓名已存在';
+ } else {
+ // 获取旧头像
+ $stmt = $db->prepare("SELECT avatar FROM persons WHERE id = ?");
+ $stmt->execute([$id]);
+ $old = $stmt->fetch();
+ $old_avatar = $old['avatar'] ?? null;
+
+ // 保存新头像
+ $avatar_path = $old_avatar;
+ if (!empty($avatar_base64)) {
+ // 删除旧头像
+ if ($old_avatar && file_exists(__DIR__ . '/' . $old_avatar)) {
+ unlink(__DIR__ . '/' . $old_avatar);
+ }
+ $avatar_path = saveBase64Image($avatar_base64, AVATAR_DIR, 'avatar');
+ }
+
+ $stmt = $db->prepare("UPDATE persons SET name = ?, color = ?, avatar = ? WHERE id = ?");
+ $stmt->execute([$name, $color, $avatar_path, $id]);
+ $message = '观影人信息已更新';
+ }
+ }
+}
+
+// ---------- 处理删除 ----------
+if (isset($_GET['action']) && $_GET['action'] === 'delete' && isset($_GET['id'])) {
+ $id = (int)$_GET['id'];
+ $stmt = $db->prepare("SELECT COUNT(*) FROM viewers WHERE person_id = ?");
+ $stmt->execute([$id]);
+ $count = $stmt->fetchColumn();
+ if ($count > 0) {
+ $error = "该观影人关联了 {$count} 部电影,不能直接删除。请先移除关联。";
+ } else {
+ // 删除头像文件
+ $stmt = $db->prepare("SELECT avatar FROM persons WHERE id = ?");
+ $stmt->execute([$id]);
+ $row = $stmt->fetch();
+ if ($row && $row['avatar'] && file_exists(__DIR__ . '/' . $row['avatar'])) {
+ unlink(__DIR__ . '/' . $row['avatar']);
+ }
+ $stmt = $db->prepare("DELETE FROM persons WHERE id = ?");
+ $stmt->execute([$id]);
+ $message = '观影人已删除';
+ }
+}
+
+// ---------- 获取列表 ----------
+$stmt = $db->query("SELECT * FROM persons ORDER BY name");
+$persons = $stmt->fetchAll();
+
+// ---------- 获取编辑数据 ----------
+$edit_data = null;
+if (isset($_GET['action']) && $_GET['action'] === 'edit' && isset($_GET['id'])) {
+ $id = (int)$_GET['id'];
+ $stmt = $db->prepare("SELECT * FROM persons WHERE id = ?");
+ $stmt->execute([$id]);
+ $edit_data = $stmt->fetch();
+ if (!$edit_data) header('Location: persons.php');
+}
+
+// 50种预设颜色
+$preset_colors = [
+ '#e74c3c','#c0392b','#e67e22','#d35400','#f39c12',
+ '#f1c40f','#f7dc6f','#2ecc71','#27ae60','#1abc9c',
+ '#16a085','#3498db','#2980b9','#9b59b6','#8e44ad',
+ '#34495e','#2c3e50','#e84393','#fd79a8','#00b894',
+ '#00cec9','#0984e3','#6c5ce7','#fdcb6e','#e17055',
+ '#d63031','#f0932b','#6ab04c','#22a6b3','#4834d4',
+ '#be2edd','#130f40','#535c68','#95afc0','#7ed6df',
+ '#e056a0','#ffb142','#33d9b2','#227093','#218c74',
+ '#b33939','#cd6133','#f97f51','#82589f','#3dc1d3',
+ '#b8e994','#78e08f','#f6b93b','#e55039','#4a69bd'
+];
+?>
+
+
+
+
+
+ 观影人管理 - 电影日志
+
+
+
+
+
+
+
+
+
+
+
= h($message) ?>
+
= $error ?>
+
+
+
+
= $edit_data ? '✏️ 编辑观影人' : '➕ 添加观影人' ?>
+
= $edit_data ? '修改观影人信息' : '录入新的观影人' ?>
+
+
+
+
+
+
👥 观影人列表
+
共 = count($persons) ?> 人
+
+ 0): ?>
+
+ | 头像 | 姓名 | 颜色 | 操作 |
+
+
+
+
+
+ |
+
+
+ = h($p['name']) ?>
+ |
+ = h($p['color'] ?: '#888888') ?> |
+
+
+ |
+
+
+
+
+
+
暂无观影人,请添加
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/search.php b/search.php
new file mode 100644
index 0000000..806b6c2
--- /dev/null
+++ b/search.php
@@ -0,0 +1,367 @@
+query("SELECT * FROM persons ORDER BY name");
+$persons = $stmt->fetchAll();
+
+// 接收搜索参数
+$params = [
+ 'title' => trim($_GET['title'] ?? ''),
+ 'cinema_id' => trim($_GET['cinema_id'] ?? ''),
+ 'person_id' => trim($_GET['person_id'] ?? ''),
+ 'date_from' => trim($_GET['date_from'] ?? ''),
+ 'date_to' => trim($_GET['date_to'] ?? ''),
+];
+
+// 检查是否有任何条件(排除空值)
+$has_conditions = false;
+foreach ($params as $v) {
+ if (!empty($v)) {
+ $has_conditions = true;
+ break;
+ }
+}
+
+$movies = [];
+if ($has_conditions) {
+ $movies = advancedSearch($params); // 这里已附带观影人信息
+}
+?>
+
+
+
+
+
+ 高级搜索 - 电影日志
+
+
+
+
+
+
+
+
+
+
+
🔍 高级搜索
+
组合多个条件精确查找电影
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🎬
+ = h($movie['title']) ?>
+
+
+
![<?= h($movie['title']) ?>](<?= $poster ?>)
+
+
+
+
= h($movie['title']) ?>
+
+ 📅 = $movie['watch_date'] ? date('Y年m月d日', strtotime($movie['watch_date'])) : '日期待补' ?>
+
+
+ 🏛️ = h($movie['cinema_name']) ?>
+
+
+
+
+
+
+
+
+ 👤 = h($viewer['name']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/stats.php b/stats.php
new file mode 100644
index 0000000..5e188f3
--- /dev/null
+++ b/stats.php
@@ -0,0 +1,318 @@
+
+
+
+
+
+
+ 统计仪表板 - 电影日志
+
+
+
+
+
+
+
+
+
📊 统计仪表板
+
综合观影数据概览
+
+
+
+
+
= $overview['total_movies'] ?>
+
🎬 电影总数
+
+
+
= $overview['total_cinemas'] ?>
+
🏛️ 影院数
+
+
+
= $overview['total_persons'] ?>
+
👥 观影人数
+
+
+
= $overview['total_viewership'] ?>
+
🎫 总观影人次
+
+
+
+
📅 观影跨度
+
+ = date('Y年m月d日', strtotime($overview['first_date'])) ?> ~ = date('Y年m月d日', strtotime($overview['last_date'])) ?>
+
+
+
+
+
+
+
+
🏛️ 按影院统计 电影数量
+
+
+
+
+
+
+ = h($item['name']) ?>
+
+
+
= $item['count'] ?>
+
+
+
+
+
暂无影院数据
+
+
+
+
+
+
+
+
👥 按观影人统计 参与电影数
+
+
+
+
+
+
+
+ = h($item['name']) ?>
+
+
+
= $item['count'] ?>
+
+
+
+
+
暂无观影人数据
+
+
+
+
+
+
📆 按年统计 观影数量
+
+
+
+
+
= h($item['year']) ?>
+
+
= $item['count'] ?>
+
+
+
+
+
暂无年份数据
+
+
+
+
+
+
+
📈 月度趋势 最近12个月观影数量
+
+
+
+
+
= h($item['month']) ?>
+
+
= $item['count'] ?>
+
+
+
+
+
暂无月度数据
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test_crop.php b/test_crop.php
new file mode 100644
index 0000000..0ca5938
--- /dev/null
+++ b/test_crop.php
@@ -0,0 +1,128 @@
+ 100) {
+ $path = saveBase64Image($base64, TICKET_DIR, 'test_crop');
+ if ($path) {
+ echo "✅ 保存成功: 查看图片";
+ } else {
+ echo "❌ 保存失败,检查目录权限和 fileinfo 扩展";
+ }
+ } else {
+ echo "⚠️ 数据太短,可能未裁剪";
+ }
+ exit;
+}
+?>
+
+
+
+
+ 测试裁剪
+
+
+
+
+ 测试裁剪保存
+ 请上传一张图片,裁剪后提交,检查是否保存成功。
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
裁剪
+
![]()
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/users.php b/users.php
new file mode 100644
index 0000000..6f9689f
--- /dev/null
+++ b/users.php
@@ -0,0 +1,442 @@
+query("SELECT * FROM persons ORDER BY name");
+$all_persons = $stmt->fetchAll();
+
+$message = '';
+$error = '';
+
+// 处理关联观影人(当前用户)
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'link_person') {
+ $admin_id = (int)$_POST['admin_id'];
+ $person_id = !empty($_POST['person_id']) ? (int)$_POST['person_id'] : null;
+
+ if ($admin_id != $_SESSION['admin_id']) {
+ $error = '只能修改自己的关联';
+ } else {
+ if (updateAdminPersonId($admin_id, $person_id)) {
+ $message = '关联观影人更新成功';
+ $_SESSION['admin_id'] = $admin_id;
+ $admin = getCurrentAdmin();
+ } else {
+ $error = '更新失败';
+ }
+ }
+}
+
+// 处理添加用户
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'add') {
+ $new_user = trim($_POST['new_username'] ?? '');
+ $new_pass = $_POST['new_password'] ?? '';
+ $confirm = $_POST['confirm_password'] ?? '';
+ $person_id = !empty($_POST['person_id']) ? (int)$_POST['person_id'] : null;
+
+ if (empty($new_user)) {
+ $error = '用户名不能为空';
+ } elseif (strlen($new_pass) < 6) {
+ $error = '密码至少6位';
+ } elseif ($new_pass !== $confirm) {
+ $error = '两次密码不一致';
+ } else {
+ if (addAdmin($new_user, $new_pass, null, $person_id)) {
+ $message = "用户 {$new_user} 添加成功";
+ } else {
+ $error = '用户名已存在';
+ }
+ }
+}
+
+// 处理编辑用户
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'edit') {
+ $edit_id = (int)$_POST['id'];
+ $username = trim($_POST['username'] ?? '');
+ $new_password = $_POST['new_password'] ?? '';
+ $confirm_password = $_POST['confirm_password'] ?? '';
+ $person_id = !empty($_POST['person_id']) ? (int)$_POST['person_id'] : null;
+
+ if (empty($username)) {
+ $error = '用户名不能为空';
+ } else {
+ // 检查用户名是否已被其他用户使用
+ $stmt = $db->prepare("SELECT COUNT(*) FROM admins WHERE username = ? AND id != ?");
+ $stmt->execute([$username, $edit_id]);
+ if ($stmt->fetchColumn() > 0) {
+ $error = '用户名已被占用';
+ } else {
+ // 构建更新语句
+ $sql = "UPDATE admins SET username = ?, person_id = ?";
+ $params = [$username, $person_id];
+ if (!empty($new_password)) {
+ if (strlen($new_password) < 6) {
+ $error = '密码至少6位';
+ } elseif ($new_password !== $confirm_password) {
+ $error = '两次密码不一致';
+ } else {
+ $hashed = password_hash($new_password, PASSWORD_DEFAULT);
+ $sql .= ", password = ?";
+ $params[] = $hashed;
+ }
+ }
+ if (empty($error)) {
+ $sql .= " WHERE id = ?";
+ $params[] = $edit_id;
+ $stmt = $db->prepare($sql);
+ if ($stmt->execute($params)) {
+ $message = "用户 {$username} 更新成功";
+ // 如果编辑的是自己,刷新 session 信息
+ if ($edit_id == $_SESSION['admin_id']) {
+ $_SESSION['admin_id'] = $edit_id;
+ $admin = getCurrentAdmin();
+ }
+ } else {
+ $error = '更新失败';
+ }
+ }
+ }
+ }
+}
+
+// 处理删除用户
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'delete') {
+ $delete_id = (int)$_POST['delete_id'];
+ if (deleteAdmin($delete_id)) {
+ $message = '用户已删除';
+ } else {
+ $error = '无法删除该用户(可能是最后一个管理员或您自己)';
+ }
+}
+
+// 获取编辑数据
+$edit_data = null;
+if (isset($_GET['action']) && $_GET['action'] === 'edit' && isset($_GET['id'])) {
+ $edit_id = (int)$_GET['id'];
+ $stmt = $db->prepare("SELECT * FROM admins WHERE id = ?");
+ $stmt->execute([$edit_id]);
+ $edit_data = $stmt->fetch();
+ if (!$edit_data) {
+ header('Location: users.php');
+ exit;
+ }
+}
+
+$users = getAllAdmins();
+?>
+
+
+
+
+
+ 用户管理 - 电影日志
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file