Files
MovieLog_Server/persons.php
T
laoyang 294c34bbc2 feat: 补齐默认头像资源,修复「删除头像」静默失效
问题:添加观影人页面的头像预览指向 /assets/default-avatar.png,但仓库里
从来没有这个文件 → 该预览一直是坏图;同时未上传头像时会按姓名首字母生成
彩色 SVG 头像,导致「没有头像」这一种状态有两种表现,行为不统一。

改动
- assets/default-avatar.png(新增):200x200 中性灰人形剪影,随仓库分发;
  生成脚本见 _tooling/make_default_avatar.py(Pillow,4 倍超采样)。
- lib/helpers.php:新增 defaultAvatarUrl();getAvatarUrl() 在未上传头像时
  一律返回默认头像(不再生成首字母头像);资源缺失时退化为内联 SVG 剪影,
  彻底消除「兜底图本身就是坏图」这类问题。
- persons.php:
  * 后端开始处理 delete_avatar —— 此前前端设了标记、后端从未读取,
    点「删除」再保存头像原样保留,是个静默失效的按钮。
  * 上传新头像优先于删除标记,避免「先点删除、后又重新选图」时上传被忽略。
  * removeAvatar() 复用同一个隐藏域,重复点击不再叠加多个同名 input。
  * 表单预览统一走 getAvatarUrl()(顺带修掉头像键存在但文件丢失时仍渲染坏图)。
- README.md:更新头像兜底说明、目录树与「已知限制」。

验证
- storage_test.py 新增第 [21] 节 21 项断言(资源合法性、HTTP 可访问、四个分支、
  资源缺失降级、页面渲染、删除头像端到端),合计 196 项全绿。
- 47 个端点的 A/B 回归:剔除「预期的头像地址替换」与「persons.php 内联 JS 重写」
  后无任何其它差异(0 处未解释变更)。
- 第 10 批部署已上线:线上 assets/default-avatar.png 的 sha256 与本地逐字节一致,
  全站无 PHP 诊断泄漏,数据行数不变(movies 219 / persons 17 / viewers 449)。
2026-09-21 22:21:01 +08:00

