Files
FamilyBook/app/StagedUpload.php
T
laoyang c3d00ad66a v24 第 20 批:拖动即时上传 / 观影图片带出 / 行程保存修复 / 日程红线与删除范围与多提醒 / 悬停统一(用户 9 条)
用户 9 条原话要点:
  ①「观影模块,编辑观影,已经保存的海报和票根、合影,都没带进来。」
  ②「个人设置页面,"只影响你自己看到的样子"这个提示删除,不要了。我怎么称呼家人,
     这个卡片,不要了。我的资料这个卡片,不要了。」
  ③「旅程模块,编辑行程,点击保存没有反应。」
  ④「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,拖动一个进去,
     就开始上传,还可以继续拖动,继续上传,最后统一保存即可。」
  ⑤「添加行程,线路名、承运方,都不要了。车辆,只在出行方式选择自驾时才出现,
     其他情况下默认不出现。出发地和到达地,应该在同一行。」
  ⑥「日程模块,周视图和日视图,应该在视图中间显示一条红线,用来表示当前进行的时间…
     用一个不断从上到下的红线,来表示当前时间在视图上的刻度和位置。」
  ⑦「对于重复的日程,当点击删除的时候,应该提示是删除这一次,还是全部都删除,
     还是删除这一次及以后的。」
  ⑧「日程的提醒,是可以多选的,就是说,一个日程,我可以选择让它 5 分钟提醒,
     10 分钟提醒等等。」
  ⑨「很多按钮的悬停效果非常奇怪,感觉纯粹是换了一个背景色…应该调整所有按钮的悬停效果,
     保持统一,但是不要单纯的换一个不协调的背景色。」

🔴 三个真根因(都是"看着不像 bug"的地方):
  1. **③ 点保存没反应 = `<form>` 嵌套**(HTML 非法):`trips/node_form.php` 的照片缩略图上
     套了 `<form action=photo_del>`,而整块在**外层行程表单内部**。浏览器解析到内层
     `</form>` 时**把外层表单提前关掉** → 「保存」按钮**掉出表单**(真机实测
     `button.form === null`)。源码里完全看不出,**只有真浏览器量 `btn.form` 才现形**;
     且**只有这段行程已经有照片时才触发** —— 所以表现为"有时候好、有时候坏"。
     → 按钮留在原位(布局不动),用 HTML5 `form="id"` 关联到**表单外**的独立表单。
  2. **⑤ 车辆藏不住(第 17 批就写过、一直没生效)**:`.field` 自己声明 `display:flex`,
     作者样式压过浏览器默认的 `[hidden]{display:none}` → `hidden` 形同虚设。
     **本项目第 4 次栽同一个坑**(.att-item / .pv / .pp-item 各一次)→ 补 `.field[hidden]`。
  3. **④ 拖动即时上传的卡片"看不见"**:模板给 `.stage-list` 加了 `hidden`(没人上传时
     不留空边框),JS 建卡片时忘了 `list.hidden = false` → 卡片在 DOM 里但整条清单
     `display:none`、尺寸 0×0。**HTTP 层断言全绿**(HTML 里确实有卡片),
     **只有真机量尺寸才发现** —— 由新写的真机探针抓到。

本批新增能力:
- **拖动即时上传**(第 4 条):新增 `upload_stage.php` + `app/StagedUpload.php` +
  `templates/_stage_drop.php`。拖进来 → **立刻** POST 到暂存目录 → 回 `stoken` →
  放进隐藏字段 `stage[]` → 保存时在 `attach_save_files()` 里**认领**(移文件 + 建附件行)。
  `media.php` 加 `?stage=` 预览(拖完立刻看得见缩略图)。
  ⚠️ 三个必须踩准:**串行队列**(并发会让缩略图顺序乱跳)、**提交守卫**
  (还有没传完就 preventDefault,否则"拖了但没保存上"必然发生)、**卡片清单要 un-hide**。
  ⚠️ 海报/票根走**独立字段名** `stage_poster`/`stage_ticket`(各自单槽位、要按比例重裁)。
  ⚠️ 落点**就近取**(`input.closest('.field')`):一个表单里多个上传区不能串到第一个去。
- **观影编辑带出已存图片**(第 1 条):编辑页显示海报 / 票根 / 合影 + 各自的删除入口;
  新增 `movies/media_del`(只删不换也能删,槽位走白名单);`movies/update` 收编辑页
  新选的照片时**合并**而不是替换(否则一编辑就把已有合影冲掉)。
- **行程表单**(第 5 条):去掉线路名 / 承运方(**库里的值保留**:UPDATE 时不参与,
  与 seat_info/gate 同一套);出发地/到达地包进 `.loc-row` 真同一行;车辆按自驾显隐。
