Files
FamilyBook/upload_stage.php
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

122 lines
5.9 KiB
PHP
Raw Permalink 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 条)。
*
* 用户原话:「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,
* 拖动一个进去,就开始上传,还可以继续拖动,继续上传,
* 最后统一保存即可。」
*
* ────────────────────────────────────────────────────────────────
* 为什么需要"暂存"这一步(而不是直接往目标上挂):
* 用户要的是「**拖一个就立刻上传**,最后统一保存」。等最后一个"保存"才落库,
* 就要求"拖进来的文件先别落库、但又要能显示缩略图" —— 那就得有个
* **暂存区**。所以:
* ① 拖进来的文件立刻 POST 到 upload_stage.php → 服务端落进**暂存目录**
* (`uploads/_staging/<随机>/<文件名>`),回一个 `stoken`(随机令牌)与
* 预览地址;
* ② 前端把 stoken 塞进隐藏字段 `stage[]`,并立刻画一张缩略图(看得见);
* ③ 用户点"保存"时,业务路由读 `stage[]`,把它**认领**到目标条目上
* (App\StagedUpload::claim —— 移文件 + 建附件行),暂存目录随之清空。
* ⚠️ 这样"立刻上传"与"最后统一保存"两件事都成立:上传是真的(文件已到服务器),
* 但**不会被挂到任何条目上**,直到用户按下保存。
*
* ⚠️ 安全边界(这个端点**不要求**登录之外的任何业务权限):
* · 必须登录(Auth::requireLogin);
* · 必须带 CSRF(与其他写接口同一套);
* · 图片走 Upload::image 的既定槽位规则(比例/尺寸/内存守卫都在那儿);
* · 非图片(PDF/Word/视频原件…)只做 MIME 嗅探与扩展名整理,不动内容;
* · 令牌是 24 字节随机串,且**只在本账号的暂存目录里**找 —— 拿到别人的令牌
* 也认领不了(claim 时会比对 owner)。
* · 暂存目录由 App\StagedUpload::sweep() 定期清(超过 TTL 的丢掉),
* 避免"拖了没保存"的文件永远堆着。
*
* 请求:POST multipart,字段 `file`(一个文件)+ `slot`(可选,图片槽位键)
* 返回:JSON {ok:true, stoken, name, is_image, kind, size, url, size_word}
* 或 {ok:false, error:"..."}
*/
declare(strict_types=1);
// ⚠️ 这个文件有**两种进入方式**,所以 bootstrap 要按需加载:
// ① 直接访问 `upload_stage.php`(老式入口);
// ② 经 `?r=upload/stage` 转发(index.php 里 `require` 进来)——
// 那时 bootstrap **已经加载过**,再 require 一次会重复 `define()` 常量 → Warning
// (而 Warning 会被打成 HTML 混进 JSON 里 → 前端 JSON.parse 直接炸,实测踩到)。
// 判据就是 bootstrap 一定会先定义的那个常量。
if (!defined('FB_APP')) {
require __DIR__ . '/app/bootstrap.php';
}
use App\Auth;
use App\StagedUpload;
header('Content-Type: application/json; charset=utf-8');
// ⚠️ 这个接口只回 JSON,任何 PHP 警告/错误如果被 display_errors 打成 HTML,
// 前端 JSON.parse 会直接炸("接口返回的不是 JSON"那一课)。
// 所以这里显式把错误转成异常、且不要往输出里打。
ini_set('display_errors', '0');
/**
* 回一个 JSON 并结束。
* ⚠️ 前端**只看 ok 字段**,错误一律给"人话",不吐堆栈(避免泄漏路径)。
*/
function stage_json(array $d): void
{
echo json_encode($d, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP);
exit;
}
// ---- 1. 登录 + CSRF ----
if (!Auth::check()) {
http_response_code(401);
stage_json(['ok' => false, 'error' => '登录已过期,刷新页面再试。']);
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
stage_json(['ok' => false, 'error' => '只接受 POST。']);
}
// csrf_check() 失败会直接结束请求并打 HTML —— 这里自己校验,保证回的是 JSON
$tok = (string)($_POST['_csrf'] ?? '');
if ($tok === '' || !hash_equals((string)csrf_token(), $tok)) {
http_response_code(419);
stage_json(['ok' => false, 'error' => '会话已过期,刷新页面再试。']);
}
// ---- 2. 收文件 ----
$file = $_FILES['file'] ?? null;
if (!is_array($file) || !array_key_exists('error', $file)) {
stage_json(['ok' => false, 'error' => '没有收到文件。']);
}
// 上传本身失败时 PHP 给的错误码要翻成人话("什么都没发生"最难排查)
$err = (int)$file['error'];
if ($err !== UPLOAD_ERR_OK) {
$map = [
UPLOAD_ERR_INI_SIZE => '文件超过了服务器允许的大小(upload_max_filesize)。',
UPLOAD_ERR_FORM_SIZE => '文件超过了表单允许的大小。',
UPLOAD_ERR_PARTIAL => '文件只传了一半,请重试。',
UPLOAD_ERR_NO_FILE => '没有选择文件。',
UPLOAD_ERR_NO_TMP_DIR => '服务器没有临时目录。',
UPLOAD_ERR_CANT_WRITE => '服务器写不了磁盘。',
UPLOAD_ERR_EXTENSION => '上传被某个扩展拦下了。',
];
stage_json(['ok' => false, 'error' => $map[$err] ?? ('上传失败(错误码 ' . $err . ')。')]);
}
$familyId = (int)Auth::familyId();
$personId = (int)Auth::personId();
$slot = trim((string)($_POST['slot'] ?? ''));
$res = StagedUpload::stage($familyId, $personId, $file, $slot);
if (empty($res['ok'])) {
stage_json(['ok' => false, 'error' => (string)($res['error'] ?? '没存上')]);
}
stage_json([
'ok' => true,
'stoken' => (string)$res['stoken'],
'name' => (string)$res['name'],
'is_image' => !empty($res['is_image']),
'kind' => (string)$res['kind'],
'size' => (int)$res['size'],
'size_word' => (string)$res['size_word'],
'url' => (string)$res['url'],
]);