系统设置里多一块「🎨 我的界面主题」,点卡片即时预览、保存后全站生效,不用重新登录。 - lib/theme.php(新增):主题注册表 ticket/indigo/forest/wine/midnight, 分别是「复古票根(暖橙,默认)/ 靛蓝 / 松林 / 绛红 / 暗夜(深色)」。 currentThemeKey() 以 session 为准,session 里没有就从 admins.theme 补读一次 —— 升级前就已经登录着的会话也能立刻用上自己账号存的主题。库里是脏值时回落默认主题。 - css/themes.css(新增):5 套调色板,全部以 [data-theme="<key>"] 规则块给出, 每套 16 个变量一个不少。**必须排在 css/style.css 之后加载**:两者优先级相同 (都是 0,1,0),靠后出现者胜。另加 --primary-rgb("R, G, B" 三元组), 页面里的淡色底/光晕一律 rgba(var(--primary-rgb), α),所以加一套主题只需动 CSS, 页面与 JS 一行都不用改;每套还要声明 color-scheme,否则暗色主题下原生控件 (日期选择器、下拉、滚动条)仍是刺眼的白底。 - css/style.css::root 保留默认主题那一份作兜底;导航底色改走 --nav-bg, 滚动条改走变量,按钮光晕改走 --primary-rgb。 - lib/db.php / lib/auth.php:admins 增列 theme(幂等 ALTER,老库自动补,空值 = 默认主题); setAdminTheme() 校验 key、写库、同步 session。 - settings.php:主题卡片区。每张预览卡自己套一层 data-theme,用的就是那套主题真实的 变量,因此「预览所见」与「换上去之后」必然一致,不必再抄一份色值;另有一小段 JS 在点选时改 <html data-theme> 做即时预览(没脚本时选中后提交同样生效)。 - 14 个页面:<html> 上输出 data-theme,并在 style.css 之后引入 themes.css; 内联样式里写死的橙色 rgba(230,126,34,…) 全部改走 rgba(var(--primary-rgb),…); 影院/观影人色板选中态的描边由 #333 改为 var(--text-primary)(暗色主题下才看得见)。 - annual_report.php:报告本身仍是深色「海报」版式,但点缀色跟随主题; Chart.js 画在 canvas 上读不到 CSS 变量,改为运行时从 <html> 的计算样式取色。 顺手修的两个坑: 1. 注释里**不能**出现 PHP 起止标签。PHP 的结束标签在 // 注释里同样会关闭 PHP 模式, 之后若再出现短标签就会被真的执行 —— lib/theme.php 的用法说明里写了完整标签示例, 导致该文件被 require 时直接 Fatal error、整站白屏,而 php -l 查不出来(只看语法)。 现在部署脚本里加了一道静态断言专门挡这个。 2. themesCssLink() 的返回值末尾补一个换行:PHP 会吃掉结束标签后面那一个换行, 不补的话渲染结果里这一行会和下一行黏成一行。 回归:theme_e2e 111/111(含老库自动补列、脏值回落、5 个选项渲染、切换后落库+落 session+ 立刻生效、跨账号隔离、非法 key 与注入被拒、themes.css 完整性、:root 与 ticket 一致、 无残留写死主色、无悬空 CSS 变量);reviews_e2e 84/84、fetch_movie 76/76、storage 196/196。 A/B 48 个响应:把「新增 head 行 / data-theme 属性 / 橙色→变量」三类等价替换归一化、 并把设置页新增区块整块删掉之后,**其余部分逐字节一致,零残留差异**。 线上 PHP 7.4.30 / 8.1.7 双版本冒烟全通过(迁移、5 套主题切换、跨账号隔离均验证, 写测试后已把 admin #1 的主题原样还原、其它账号一字未动)。
203 lines
9.0 KiB
PHP
203 lines
9.0 KiB
PHP
<?php
|
||
// ============================================
|
||
// 文件: lib/db.php
|
||
// 说明: 数据库连接与初始化(建表 / 字段迁移 / 历史数据迁移 / 默认管理员)
|
||
// ============================================
|
||
|
||
// 数据库连接
|
||
function getDB() {
|
||
try {
|
||
$db = new PDO('sqlite:' . DB_PATH);
|
||
$db->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();
|
||
|
||
// 管理员表
|
||
// theme:该账号选的界面配色主题(见 lib/theme.php 的主题注册表)。
|
||
// 留空/NULL 表示用默认主题,所以老账号不需要回填数据。
|
||
$db->exec("
|
||
CREATE TABLE IF NOT EXISTS admins (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
username TEXT UNIQUE NOT NULL,
|
||
password TEXT NOT NULL,
|
||
avatar TEXT,
|
||
theme 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
|
||
)
|
||
");
|
||
|
||
// 系统设置表(键值结构,用于存储方式等配置)
|
||
$db->exec("
|
||
CREATE TABLE IF NOT EXISTS settings (
|
||
name TEXT PRIMARY KEY,
|
||
value TEXT,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
");
|
||
|
||
// 电影评价表
|
||
// 评价人是「登录账号」(admins)——不是观影人(persons / viewers):
|
||
// 没一起去看的人只要有账号,也能打分、写评价并参与讨论。
|
||
// feelings:写评价时选的「观后感受」,与评论里的感受同一套目录、同一种存法。
|
||
$db->exec("
|
||
CREATE TABLE IF NOT EXISTS reviews (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
movie_id INTEGER NOT NULL,
|
||
admin_id INTEGER NOT NULL,
|
||
rating INTEGER NOT NULL,
|
||
content TEXT NOT NULL,
|
||
feelings TEXT,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (movie_id) REFERENCES movies(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE
|
||
)
|
||
");
|
||
|
||
// 评价下的讨论(评论)。评论是**两级结构**:
|
||
// parent_id 为空 → 顶层评论,直接挂在某条评价下;
|
||
// parent_id 非空 → 对某条评论的回复,值指向「被回复的那条评论」的 id。
|
||
// 页面展示时所有回复都平铺在它所属的顶层评论之下(不再逐层缩进),
|
||
// 只把「回复 @谁」写出来;这样无论回复多深,视觉上都只有两层。
|
||
// reply_to_admin_id → 被回复者(admins.id),用于显示「回复 @某某」。
|
||
// 它存「人」而不是「评论 id」,所以对方那条评论被删掉也不会留下悬空引用。
|
||
// feelings 存的是感受 key 的 JSON 数组(如 [\"happy\",\"sad\"]),
|
||
// emoji 与文案不入库——统一在 lib/reviews.php 的目录里维护,改文案不用动数据。
|
||
$db->exec("
|
||
CREATE TABLE IF NOT EXISTS review_comments (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
review_id INTEGER NOT NULL,
|
||
admin_id INTEGER NOT NULL,
|
||
parent_id INTEGER,
|
||
reply_to_admin_id INTEGER,
|
||
content TEXT NOT NULL,
|
||
feelings TEXT,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE
|
||
)
|
||
");
|
||
|
||
// 一部电影每个账号只保留一条评价:再次提交就是修改(见 saveReview)
|
||
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_movie_admin ON reviews(movie_id, admin_id)");
|
||
$db->exec("CREATE INDEX IF NOT EXISTS idx_reviews_movie ON reviews(movie_id)");
|
||
$db->exec("CREATE INDEX IF NOT EXISTS idx_review_comments_review ON review_comments(review_id)");
|
||
|
||
// ===== 迁移字段 =====
|
||
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) {}
|
||
// 新增:评价本身也可以带观后感受(评价表先于该字段上线过一版,故需要迁移)
|
||
try { $db->exec("ALTER TABLE reviews ADD COLUMN feelings TEXT"); } catch (PDOException $e) {}
|
||
// 新增:评论支持回复(两级结构,含义见上面 review_comments 建表处的说明)。
|
||
// 评论表同样先于这两个字段上线过一版,线上是老结构,必须能自动补列。
|
||
try { $db->exec("ALTER TABLE review_comments ADD COLUMN parent_id INTEGER"); } catch (PDOException $e) {}
|
||
try { $db->exec("ALTER TABLE review_comments ADD COLUMN reply_to_admin_id INTEGER"); } catch (PDOException $e) {}
|
||
|
||
// 新增:每个账号自己的界面配色主题(见 lib/theme.php)。
|
||
// 只要空列,不需要给老账号回填任何数据 —— 空值即「用默认主题」。
|
||
try { $db->exec("ALTER TABLE admins ADD COLUMN theme TEXT"); } catch (PDOException $e) {}
|
||
|
||
// 删顶层评论时要按 parent_id 找它下面的回复一并清掉,给它建个索引。
|
||
// 这条索引必须放在上面的「补列」之后:老库的 review_comments 是旧结构、没有
|
||
// parent_id 列,而上面建表用的是 IF NOT EXISTS(对已存在的表不生效),
|
||
// 索引若跑在补列之前会直接抛 "no such column: parent_id" 让整个 initDB 挂掉。
|
||
$db->exec("CREATE INDEX IF NOT EXISTS idx_review_comments_parent ON review_comments(parent_id)");
|
||
|
||
// ===== 迁移旧数据 =====
|
||
// 将 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]);
|
||
}
|
||
}
|