- **当前时间红线**(第 6 条):位置在服务端算(与 `.tg-block` 同一套"24h = 100%"坐标),
  前端每分钟自己挪(对齐到下一分钟整再起定时器),跨午夜 reload;只画在**今天那一列**。
- **重复日程删除三档**(第 7 条):与「编辑」那三档同名同序(全部 / 这一次及以后 /
  只这一次)。「只删这一次」→ `skipOnce`(**绝不删整条系列**);「这一次及以后」→
  老系列截 UNTIL,**必须清掉 COUNT**(两者并存时 COUNT 优先,不清就截不断)。
- **提醒多选**(第 8 条):同列存逗号串(`5,15,60`);Schema 把它从 **INT 改 VARCHAR**
  (INT 存逗号串会被隐式转换,多档会静默只剩第一档);ICS 导出吐**多个 VALARM**。
- **悬停统一**(第 9 条):原来 14 种互不相干的 hover 底色 → 统一成**两套手法**:
  有底色 `filter: brightness(.92)` 同色系加深 + 轻抬升;描边白底 `--hov-veil` 中性薄雾
  + 边框微显。放在 CSS **最末**用一条兜底收口(新按钮自动继承),并把十几条旧规则
  清成"交给统一段"。`person_picker.js` 同时改为按需加载。

验证(本地全套):
- e2e **1004/0**(+21 条本批断言)· 升级 149/0 · 桌面几何 428/0 ·
  **真机探针 25/0**(连续拖两次 / 红线位置与颜色与几何 / 车辆显隐 / 出发到达同行)。
- **反向验证 5/5**:①删除按钮改回嵌套 form ②卡片清单不 un-hide ③观影编辑不渲染海报
  ④车辆隐藏失效 ⑤「只删这一次」做成删全部 —— 五种坏法全部被抓住,还原后复绿 1004/0。
  过程中还**抓到两条弱断言**(只查 DOM 有无、不查可见性/是否真渲染),已一并加强。
- 部署脚本静态断言段本地空跑 **305 条全过**。
2026-10-06 03:14:18 +08:00

