From d7c6ac2559e16f78f7c0e07c21da68fdb3aa44ce Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 21 Sep 2026 08:16:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E7=94=B5=E5=BD=B1?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=B3=BB=E7=BB=9F=E6=BA=90=E7=A0=81=E4=B8=8E?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能模块: - 观影记录:列表(瀑布流)/ 添加 / 编辑 / 详情 / 删除,删除时同步清理图片文件 - 首页快捷搜索 + 高级搜索(片名、影院、观影人、日期区间组合筛选) - 影院管理:三段式信息 + 50 种预设配色,删除已引用影院时二次确认 - 观影人管理:头像裁剪上传 + 专属配色,已引用者禁止删除 - 图片处理:Cropper.js 裁剪票根/海报/观影照片/头像,多图上传,首字母 SVG 头像兜底 - OMDb 自动获取影片信息(fetch_movie.php 代理,返回 JSON) - CSV 批量导入:UTF-8/GBK 自动识别,影院去重,事务保护,逐行错误报告 - 统计仪表板:总览 + 按影院/观影人/年份/近 12 个月排行 - 年度报告:按账号关联的观影人视角生成,含 Chart.js 月度与星期分布图 - 多用户:账号增删改、账号与观影人绑定、修改自己的用户名与密码 - 登录鉴权:Session + password_hash,全站页面登录校验 文档: - 重写 README:功能特性、技术栈、目录结构、数据库结构、部署与使用指南、接口说明、CSV 格式、安全注意事项、已知限制 - 新增 .gitignore,排除 db/ 与 uploads/ 等运行时数据 --- .gitignore | 23 ++ README.md | 289 ++++++++++++++++- add.php | 739 +++++++++++++++++++++++++++++++++++++++++++ annual_report.php | 582 ++++++++++++++++++++++++++++++++++ assets/.gitkeep | 0 change_password.php | 204 ++++++++++++ cinemas.php | 474 ++++++++++++++++++++++++++++ config.php | 740 ++++++++++++++++++++++++++++++++++++++++++++ css/style.css | 483 +++++++++++++++++++++++++++++ delete.php | 42 +++ detail.php | 383 +++++++++++++++++++++++ edit.php | 634 +++++++++++++++++++++++++++++++++++++ fetch_movie.php | 76 +++++ import.php | 333 ++++++++++++++++++++ index.php | 200 ++++++++++++ js/main.js | 11 + login.php | 199 ++++++++++++ logout.php | 5 + nav.php | 84 +++++ persons.php | 484 +++++++++++++++++++++++++++++ search.php | 367 ++++++++++++++++++++++ stats.php | 318 +++++++++++++++++++ test_crop.php | 128 ++++++++ users.php | 442 ++++++++++++++++++++++++++ 24 files changed, 7238 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 add.php create mode 100644 annual_report.php create mode 100644 assets/.gitkeep create mode 100644 change_password.php create mode 100644 cinemas.php create mode 100644 config.php create mode 100644 css/style.css create mode 100644 delete.php create mode 100644 detail.php create mode 100644 edit.php create mode 100644 fetch_movie.php create mode 100644 import.php create mode 100644 index.php create mode 100644 js/main.js create mode 100644 login.php create mode 100644 logout.php create mode 100644 nav.php create mode 100644 persons.php create mode 100644 search.php create mode 100644 stats.php create mode 100644 test_crop.php create mode 100644 users.php 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; + } + } +} +?> + + + + + + 添加电影 - 电影日志 + + + + + + + +
+
+
+

🎬 添加新电影

+

记录一部你曾看过的电影

+ + +
+ + +
+ +
+ +
+ + +
+
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ $val): + $new_name = $viewer_news[$idx] ?? ''; + ?> +
+ + + 0): ?> + + +
+ +
+ +
选择已有观影人,或选择“新增”后输入姓名
+
+ + +
+
+ + + +
推荐比例 2:3
+
+ +
+ + + + +
+
+ + +
+ + +
按住 Ctrl(或 ⌘)可一次选择多张
+
+ +
+ + 取消 +
+
+
+
+
+ + + + + + + + \ 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 ?> 年度观影报告 - 电影日志 + + + + + + + + +
+
+
+

