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 条全过**。
This commit is contained in:
2026-10-06 03:14:18 +08:00
parent 1756c6265d
commit c3d00ad66a
20 changed files with 1656 additions and 149 deletions
+67 -4
View File
@@ -582,13 +582,76 @@ final class Calendar
}
/** 提醒分钟数:只认预设档位与 0/NULL,乱传的落 NULL(不提醒) */
private static function reminderMinutes($v): ?int
/**
* 提醒值 → 存的字符串(v24 第 20 批 第 8 条:**可以多选**)。
*
* 用户原话:「日程的提醒,是可以多选的,就是说,一个日程,
* 我可以选择让它5分钟提醒,10分钟提醒等等。」
*
* ⚠️ 存的形态:**逗号分隔的分钟数升序**,如 `5,15,60`。
* 为什么不动表结构(不加新列、不改成 JSON):
* · 那一列是 `INT`,存 `"5,15,60"` 时 MySQL 会做隐式转换 —— 有风险,所以
* 把列改成 VARCHAR(见 Schema 的升级段),但**老值一个都不丢**(数字串照样读)。
* · 逗号串人眼可读、`WHERE reminder_minutes LIKE …` 也还能粗筛,
* 而 JSON 存进去就再也说不清"这列以前存过什么"。
*
* @param mixed $v 表单来的(数组 或 逗号串 或 单个数字)
* @return string|null 合法时返回规范化串,一个都没有时返回 null(=不提醒)
*/
private static function reminderMinutes($v): ?string
{
$mins = self::reminderList($v);
return $mins ? implode(',', $mins) : null;
}
/**
* 任意形态的提醒值 → **合法分钟数数组**(升序、去重)。
*
* ⚠️ 白名单:只认 `self::REMINDERS` 里那几个档位。手搓请求塞 999999
* 不能变成"提前 999999 分钟提醒"(那会导出一个客户端算不明白的 VALARM)。
*
* @param mixed $v 数组 / "5,15" / "5" / null
* @return int[]
*/
public static function reminderList($v): array
{
if ($v === null || $v === '' || $v === false) {
return null;
return [];
}
$n = (int)$v;
return isset(self::REMINDERS[$n]) ? $n : null;
if (is_array($v)) {
$raw = $v;
} else {
$raw = explode(',', (string)$v);
}
$out = [];
foreach ($raw as $one) {
$one = trim((string)$one);
if ($one === '') {
continue;
}
$n = (int)$one;
if (isset(self::REMINDERS[$n]) && !in_array($n, $out, true)) {
$out[] = $n;
}
}
sort($out);
return $out;
}
/** 一个日程的提醒 → 人话('提前 5 分钟、提前 1 小时');没有则空串 */
public static function reminderWords($v): string
{
$words = [];
foreach (self::reminderList($v) as $n) {
$words[] = (string)self::REMINDERS[$n];
}
return implode('、', $words);
}
/** 老调用点还在用「单值」语义时(如 _event_fields 回显)取一个"是否含某一档" */
public static function hasReminder($v, int $mins): bool
{
return in_array($mins, self::reminderList($v), true);
}
private static function statusOf($v): string
+8 -3
View File
@@ -101,13 +101,18 @@ final class Ics
if (!empty($e['updated_at'])) {
$out[] = 'LAST-MODIFIED:' . self::stampOf($e['updated_at']);
}
// 提醒 = VALARM(TRIGGER 用**负**持续时间表示"提前多久")
if (($e['reminder_minutes'] ?? null) !== null && (int)$e['reminder_minutes'] > 0) {
// 提醒 = VALARM(TRIGGER 用**负**持续时间表示"提前多久")。
// ⚠️ v24 第 20 批 第 8 条:一个日程可以有多档提醒 → **导出多个 VALARM**。
// RFC 5545 允许一个 VEVENT 带任意多个 VALARM,客户端会各响一次;
// 所以这里按 calendar_events.reminder_minutes 里的逗号串逐个吐。
// 老数据(只存了一个数字)走同一条路,导出的还是那个单一 VALARM。
foreach (\App\Calendar::reminderList($e['reminder_minutes'] ?? null) as $rvMin) {
if ($rvMin <= 0) { continue; }
$out = array_merge($out, [
'BEGIN:VALARM',
'ACTION:DISPLAY',
'DESCRIPTION:' . self::escapeText((string)($e['title'] ?? '提醒')),
'TRIGGER:-' . self::duration((int)$e['reminder_minutes']),
'TRIGGER:-' . self::duration($rvMin),
'END:VALARM',
]);
}
+27
View File
@@ -1283,6 +1283,33 @@ SQL);
"ADD COLUMN `location` VARCHAR(255) DEFAULT NULL COMMENT '地点(RFC 5545 LOCATION)' AFTER `title`");
self::addColumn($db, 'calendar_events', 'reminder_minutes',
"ADD COLUMN `reminder_minutes` INT DEFAULT NULL COMMENT '提前多少分钟提醒(对应 VALARM 的 TRIGGER);NULL=不提醒' AFTER `all_day`");
/* ---- 提醒改**多档**(v24 第 20 批 第 8 条)----
用户原话:「日程的提醒,是可以多选的,就是说,一个日程,
我可以选择让它5分钟提醒,10分钟提醒等等。」
⚠️ 一个日程可以有**多个** VALARM(RFC 5545 完全支持),所以存
"逗号分隔的分钟数升序"(如 `5,15,60`)最省事:
· 不动语义(老值 "15" 照样读成 [15],一个字都不用迁);
· 人眼可读、LIKE 还能粗筛;
· 比新建一张 `calendar_reminders` 表少一层 join —— 家庭场景一个日程
最多挂几个提醒,不值得为它开表。
⚠️ 必须把列从 INT 改成 VARCHAR:INT 列里存 "5,15,60" 会被 MySQL
做**隐式数值转换**(变成 5 后截断,还可能是 Warning/严格模式报错)——
那样多档提醒会**静默只剩第一档**,最难查。
⚠️ `MODIFY` 是幂等的(已经是 VARCHAR 时再 MODIFY 一次也无害)。 */
self::tryStep('提醒列改成可存多档(INT → VARCHAR)', static function () use ($db) {
$st = $db->prepare(
'SELECT DATA_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'
);
$st->execute(['calendar_events', 'reminder_minutes']);
$type = strtolower((string)($st->fetchColumn() ?: ''));
if ($type !== '' && $type !== 'varchar') {
$db->exec(
"ALTER TABLE `calendar_events` MODIFY `reminder_minutes` VARCHAR(64) DEFAULT NULL "
. "COMMENT '提前多少分钟提醒,可多档(逗号分隔升序,如 5,15,60);NULL=不提醒'"
);
}
});
self::addColumn($db, 'calendar_events', 'status',
"ADD COLUMN `status` ENUM('confirmed','tentative','cancelled') NOT NULL DEFAULT 'confirmed' COMMENT 'RFC 5545 STATUS:确定 / 待定 / 取消' AFTER `reminder_minutes`");
self::addColumn($db, 'calendar_events', 'transp',
+323
View File
@@ -0,0 +1,323 @@
<?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);
}
}
+16 -4
View File
@@ -763,6 +763,14 @@ final class Trip
self::textOrNull2($d['line_name'] ?? ''),
self::textOrNull2($d['carrier'] ?? ''),
self::textOrNull($d['trip_no'] ?? ''),
// ⚠️ v24 第 20 批 第 5 条:线路名 / 承运方**不再从表单接收**
// (用户:「添加行程,线路名、承运方,都不要了。」)。
// 它们与上面的 seat_info / gate 同一处境:**只在 INSERT 时写入,
// UPDATE 时不参与** —— 否则表单不带这两个字段 → `$d` 里是空串 →
// `textOrNull2('')` 返回 `''`(不是 null!)→ 每次编辑行程都会把
// TripLog 迁过来的「京沪高铁 / 中国铁路」**静默清成空串**。
// 这正是"第 17 批删了字段但数据没丢"能成立的原因,照抄同一套。
// (下面 $colsKeep 与 array_splice 的说明里也点了这两列。)
// ⚠️ 第 17 批:这两个字段**不再从表单接收**(用户第 3 条要求去掉)。
// 但编辑老行程时如果把它们写成 null,TripLog 迁来的座位信息就永远消失了
// —— 所以这里"保持原值不变"(下面对 UPDATE 单独处理这两列)。
@@ -785,14 +793,18 @@ final class Trip
if ($id !== null && $id > 0) {
// ⚠️ `seat_info` / `gate` 在 UPDATE 里**不参与**(表单已不提交它们):
// 写成 null 会把 TripLog 迁来的老数据抹掉。这两列从此只读不写。
// ⚠️ v24 第 20 批 第 5 条:`line_name` / `carrier` 也加进"只读不写"那一组 ——
// 原因见上面 $fields 里那段(表单不再收它们,写进去就会清空老数据)。
$colsKeep = 'title=?, detail=?, place=?, starts_at=?, ends_at=?, node_type=?, status=?,
category=?, position=?, stage=?, travel_mode=?, vehicle_id=?, line_name=?,
carrier=?, trip_no=?, from_place=?, to_place=?,
category=?, position=?, stage=?, travel_mode=?, vehicle_id=?,
trip_no=?, from_place=?, to_place=?,
from_city=?, to_city=?, from_addr=?, to_addr=?,
from_lng=?, from_lat=?, to_lng=?, to_lat=?';
$keep = $fields;
// 去掉 seat_info / gate 两项(它们在 $fields 里的下标是 15、16)
array_splice($keep, 15, 2);
// ⚠️ 先 splice **下标大**的那一组,再 splice 小的 —— 反了会错位。
// $fields 的下标:12=line_name, 13=carrier, 15=seat_info, 16=gate。
array_splice($keep, 15, 2); // 去掉 seat_info / gate
array_splice($keep, 12, 2); // 去掉 line_name / carrier
$db->prepare('UPDATE trip_nodes SET ' . $colsKeep . ' WHERE id=?')
->execute([...$keep, $id]);
$nid = $id;