479 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ============================================
// 文件: persons.php (含头像裁剪)
// ============================================
require_once 'config.php';
checkLogin();
$admin = getCurrentAdmin();
$db = getDB();
$message = '';
$error = '';
// ---------- 处理添加 ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'add') {
$name = trim($_POST['name'] ?? '');
$color = normalizeHexColor($_POST['color'] ?? '#888888');
// 头像 base64
$avatar_base64 = $_POST['avatar_base64'] ?? '';
if (empty($name)) {
$error = '姓名不能为空';
} else {
// 检查重复
$stmt = $db->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 = normalizeHexColor($_POST['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)) {
// 上传了新头像(优先级最高):先删旧文件,再存新的
deleteStoredFile($old_avatar);
$avatar_path = saveBase64Image($avatar_base64, AVATAR_DIR, 'avatar');
} elseif (isset($_POST['delete_avatar'])) {
// 用户点了「删除」:清空头像字段 → 页面回落到默认头像。
// (前端 removeAvatar() 会带上 delete_avatar=1;此前后端没有读这个标记,
// 导致点了「删除」保存后头像原样保留 —— 是个静默失效的按钮。)
deleteStoredFile($old_avatar);
$avatar_path = null;
}
$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) {
deleteStoredFile($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'
];
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>观影人管理 - 电影日志</title>
<link rel="stylesheet" href="css/style.css">
<!-- Cropper.js -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.css">
<style>
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
.card { background: var(--card-bg); border-radius: 16px; padding: 24px 28px; box-shadow: var(--shadow); margin-bottom: 24px; }
.card h2 { font-size: 22px; font-weight: 700; margin: 0 0 8px; color: var(--text-primary); }
.card .sub { color: var(--text-secondary); font-size: 14px; margin-bottom: 16px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-weight: 600; font-size: 14px; color: var(--text-primary); margin-bottom: 4px; }
.form-group input { width: 100%; padding: 10px 14px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 15px; box-sizing: border-box; }
.form-group input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 4px rgba(230,126,34,0.12); }
.form-row { display: flex; gap: 16px; flex-wrap: wrap; }
.form-row .form-group { flex: 1; min-width: 180px; }
.btn { padding: 10px 20px; border-radius: 8px; font-size: 14px; font-weight: 600; border: none; cursor: pointer; transition: var(--transition); text-decoration: none; display: inline-block; }
.btn-primary { background: linear-gradient(135deg, var(--primary), var(--primary-light)); color: #fff; }
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 28px rgba(230,126,34,0.35); }
.btn-danger { background: #e74c3c; color: #fff; }
.btn-danger:hover { background: #c0392b; }
.btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-color); }
.btn-outline:hover { background: var(--bg-secondary); }
.btn-sm { padding: 4px 12px; font-size: 13px; }
.message-box { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
.message-box.success { background: rgba(39,174,96,0.08); border: 1px solid rgba(39,174,96,0.2); color: #27ae60; }
.message-box.error { background: rgba(231,76,60,0.08); border: 1px solid rgba(231,76,60,0.2); color: #e74c3c; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
table th { text-align: left; padding: 10px 8px; border-bottom: 2px solid var(--border-color); color: var(--text-secondary); font-weight: 600; }
table td { padding: 10px 8px; border-bottom: 1px solid var(--border-color); }
table tr:hover td { background: var(--bg-secondary); }
.actions { display: flex; gap: 8px; }
.empty-row { text-align: center; color: var(--text-secondary); padding: 20px 0; }
.preset-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.preset-color { display: inline-block; width: 28px; height: 28px; border-radius: 50%; cursor: pointer; border: 2px solid transparent; transition: 0.2s; }
.preset-color:hover { transform: scale(1.15); }
.preset-color.active { border-color: #333; }
/* 头像样式 */
.avatar-upload {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.avatar-preview {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--border-color);
}
.avatar-upload input[type="file"] {
flex: 1;
padding: 6px;
}
.current-avatar {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 14px;
}
/* 裁剪模态框 */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 999;
justify-content: center;
align-items: center;
}
.modal-overlay.active {
display: flex;
}
.modal-content {
background: var(--card-bg);
max-width: 600px;
width: 90%;
padding: 24px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
max-height: 90vh;
overflow-y: auto;
}
.modal-content .modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.modal-content .modal-header h3 {
margin: 0;
}
.modal-content .modal-header .close-btn {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: var(--text-secondary);
}
.modal-content .modal-body img {
max-width: 100%;
display: block;
}
.modal-content .modal-footer {
margin-top: 16px;
display: flex;
gap: 12px;
justify-content: flex-end;
}
@media (max-width: 600px) {
.card { padding: 16px; }
.form-row { flex-direction: column; }
.actions { flex-wrap: wrap; }
.modal-content { width: 95%; padding: 16px; }
}
</style>
</head>
<body>
<?php include 'nav.php'; ?>
<main class="main-content">
<div class="container">
<?php if ($message): ?><div class="message-box success"><?= h($message) ?></div><?php endif; ?>
<?php if ($error): ?><div class="message-box error"><?= $error ?></div><?php endif; ?>
<!-- 添加/编辑表单 -->
<div class="card">
<h2><?= $edit_data ? '✏️ 编辑观影人' : '➕ 添加观影人' ?></h2>
<p class="sub"><?= $edit_data ? '修改观影人信息' : '录入新的观影人' ?></p>
<form method="POST" action="" id="personForm" enctype="multipart/form-data">
<input type="hidden" name="action" value="<?= $edit_data ? 'edit' : 'add' ?>">
<?php if ($edit_data): ?><input type="hidden" name="id" value="<?= $edit_data['id'] ?>"><?php endif; ?>
<!-- 隐藏字段存放裁剪后的 base64 -->
<input type="hidden" name="avatar_base64" id="avatar_base64" value="">
<div class="form-row">
<div class="form-group">
<label>姓名 <span class="hint">*必填</span></label>
<input type="text" name="name" value="<?= $edit_data ? h($edit_data['name']) : '' ?>" placeholder="如:张三" required>
</div>
</div>
<div class="form-group">
<label>主题颜色</label>
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<input type="color" name="color" id="colorPicker" value="<?= $edit_data ? h($edit_data['color']) : '#888888' ?>" style="width:50px;height:40px;padding:0;border:none;cursor:pointer;">
<span style="font-size:13px;color:var(--text-secondary);">点击色块选择,或使用拾色器</span>
</div>
<div class="preset-grid">
<?php foreach ($preset_colors as $pc): ?>
<span class="preset-color" data-color="<?= $pc ?>" style="background:<?= $pc ?>;" onclick="setColor('<?= $pc ?>')"></span>
<?php endforeach; ?>
</div>
</div>
<div class="form-group">
<label>头像</label>
<div class="avatar-upload">
<?php /* 已上传 → 自己的头像;未上传 → 默认头像(由 getAvatarUrl 统一兜底) */ ?>
<img src="<?= getAvatarUrl($edit_data['avatar'] ?? null) ?>" class="avatar-preview" id="avatarPreview">
<input type="file" name="avatar_upload" id="avatarUpload" accept="image/*">
<span style="font-size:13px;color:var(--text-secondary);">点击选择图片,裁剪后生效</span>
</div>
<?php if ($edit_data && $edit_data['avatar']): ?>
<div class="current-avatar">
当前头像:<img src="<?= storageUrl($edit_data['avatar']) ?>" style="width:30px;height:30px;border-radius:50%;vertical-align:middle;">
<a href="javascript:void(0)" onclick="removeAvatar()" style="color:var(--danger, #e74c3c);font-size:13px;">删除</a>
</div>
<?php endif; ?>
</div>
<div style="margin-top:8px;">
<button type="submit" class="btn btn-primary">💾 保存</button>
<?php if ($edit_data): ?><a href="persons.php" class="btn btn-outline">取消编辑</a><?php endif; ?>
</div>
</form>
</div>
<!-- 列表 -->
<div class="card">
<h2>👥 观影人列表</h2>
<p class="sub">共 <?= count($persons) ?> 人</p>
<div class="table-wrap">
<?php if (count($persons) > 0): ?>
<table>
<thead><tr><th>头像</th><th>姓名</th><th>颜色</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($persons as $p): ?>
<tr>
<td>
<img src="<?= getAvatarUrl($p['avatar'], $p['name']) ?>" style="width:30px;height:30px;border-radius:50%;object-fit:cover;vertical-align:middle;">
</td>
<td>
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:<?= h($p['color'] ?: '#888888') ?>;vertical-align:middle;margin-right:6px;"></span>
<?= h($p['name']) ?>
</td>
<td><span style="background:<?= h($p['color'] ?: '#888888') ?>;padding:2px 10px;border-radius:4px;color:#fff;font-size:12px;"><?= h($p['color'] ?: '#888888') ?></span></td>
<td>
<div class="actions">
<a href="?action=edit&id=<?= $p['id'] ?>" class="btn btn-outline btn-sm">✏️ 编辑</a>
<a href="?action=delete&id=<?= $p['id'] ?>" class="btn btn-danger btn-sm" onclick="return confirm('确定删除「<?= h($p['name']) ?>」吗?')">🗑️ 删除</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<div class="empty-row">暂无观影人,请添加</div>
<?php endif; ?>
</div>
</div>
</div>
</main>
<!-- 裁剪模态框 -->
<div class="modal-overlay" id="cropModal">
<div class="modal-content">
<div class="modal-header">
<h3>✂️ 裁剪头像</h3>
<button class="close-btn" onclick="closeCrop()">&times;</button>
</div>
<div class="modal-body">
<img id="cropImage" src="">
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeCrop()">取消</button>
<button class="btn btn-primary" onclick="cropSave()">确认裁剪</button>
</div>
</div>
</div>
<!-- Cropper.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js"></script>
<script>
let cropper = null;
let currentFile = null;
// 默认头像地址(与后端 getAvatarUrl(null, '') 保持一致)
const DEFAULT_AVATAR_URL = <?= json_encode(defaultAvatarUrl()) ?>;
// ---------- 颜色 ----------
function setColor(color) {
document.getElementById('colorPicker').value = color;
document.querySelectorAll('.preset-color').forEach(el => el.classList.remove('active'));
document.querySelector(`.preset-color[data-color="${color}"]`).classList.add('active');
}
document.addEventListener('DOMContentLoaded', function() {
var currentColor = document.getElementById('colorPicker').value;
if (currentColor) {
var preset = document.querySelector(`.preset-color[data-color="${currentColor}"]`);
if (preset) preset.classList.add('active');
}
});
// ---------- 头像裁剪 ----------
document.getElementById('avatarUpload').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
// 检查文件类型
if (!file.type.startsWith('image/')) {
alert('请选择图片文件');
this.value = '';
return;
}
currentFile = file;
const reader = new FileReader();
reader.onload = function(ev) {
const img = document.getElementById('cropImage');
img.src = ev.target.result;
document.getElementById('cropModal').classList.add('active');
// 初始化 Cropper
if (cropper) cropper.destroy();
cropper = new Cropper(img, {
aspectRatio: 1,
viewMode: 1,
dragMode: 'move',
autoCropArea: 0.8,
responsive: true,
});
};
reader.readAsDataURL(file);
});
function closeCrop() {
document.getElementById('cropModal').classList.remove('active');
if (cropper) {
cropper.destroy();
cropper = null;
}
document.getElementById('avatarUpload').value = '';
}
function cropSave() {
if (!cropper) return;
// 获取裁剪后的 canvas
const canvas = cropper.getCroppedCanvas({
width: 200,
height: 200,
imageSmoothingQuality: 'high',
});
if (!canvas) return;
// 转换为 base64
const base64 = canvas.toDataURL('image/jpeg', 0.9);
// 更新预览
document.getElementById('avatarPreview').src = base64;
// 存入隐藏字段
document.getElementById('avatar_base64').value = base64;
// 选了新头像就不再是「删除」语义,撤掉删除标记(避免上传被删除标记覆盖)
const delFlag = document.querySelector('input[name="delete_avatar"]');
if (delFlag) delFlag.remove();
closeCrop();
}
// 删除头像(仅编辑时可用)
function removeAvatar() {
if (!confirm('确定删除头像吗?')) return;
// 预览回落到默认头像
document.getElementById('avatarPreview').src = DEFAULT_AVATAR_URL;
document.getElementById('avatar_base64').value = '';
// 打「删除头像」标记,由后端在保存时清空该字段(清空后页面自然显示默认头像)。
// 注意复用同一个隐藏域:重复点击不再往表单里叠加多个同名 input。
let flag = document.querySelector('input[name="delete_avatar"]');
if (!flag) {
flag = document.createElement('input');
flag.type = 'hidden';
flag.name = 'delete_avatar';
document.getElementById('personForm').appendChild(flag);
}
flag.value = '1';
}
</script>
<script src="js/main.js"></script>
</body>
</html>