🎬 观影年度报告

+

回顾你这一年的光影旅程

+
+
+ +
+
+
+ + +
+ 📭 +

这一年还没有观影记录

+

快去添加电影,开启你的光影之旅吧

+ ➕ 添加电影 +
+ + + +
+
+
+
🎬 观影总数
+
+
+
+
📅 观影天数
+
+
+
+
📊 月均观影
+
+
+
+
📆 平均每 0 ? $stats['avg_days_between'] : '-' ?>天一部
+
+
+ + +
+
+
🏛️ 最常去的影院
+
( 次)
+
+
+
👥 最常一起观影的人
+
( 次)
+ +
✅ 已排除 "" 自己
+ +
⚠️ 未设置自己的观影人身份,未排除自己
+ +
+
+
🎫 单次最多人
+
( 人)
+
+
+ + +
+
+

📈 月度观影分布

+ +
+
+

📆 星期观影偏好

+ +
+
+ + + +
+

👥 今年共同观影人

+
+ +
+ + + 共同观影 +
+ +
+
+ + + + +
+

🎥 年末最近观影

+
+ + + +
+
+ + + + +
+

🎞️ 我的观影记录 ( 部)

+ +
+ + <?= h($movie['title']) ?> +
+
+
📅
+
+ + + + + + + + + 独自观影 + +
+
+
+ +
+ + + +
+ 首部观影: +  ·  + 最后一部: +
+ + +
+
+ + + + \ 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个 +?> + + + + + + 影院管理 - 电影全纪录 + + + + + +
+
+ +
+ + +
+ + + +
+

+

+
+ + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + + +
+
+ +
+ + + 取消编辑 + +
+
+
+ + +
+

🏛️ 影院列表

+

家影院

+
+ 0): ?> + + + + + + + + + + + + + + + + + + + +
名称所在地方详细地址操作
+ + + + +
+ +
暂无影院,请添加
+ +
+
+
+
+ + + + \ 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("{$letter}"); + } + 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']) ?> + +
+
+

+
+ 📅 + + 🏛️ + + 🎫 观影人: +
+ +
+ + +
📝
+ + +
+
+ + +
+

👥 一起看的人

+ 0): ?> +
+ + + <?= h($viewer['name']) ?> + + + +
+ +

暂无记录

+ +
+ + +
+

🏛️ 电影院信息

+ +
+ 所在地方 + + 影院名称 + + + + 详细地址 + +
+ +

未记录电影院信息

+ +
+ + + $movie['photo_path'], 'label' => '📸 旧照片']; + } + foreach ($movie['photos'] as $p) { + if (file_exists(__DIR__ . '/' . $p['photo_path'])) { + $all_photos[] = ['path' => $p['photo_path'], 'label' => '📸 观影照片']; + } + } + ?> + 0): ?> +
+

🖼️ 影像记录

+ +
+ +
+
+ + + + \ 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导入 - 电影日志 + + + + + + +
+
+
+

📥 CSV批量导入

+

将整理好的电影数据一次性导入系统

