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 条修的是这里:用户又报了一次「车辆,只在出行方式选择自驾时 + 才出现,其他情况下,默认不出现」—— 说明**上一批的隐藏根本没生效**。 + 根因:模板里写的是 `
地点
- -
提醒
-
-
+ + + +
提醒
备注
@@ -116,13 +117,57 @@ $canWrite = \App\Auth::canWrite() && !empty($ev['_can_write']); 套
是非法 HTML, - 浏览器会把外层表单在嵌套处切开,"保存"按钮就失效了。 */ ?> - - - - - -
+ 浏览器会把外层表单在切开处把外层提前关掉,"保存"按钮就失效了。 */ ?> + + +
+ + + + +

这是一条重复日程,删哪些?

+
+ + + +
+ +
+ +
+ + + + + + +
+ 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 兜底不受影响:组件的 `