323 lines
13 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
/**
* 暂存上传(v24 第 20 批 用户第 4 条)。
*
* 用户原话:「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,
* 拖动一个进去,就开始上传,还可以继续拖动,继续上传,
* 最后统一保存即可。」
*
* ────────────────────────────────────────────────────────────────
* 职责划分(与 `upload_stage.php` 端点配套):
* · stage() 把拖进来的**一个**文件落进暂存目录,回一个随机令牌(stoken);
* · claim() 保存时把一批令牌**认领**到某个条目上(移文件 + 建附件行);
* · sweep() 清掉过期(没被认领)的暂存文件,别让它们永远堆着。
*
* 🔴 两条设计取舍(都栽过同类):
* ① **暂存文件不能只放 tmp**:web 请求结束 tmp 就没了,而用户可能在页面上
* 停留几十分钟才点保存。所以落到 `uploads/_staging/` 下,并按 TTL 清。
* ② **认领要"要么全成、要么不动"**:认领到一半失败(比如某张图坏了)时,
* 已经移过去的文件要能回滚 —— 否则用户会看到"保存了但少了一张"。
* 这里用"先全部校验、再逐个移"的顺序,把失败面收到最小;
* 单个失败时只跳过那一个并如实回报告,不静默吞。
*/
declare(strict_types=1);
namespace App;
use App\Storage\Storage;
final class StagedUpload
{
/** 暂存根目录(在 uploads 下,Web 不可直接访问 —— 由 media.php 判登录态) */
private const DIR = '_staging';
/** 令牌长度(字节 → 十六进制 48 字符,猜不出来) */
private const TOK_BYTES = 24;
/** 多久没被认领就当垃圾清掉(秒)。2 小时:够一次正常的"慢慢挑图" */
public const TTL = 7200;
/** 一次最多认领多少个(防手搓请求塞几百个) */
public const MAX_CLAIM = 60;
/** 暂存目录绝对路径 */
private static function root(): string
{
return FB_UPLOADS . '/' . self::DIR;
}
/** 一个令牌对应的目录(令牌只允许十六进制,杜绝 `..` 穿越) */
private static function tokDir(string $stoken): string
{
if (!preg_match('/^[0-9a-f]{16,64}$/', $stoken)) {
return '';
}
return self::root() . '/' . $stoken;
}
/**
* 落一个文件到暂存区。
*
* @return array{ok:bool,stoken?:string,name?:string,is_image?:bool,kind?:string,
* size?:int,size_word?:string,url?:string,error?:string}
*/
public static function stage(int $familyId, int $personId, array $file, string $slot = ''): array
{
$tmp = (string)($file['tmp_name'] ?? '');
if ($tmp === '' || !is_uploaded_file($tmp)) {
return ['ok' => false, 'error' => '临时文件不见了,请重试。'];
}
$origName = self::safeName((string)($file['name'] ?? ''));
$fino = new \finfo(FILEINFO_MIME_TYPE);
$mime = (string)$fino->file($tmp);
$isImage = strpos($mime, 'image/') === 0;
$stoken = bin2hex(random_bytes(self::TOK_BYTES));
$dir = self::tokDir($stoken);
if ($dir === '') {
return ['ok' => false, 'error' => '令牌生成异常。'];
}
if (!@mkdir($dir, 0755, true) && !is_dir($dir)) {
return ['ok' => false, 'error' => '暂存目录建不出来(检查 uploads 权限)。'];
}
if ($isImage) {
// 图片:走既定的槽位规则(比例 / 尺寸 / 内存守卫都在 Upload 里,一处说了算)。
// ⚠️ 槽位键必须是 ImageSlot::SLOTS 里真实存在的 —— 传空/未知时**回落到 'photo'**
// (那个是 ratio=free 的"只缩不裁"槽位,正是"上传照片"该有的行为)。
$slotKey = ($slot !== '' && ImageSlot::get($slot) !== null) ? $slot : 'photo';
$up = Upload::image($familyId, $file, $slotKey);
if (empty($up['ok'])) {
self::rmDir($dir);
return ['ok' => false, 'error' => (string)($up['error'] ?: '图片没存上')];
}
// Upload 已经把图落到**正式目录**了;但"还没保存"这段时间不该占用正式目录的语义。
// ⚠️ 这里**不做"先落正式再搬暂存"**:Upload 只支持落到存储层,
// 而暂存在本地。所以取回那个正式键里的**字节**再写进暂存目录,
// 最后把正式键删掉 —— 全程只多一次读+写,换来语义干净。
// ⚠️ 读字节必须用 Storage::read()(**不是** Storage::get —— 那是个不存在的方法,
// 第一版就写错成 get 了;read 才同时覆盖"本地文件"与"OSS 取回"两条路)。
$formalkey = (string)$up['file'];
$got = Storage::read($familyId, $formalkey);
Storage::delete($familyId, $formalkey);
$bin = (string)($got['body'] ?? '');
if ($bin === '') {
self::rmDir($dir);
return ['ok' => false, 'error' => '图片转存失败,请重试。'];
}
$fname = 'blob.jpg'; // Upload 输出恒为 JPEG(见 Upload 类注释)
if (@file_put_contents($dir . '/' . $fname, $bin) === false) {
self::rmDir($dir);
return ['ok' => false, 'error' => '写暂存文件失败。'];
}
} else {
// 非图片(视频 / PDF / 视频原件…):原样存,不压缩(压缩会把它们弄坏)
$fname = 'blob' . self::extOf($origName, $mime);
if (!@copy($tmp, $dir . '/' . $fname)) {
self::rmDir($dir);
return ['ok' => false, 'error' => '写暂存文件失败。'];
}
}
$size = (int)@filesize($dir . '/' . $fname);
// 元信息写一个小 json:认领时要用它的**原始文件名**与 mime
// (不能认领时再去嗅探一遍 —— 多一次 IO,且非图片形态各一)
@file_put_contents($dir . '/meta.json', json_encode([
'name' => $origName,
'mime' => $mime,
'is_image' => $isImage,
'size' => $size,
'file' => $fname,
'by' => $personId,
'at' => time(),
], JSON_UNESCAPED_UNICODE));
return [
'ok' => true,
'stoken' => $stoken,
'name' => $origName,
'is_image' => $isImage,
'kind' => self::kindWord($mime, $origName, $isImage),
'size' => $size,
'size_word' => Attachment::sizeWord($size),
// 预览地址:走 media.php 的 `stage` 形态(见 media.php 里那段的说明)
'url' => 'media.php?stage=' . $stoken,
];
}
/**
* 把一批令牌**认领**到某个条目上(保存时调用)。
*
* @param string $type 附件宿主类型('trip_node' / 'resource' / 'calendar' / ...)
* @param int $targetId 宿主 id
* @return array{ok:int,warns:string[]} ok=成功认领的个数
*/
public static function claim(int $familyId, int $personId, array $stokens,
string $type, int $targetId): array
{
$warns = [];
$done = 0;
if (!$stokens) {
return ['ok' => 0, 'warns' => []];
}
// ⚠️ 上限:手搓请求塞几百个令牌会把这次保存拖死
$stokens = array_slice(array_values(array_unique(array_map('strval', $stokens))), 0, self::MAX_CLAIM);
foreach ($stokens as $stoken) {
$dir = self::tokDir($stoken);
if ($dir === '' || !is_dir($dir)) {
continue; // 已在别处认领过 / 已被清掉 → 跳过(不是错误)
}
$metaRaw = @file_get_contents($dir . '/meta.json');
$meta = $metaRaw !== false ? json_decode($metaRaw, true) : null;
if (!is_array($meta)) {
self::rmDir($dir);
continue;
}
// 只认**自己**暂存的(拿到别人的令牌也认领不了)
if ((int)($meta['by'] ?? 0) !== $personId) {
$warns[] = '有一个文件不是你自己上传的,已跳过。';
continue;
}
$src = $dir . '/' . (string)($meta['file'] ?? '');
if (!is_file($src)) {
self::rmDir($dir);
continue;
}
$name = (string)($meta['name'] ?? '');
$mime = (string)($meta['mime'] ?? '');
$isImage = !empty($meta['is_image']);
// 复用 Attachment::save 的既定路径(它负责落存储 + 建行 + 位置 + 限额)
$fake = [
'name' => $name !== '' ? $name : '文件',
'type' => $mime,
'tmp_name' => $src,
'error' => UPLOAD_ERR_OK,
'size' => (int)@filesize($src),
];
$res = \App\Attachment::save($familyId, $type, $targetId, $fake, $personId);
if (!empty($res['ok'])) {
$done++;
self::rmDir($dir);
} else {
$msg = (string)($res['error'] ?? '');
if ($msg !== '') {
$warns[] = ($name !== '' ? $name : '一个文件') . ':' . $msg;
}
// 存不上就别留暂存(留着下次还会失败,且占地方)
self::rmDir($dir);
}
}
return ['ok' => $done, 'warns' => $warns];
}
/** 从 media.php 回一个暂存文件的字节(只在刚拖完、还没保存时用) */
public static function read(int $familyId, int $personId, string $stoken): ?array
{
$dir = self::tokDir($stoken);
if ($dir === '' || !is_dir($dir)) {
return null;
}
$metaRaw = @file_get_contents($dir . '/meta.json');
$meta = $metaRaw !== false ? json_decode($metaRaw, true) : null;
if (!is_array($meta) || (int)($meta['by'] ?? 0) !== $personId) {
return null;
}
$src = $dir . '/' . (string)($meta['file'] ?? '');
if (!is_file($src)) {
return null;
}
return [
'path' => $src,
'mime' => (string)($meta['mime'] ?? 'application/octet-stream'),
'name' => (string)($meta['name'] ?? ''),
];
}
/** 清掉一个暂存目录(被"自己接管了文件"的调用方用完时调,比如海报/票根那条路) */
public static function forget(string $stoken): void
{
$dir = self::tokDir($stoken);
if ($dir !== '') {
self::rmDir($dir);
}
}
/** 清掉过期没被认领的暂存目录(在 business 路由里顺手调一次,不必挂定时任务) */
public static function sweep(): void
{
$root = self::root();
if (!is_dir($root)) {
return;
}
$now = time();
foreach ((array)@scandir($root) as $d) {
if ($d === '.' || $d === '..') {
continue;
}
$p = $root . '/' . $d;
if (!is_dir($p)) {
continue;
}
$mt = (int)@filemtime($p);
if ($mt > 0 && ($now - $mt) > self::TTL) {
self::rmDir($p);
}
}
}
// ---------------- 内部 ----------------
private static function safeName(string $n): string
{
$n = str_replace(['\\', '/', ':', '*', '?', '"', '<', '>', '|'], '', $n);
$n = trim(preg_replace('/\s+/u', ' ', $n));
return mb_substr($n, 0, 180, 'UTF-8');
}
/** 扩展名:优先用原文件名的,没有就按 mime 猜一个(都认不出就不带) */
private static function extOf(string $name, string $mime): string
{
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$ext = preg_replace('/[^a-z0-9]/', '', $ext);
if ($ext !== '') {
return '.' . $ext;
}
$m = [
'video/mp4' => 'mp4', 'video/quicktime' => 'mov', 'video/webm' => 'webm',
'application/pdf' => 'pdf', 'audio/mpeg' => 'mp3', 'audio/mp4' => 'm4a',
];
return isset($m[$mime]) ? '.' . $m[$mime] : '';
}
private static function kindWord(string $mime, string $name, bool $isImage): string
{
if ($isImage) {
return '图片';
}
return Attachment::kindWord(Attachment::kind([
'is_image' => 0, 'mime' => $mime, 'name' => $name,
]));
}
private static function rmDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
foreach ((array)@scandir($dir) as $f) {
if ($f === '.' || $f === '..') {
continue;
}
$p = $dir . '/' . $f;
if (is_dir($p)) {
self::rmDir($p);
} else {
@unlink($p);
}
}
@rmdir($dir);
}
}