+ + +
+ + + +
+ ✅ 导入完成!成功 条,跳过 条。 + +
+ +
⚠️
+ +
+ +
+ + +
+
+ + +
+ +
+ +
+ 📋 CSV格式要求:
+ 列顺序(共9列):
+ 电影名称, 地点(忽略), 电影院名称, 观影日期, 观影人(以空格分隔), 电影票图片路径, 海报图片路径, 备注, 合影图片路径
+
+ 观影人格式: 多个姓名之间用空格分隔,例如 张三 李四 王五
+
+ 图片路径格式: 可以包含路径,系统会自动提取最后一个 / 后的文件名。
+ 如果路径用括号括起来,如 (电影票扫描件/电影票_阿甘正传.jpg),系统会自动提取括号内的内容再取文件名。
+
+ 示例行:
+ 阿甘正传, , 万达影城, 2024-01-15, 张三 李四, (电影票/阿甘正传.jpg), (海报/阿甘正传.jpg), 经典励志片, (合影/阿甘正传.jpg)
+
+ 📌 注意: 导入前请将图片文件放入对应的 uploads/tickets/uploads/posters/uploads/photos/ 目录,并确保文件名与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 @@ + + + + + + + 电影日志 - 我的电影 + + + + + + +
+ + + + + + + + +
+ +
+
+ +
+ 🎬 + +
+ + <?= h($movie['title']) ?> + +
+
+

+
+ 📅 + + + 🏛️ + + +
+ + +
+ + + <?= 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 = '用户名或密码错误'; + } + } +} +?> + + + + + + 电影全纪录 - 登录 + + + + +
+ + + +
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + \ 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' +]; +?> + + + + + + 观影人管理 - 电影日志 + + + + + + + + +
+
+
+
+ + +
+

+

+
+ + + + + +
+
+ + +
+
+ +
+ +
+ + 点击色块选择,或使用拾色器 +
+
+ + + +
+
+ +
+ +
+ + + + + + + 点击选择图片,裁剪后生效 +
+ +
+ 当前头像: + 删除 +
+ +
+ +
+ + 取消编辑 +
+
+
+ + +
+

👥 观影人列表

+

+
+ 0): ?> + + + + + + + + + + + + +
头像姓名颜色操作
+ + + + + + +
+ +
暂无观影人,请添加
+ +
+
+
+
+ + + + + + + + + \ 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']) ?> + +
+
+

+
+ 📅 + + + 🏛️ + + +
+ + +
+ + + 👤 + + +
+ +
+
+ +
+ + +
+ 🔎 +

设置搜索条件后点击“搜索”

+
+ +
+
+ + + + \ 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 @@ + + + + + + + 统计仪表板 - 电影日志 + + + + + + +
+
+

📊 统计仪表板

+

综合观影数据概览

+ + +
+
+
+
🎬 电影总数
+
+
+
+
🏛️ 影院数
+
+
+
+
👥 观影人数
+
+
+
+
🎫 总观影人次
+
+ +
+
📅 观影跨度
+
+ +
+
+ +
+ + +
+

🏛️ 按影院统计 电影数量

+ +
+ +
+ + + + +
+
+
+ +
+ +
+ +
暂无影院数据
+ +
+ + +
+ +
+

👥 按观影人统计 参与电影数

+ +
+ +
+ + <?= h($item['name']) ?> + + + +
+
+
+ +
+ +
+ +
暂无观影人数据
+ +
+ + +
+

📆 按年统计 观影数量

+ +
+ +
+ +
+
+
+ +
+ +
+ +
暂无年份数据
+ +
+
+ + +
+

📈 月度趋势 最近12个月观影数量

+ +
+ +
+ +
+
+
+ +
+ +
+ +
暂无月度数据
+ +
+
+
+ + + + \ 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(); +?> + + + + + + 用户管理 - 电影日志 + + + + + + +
+
+ +
+

🔗 关联我的观影人身份

+

设置你作为观影人的身份,用于年度报告等统计功能(排除自己)

+ + +
+ + +
+ + + +
+ + +
+

+

+ +
+ + + + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + 取消 + +
+
+
+
+ + +
+

👥 现有管理员

+

个账户

+ 0): ?> +
    + +
  • + + + + 当前登录 + + 管理员 + + + 👤 关联: + + + + ✏️ 编辑 + +
    + + + +
    + + 不可删除自己 + +
    +
  • + +
+ +

暂无其他管理员

+ +
+
+
+ + \ No newline at end of file