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;
+245 -18
View File
@@ -81,7 +81,7 @@ a:hover { text-decoration: underline; }
.who-menu { position: relative; }
.who-menu > summary { list-style: none; cursor: pointer; padding: 4px 6px; border-radius: 999px; }
.who-menu > summary::-webkit-details-marker { display: none; }
.who-menu > summary:hover { background: var(--line-2); }
.who-menu > summary:hover { } /* 统一段处理 */
.who-menu > summary:hover .who-name { color: var(--brand); }
.who-menu[open] > summary { background: var(--line-2); }
.who-menu[open] > summary .caret { transform: rotate(180deg); }
@@ -139,7 +139,7 @@ a:hover { text-decoration: underline; }
display: inline-flex; align-items: center; justify-content: center;
line-height: 1;
}
.btn-icon:hover { background: #FAF7F1; color: var(--brand); border-color: var(--brand); }
.btn-icon:hover { color: var(--brand); border-color: var(--brand); } /* 底色交给统一段 */
.btn-icon svg { display: block; }
/* 观影设置菜单:从右上角改成**按钮下方右对齐**(按钮贴右,菜单也贴右才不会跑出屏外) */
@@ -269,7 +269,9 @@ a:hover { text-decoration: underline; }
transition: border-color .15s ease, color .15s ease, background .15s ease;
}
.icon-btn:hover {
color: var(--brand); border-color: var(--brand-2); background: #FBF7F1;
color: var(--brand); border-color: var(--brand-2);
/* 底色交给文末「全站悬停统一」段(中性薄雾) */
background: var(--hov-veil, rgba(44,35,29,.05));
}
.icon-btn svg { display: block; }
@@ -438,22 +440,21 @@ textarea.input { min-height: 76px; resize: vertical; }
border-radius: var(--radius-s); border: 1px solid transparent; cursor: pointer;
background: var(--brand); color: #fff; transition: background .15s ease;
}
.btn:hover { background: #944022; text-decoration: none; }
.btn:hover { text-decoration: none; } /* 底色交给文末「全站悬停统一」段(同色系加深) */
.btn-ghost { background: #fff; color: var(--ink); border-color: var(--line); }
.btn-ghost:hover { background: #FAF7F1; }
.btn-ghost:hover { } /* 同上:中性薄雾 + 边框微显,见文末统一段 */
.btn-danger { background: #fff; color: var(--red); border-color: rgba(192,83,63,.35); }
.btn-danger:hover { background: #FDF1EE; }
.btn-danger:hover { } /* 同上(危险类走薄雾带一点红) */
/* v24 第 19 批 用户第 7 条:行程行的动作按钮要**看得出颜色**——
「修改是黄色背景,完成绿色,未成行红色」。
⚠️ 这三个是**实心**色块(不是 .btn-danger 那种淡红描边):用户说的是背景色,
要的是"一眼分得清哪个是哪个"。所以单独定义,不去改 .btn-danger 的既有语义
(全站十来处「删除」都在用它,改成实心会到处变样)。 */
.btn-warn { background: #E8A33D; color: #fff; border-color: transparent; }
.btn-warn:hover { background: #D79230; }
.btn-ok { background: #5E9E4E; color: #fff; border-color: transparent; }
.btn-ok:hover { background: #518B43; }
.btn-del { background: #C0533F; color: #fff; border-color: transparent; }
.btn-del:hover { background: #AC4835; }
/* ⚠️ 这三个的 hover 统一交给文件靠后的「第 20 批 全站悬停统一」那一段
(有底色的一律 `filter: brightness(.92)` 同色系加深,不再各写一个色值)。 */
.btn-sm { padding: 5px 12px; font-size: 13px; }
.btn-row { display: flex; gap: 10px; margin-top: 20px; }
@@ -1360,7 +1361,8 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
background: var(--brand); color: #fff; border-color: transparent;
}
.page-head .actions > .btn:not(.btn-icon):hover,
.page-head .actions > form > button.btn:not(.btn-icon):hover { background: #944022; }
/* 底色交给文末统一段(同色系加深) */
.page-head .actions > form > button.btn:not(.btn-icon):hover { }
/* 层 2:次要按钮(.btn-ghost)—— 品牌描边 + 极浅品牌底,比主按钮轻一档。
a / button 两种形态各写一条、特异性对齐(见层 1 的警告)。 */
@@ -1374,7 +1376,7 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
.page-head .actions > a.btn-ghost:not(.btn-icon):hover,
.page-head .actions > form > .btn-ghost:not(.btn-icon):hover,
.page-head .actions > form > button.btn-ghost:not(.btn-icon):hover {
background: #F7EDE4; border-color: var(--brand);
background: var(--hov-veil, rgba(44,35,29,.05)); border-color: var(--brand);
}
/* 层 3:危险按钮(.btn-danger)—— 淡红底 + 红字,同样要有颜色、但不能看着像主按钮。
@@ -1391,7 +1393,7 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
.page-head .actions > .btn-danger:not(.btn-icon):hover,
.page-head .actions > a.btn-danger:not(.btn-icon):hover,
.page-head .actions > form > .btn-danger:not(.btn-icon):hover,
.page-head .actions > form > button.btn-danger:not(.btn-icon):hover { background: #F7E4DF; }
.page-head .actions > form > button.btn-danger:not(.btn-icon):hover { background: rgba(192, 83, 63, .09); }
/* 「设置」图标按钮:**唯一无色的**(白底描边、方形),与上面所有彩色按钮区分。
padding 归零、做成正方形,但高度必须仍是 --act-h(= 文字按钮的 43.8px)——
写死过 40px,比旁边按钮矮 3.8px,是用户反复反馈的那个问题。 */
@@ -1403,12 +1405,13 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
}
.page-head .actions > .movie-set > summary.btn-icon:hover,
.page-head .actions > .movie-set > summary.btn:hover {
background: #FAF7F1; border-color: var(--brand); color: var(--brand);
border-color: var(--brand); color: var(--brand);
/* 底色交给文末统一段(中性薄雾),别在这里另糊一个 #FAF7F1 */
}
.movie-set[open] > summary.btn-icon,
.movie-set[open] > summary.btn { border-color: var(--brand); color: var(--brand); }
.btn.danger { color: var(--red); border-color: rgba(192, 83, 63, .35); }
.btn.danger:hover { background: rgba(192, 83, 63, .08); }
.btn.danger:hover { } /* 统一段处理 */
/* ---------------- 图片裁剪浮层 ---------------- */
/* 覆盖层默认不占空间;打开时铺满整屏,把底下的页面锁住(裁剪时不该能点到别处) */
@@ -1506,7 +1509,7 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
padding: 8px 20px; border-radius: 8px; font-weight: 600;
cursor: pointer; white-space: nowrap;
}
.search-bar button:hover { background: var(--brand-2); }
.search-bar button:hover { } /* 统一段处理(同色系加深,不换成 brand-2) */
.search-clear { color: var(--muted); font-size: 14px; padding: 8px 4px; white-space: nowrap; }
/* ---- 瀑布流海报墙 ---- */
@@ -1656,7 +1659,7 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
align-items: center; justify-content: center;
transition: background-color .2s ease; font-family: inherit;
}
.lb-btn:hover { background: rgba(255, 255, 255, .26); }
.lb-btn:hover { } /* 深底上不能用薄雾:走文末统一段的 brightness 提亮 */
.lb-btn[hidden] { display: none; }
.lb-close { top: 14px; right: 16px; width: 40px; height: 40px; border-radius: 50%; font-size: 18px; }
/* 下载按钮在关闭按钮左边,同高、同一套视觉(v24 第 3 条:预览层右上角要有下载) */
@@ -2199,7 +2202,7 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
background: none; border: none; cursor: pointer; color: var(--muted);
font-size: 14px; line-height: 1; padding: 4px 6px; border-radius: 6px;
}
.rv-del-btn:hover { background: rgba(192, 83, 63, .12); color: var(--red); }
.rv-del-btn:hover { color: var(--red); } /* 底色交给统一段 */
.rv-content {
margin: 8px 0 2px 44px; font-size: 15px; line-height: 1.75;
color: var(--ink); word-break: break-word;
@@ -2886,7 +2889,7 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
.seg-btn + .seg-btn { border-left: 0; }
.seg-btn:first-child { border-radius: var(--radius-s) 0 0 var(--radius-s); }
.seg-btn:last-child { border-radius: 0 var(--radius-s) var(--radius-s) 0; }
.seg-btn:hover { background: var(--surface-2, #FDFBF7); text-decoration: none; }
.seg-btn:hover { text-decoration: none; } /* 原来那层 #FDFBF7 在米色底上像脏了一块 */
.seg-btn.is-on {
background: var(--brand); border-color: var(--brand); color: #fff; font-weight: 600;
}
@@ -3514,3 +3517,227 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
.pp-modal-box { max-height: 88vh; }
.pp-stage { min-height: 108px; max-height: 38vh; }
}
/* ============================================================
v24 第 20 批 用户第 9 条:**全站按钮悬停统一**
------------------------------------------------------------
用户原话:「很多按钮的悬停效果,非常奇怪,感觉纯粹是换了一个背景色,
这个背景色,跟这个模块完全不协调,应该调整所有按钮的悬停效果,
保持统一,但是不要单纯的换一个不协调的背景色。」
------------------------------------------------------------
⚠️ 改之前的问题(复现探针量出来 **15 种**互不相干的 hover 背景色):
#944022 / #FAF7F1 / #FBF7F1 / #F7EDE4 / #FDF1EE / #F7E4DF / #D79230 /
#518B43 / #AC4835 / var(--line-2) / var(--surface-2,#FDFBF7) / rgba(192,83,63,.08)
—— 同一行里「取消」和「保存」悬停变的是**两套不相干的色**;
`.seg-btn` 那种更是直接糊一层 `#FDFBF7`,在米色底上看着像"脏了一块"。
而且全是"只换背景"这一种表达 —— 这正是用户说的"纯粹是换了一个背景色"。
统一后的**一套手法**(所有按钮共用同一套手感,底色只跟着自己的色系走):
① **同色系加深**:有底色的一律 `filter: brightness(.92)` ——
深色按钮(品牌红 / 黄 / 绿 / 红)自然变深一点,**不再跳到另一个色**;
② **描边留白按钮**(.btn-ghost / .btn-danger / .btn-icon 及各处白底按钮):
统一用**中性薄雾** `rgba(44,35,29,.05)` —— 落在宣纸米底上只是"轻轻按了一下",
不会再出现 #F7EDE4 那种偏色的脏块;
③ **边框微显 + 极轻抬升**:`border-color: rgba(44,35,29,.16)` + `translateY(-1px)`
+ 一层很浅的投影 —— 给的是"这个能按"的触感,而不是换色;
④ 统一 `transition`(背景 / 边框 / 颜色 / 投影 / 位移一起过渡),避免"啪一下变色"。
⚠️ 实现上放在**文件最靠后**并带 `!important` 收口:
历史上有十几处散落的 hover(.btn:hover、.page-head 里那几层、.seg-btn:hover…)
优先级各不相同,逐个改既容易漏、又会被后加的规则盖掉。
收口之后**新加的按钮自动继承同一套手感**,作者不必再记一次。
⚠️ 深色浮层上的按钮(.lb-btn 在黑色遮罩上)不适用中性薄雾(深底上看不见),
由浮层自己的规则覆盖;.pv-nav / .pv-dl 同理,见各自定义处。
============================================================ */
:root {
/* 悬停手感三件套(改这里 = 全站按钮悬停一起变) */
--hov-lift: -1px;
--hov-ring: rgba(44, 35, 29, .16);
--hov-veil: rgba(44, 35, 29, .05);
--hov-dim: brightness(.92);
--hov-t: background-color .15s ease, border-color .15s ease,
color .15s ease, box-shadow .15s ease, transform .15s ease;
}
.btn, .btn-ghost, .btn-danger, .btn-warn, .btn-ok, .btn-del, .btn-sm, .btn-icon,
button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-btn {
transition: var(--hov-t);
}
/* ① 有底色的按钮:同色系加深(不换色)+ 轻抬升
⚠️ 这里**只加 filter,绝不能写 `background-color: inherit`** ——
`inherit` 取的是**父元素**的背景(多半是透明),会把实心按钮变透明
(实测:黄/绿按钮悬停后变成 alpha 0.008 的鬼影)。
不改底色就"不写底色",让按钮自己的 background 留着。 */
.btn:not(.btn-ghost):not(.btn-danger):not(.btn-icon):not(.btn-warn):not(.btn-ok):not(.btn-del):hover,
.btn-warn:hover, .btn-ok:hover, .btn-del:hover,
.search-bar button:hover {
filter: var(--hov-dim);
transform: translateY(var(--hov-lift));
box-shadow: var(--shadow-l);
}
/* ② 描边留白按钮:中性薄雾 + 边框微显 + 轻抬升(不再各自糊一个偏色底) */
.btn-ghost:hover, .btn-icon:hover, .seg-btn:hover, .icon-btn:hover,
.who-menu > summary:hover,
.page-head .actions > .btn-ghost:not(.btn-icon):hover,
.page-head .actions > a.btn-ghost:not(.btn-icon):hover,
.page-head .actions > form > .btn-ghost:not(.btn-icon):hover,
.page-head .actions > form > button.btn-ghost:not(.btn-icon):hover {
background: var(--hov-veil) !important;
border-color: var(--hov-ring) !important;
transform: translateY(var(--hov-lift));
box-shadow: var(--shadow);
}
.page-head .actions > .movie-set > summary.btn-icon:hover,
.page-head .actions > .movie-set > summary.btn:hover {
background: var(--hov-veil) !important;
border-color: var(--hov-ring) !important;
}
/* ③ 危险 / 删除类:同一套手法,薄雾里带一点红(语义不丢,但不刺眼) */
.btn-danger:hover, .rv-del-btn:hover, .btn.danger:hover,
.page-head .actions > .btn-danger:not(.btn-icon):hover,
.page-head .actions > a.btn-danger:not(.btn-icon):hover,
.page-head .actions > form > .btn-danger:not(.btn-icon):hover,
.page-head .actions > form > button.btn-danger:not(.btn-icon):hover {
background: rgba(192, 83, 63, .09) !important;
border-color: rgba(192, 83, 63, .30) !important;
color: var(--red) !important;
transform: translateY(var(--hov-lift));
box-shadow: var(--shadow);
}
/* ⚠️ .btn-del 是**实心红**(第 19 批 行程行那种)—— 上面那条 ③ 会把它拉成淡红,
所以实心那一类要单独拉回来:不改底色(保住 #C0533F),只走"同色系加深"。 */
.btn-del:hover {
background: #C0533F !important;
color: #fff !important;
border-color: transparent !important;
}
/* ④ 纯图标 / 文字小按钮:不占背景,只做"轻抬升 + 轻微加深" */
.person-more-btn:hover, .rv-act-btn:hover, .bf-btn:hover, .cs-btn:hover,
.more-set > summary:hover, .rv-open-btn:hover {
filter: var(--hov-dim);
transform: translateY(var(--hov-lift));
text-decoration: none;
}
/* ⑤ 浮在深色遮罩上的按钮:深底上中性薄雾看不见 → 用"提亮"代替,
但**同一套手法**(变亮 + 轻抬升),保持全局手感一致。 */
.lb-btn:hover, .pv-close:hover, .crop-x:hover {
filter: brightness(1.18);
transform: translateY(var(--hov-lift));
}
/* v24 第 20 批 第 3 条:`form="id"` 关联用的**表单外壳**。
它们只承载字段、不出现在视觉里(按钮在原来的位置上,用 form= 指过来)。
⚠️ 必须 display:none —— 不藏的话页面底部会多出几块空的表单块。 */
.form-outside { display: none; }
/* ============================================================
v24 第 20 批 第 5 条:添加行程的三处版面调整
============================================================ */
/* ---- ① 出发地 / 到达地**同一行**(用户原话:"应该在同一行")----
⚠️ 必须显式开一个"一行两列"的容器,不能靠两个 .field 相邻 ——
.form-grid 是两列网格,每个 .field 各占一格;窄屏折行后顺序还会乱。 */
.loc-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 560px) {
/* 窄屏放不下并排两个地址框(中文地名 + 高德联想候选),改成上下 */
.loc-row { grid-template-columns: 1fr; }
}
/* ---- ② 「车辆」只在出行方式 = 自驾 时出现(第 17 批的**同一句原话**)----
🔴 v24 第 20 批 第 5 条修的是这里:用户又报了一次「车辆,只在出行方式选择自驾时
才出现,其他情况下,默认不出现」—— 说明**上一批的隐藏根本没生效**。
根因:模板里写的是 `<div class="field" data-mode-vehicle hidden>`,
而 `.field` 自己声明了 `display:flex` —— **作者样式优先于浏览器默认的
`[hidden]{display:none}`**,所以 `hidden` 形同虚设、车辆永远显示着。
(与 .pp-item / .pv 那几处是**同一个坑**,项目里已经栽过三次。)
修法:给自己声明过 display 的元素补一条 `[hidden]` 规则。 */
.field[hidden],
.field.full[hidden],
[data-mode-vehicle][hidden],
[data-mode-ticket][hidden] { display: none !important; }
/* ============================================================
v24 第 20 批 第 4 条:拖动即时上传的**暂存区** UI
------------------------------------------------------------
`.stage-drop` 是那个"可以往里拖"的落点;`[data-stage-list]` 装卡片。
卡片三态:.is-uploading(转圈感)/ 默认(缩略图)/ .is-failed(标红)。
============================================================ */
.stage-drop {
border: 1px dashed var(--line); border-radius: var(--radius-s);
background: #FDFBF7; padding: 10px 12px; margin-top: 6px;
}
.stage-drop.is-over { outline: 2px dashed var(--brand); outline-offset: 3px; background: #FDF3EC; }
.stage-drop-tip { display: block; font-size: 12px; color: var(--muted); margin-bottom: 8px; }
.stage-list { display: flex; flex-wrap: wrap; gap: 8px; }
/* ⚠️ .stage-list 自己声明了 display:flex → 要藏就得补 [hidden](本项目老坑) */
.stage-list[hidden] { display: none; }
.stage-cell {
position: relative; width: 84px; padding: 6px 4px; border-radius: 8px;
border: 1px solid var(--line); background: var(--surface);
display: flex; flex-direction: column; align-items: center; gap: 3px;
font-size: 11px; text-align: center; box-sizing: border-box;
}
.stage-cell img { width: 72px; height: 54px; object-fit: cover; border-radius: 5px; display: block; }
.stage-cell .stage-ico {
width: 72px; height: 54px; border-radius: 5px; background: #5F6672; color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700;
}
.stage-cell .stage-name {
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink);
}
.stage-cell .stage-meta { color: var(--muted); }
.stage-cell.is-uploading { opacity: .7; }
.stage-cell.is-uploading .stage-meta { color: var(--brand); }
.stage-cell.is-failed { border-color: var(--red); background: #FDF1EE; }
.stage-cell.is-failed .stage-meta { color: var(--red); }
.stage-cell .stage-x {
position: absolute; top: -6px; right: -6px; width: 20px; height: 20px;
border-radius: 50%; border: 1px solid var(--line); background: #fff; color: var(--muted);
font-size: 13px; line-height: 1; cursor: pointer; padding: 0;
}
.stage-cell .stage-x:hover { color: #fff; background: var(--red); border-color: var(--red); }
/* ============================================================
v24 第 20 批 第 6 条:周 / 日视图的「当前时间红线」
------------------------------------------------------------
用户原话:「应该在视图中间显示一条红线,用来表示当前进行的时间,
用一个不断从上到下的红线,来表示当前时间在视图上的刻度和位置。」
⚠️ 用**绝对定位**(top 由服务端/JS 给百分比)—— 与 .tg-block 同一套坐标,
所以线正好落在时间刻度的位置上,不会与日程块错开。
⚠️ 只在**今天那一列**出现(`.is-today`),由模板决定渲染不渲染。
⚠️ `pointer-events: none`:这条线是"刻度提示",不该挡住它下面的日程点击
(用户点线附近想点的是日程,不是这条线)。
============================================================ */
.tg-now {
position: absolute; left: 0; right: 0; height: 0;
border-top: 2px solid #E0453B; /* 红线(用户点名"红线") */
z-index: 4; /* 在日程块(z-index 2)之上,要看得见 */
pointer-events: none;
}
/* 左端那个小圆点:让"这条线是活的、代表此刻"更明显(也与日历 App 的通行做法一致) */
.tg-now::before {
content: ""; position: absolute; left: -4px; top: -5px;
width: 8px; height: 8px; border-radius: 50%; background: #E0453B;
box-shadow: 0 0 0 2px rgba(255, 255, 255, .9);
}
/* 过午夜(或跨天翻页)时线要能"走出去",不要卡在格子边界上闪 */
.tg-col { overflow: hidden; }
/* ============================================================
v24 第 20 批 第 7 条:重复日程删除的**范围选择**
(只删这一次 / 这一次及以后 / 全部)
⚠️ 三档要**竖着列**并带说明文字:这是不可逆操作,横排挤成一行会看不清后果。
============================================================ */
.ev-del-scope { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.ev-del-head { margin: 0; font-size: 14px; color: var(--ink); }
.ev-del-picks { display: flex; flex-direction: column; gap: 6px; }
.ev-del-picks .chk-inline { align-items: flex-start; }
/* v24 第 20 批 第 8 条:提醒多选的排版(勾选框多了要能换行,别挤成一长条) */
.rem-picks { display: flex; flex-wrap: wrap; gap: 6px 14px; }
.rem-pick { font-size: 13px; }
+225 -1
View File
@@ -401,7 +401,22 @@
over(false);
var files = Array.prototype.slice.call(e.dataTransfer.files || []);
if (!files.length) { return; }
// 目标是普通文件框时,**单选的只取第一个**(多选也塞多个会与 accept 语义打架:
// ⚠️ 目标是**暂存式**拖放区(第 20 批 第 4 条)→ 文件直接**即刻上传**,
// 一个接一个排队;用户可以接着再拖,最后一次"保存"统一认领。
// 普通文件框(没有 data-drop-stage)仍走第 19 批那套"写回 input.files"。
// 🔴 找落点必须**就近**:一个表单里可能有好几个(电影的 海报/票根/合影 三块),
// `form.querySelector('[data-drop-stage]')` 只会拿到**第一个** ——
// 那样往"票根"里拖会跑到"海报"上(用户看到的是"拖到这儿,结果上到那儿")。
// 所以先找**同一个 .field 里**的那个,找不到才退回表单里的第一个。
var stageBox = null;
var ownField = input.closest ? input.closest('.field') : null;
if (ownField) { stageBox = ownField.querySelector('[data-drop-stage]'); }
if (!stageBox && input.form) { stageBox = input.form.querySelector('[data-drop-stage]'); }
if (stageBox) {
queueDrop(stageBox, input, files);
return;
}
// 单选的只取第一个(多选也塞多个会与 accept 语义打架:
// 题图/头像那种单选框塞两个,浏览器提交的仍是第一个,但用户会以为两个都传了)
var multi = input.hasAttribute('multiple');
var put = multi ? files : files.slice(0, 1);
@@ -420,6 +435,166 @@
});
}
/* ============================================================
暂存式"拖一个就上传"(v24 第 20 批 用户第 4 条)
------------------------------------------------------------
用户原话:「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,
拖动一个进去,就开始上传,还可以继续拖动,继续上传,
最后统一保存即可。」
做法(三条链路,缺一不可):
① 拖进来 → **立刻** POST 到 upload_stage.php(`?r=upload/stage` 由路由转过去),
服务端落进暂存目录并回一个 `stoken`;
② 把 stoken 塞进隐藏字段 `<input type=hidden name="stage[]">`——
表单提交时这些令牌就跟着走了,业务路由在 attach_save_files 里**认领**;
③ 立刻画一张缩略图(指向 `media.php?stage=<stoken>`),
让用户看得见"这一张已经上去了"。
⚠️ 三个必须踩准的点(都是"看着能跑、实际会丢"的坑):
· **队列要串行**:用户一次拖 5 个/连着拖几次时,并发 POST 会让"完成顺序"
与"显示顺序"不一致,用户看到的缩略图顺序跳来跳去。这里用一个 FIFO 队列,
一张传完再传下一张(上传是本地网络,串行也够快)。
· **失败必须显示出来**:不能静默丢("我拖了三张,怎么只上去一张")。
失败的那张在缩略图上标红并写清原因。
· **保存前要拦住还在上传的**:用户拖完立刻点"保存"时,最后一张可能还在传。
提交时若队列没空,`preventDefault` 并提示等一会儿 —— 否则
"拖了但没保存上"是必然发生的(概率还不低)。
============================================================ */
function queueDrop(box, input, files) {
var form = input.form;
if (!form) { return; }
var list = box.querySelector('[data-stage-list]');
var tip = box.querySelector('[data-stage-tip]');
// 队列状态挂在 box 上:**跨多次拖放**共享(用户拖一次、再拖一次,仍要串行)
var st = box.__stage || (box.__stage = { pending: 0, chain: Promise.resolve() });
// 提交守卫:还有没传完的就别让它提交(见上面第 ③ 点)。
// ⚠️ 只挂一次(用 __stageGuard 记着)—— 反复拖会让 form 上叠出十几个监听。
if (!st.guard) {
st.guard = true;
form.addEventListener('submit', function (ev) {
if (st.pending > 0) {
ev.preventDefault();
if (tip) { tip.textContent = '还有 ' + st.pending + ' 个文件在上传,稍等一下再保存。'; }
}
});
}
files.forEach(function (f) {
// 每张一个卡片:立刻出现"上传中",成功换成缩略图,失败标红
var cell = document.createElement('div');
cell.className = 'stage-cell is-uploading';
var nm = document.createElement('span');
nm.className = 'stage-name';
nm.textContent = f.name;
var meta = document.createElement('span');
meta.className = 'stage-meta';
meta.textContent = '上传中…';
cell.appendChild(nm);
cell.appendChild(meta);
if (list) {
list.appendChild(cell);
// 🔴 必须把它**显示出来**:模板里 `.stage-list` 初始带 `hidden`
// (没人上传时不留一条空边框)。不 un-hide 的话卡片确实建出来了、
// 但整条清单是 display:none —— 用户看到的是"拖了没反应",
// 而且缩略图也点不到(真机探针实测:卡片在 DOM 里、尺寸 0×0)。
list.hidden = false;
}
st.pending++;
// 🔴 串行:把这次上传接到链尾。**不能 Promise.all** ——
// 并发时"谁先传完"不定,缩略图的顺序会跳(见上面第 ① 点)。
st.chain = st.chain.then(function () {
var fd = new FormData();
fd.append('file', f);
fd.append('_csrf', csrfToken(form));
var slot = input.getAttribute('data-stage-slot') || '';
if (slot) { fd.append('slot', slot); }
return fetch(stageUrl, { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (r) {
// ⚠️ 服务端哪怕回 419/500,也要**先试 json**:它总归是 JSON 契约;
// 解析不出来才按"不是 JSON"报(比如整页 HTML 的错误页)。
return r.json().catch(function () {
return { ok: false, error: '服务器返回的不是 JSON(可能登录已过期)。' };
});
})
.then(function (d) {
if (d && d.ok) {
cell.className = 'stage-cell';
cell.innerHTML = '';
if (d.is_image) {
var im = document.createElement('img');
im.src = d.url;
im.alt = '';
cell.appendChild(im);
} else {
var ic = document.createElement('span');
ic.className = 'stage-ico';
ic.textContent = d.kind || '文件';
cell.appendChild(ic);
}
var n2 = document.createElement('span');
n2.className = 'stage-name';
n2.textContent = d.name || f.name;
cell.appendChild(n2);
var m2 = document.createElement('span');
m2.className = 'stage-meta';
m2.textContent = d.size_word || '';
cell.appendChild(m2);
// 令牌进隐藏字段 → 随表单提交,服务端在 attach_save_files 里认领它
var hid = document.createElement('input');
hid.type = 'hidden';
// ⚠️ 字段名由落点决定:附件类是 `stage[]`(服务端一次认领一批),
// 海报/票根是 `stage_poster` / `stage_ticket`(各自单槽位、要按比例重裁)。
// 写死 `stage[]` 会让海报存成 1:1、页面上变形(见 _stage_drop.php 的说明)。
hid.name = box.getAttribute('data-stage-field') || 'stage[]';
hid.value = d.stoken;
cell.appendChild(hid);
// 撤回这张(令牌与卡片一起去掉;服务端的暂存由 TTL 清)
var x = document.createElement('button');
x.type = 'button';
x.className = 'stage-x';
x.textContent = '×';
x.title = '撤掉这张';
x.addEventListener('click', function () {
if (hid.parentNode) { hid.parentNode.removeChild(hid); }
if (cell.parentNode) { cell.parentNode.removeChild(cell); }
// 一张都不剩了就把清单藏回去(别留一条空边框 —— 与初始状态一致)
if (list && !list.querySelector('.stage-cell')) { list.hidden = true; }
if (tip) { tip.textContent = '已撤掉,保存时不会带上它。'; }
});
cell.appendChild(x);
if (tip) { tip.textContent = '已上传,可以继续拖;点「保存」时才真正记到这条上。'; }
} else {
// 🔴 失败必须**看得见**(写在卡片上,不弹 alert):静默丢是
// "我拖了三张怎么只上去一张"的根源。
cell.className = 'stage-cell is-failed';
meta.textContent = (d && d.error) ? d.error : '上传失败';
if (tip) { tip.textContent = '有文件没传上去,见红色那张。'; }
}
})
.catch(function (err) {
cell.className = 'stage-cell is-failed';
meta.textContent = '网络错误:' + (err && err.message ? err.message : '重试一下');
if (tip) { tip.textContent = '有文件没传上去,见红色那张。'; }
})
.then(function () { st.pending--; }); // 无论成败都要减,否则守卫会把表单永久锁住
});
});
}
/** 取当前表单里的 CSRF(上传接口要带;与 csrf_field() 同一个值) */
function csrfToken(form) {
var el = form.querySelector('input[name="_csrf"]');
return el ? el.value : '';
}
/** 暂存上传地址(从 meta 读,页面上没有就退回约定路由) */
var stageUrl = (function () {
var m = document.querySelector('meta[name="fb-stage-url"]');
return (m && m.getAttribute('content')) || 'index.php?r=upload/stage';
})();
/** 这次拖动里有没有真文件(拖选中的文字/链接也会触发 drag 事件,那些不该管) */
function hasFiles(e) {
var t = e.dataTransfer;
@@ -1378,3 +1553,52 @@
if ('requestIdleCallback' in window) { requestIdleCallback(boot, { timeout: 3000 }); }
else { setTimeout(boot, 500); }
})();
/* ============================================================
周/日视图的「当前时间红线」自己走(v24 第 20 批 用户第 6 条)
------------------------------------------------------------
用户原话:「用一个**不断**从上到下的红线,来表示当前时间在视图上的刻度和位置。」
⚠️ 服务端只给"渲染那一刻"的位置(%.4f,见 index.php)—— 用户开着页面不动,
过一小时线还停在原处就荒谬了。所以这里起一个定时器让它自己挪。
⚠️ 定时器**每分钟**走一次即可(线的精度就是分钟;每秒重排会白白耗电,
而这条线在视野里根本看不出每秒的差别)。
⚠️ 位置算法必须与 .tg-block / 服务端**同一套**:列高固定 24 小时 →
1 分钟 = 100/1440 %。三处口径一致,线才不会与日程块错位。
⚠️ 跨过午夜时线要**消失**(那一天已经不是"今天"了)—— 这里用"日期变了就重载一次"
处理,而不是把算出来的 top 硬塞到 >100%(那会让线停在格子底部不动,
看着像卡住了)。重载一次最稳,而且一天最多发生一次。
⚠️ 只在页面上真有这条线时才起定时器(月视图没有 `.tg-now`)。
============================================================ */
(function () {
'use strict';
var lines = document.querySelectorAll('[data-now-line]');
if (!lines.length) { return; }
var dayAtLoad = new Date().toDateString();
function place() {
var d = new Date();
if (d.toDateString() !== dayAtLoad) {
// 已经跨过午夜:这一列不再是"今天"了 → 让服务端重新渲染一次(见上面第 4 点)
window.location.reload();
return;
}
var pct = ((d.getHours() * 60 + d.getMinutes()) / 1440) * 100;
Array.prototype.forEach.call(lines, function (el) {
el.style.top = pct.toFixed(4) + '%';
el.setAttribute('title', '现在 ' + ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2));
});
}
// 对齐到"下一分钟整"再开始(否则第一次跳动会晚最多 60 秒,看着像没动)
var msToNextMinute = 60000 - (Date.now() % 60000);
setTimeout(function () {
place();
setInterval(place, 60000);
}, msToNextMinute);
// 从后台切回来时立刻纠一次(有些浏览器会冻结后台标签页的定时器)
document.addEventListener('visibilitychange', function () {
if (!document.hidden) { place(); }
});
})();
+224 -13
View File
@@ -281,6 +281,21 @@ function render_me_settings(array $person, array $errors = []): void
*/
function attach_save_files(int $familyId, string $type, int $targetId, string $field, int $uploaderId): array
{
// v24 第 20 批 第 4 条:先把**拖动即时上传**的暂存文件认领到这个条目上。
// 用户原话:「拖动一个进去,就开始上传…最后统一保存即可。」
// 那些文件在拖动时就已经传到服务器了(upload_stage.php),但**没有挂到任何条目**;
// 保存时(这里)才真正认领 —— 于是"立刻上传"与"最后统一保存"两件事都成立。
// ⚠️ 认领放在临时文件之前:两者最终都进同一张附件表、按 position 顺序排,
// 先认领的在前(也就是"拖动加进来的先出现"),符合操作顺序。
$staged = (array)($_POST['stage'] ?? []);
$stageWarns = [];
if ($staged) {
$res = \App\StagedUpload::claim($familyId, $uploaderId, $staged, $type, $targetId);
$stageWarns = (array)($res['warns'] ?? []);
}
// 顺手清掉过期没认领的暂存(不必挂定时任务)
\App\StagedUpload::sweep();
$files = [];
if (!empty($_FILES[$field]) && is_array($_FILES[$field])) {
$f = $_FILES[$field];
@@ -298,7 +313,7 @@ function attach_save_files(int $familyId, string $type, int $targetId, string $f
$files[] = $f;
}
}
$warns = [];
$warns = $stageWarns ?? [];
foreach ($files as $f) {
$res = \App\Attachment::save($familyId, $type, $targetId, $f, $uploaderId);
if (empty($res['ok']) && (string)($res['error'] ?? '') !== '') {
@@ -583,6 +598,26 @@ function filter_family_person_ids(array $ids, int $familyId): array
switch ($r) {
/**
* 暂存上传(v24 第 20 批 用户第 4 条)。
*
* 用户原话:「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,
* 拖动一个进去,就开始上传,还可以继续拖动,继续上传,最后统一保存即可。」
*
* ⚠️ 真正的实现放在**独立文件** `upload_stage.php` 里,这里只做一次转发:
* · 那个端点要自己控制响应头(纯 JSON)、自己的错误处理
* (任何 PHP 警告被 display_errors 打成 HTML,前端 JSON.parse 就炸);
* · 放在独立文件里也和 media.php 同一个思路:**自成一条出入口,便于审阅**。
* ⚠️ 转发必须把这次请求的**全部**上下文原样交给它($_FILES/$_POST 是超全局,
* 自动带过去;但 __DIR__ 与相对 require 的基准会变,所以先 chdir 到站点根)。
* ⚠️ `require` 之后脚本**不会回来**(那个文件自己 exit),所以这里不必写 break;
* 但语法上留着更清楚"这里就是终点"。
*/
case 'upload/stage':
chdir(__DIR__);
require __DIR__ . '/upload_stage.php';
break;
// ==================== 登录 / 退出 ====================
case 'auth/login':
if (Auth::check()) {
@@ -2313,8 +2348,14 @@ switch ($r) {
}
}
}
// 现场照片(多选,仅新增表单提供;编辑页在详情页里逐张补/删)
if (!$isEdit) {
// 现场照片(多选)。
// ⚠️ v24 第 20 批 第 1 条:**编辑时也收**这里传上来的照片了。
// 以前只有新增表单给这个字段(编辑页只在详情页里逐张补),所以这里写着
// `if (!$isEdit)`。现在编辑页也能一次多选加几张,所以要处理"合并"而不是替换:
// 编辑 = 库里已有的(在编辑页逐张删过的会被 photo_del 立刻删掉,库里就是最新的)
// + 这次新选上来的。
// ⚠️ 不能沿用"整组替换":那样一编辑就会把已有的照片全冲掉(用户报的
// "合影没带进来"如果按替换实现,会变成更难发现的"合影丢了")。
$photoFiles = [];
if (!empty($_FILES['photo']) && is_array($_FILES['photo']['name'])) {
foreach ($_FILES['photo']['name'] as $i => $_n) {
@@ -2327,7 +2368,10 @@ switch ($r) {
];
}
}
$photoKeys = [];
if ($photoFiles) {
$photoKeys = $isEdit
? array_map('strval', array_column(Movie::photos($id), 'key')) // 保住已有的
: [];
foreach ($photoFiles as $f) {
$up = Upload::image($familyId, $f, 'photo');
if ($up['ok']) {
@@ -2337,6 +2381,53 @@ switch ($r) {
}
}
$d['photos'] = $photoKeys;
} elseif (!$isEdit) {
// 新增且一张都没选 → 明确给空(否则 create 会拿到一个不存在的键)
$d['photos'] = [];
}
/* ---- v24 第 20 批 第 4 条:拖动**即时上传**的海报 / 票根 ----
用户原话:「拖动一个进去,就开始上传…最后统一保存即可。」
拖动会上传到暂存区并回一个 stoken,前端放进隐藏字段 `stage_poster` /
`stage_ticket`(**海报/票根各一个字段名**,因为它们要区分槽位)。
⚠️ 这里直接取 stoken 对应的暂存文件来重跑 Upload::image(按该槽位的比例),
而不是把暂存文件原样搬过去 —— 海报是 2:3、票根是 free,
不按槽位处理就会存成比例不对的图(页面上会变形)。
⚠️ 临时文件上传(`$_FILES[$slot]`)优先:用户**又选又拖**时以"选"的为准
(那是他更明确的动作),免得两个来源打架。
⚠️ 认领失败要记进 $warns(不静默丢),且**不影响保存**(与既有口径一致)。 */
foreach (['poster' => 'stage_poster', 'ticket' => 'stage_ticket'] as $slotKey => $fieldName) {
if (!empty($_FILES[$slotKey]) && (int)($_FILES[$slotKey]['error'] ?? 4) === UPLOAD_ERR_OK) {
continue; // 选了新文件 → 上面的常规路径已经处理过了
}
$stoken = trim((string)($_POST[$fieldName] ?? ''));
if ($stoken === '') {
continue;
}
$sf = \App\StagedUpload::read($familyId, (int)Auth::personId(), $stoken);
if ($sf === null) {
$warns[] = ($slotKey === 'poster' ? '海报' : '票根') . ':暂存文件已失效,请重拖一次。';
continue;
}
$fake = [
'name' => (string)$sf['name'],
'type' => (string)$sf['mime'],
'tmp_name' => (string)$sf['path'],
'error' => UPLOAD_ERR_OK,
'size' => (int)@filesize((string)$sf['path']),
];
$up = Upload::image($familyId, $fake, $slotKey);
if (!empty($up['ok'])) {
$newKey = (string)$up['file'];
$oldKey = $isEdit ? (string)($m[$slotKey . '_key'] ?? '') : '';
$d[$slotKey . '_key'] = $newKey;
if ($oldKey !== '' && $oldKey !== $newKey) {
\App\Storage\Storage::delete($familyId, $oldKey);
}
\App\StagedUpload::forget($stoken); // 暂存那份不再需要
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = ($slotKey === 'poster' ? '海报' : '票根') . '没有保存:' . $up['error'];
}
}
if ($errors) {
@@ -2399,9 +2490,53 @@ switch ($r) {
}
Movie::deletePhoto($familyId, $id, $pid);
flash('ok', '照片已删除。');
// ⚠️ v24 第 20 批 第 1 条:编辑页也能删照片了,所以按来路回去 ——
// 从编辑页删的就回编辑页(否则用户一删就被甩回详情页,还得再点一次编辑)。
// 来路用表单里的 `back` 判断(由模板给出,白名单,免得开放重定向)。
$back = (string)($_POST['back'] ?? '');
if ($back === 'edit') {
redirect('movies/edit', ['id' => $id]);
}
redirect('movies/show', ['id' => $id]);
break;
/**
* 删掉海报 / 票根(v24 第 20 批 第 1 条)。
*
* 为什么需要它:编辑页现在把已存的海报/票根**显示出来**了,
* 用户看着一张不想要的图,唯一的动作就是"再选一张新的"——
* 而他可能只是**想去掉它**(海报库里没有更好的,删掉反而干净)。
*
* ⚠️ 顺带把存储对象一起删(只清库不留孤儿文件,与 Movie::deletePhoto 同一口径)。
* ⚠️ 换图时(movies/update)服务端已经会删旧图,所以这里只管"只删不换"。
*/
case 'movies/media_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$slot = (string)($_POST['slot'] ?? '');
$m = Movie::find($familyId, $id, (int)Auth::personId());
if (!$m) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
// 白名单:只认这两个槽位(表单可以被改,不能凭它去删别的字段/别的对象)
$col = ['poster' => 'poster_key', 'ticket' => 'ticket_key'][$slot] ?? '';
if ($col === '') {
flash('err', '不知道要删哪一样。');
redirect('movies/edit', ['id' => $id]);
}
$old = (string)($m[$col] ?? '');
Database::get()->prepare('UPDATE movie_details SET ' . $col . ' = NULL WHERE event_id = ?')
->execute([$id]);
if ($old !== '') {
\App\Storage\Storage::delete($familyId, $old);
}
flash('ok', ($slot === 'poster' ? '海报' : '票根') . '已删除。');
redirect('movies/edit', ['id' => $id]);
break;
case 'movies/destroy':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
@@ -3590,11 +3725,21 @@ switch ($r) {
// 点空白处只带日期/时刻过来,标题等还是空的(不能替用户瞎填)。
$newAt = trim((string)($_GET['new_at'] ?? ''));
/* ---- 当前时间红线(v24 第 20 批 用户第 6 条)----
用户原话:「周视图和日视图,应该在视图中间显示一条红线…来表示当前时间
在视图上的刻度和位置。」
⚠️ 位置在**服务端**算好(与 .tg-block 的 top/height 同一套算法:
列高固定 24 小时 → 1 分钟 = 100/1440 %),前端只负责"过一会儿自己挪一下"。
不在 CSS 里写死时间,也不在前端算 —— 算法只有一处,不会两边对不上。 */
$nowMinutes = ((int)date('G')) * 60 + (int)date('i');
$nowTop = round($nowMinutes / 1440 * 100, 4);
View::render('calendar/index', [
'title' => '日程',
'ym' => $ym,
'view' => $view,
'anchor' => $anchor,
'nowTop' => $nowTop,
'cols' => $view === 'month' ? [] : ($cols ?? []),
'weeks' => $view === 'month' ? $grid : [],
'tgrid' => $view === 'month' ? null : $grid,
@@ -3857,7 +4002,11 @@ switch ($r) {
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'all_day' => $allDay,
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
// v24 第 20 批 第 8 条:提醒**多选** → 表单来的是数组 `reminder_minutes[]`。
// ⚠️ 不要再 `(string)` 强转 —— 那会得到 "Array" 字样(PHP 的经典坑),
// Calendar::reminderMinutes() 收到它就一份都认不出来,提醒静默丢光。
// 直接原样把数组交下去,由 Calendar 那边做白名单 + 规范化。
'reminder_minutes' => ($_POST['reminder_minutes'] ?? ''),
'status' => (string)($_POST['status'] ?? 'confirmed'),
'transp' => !empty($_POST['transp']),
// 用户 2026-10-03 第 5、6 条:重复规则(RFC 5545 RRULE 原文)
@@ -3951,7 +4100,11 @@ switch ($r) {
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'all_day' => $allDay,
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
// v24 第 20 批 第 8 条:提醒**多选** → 表单来的是数组 `reminder_minutes[]`。
// ⚠️ 不要再 `(string)` 强转 —— 那会得到 "Array" 字样(PHP 的经典坑),
// Calendar::reminderMinutes() 收到它就一份都认不出来,提醒静默丢光。
// 直接原样把数组交下去,由 Calendar 那边做白名单 + 规范化。
'reminder_minutes' => ($_POST['reminder_minutes'] ?? ''),
'status' => (string)($_POST['status'] ?? 'confirmed'),
'transp' => !empty($_POST['transp']),
'rrule' => Repeat::build($_POST),
@@ -4032,21 +4185,79 @@ switch ($r) {
$ym = trim((string)($_POST['ym'] ?? ''));
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
$st = Database::get()->prepare(
'SELECT e.id, e.calendar_id FROM calendar_events e
'SELECT e.id, e.calendar_id, e.rrule, e.starts_at FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
WHERE e.id = ? AND c.family_id = ?'
);
$st->execute([$id, $familyId]);
$ev = $st->fetch();
if ($ev) {
if (!$ev) {
flash('err', '找不到这条日程。');
redirect('calendar', ['ym' => $ym]);
}
$cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: [];
if (Calendar::canWrite($cal, $me)) {
if (!Calendar::canWrite($cal, $me)) {
flash('err', '这本日历你没有写权限。');
redirect('calendar', ['ym' => $ym]);
}
/* ==================== 重复日程的删除范围(v24 第 20 批 用户第 7 条)====================
用户原话:「对于重复的日程,当点击删除的时候,应该提示是删除这一次,
还是全部都删除,还是删除这一次及以后的。」
⚠️ 三档与**编辑**那三档(第 13 批做的 scope)刻意对齐,用户学一次就够:
once = 只删这一次 → 在 calendar_event_skips 里记一天(**不是**删整条系列!
真删了就"全部都没了",正是用户要避免的);
future = 删除这一次及以后 → 老系列截止到「这一次的前一天」;
这一步**必须**算出截止日并写进 RRULE,否则整条系列都会留着一个
"还没结束的重复",照样冒出来;
all = 全部(整条删除,与以前一样)。
⚠️ 范围信息从哪来:`scope`(用户在弹框里选的)+ `occ`(点开的是哪一天)。
两者缺一不可 —— 少了 occ 就定位不到"这一次"(重复日程每一天都是同一条记录 id)。
⚠️ 不重复的日程直接按 all(没有"系列"可言),表单也不会给它选范围。
*/
$scope = (string)($_POST['scope'] ?? 'all');
$occDay = substr(trim((string)($_POST['occ'] ?? '')), 0, 10);
$rrule = (string)($ev['rrule'] ?? '');
$hasOcc = (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $occDay);
if ($rrule !== '' && $hasOcc && $scope === 'once') {
// 只删这一次:记一个"跳过这一天的例外",系列原样保留
Calendar::skipOnce($id, $occDay);
flash('ok', '只删了 ' . $occDay . ' 这一次;后面的照旧。');
redirect('calendar', ['ym' => $ym]);
}
if ($rrule !== '' && $hasOcc && $scope === 'future') {
// 这一次及以后:老系列截至「这一次的前一天」。
$rp = Repeat::parse($rrule);
$cut = date('Y-m-d', strtotime($occDay . ' -1 day'));
// ⚠️ 截断结果要重新拼 RRULE:只改 UNTIL、并清掉 COUNT
// (COUNT 与 UNTIL 同时存在时以 COUNT 为准,不干掉它这次截断就失效)。
$rp['count'] = 0;
$rp['until'] = $cut;
// ⚠️ 边界:如果"这一天"就是系列的第一次(cut 落在首次之前),
// 那"这一次及以后"等于**全部** —— 截断会产出一条永不发生的系列,
// 留着纯属垃圾,所以那种情况直接整条删掉。
$firstDay = substr((string)$ev['starts_at'], 0, 10);
if ($cut < $firstDay) {
Calendar::deleteEvent($id);
flash('ok', '这一天就是第一次,「这一次及以后」等于整条删除。');
redirect('calendar', ['ym' => $ym]);
}
$newRule = 'FREQ=' . strtoupper((string)$rp['freq'])
. (!empty($rp['interval']) && (int)$rp['interval'] > 1 ? ';INTERVAL=' . (int)$rp['interval'] : '')
. ($rp['freq'] === 'weekday' ? ';BYDAY=MO,TU,WE,TH,FR' : '')
. ';UNTIL=' . str_replace('-', '', $cut) . 'T235959Z';
Database::get()->prepare('UPDATE calendar_events SET rrule = ?, sequence = sequence + 1 WHERE id = ?')
->execute([$newRule, $id]);
flash('ok', $occDay . ' 及以后的那些都删了(之前的保持不变)。');
redirect('calendar', ['ym' => $ym]);
}
// all(或本来就不重复):整条删除
Calendar::deleteEvent($id);
flash('ok', '日程已删除。');
} else {
flash('err', '这本日历你没有写权限。');
}
}
redirect('calendar', ['ym' => $ym]);
break;
+26
View File
@@ -41,6 +41,7 @@ function media_fail(int $code, string $message): void
}
$key = (string)($_GET['k'] ?? '');
$stageTok = (string)($_GET['stage'] ?? '');
// ---- 1. 鉴权 ----
if (!Auth::check()) {
@@ -49,6 +50,31 @@ if (!Auth::check()) {
}
$familyId = (int)Auth::familyId();
/* ---- 1b. 暂存预览(v24 第 20 批 第 4 条)----
刚拖进来的文件还没"保存",所以它还没有附件行、也没有正式键 —— 但用户
要**立刻看到缩略图**("拖一个就开始上传",看不见等于没上传)。
所以这里给一个 `?stage=<令牌>` 的形态:只读**本账号自己**的暂存目录。
⚠️ 令牌校验与归属检查都在 StagedUpload::read 里(拿到别人的令牌也读不到)。 */
if ($stageTok !== '') {
$sf = \App\StagedUpload::read($familyId, (int)Auth::personId(), $stageTok);
if ($sf === null) {
media_fail(404, '没有这个暂存文件');
}
$sfMime = (string)$sf['mime'];
if ($sfMime === '') {
$sfMime = 'application/octet-stream';
}
$sfSize = (int)@filesize((string)$sf['path']);
http_response_code(200);
header('Content-Type: ' . $sfMime);
header('Content-Length: ' . $sfSize);
// 暂存内容随时会被认领/清掉 → 绝不缓存(缓存住会看到"已保存"后的旧份)
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
readfile((string)$sf['path']);
exit;
}
// ---- 2. 键净化 ----
$key = str_replace('\\', '/', $key);
$key = ltrim($key, '/');
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* 拖动即时上传的**落点 + 暂存清单**(v24 第 20 批 用户第 4 条)。
*
* 用户原话:「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,
* 拖动一个进去,就开始上传,还可以继续拖动,继续上传,最后统一保存即可。」
*
* 这个片段只负责**画出落点**(一个虚线框 + 一个空清单容器),
* 真正的行为在 assets/js/app.js 的 `queueDrop()` 里:
* 拖进来 → 立刻 POST 到 upload/stage → 回来把 `stoken` 塞进隐藏字段 `stage[]`。
*
* ⚠️ 为什么"落点"要和"文件框"分开:
* 文件框(`<input type=file>`)仍然保留(点它一样能选文件),
* 拖动只是**多一种**输入方式;而且"拖到哪一块"需要一块**看得见**的区域 ——
* 单靠文件框本身,用户不知道该往哪儿拖。
*
* ⚠️ 落点必须能被 app.js 找回来:`data-drop-stage` 挂在 `<div>` 上,
* app.js 从「被拖的文件框」`input.form.querySelector('[data-drop-stage]')` 找它。
* 所以**每个上传区都要有一个文件框**(哪怕隐藏)—— 这符合现状(都有)。
*
* 用法(require 前设好,都可选):
* $sdHint 提示文案
* $sdSlot 图片槽位键('poster' / 'ticket' / 'cover' / 'photo'…;决定图片怎么裁)
* $sdMulti 是否允许多次/多个(默认 true)
* $sdAccept accept 提示(只影响选择框,不影响拖动)
*/
$sdHint = (string)($sdHint ?? '也可以把文件直接拖到这块虚线区域里 —— 拖一个就开始上传,可以接着再拖,最后点「保存」一起记下。');
$sdSlot = (string)($sdSlot ?? '');
$sdMulti = $sdMulti ?? true;
$sdAccept = (string)($sdAccept ?? '');
/* ⚠️ **海报 / 票根走另一条路**:它们各自只有一个槽位、且比例不同
(海报 2:3、票根 free)。如果也塞进 `stage[]` 一起认领,
服务端就只能统一按 'photo' 处理 —— 海报会被存成不是 2:3 的图,
页面上就变形了。所以这两个用**独立的字段名**把令牌带回去,
由 movies 路由按槽位重跑 Upload::image(见 index.php 里那一段)。
其余所有附件类上传(照片/资源/任务/日历)都走通用的 `stage[]`。 */
$sdField = in_array($sdSlot, ['poster', 'ticket'], true) ? ('stage_' . $sdSlot) : 'stage[]';
?>
<div class="stage-drop" data-drop-stage data-stage-field="<?= e($sdField) ?>"
<?= $sdSlot !== '' ? 'data-stage-slot="' . e($sdSlot) . '"' : '' ?>>
<span class="stage-drop-tip" data-stage-tip><?= e($sdHint) ?></span>
<div class="stage-list" data-stage-list hidden></div>
</div>
<?php
// ⚠️ 只 unset 本片段自己的变量(与 _datetime.php / _loc_field.php 同一约定)
unset($sdHint, $sdSlot, $sdMulti, $sdAccept, $sdField);
+15 -8
View File
@@ -36,16 +36,23 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
<div class="field">
<label>提醒</label>
<?php /* 不提醒 = NULL(导出 ics 时不写 VALARM);档位就是用户第 6 条列的那些。 */ ?>
<select class="select" name="reminder_minutes" aria-label="提前多久提醒">
<?php $cur = $evF['reminder_minutes'] ?? null; ?>
<option value=""<?= $cur === null || $cur === '' ? ' selected' : '' ?>>不提醒</option>
<?php /* v24 第 20 批 第 8 条:**多选**(用户原话:「一个日程,我可以选择让它
5分钟提醒,10分钟提醒等等。」)。
⚠️ 用 checkbox 而不是 `<select multiple>`:后者在手机上要"按住再点",
几乎没法用(任务共享那个 multiple 下拉就是因此被换掉的)。
⚠️ 字段名必须是 `reminder_minutes[]` —— Calendar::reminderMinutes() 认得数组。
写成 `reminder_minutes`(不带 [])会让 PHP 只留最后一个勾选。 */
$curReminders = \App\Calendar::reminderList($evF['reminder_minutes'] ?? null); ?>
<div class="rem-picks">
<?php foreach (\App\Calendar::REMINDERS as $mins => $word): ?>
<option value="<?= (int)$mins ?>"<?= (string)$cur === (string)$mins ? ' selected' : '' ?>>
<?= e($word) ?>
</option>
<label class="chk-inline rem-pick">
<input type="checkbox" name="reminder_minutes[]" value="<?= (int)$mins ?>"
<?= in_array((int)$mins, $curReminders, true) ? 'checked' : '' ?>>
<span><?= e($word) ?></span>
</label>
<?php endforeach; ?>
</select>
</div>
<span class="tip">可以选多个(到点各响一次);一个都不选就是不提醒。</span>
</div>
<?php /* v24 第 5、6 条:起止各一个「日期 + 时分秒」合在一起的控件(全局统一),
+50 -5
View File
@@ -33,10 +33,11 @@ $canWrite = \App\Auth::canWrite() && !empty($ev['_can_write']);
<?php if (!empty($ev['location'])): ?>
<div class="kv-row"><dt>地点</dt><dd><?= e((string)$ev['location']) ?></dd></div>
<?php endif; ?>
<?php if (($ev['reminder_minutes'] ?? null) !== null): ?>
<div class="kv-row"><dt>提醒</dt>
<dd><?= e(\App\Calendar::REMINDERS[(int)$ev['reminder_minutes']] ?? '') ?></dd>
</div>
<?php /* v24 第 20 批 第 8 条:提醒可以**多档**,所以要列全("提前 5 分钟、提前 1 小时")。
取的是同一个逗号串,Calendar::reminderWords() 负责翻成人话。 */ ?>
<?php $rvWords = \App\Calendar::reminderWords($ev['reminder_minutes'] ?? null); ?>
<?php if ($rvWords !== ''): ?>
<div class="kv-row"><dt>提醒</dt><dd><?= e($rvWords) ?></dd></div>
<?php endif; ?>
<?php if (!empty($ev['note'])): ?>
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e((string)$ev['note'])) ?></dd></div>
@@ -116,13 +117,57 @@ $canWrite = \App\Auth::canWrite() && !empty($ev['_can_write']);
</div>
<?php endif; ?>
<?php /* ⚠️ 删除表单必须放在上面那个表单**外面**:<form> 套 <form> 是非法 HTML,
浏览器会把外层表单在嵌套处切开,"保存"按钮就失效了。 */ ?>
浏览器会把外层表单在切开处把外层提前关掉,"保存"按钮就失效了。 */ ?>
<?php
/* ==================== 删除(v24 第 20 批 用户第 7 条)====================
用户原话:「对于重复的日程,当点击删除的时候,应该提示是删除这一次,
还是全部都删除,还是删除这一次及以后的。」
⚠️ 用**原生 radio + 提交**而不是 JS 弹框:不用 JS 也能选范围,
而且"删哪一档"这件事必须让人看清文字再点(三种后果差别很大,手滑代价高)。
⚠️ 只有**重复日程**才给这三档。不重复的给三档会让人以为"是不是还有别的次数"
(其实就一条),反而制造困惑 —— 那种情况只给一个「删除」。
⚠️ `occ`(点开的是哪一天)必须带上:「只删这一次」「这一次及以后」都靠它定位。
从日历某一天点进来才有它(URL 的 ?d=);从列表直接打开就只给"全部删除"。
⚠️ 三档的措辞与**编辑**那三档保持一致(改全部 / 改这一次及以后 / 只改这一次),
用户学一次就够,不用记第二套说法。
*/
$evIsRepeat = trim((string)($ev['rrule'] ?? '')) !== '';
?>
<?php if ($evIsRepeat && !empty($occ)): ?>
<form method="post" action="<?= e(url('calendar/event_del')) ?>" class="ev-del ev-del-scope"
onsubmit="return confirm('按上面选的范围删除?')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
<input type="hidden" name="ym" value="<?= e($ym) ?>">
<input type="hidden" name="occ" value="<?= e($occ) ?>">
<p class="ev-del-head">这是一条<b>重复日程</b>,删哪些?</p>
<div class="ev-del-picks">
<label class="chk-inline">
<input type="radio" name="scope" value="once">
<span>只删<b>这一次</b>(<?= e($occ) ?>)</span>
</label>
<label class="chk-inline">
<input type="radio" name="scope" value="future">
<span>删<b>这一次及以后</b>(<?= e($occ) ?> 起,之前的留着)</span>
</label>
<label class="chk-inline">
<input type="radio" name="scope" value="all" checked>
<span>删<b>全部</b>(整条系列)</span>
</label>
</div>
<button class="btn btn-danger btn-sm" type="submit">删除</button>
</form>
<?php else: ?>
<form method="post" action="<?= e(url('calendar/event_del')) ?>" class="ev-del"
onsubmit="return confirm('删除这条日程?')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
<input type="hidden" name="ym" value="<?= e($ym) ?>">
<?php if (!empty($occ)): ?><input type="hidden" name="occ" value="<?= e($occ) ?>"><?php endif; ?>
<input type="hidden" name="scope" value="all">
<button class="btn btn-danger btn-sm" type="submit">删除这条日程</button>
</form>
<?php endif; ?>
<?php endif; ?>
</div>
+18
View File
@@ -215,6 +215,24 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
<?php for ($h = 0; $h < 24; $h++): ?>
<span class="tg-line"></span>
<?php endfor; ?>
<?php /* ==================== 当前时间红线(v24 第 20 批 用户第 6 条)====================
用户原话:「日程模块,周视图和日视图,应该在视图中间显示一条红线,
用来表示当前进行的时间,就是说,用一个不断从上到下的红线,
来表示当前时间在视图上的刻度和位置。」
⚠️ 只在**今天那一列**画(别的日子没有"现在"可言)。
⚠️ 位置是服务端算好的百分比(与上面 .tg-block 用的是同一套:
列高 = 24h,所以 1 分钟 = 100/1440 %)。**不在 CSS 里写死**
某个时间 —— 那就成了"固定的一条线",不叫"当前时间"。
⚠️ 线的**移动**由 app.js 里的一分钟定时器负责(`initNowLines()`):
服务端给的是"渲染那一刻"的位置,用户开着页面不走,
过一小时线还停在那儿就荒谬了。
⚠️ 全天日程的"全天"那一行**不画** —— 那条线表达的是"一天之内的时间刻度",
画在全天行上会让人以为全天日程跟时间有关。 */ ?>
<?php if ($c['is_today'] && !empty($nowTop)): ?>
<span class="tg-now" data-now-line style="top: <?= (float)$nowTop ?>%;"
title="现在 <?= e(date('H:i')) ?>"></span>
<?php endif; ?>
<?php foreach (($tgrid['timed'][$i] ?? []) as $ev): ?>
<a class="tg-block" data-cal="<?= (int)$ev['cal'] ?>"
style="background: <?= e((string)$ev['color']) ?>; top: <?= (float)$ev['top'] ?>%; height: <?= (float)$ev['height'] ?>%;"
+17 -1
View File
@@ -146,7 +146,23 @@ $isLoggedIn = \App\Auth::check();
</div>
<script src="<?= e(asset('/assets/js/app.js')) ?>"></script>
<script src="<?= e(asset('/assets/js/person_picker.js')) ?>"></script>
<?php /* v24 第 20 批:选人组件脚本改成**按需加载**(页面上真有 `.pp` 才拉)。
⚠️ 理由与 Cropper.js 同一套:`person_picker.js` 只在放选人组件的页面才有用,
而它并不小;每页都无条件带一份,等于让"没有选人组件"的页面白付流量。
⚠️ 判据用 `document.querySelector('.pp[data-pp]')`(组件根节点的真实标记),
**不是**含糊地找 "pp" 这两个字母(页面里 `pp` 太容易撞:分页、其它类名…)。
⚠️ DOM 必须已经解析到组件才判断 → 脚本放在 `</body>` 前(本文件本来就是),
所以这里能直接查;不需要 DOMContentLoaded 包一层。
⚠️ 无 JS 兜底不受影响:组件的 `<noscript>` 块本来就是给"脚本没跑"准备的。 */ ?>
<script>
(function () {
if (!document.querySelector('.pp[data-pp]')) { return; }
var s = document.createElement('script');
s.src = <?= json_encode(asset('/assets/js/person_picker.js')) ?>;
s.defer = true;
document.head.appendChild(s);
})();
</script>
<script src="<?= e(asset('/assets/js/filter.js')) ?>"></script>
<script src="<?= e(asset('/assets/js/preview.js')) ?>"></script>
</body>
+109 -10
View File
@@ -164,29 +164,99 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
<textarea class="input" id="note" name="note" rows="2"><?= e(old('note', $m['note'] ?? '')) ?></textarea>
</div>
<?php
/**
* v24 第 20 批 第 1 条:编辑页把**已经存过的**海报 / 票根 / 现场合影**显示出来**。
*
* 用户原话:「观影模块,编辑观影,已经保存的海报和票根、合影,都没带进来。」
* ⚠️ 原来这里只有两句灰字提示("已有海报,不选新图就保留原图。")——
* 也就是说**图上你根本看不见图**:想确认"当时选的是哪张"、或想换掉它,
* 都得先回到详情页看一眼。用户要的是"带进来",即**看得见**。
*
* 做法:能预览的(图片)给缩略图 + 删除;不可预览的给一个下载入口。
* ⚠️ 换图仍然靠"选新文件"(服务端换新图时会顺手删掉旧对象,见 index.php movies/update),
* 所以这里**不需要**"确认换这张"之类的中间态。
* ⚠️ 每一块的删除按钮都关联到一个**独立的表单** —— 它们必须在**外层表单之外**
* (form 嵌套会让外层表单提前关闭,"保存"键就失效了,第 20 批 第 3 条刚栽过)。
* 按钮留在原位、用 HTML5 的 `form="id"` 关联到外面那个表单。
*/
$mHasPoster = $isEdit && !empty($m['poster_key']);
$mHasTicket = $isEdit && !empty($m['ticket_key']);
$mPhotos = $isEdit ? (array)($m['photos'] ?? []) : [];
$mDelForms = []; // 收集起来,在表单**外面**统一渲染
?>
<div class="form-grid" style="margin-top:4px">
<?php /* ---- 海报 ---- */ ?>
<div class="field">
<label for="poster">海报</label>
<input class="input" id="poster" name="poster" type="file" accept="image/*">
<?php if ($isEdit && !empty($m['poster_key'])): ?>
<span class="tip">已有海报,不选新图就保留原图。</span>
<?php if ($mHasPoster): ?>
<?php $mDelForms['poster'] = 'movies/media_del'; ?>
<div class="mv-slot">
<a href="<?= e(media_url((string)$m['poster_key'])) ?>" data-prev="image"
data-src="<?= e(media_url((string)$m['poster_key'])) ?>"
data-dl="<?= e(media_url((string)$m['poster_key'], ['dl' => 1])) ?>"
data-name="海报">
<img src="<?= e(media_url((string)$m['poster_key'])) ?>" alt="海报">
</a>
<button class="btn btn-ghost btn-sm" type="submit" form="mv-del-poster"
onclick="return confirm('删掉现在这张海报?')">删除这张</button>
</div>
<?php endif; ?>
<input class="input" id="poster" name="poster" type="file" accept="image/*">
<span class="tip"><?= $mHasPoster ? '想换就选一张新的(旧的会自动删掉)。' : '还没有海报。' ?></span>
<?php $sdSlot = 'poster'; require __DIR__ . '/../_stage_drop.php'; ?>
</div>
<?php /* ---- 票根 ---- */ ?>
<div class="field">
<label for="ticket">票根</label>
<input class="input" id="ticket" name="ticket" type="file" accept="image/*">
<?php if ($isEdit && !empty($m['ticket_key'])): ?>
<span class="tip">已有票根,不选新图就保留原图。</span>
<?php if ($mHasTicket): ?>
<div class="mv-slot">
<a href="<?= e(media_url((string)$m['ticket_key'])) ?>" data-prev="image"
data-src="<?= e(media_url((string)$m['ticket_key'])) ?>"
data-dl="<?= e(media_url((string)$m['ticket_key'], ['dl' => 1])) ?>"
data-name="票根">
<img src="<?= e(media_url((string)$m['ticket_key'])) ?>" alt="票根">
</a>
<button class="btn btn-ghost btn-sm" type="submit" form="mv-del-ticket"
onclick="return confirm('删掉现在这张票根?')">删除这张</button>
</div>
<?php endif; ?>
<input class="input" id="ticket" name="ticket" type="file" accept="image/*">
<span class="tip"><?= $mHasTicket ? '想换就选一张新的(旧的会自动删掉)。' : '还没有票根。' ?></span>
<?php $sdSlot = 'ticket'; require __DIR__ . '/../_stage_drop.php'; ?>
</div>
</div>
<?php if (!$isEdit): ?>
<?php /* ---- 现场合影(用户第 1 条点名的三样之一)----
新增时可以一次多选;**编辑时在这里逐张看、逐张删**(与新选的图合并保存)。*/ ?>
<div class="field full">
<label for="photo">现场照片(可多选)</label>
<input class="input" id="photo" name="photo[]" type="file" accept="image/*" multiple>
<span class="tip">之后也可以在详情页里逐张补传。</span>
<label for="photo">现场合影</label>
<?php if ($mPhotos): ?>
<div class="photo-grid">
<?php foreach ($mPhotos as $mp): $mpId = (int)$mp['id']; ?>
<figure class="photo-cell">
<a href="<?= e(media_url((string)$mp['key'])) ?>" data-prev="image"
data-src="<?= e(media_url((string)$mp['key'])) ?>"
data-dl="<?= e(media_url((string)$mp['key'], ['dl' => 1])) ?>"
data-name="现场照片">
<img src="<?= e(media_url((string)$mp['key'])) ?>" alt="">
</a>
<button class="btn btn-ghost btn-sm" type="submit" form="mv-del-photo-<?= $mpId ?>"
onclick="return confirm('删掉这张照片?')">删除</button>
</figure>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($isEdit): ?>
<input class="input" id="photo" name="photo[]" type="file" accept="image/*" multiple>
<span class="tip">可以继续加(可多选);删掉的在保存后生效。</span>
<?php $sdSlot = 'photo'; $sdHint = '也可以把文件直接拖到这里 —— 拖一个就开始上传,可以接着再拖,最后点「保存」一起记下。'; require __DIR__ . '/../_stage_drop.php'; ?>
<?php else: ?>
<input class="input" id="photo" name="photo[]" type="file" accept="image/*" multiple>
<span class="tip">可以多选;之后也能在详情页里逐张补传。</span>
<?php $sdSlot = 'photo'; $sdHint = '也可以把文件直接拖到这里 —— 拖一个就开始上传,可以接着再拖,最后点「记下这场电影」一起记下。'; require __DIR__ . '/../_stage_drop.php'; ?>
<?php endif; ?>
</div>
<div class="btn-row">
@@ -197,6 +267,35 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
</div>
</form>
<?php /* ---- 删除海报 / 票根 / 合影的**独立表单**(必须在外层表单之外)----
⚠️ 见本文件顶部关于 form 嵌套的说明:按钮用 `form="id"` 指过来,按钮位置不变。
⚠️ 关联表单要自带全部字段(csrf / 记录 id / 照片 id)—— 提交的是**它自己**的字段。 */ ?>
<?php if ($mHasPoster): ?>
<form id="mv-del-poster" method="post" action="<?= e(url('movies/media_del')) ?>" class="form-outside">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="slot" value="poster">
</form>
<?php endif; ?>
<?php if ($mHasTicket): ?>
<form id="mv-del-ticket" method="post" action="<?= e(url('movies/media_del')) ?>" class="form-outside">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="slot" value="ticket">
</form>
<?php endif; ?>
<?php foreach ($mPhotos as $mp): ?>
<form id="mv-del-photo-<?= (int)$mp['id'] ?>" method="post"
action="<?= e(url('movies/photo_del')) ?>" class="form-outside">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="photo_id" value="<?= (int)$mp['id'] ?>">
<?php /* 从编辑页删的 → 删完回编辑页(见 index.php movies/photo_del 的说明) */ ?>
<input type="hidden" name="back" value="edit">
</form>
<?php endforeach; ?>
<?php unset($mHasPoster, $mHasTicket, $mPhotos, $mDelForms, $mp, $mpId); ?>
<?php /* ---- 新建影院:弹框(不跳转,见上)---- */ ?>
<div class="cn-modal" id="cinemaNewModal" hidden>
<div class="cn-mask" data-cn-close></div>
+1
View File
@@ -101,6 +101,7 @@ $canWrite = \App\Auth::canWrite();
<div class="field full">
<label for="rs-file">附件(可多选)</label>
<input class="input" type="file" id="rs-file" name="attach[]" multiple>
<?php $sdSlot = 'photo'; $sdHint = '也可以把文件直接拖到这里 —— 拖一个就开始上传,可以接着再拖,最后点「保存」/「新建」一起记下。'; require __DIR__ . '/../_stage_drop.php'; ?>
</div>
</div>
<div class="btn-row">
+21 -22
View File
@@ -4,12 +4,23 @@
*
* 与「系统设置」(?r=settings,仅管理员)的分工:
* · 系统设置改一次**全家都变**:slogan 兜底、附件存储、运行环境、**公共分组**;
* · 个人设置只影响**我自己看到的样子**:我怎么称呼家人、我自己的分组。
* · 个人设置只影响**我自己看到的样子**:我自己的分组、天气地点、首页与导航的模块。
*
* ⚠️ 「我的资料」表单**不在这页**(用户明确要求合并):
* 「编辑成员」和「改自己的资料」本来就是一件事,所以只剩
* `templates/people/form.php` 那一张表单,两个入口都指过去 ——
* 这页顶部那个按钮,和名册卡片上的「编辑资料」,落到的完全是同一个页面。
* `templates/people/form.php` 那一张表单,两个入口都指过去。
*
* ────────────────────────────────────────────────────────────────
* v24 第 20 批 第 2 条(用户明确"不要了"的三处,都已删除):
* ① 页头那句副标题「只影响你自己看到的样子」—— **删掉**(用户点名);
* ② 「我怎么称呼家人」整张卡片(templates/settings/_nicknames.php)
* —— **不再加载**。⚠️ 「我叫他什么」这个能力本身还在(名册/详情页里照常出现、
* `person_name()` 照常按昵称显示);只是不再在这页单独占一张卡。
* 所以是"撤掉入口"而不是"删功能",数据一个字没动。
* ③ 「我的资料」卡片(姓名/头像/生日那句 + 两个按钮)—— **删掉**。
* ⚠️ 改资料的入口在别处都还有:右上角头像下拉 →「个人设置」进来后有页头那个
* 「编辑我的资料」按钮,名册与详情页也各有「编辑资料」;所以不会"进不去"。
* 页头那个按钮**保留**(它就是"我的资料"现在唯一的入口,删了就真没地方点了)。
*/
$groupCounts = $groupCounts ?? [];
$groupTotal = count($groups);
@@ -17,7 +28,7 @@ $rosterCount = $rosterCount ?? 0;
?>
<div class="page-head">
<h1>个人设置</h1>
<span class="sub">只影响你自己看到的样子</span>
<?php /* v24 第 20 批 第 2 条:副标题「只影响你自己看到的样子」按用户要求删除 */ ?>
<div class="actions">
<a class="btn btn-sm" href="<?= e(url('people/edit', ['id' => (int)$person['id']])) ?>">编辑我的资料</a>
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/show', ['id' => (int)$person['id']])) ?>">看我的详情页</a>
@@ -27,24 +38,10 @@ $rosterCount = $rosterCount ?? 0;
</div>
</div>
<div class="card card-pad">
<h2 class="section-title">我的资料 <span class="hint">姓名、头像、生日、联系方式</span></h2>
<p class="small muted" style="margin:0 0 14px">
<?= e((string)$person['name']) ?>
<?php if (trim((string)($person['slogan'] ?? '')) !== ''): ?>
· 「<?= e((string)$person['slogan']) ?>」
<?php endif; ?>
<br>
资料的编辑表单只有一张,和「编辑成员」是同一个页面 —— 从这里进去改的就是你自己。
</p>
<div class="btn-row" style="margin-top:0">
<a class="btn" href="<?= e(url('people/edit', ['id' => (int)$person['id']])) ?>">编辑我的资料</a>
<a class="btn btn-ghost" href="<?= e(url('auth/password')) ?>">改登录密码</a>
</div>
</div>
<div class="stack-gap"></div>
<?php require __DIR__ . '/_nicknames.php'; ?>
<?php /* v24 第 20 批 第 2 条:原来这里有两块,用户要求都删掉 ——
① 「我的资料」卡片(姓名 + slogan + 「编辑我的资料 / 改登录密码」两个按钮)
② 「我怎么称呼家人」卡片(require _nicknames.php)
下面直接接天气地点。改登录密码在右上角头像下拉里也有入口。 */ ?>
<!-- ==================== 天气地点(用户 2026-10-03 第 1 条)==================== -->
<?php /* 原话:「天气这个这样吧,让用户在个人设置中填写自己的城市。」
@@ -54,6 +51,8 @@ $rosterCount = $rosterCount ?? 0;
<div class="card card-pad" id="weather" style="margin-top:20px">
<h2 class="section-title">
天气 · 我所在的城市
<?php /* v24 第 20 批 第 2 条:这里的 "只影响你自己首页上的那一格" 保留 ——
用户点名要删的是页头那句副标题,不是这一处。 */ ?>
<span class="hint">只影响你自己首页上的那一格</span>
</h2>
<p class="small muted" style="margin:0 0 16px">
+1
View File
@@ -259,6 +259,7 @@ $isChild = !empty($t['parent_id']);
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<input type="file" name="attach[]" multiple required>
<button class="btn btn-sm" type="submit">上传附件</button>
<?php $sdSlot = 'photo'; $sdHint = '也可以把文件直接拖到这里 —— 拖一个就开始上传,可以接着再拖,最后点「上传附件」一起记下。'; require __DIR__ . '/../_stage_drop.php'; ?>
</form>
<?php endif; ?>
</div>
+54 -19
View File
@@ -191,21 +191,23 @@ $amap = \App\Geo::jsConfig();
value="<?= e(old('trip_no', $n['trip_no'] ?? '')) ?>" placeholder="如:G1234、CA1234">
<span class="tip">座位这类同团不同座的信息就不记了。</span>
</div>
<?php /* 线路名与承运方:火车/飞机时最有意义(「京沪高铁」「中国铁路」),
也允许其他方式填(比如「轮渡 5 号线」)—— 所以不按方式隐藏。 */ ?>
<div class="field">
<label for="nf-line">线路名</label>
<input class="input" id="nf-line" name="line_name"
value="<?= e(old('line_name', $n['line_name'] ?? '')) ?>" placeholder="如:京沪高铁、地铁 3 号线">
</div>
<div class="field">
<label for="nf-carrier">承运方</label>
<input class="input" id="nf-carrier" name="carrier"
value="<?= e(old('carrier', $n['carrier'] ?? '')) ?>" placeholder="如:中国铁路、国航">
</div>
<?php /* 线路名与承运方:v24 第 20 批 第 5 条 用户明确「都不要了」——
原话:「添加行程,线路名、承运方,都不要了。」
⚠️ 数据库那两列**保留**(`line_name` / `carrier`):TripLog 迁过来的
老数据还在里面(第 19 批补迁又填了一批),删列不可逆。
这里只是**界面不再收/不再显示**;saveNode 收到的空值会
保持原值不动(见 Trip::saveNode 里对这两列的说明)。 */ ?>
<?php /* ---- 出发地 / 到达地:高德联想 + 详细地址(用户第 17 批 第 3 条)----
用户原话:「出发地和到达地,都用高德地图的接口,记录详细地址」。 */ ?>
用户原话:「出发地和到达地,都用高德地图的接口,记录详细地址」。
v24 第 20 批 第 5 条:「出发地和到达地,应该在同一行。」
⚠️ 做法:把两个 .field 包进一个 `.loc-row`(`grid-column: 1/-1` 占满整行),
行内再分两列 —— 不能只把两个 field 相邻放着"指望它们拼一行":
`.form-grid` 是两列网格,每个 .field 各占一格,
两格之间会有 gap、且窄屏折行后顺序会乱。
包一层显式的一行两列,宽窄屏行为都可预期。 */ ?>
<div class="field full">
<div class="loc-row">
<div class="field">
<?php $locPrefix = 'from'; $locLabel = '出发地'; $locIcon = '🚩';
$locPlaceholder = '输入地址,如「家里」「青岛北站」'; $locRow = $fromRow;
@@ -216,6 +218,8 @@ $amap = \App\Geo::jsConfig();
$locPlaceholder = '输入地址,如「青岛北站」「栈桥」'; $locRow = $toRow;
require __DIR__ . '/_loc_field.php'; ?>
</div>
</div>
</div>
<div class="field">
<label for="nf-status">执行状态</label>
@@ -257,12 +261,28 @@ $amap = \App\Geo::jsConfig();
<?php endif; ?>
</div>
<?php /* ---- 照片(用户第 16 批 第 3 条:图片视频都针对一次具体行程)---- */ ?>
<?php /* ---- 照片(用户第 16 批 第 3 条:图片视频都针对一次具体行程)----
🔴 v24 第 20 批 第 3 条修的是这里:用户报「编辑行程,点击保存没有反应」。
根因是**这里原来每个缩略图上套了一个 `<form>`**,而整块又在外面那个
行程表单**里面** —— HTML 不允许 form 嵌套:浏览器解析到内层 `</form>` 时
会把**外层表单提前关掉**,于是紧随其后的「保存」按钮**掉出了表单**
(真机实测 `button.form === null`),点了自然毫无反应。
⚠️ 源码里完全看不出来(标签看着都对),只有真浏览器量 `btn.form` 才现形;
而且**只有这段行程已经有照片时**才触发 —— 所以"有时候好、有时候坏"。
修法:用 HTML5 的 `form="id"` 属性。删除按钮**留在原位**(外观、布局都不变),
但把它**关联到页面末尾那个独立表单**上 —— 不需要真的套一层 <form>,
也就不会破坏外层表单。(比"把 <form> 搬到外面再用绝对定位摆回去"稳得多:
那种做法一旦父容器有 transform/filter,定位元素会改锚点、按钮就飘了。)
⚠️ 每个缩略图的 `photo_id` 不同 → 每张一个独立的删除表单(id 带 $ph['id'])。*/ ?>
<div class="field full">
<label for="nf-photo">这一段行程的照片</label>
<?php $delForms = []; ?>
<?php if ($isEdit && !empty($n['attachs'])): ?>
<div class="photo-grid">
<?php foreach ($n['attachs'] as $ph): ?>
<?php $phDelId = 'nf-phdel-' . (int)$ph['id'];
$delForms[] = $ph; ?>
<figure class="photo-cell">
<a href="<?= e(media_url((string)$ph['key'])) ?>" data-prev="image"
data-src="<?= e(media_url((string)$ph['key'])) ?>"
@@ -270,19 +290,16 @@ $amap = \App\Geo::jsConfig();
data-name="<?= e((string)($ph['name'] ?? '照片')) ?>">
<img src="<?= e(media_url((string)$ph['key'])) ?>" alt="">
</a>
<form method="post" action="<?= e(url('trips/photo_del')) ?>" class="photo-del">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
<input type="hidden" name="photo_id" value="<?= (int)$ph['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
form="<?= e($phDelId) ?>"
onclick="return confirm('删掉这张照片?')">删除</button>
</form>
</figure>
<?php endforeach; ?>
</div>
<?php endif; ?>
<input class="input" type="file" id="nf-photo" name="photo[]" accept="image/*" multiple>
<span class="tip">可多选;图片、视频都会存到这段行程下面。</span>
<?php $sdSlot = 'photo'; $sdHint = '也可以把文件直接拖到这里 —— 拖一个就开始上传,可以接着再拖,最后点「保存」一起记下。'; require __DIR__ . '/../_stage_drop.php'; ?>
</div>
</div>
@@ -293,6 +310,24 @@ $amap = \App\Geo::jsConfig();
</form>
</div>
<?php /* ---- 删除照片用的**独立表单**(在外层行程表单之外)----
见上面 photo-grid 的说明:用 `form="id"` 把按钮关联到这里,
既保住"不嵌套",又保住按钮的视觉位置。
⚠️ 关联表单必须**自带全部字段**(csrf / 宿主的 id / photo_id)——
按 `form=` 关联时,浏览器提交的是**那个表单自己的**字段,
不会把外层表单的字段带过去(少了 csrf 会被 csrf_check 挡下,
少了 id 会定位不到旅程)。所以这里逐个写全。
⚠️ 平时是全空的(没有照片就没有这些表单)。 */ ?>
<?php foreach (($delForms ?? []) as $ph): ?>
<form id="nf-phdel-<?= (int)$ph['id'] ?>" method="post"
action="<?= e(url('trips/photo_del')) ?>" class="form-outside">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
<input type="hidden" name="photo_id" value="<?= (int)$ph['id'] ?>">
</form>
<?php endforeach; ?>
<?php unset($delForms); ?>
<?php /* ==================== 花销(只在编辑时出现)====================
用户第 2 条要"一共花了多少钱"、第 16 批确认「行程级花销记录」。
⚠️ 新增时不给花销面板:那时行程 id 还不存在,花销挂不上(先存行程再加花销,
+122
View File
@@ -0,0 +1,122 @@
<?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'],
]);