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)。
This commit is contained in:
2026-09-21 22:21:01 +08:00
parent 673a8abf78
commit 294c34bbc2
4 changed files with 61 additions and 39 deletions
+4 -3
View File
@@ -17,7 +17,7 @@
### 图片处理
- **内置裁剪器**:基于 Cropper.js,票根 / 海报 / 观影照片 / 观影人头像均可在浏览器内裁剪后再保存,服务端接收 Base64 图片落盘。
- **多图上传**:观影照片支持一次选择多张。
- **头像兜底**:未上传头像的观影人自动生成"首字母 + 配色"的 SVG 头像,配色由姓名哈希决定,同名同色。
- **头像兜底**:未上传头像的观影人一律显示 `assets/default-avatar.png` 默认头像(人形剪影);若该资源缺失(例如只部署了 PHP 而漏了 `assets/`),`getAvatarUrl()` 会退化为内联 SVG 剪影 —— 保证任何情况下都不会出现"坏图"。
- 上传目录按类型分离:`uploads/posters/`、`uploads/tickets/`、`uploads/photos/`、`uploads/avatars/`。
- **破图兜底**:图片加载失败时由前端脚本替换为内联占位图,避免会话过期或权限异常时满屏破图刷屏控制台。
@@ -116,7 +116,8 @@ MovieLog_Server/
├── test_crop.php # 开发调试页:验证 Base64 图片落盘是否正常
├── css/style.css # 全局样式
├── js/main.js # 公共脚本(含各页面共用的表单联动函数、破图兜底)
├── assets/.gitkeep # 静态占位资源目录
├── assets/ # 静态资源
│ └── default-avatar.png # 默认头像(未上传头像时统一使用)
├── db/movies.db # SQLite 数据库(运行时自动生成,不纳入版本控制)
└── uploads/ # 上传图片(运行时自动生成,不纳入版本控制)
├── posters/ # 海报
@@ -565,7 +566,7 @@ curl -sk -H "Host: 你的域名" -H "Cookie: PHPSESSID=xxxx" -o /dev/null \
- 暂无分页,数据量增大后首页与统计页需要补充分页或懒加载。
- `config.php` 的 `initDB()` 在每次请求都会跑一遍建表与迁移,数据量或并发上升后可改为一次性迁移脚本。
- 无「回收站」,删除电影与其图片文件不可恢复。
- `assets/placeholder-poster.jpg`、`assets/default-avatar.png` 为代码中的兜底引用,当前仓库未包含这两个文件;由于页面在检测到 placeholder 时会改用 CSS 占位块渲染,正常使用不受影响。
- `assets/placeholder-poster.jpg` 仍是代码中的兜底引用,**仓库未包含该文件**;页面在检测到 placeholder 时会改用 CSS 占位块渲染,正常使用不受影响。`assets/default-avatar.png` 已随仓库提供(默认头像),并在资源缺失时由 `getAvatarUrl()` 退化为内联 SVG 剪影。
- **「直连存储」模式下裁剪功能不可用**:裁剪需要读取图片像素,直连时图片来自对象存储域名,必须同时满足「桶配 CORS」与「`<img crossorigin="anonymous">`」两个条件才能读像素;本仓库未加 `crossorigin` 属性,因此直连模式下只能上传、不能二次裁剪。需要裁剪请改用「服务端代理」(同源,两个条件全免)。详见《图片存储与对象存储》§3③。
- **服务端代理模式下所有图片流量经服务器中转**,会占用服务器出网带宽,并给每张图增加一次服务器↔存储的往返(同地域约 10–30 ms,跨地域约 60–100 ms)。图片量大且可公开时可考虑改回直连。
- **内网 endpoint 只在服务器与桶同地域时生效**。跨地域部署下这条免费通道用不了,程序会自动跳过并走公网(详见 §4)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+30 -7
View File
@@ -135,17 +135,40 @@ function posterUrl($path) {
}
// ===== 头像处理 =====
// 默认头像 URL:未上传头像的人统一使用它。
// 资源随仓库分发(assets/default-avatar.png)。
// 若该文件缺失——比如只部署了 PHP 而漏了 assets/——则退化为内联 SVG 剪影,
// 避免出现「兜底图本身就是坏图」:这正是本函数此前的问题(返回了一个并不存在的路径)。
// 注意:这里用 is_file() 探测,路径在站点目录内,不会像站外路径那样触碰 open_basedir 限制。
function defaultAvatarUrl() {
static $url = null;
if ($url !== null) {
return $url;
}
$root = defined('ROOT_DIR') ? ROOT_DIR : dirname(__DIR__);
if (is_file($root . '/assets/default-avatar.png')) {
$url = '/assets/default-avatar.png';
return $url;
}
// 几何比例与 assets/default-avatar.png 保持一致,两种情形视觉无差别
$svg = "<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200' viewBox='0 0 200 200'>"
. "<rect width='200' height='200' fill='#e9edf3'/>"
. "<circle cx='100' cy='74' r='33' fill='#7f8896'/>"
. "<ellipse cx='100' cy='200' rx='74' ry='93' fill='#7f8896'/>"
. "</svg>";
$url = 'data:image/svg+xml,' . rawurlencode($svg);
return $url;
}
// 头像 URL:已上传 → 存储层 URL;未上传 → 统一的默认头像。
// 说明:$name 是历史遗留参数(早期按姓名首字母生成彩色 SVG 头像),
// 现在一律使用默认头像,参数仅保留以兼容既有调用点。
function getAvatarUrl($avatar_path, $name = '') {
if (storageExists($avatar_path)) {
return storageUrl($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("<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'><rect width='40' height='40' fill='{$color}'/><text x='20' y='28' text-anchor='middle' font-size='20' fill='#fff' font-family='sans-serif' font-weight='bold'>{$letter}</text></svg>");
}
return '/assets/default-avatar.png';
return defaultAvatarUrl();
}
// ===== 保存 Base64 图片 =====
+27 -29
View File
@@ -68,9 +68,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
// 保存新头像
$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 = ?");
@@ -295,11 +301,8 @@ $preset_colors = [
<div class="form-group">
<label>头像</label>
<div class="avatar-upload">
<?php if ($edit_data && $edit_data['avatar']): ?>
<img src="<?= storageUrl($edit_data['avatar']) ?>" class="avatar-preview" id="avatarPreview">
<?php else: ?>
<img src="<?= getAvatarUrl(null, $edit_data['name'] ?? '') ?>" class="avatar-preview" id="avatarPreview">
<?php endif; ?>
<?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>
@@ -377,6 +380,8 @@ $preset_colors = [
<script>
let cropper = null;
let currentFile = null;
// 默认头像地址(与后端 getAvatarUrl(null, '') 保持一致)
const DEFAULT_AVATAR_URL = <?= json_encode(defaultAvatarUrl()) ?>;
// ---------- 颜色 ----------
function setColor(color) {
@@ -445,36 +450,29 @@ $preset_colors = [
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('确定删除头像吗?')) {
// 清空预览为默认
var name = document.querySelector('input[name="name"]').value || '';
document.getElementById('avatarPreview').src = '<?= getAvatarUrl(null, '') ?>';
document.getElementById('avatar_base64').value = '';
// 删除旧头像文件由后端处理(保存时如果 base64 为空则保留旧头像,但我们通过隐藏字段为空表示删除)
// 这里我们设置一个标记,但需要后端处理:如果 avatar_base64 为空且没有上传新文件,则删除头像。
// 我们可以在后端增加逻辑:如果 avatar_base64 为空且没有上传文件,则保留原头像;但用户想删除,我们可加一个隐藏的删除标记。
// 简便做法:提交时如果 avatar_base64 为空,且上传文件也为空,则不修改头像。
// 但用户点击删除,我们希望删除。所以增加一个隐藏字段 delete_avatar=1
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'delete_avatar';
input.value = '1';
document.getElementById('personForm').appendChild(input);
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';
}
// 在表单提交前,如果没有 base64 且没有上传文件,但 delete_avatar 存在,则后端处理删除
// 后端逻辑:如果 post 中有 delete_avatar,则 avatar_path = null
// 在 persons.php 的编辑中添加:
// if (isset($_POST['delete_avatar'])) { $avatar_path = null; 删除旧文件 }
// 我会在后端代码中添加此逻辑,但由于代码已生成,我在这里说明。
// 另外,如果用户上传了文件但未裁剪直接取消,则不清除文件输入,但不会触发裁剪,所以正常。
</script>
<script src="js/main.js"></script>
</body>