diff --git a/app/Calendar.php b/app/Calendar.php
index c397869..e12037b 100644
--- a/app/Calendar.php
+++ b/app/Calendar.php
@@ -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
diff --git a/app/Ics.php b/app/Ics.php
index 0c6e6dd..c8be9f9 100644
--- a/app/Ics.php
+++ b/app/Ics.php
@@ -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',
]);
}
diff --git a/app/Schema.php b/app/Schema.php
index 05d7797..ac94d4b 100644
--- a/app/Schema.php
+++ b/app/Schema.php
@@ -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',
diff --git a/app/StagedUpload.php b/app/StagedUpload.php
new file mode 100644
index 0000000..c14285a
--- /dev/null
+++ b/app/StagedUpload.php
@@ -0,0 +1,323 @@
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/app/Trip.php b/app/Trip.php
index fd8a34c..da354f2 100644
--- a/app/Trip.php
+++ b/app/Trip.php
@@ -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;
diff --git a/assets/css/app.css b/assets/css/app.css
index 7601638..6086b2c 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -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 条修的是这里:用户又报了一次「车辆,只在出行方式选择自驾时
+ 才出现,其他情况下,默认不出现」—— 说明**上一批的隐藏根本没生效**。
+ 根因:模板里写的是 `
`,
+ 而 `.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; }
diff --git a/assets/js/app.js b/assets/js/app.js
index 195111f..0b77998 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -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 塞进隐藏字段 `
`——
+ 表单提交时这些令牌就跟着走了,业务路由在 attach_save_files 里**认领**;
+ ③ 立刻画一张缩略图(指向 `media.php?stage=
`),
+ 让用户看得见"这一张已经上去了"。
+
+ ⚠️ 三个必须踩准的点(都是"看着能跑、实际会丢"的坑):
+ · **队列要串行**:用户一次拖 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(); }
+ });
+})();
diff --git a/index.php b/index.php
index 295175b..02c4442 100644
--- a/index.php
+++ b/index.php
@@ -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,21 +2348,30 @@ switch ($r) {
}
}
}
- // 现场照片(多选,仅新增表单提供;编辑页在详情页里逐张补/删)
- if (!$isEdit) {
- $photoFiles = [];
- if (!empty($_FILES['photo']) && is_array($_FILES['photo']['name'])) {
- foreach ($_FILES['photo']['name'] as $i => $_n) {
- $photoFiles[] = [
- 'name' => $_FILES['photo']['name'][$i],
- 'type' => $_FILES['photo']['type'][$i],
- 'tmp_name' => $_FILES['photo']['tmp_name'][$i],
- 'error' => $_FILES['photo']['error'][$i],
- 'size' => $_FILES['photo']['size'][$i],
- ];
- }
+ // 现场照片(多选)。
+ // ⚠️ v24 第 20 批 第 1 条:**编辑时也收**这里传上来的照片了。
+ // 以前只有新增表单给这个字段(编辑页只在详情页里逐张补),所以这里写着
+ // `if (!$isEdit)`。现在编辑页也能一次多选加几张,所以要处理"合并"而不是替换:
+ // 编辑 = 库里已有的(在编辑页逐张删过的会被 photo_del 立刻删掉,库里就是最新的)
+ // + 这次新选上来的。
+ // ⚠️ 不能沿用"整组替换":那样一编辑就会把已有的照片全冲掉(用户报的
+ // "合影没带进来"如果按替换实现,会变成更难发现的"合影丢了")。
+ $photoFiles = [];
+ if (!empty($_FILES['photo']) && is_array($_FILES['photo']['name'])) {
+ foreach ($_FILES['photo']['name'] as $i => $_n) {
+ $photoFiles[] = [
+ 'name' => $_FILES['photo']['name'][$i],
+ 'type' => $_FILES['photo']['type'][$i],
+ 'tmp_name' => $_FILES['photo']['tmp_name'][$i],
+ 'error' => $_FILES['photo']['error'][$i],
+ 'size' => $_FILES['photo']['size'][$i],
+ ];
}
- $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) {
- $cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: [];
- if (Calendar::canWrite($cal, $me)) {
- Calendar::deleteEvent($id);
- flash('ok', '日程已删除。');
- } else {
- flash('err', '这本日历你没有写权限。');
- }
+ if (!$ev) {
+ flash('err', '找不到这条日程。');
+ redirect('calendar', ['ym' => $ym]);
}
+ $cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: [];
+ 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', '日程已删除。');
redirect('calendar', ['ym' => $ym]);
break;
diff --git a/media.php b/media.php
index e3a62db..6426780 100644
--- a/media.php
+++ b/media.php
@@ -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, '/');
diff --git a/templates/_stage_drop.php b/templates/_stage_drop.php
new file mode 100644
index 0000000..a077229
--- /dev/null
+++ b/templates/_stage_drop.php
@@ -0,0 +1,46 @@
+`)仍然保留(点它一样能选文件),
+ * 拖动只是**多一种**输入方式;而且"拖到哪一块"需要一块**看得见**的区域 ——
+ * 单靠文件框本身,用户不知道该往哪儿拖。
+ *
+ * ⚠️ 落点必须能被 app.js 找回来:`data-drop-stage` 挂在 `` 上,
+ * 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[]';
+?>
+
+
-
-
地点= e((string)$ev['location']) ?>
-
- 提醒
- = e(\App\Calendar::REMINDERS[(int)$ev['reminder_minutes']] ?? '') ?>
-
+
+
+
+ 提醒= e($rvWords) ?>
备注= nl2br(e((string)$ev['note'])) ?>
@@ -116,13 +117,57 @@ $canWrite = \App\Auth::canWrite() && !empty($ev['_can_write']);
套
+ 浏览器会把外层表单在切开处把外层提前关掉,"保存"按钮就失效了。 */ ?>
+
+
+
+
+
+
diff --git a/templates/calendar/index.php b/templates/calendar/index.php
index f713322..e9a3a32 100644
--- a/templates/calendar/index.php
+++ b/templates/calendar/index.php
@@ -215,6 +215,24 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
+
+
+
+
-
+` 前(本文件本来就是),
+ 所以这里能直接查;不需要 DOMContentLoaded 包一层。
+ ⚠️ 无 JS 兜底不受影响:组件的 `