Files
FamilyBook/app/Ics.php
T
laoyang c3d00ad66a v24 第 20 批:拖动即时上传 / 观影图片带出 / 行程保存修复 / 日程红线与删除范围与多提醒 / 悬停统一(用户 9 条)
用户 9 条原话要点:
  ①「观影模块,编辑观影,已经保存的海报和票根、合影,都没带进来。」
  ②「个人设置页面,"只影响你自己看到的样子"这个提示删除,不要了。我怎么称呼家人,
     这个卡片,不要了。我的资料这个卡片,不要了。」
  ③「旅程模块,编辑行程,点击保存没有反应。」
  ④「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,拖动一个进去,
     就开始上传,还可以继续拖动,继续上传,最后统一保存即可。」
  ⑤「添加行程,线路名、承运方,都不要了。车辆,只在出行方式选择自驾时才出现,
     其他情况下默认不出现。出发地和到达地,应该在同一行。」
  ⑥「日程模块,周视图和日视图,应该在视图中间显示一条红线,用来表示当前进行的时间…
     用一个不断从上到下的红线,来表示当前时间在视图上的刻度和位置。」
  ⑦「对于重复的日程,当点击删除的时候,应该提示是删除这一次,还是全部都删除,
     还是删除这一次及以后的。」
  ⑧「日程的提醒,是可以多选的,就是说,一个日程,我可以选择让它 5 分钟提醒,
     10 分钟提醒等等。」
  ⑨「很多按钮的悬停效果非常奇怪,感觉纯粹是换了一个背景色…应该调整所有按钮的悬停效果,
     保持统一,但是不要单纯的换一个不协调的背景色。」

🔴 三个真根因(都是"看着不像 bug"的地方):
  1. **③ 点保存没反应 = `<form>` 嵌套**(HTML 非法):`trips/node_form.php` 的照片缩略图上
     套了 `<form action=photo_del>`,而整块在**外层行程表单内部**。浏览器解析到内层
     `</form>` 时**把外层表单提前关掉** → 「保存」按钮**掉出表单**(真机实测
     `button.form === null`)。源码里完全看不出,**只有真浏览器量 `btn.form` 才现形**;
     且**只有这段行程已经有照片时才触发** —— 所以表现为"有时候好、有时候坏"。
     → 按钮留在原位(布局不动),用 HTML5 `form="id"` 关联到**表单外**的独立表单。
  2. **⑤ 车辆藏不住(第 17 批就写过、一直没生效)**:`.field` 自己声明 `display:flex`,
     作者样式压过浏览器默认的 `[hidden]{display:none}` → `hidden` 形同虚设。
     **本项目第 4 次栽同一个坑**(.att-item / .pv / .pp-item 各一次)→ 补 `.field[hidden]`。
  3. **④ 拖动即时上传的卡片"看不见"**:模板给 `.stage-list` 加了 `hidden`(没人上传时
     不留空边框),JS 建卡片时忘了 `list.hidden = false` → 卡片在 DOM 里但整条清单
     `display:none`、尺寸 0×0。**HTTP 层断言全绿**(HTML 里确实有卡片),
     **只有真机量尺寸才发现** —— 由新写的真机探针抓到。

本批新增能力:
- **拖动即时上传**(第 4 条):新增 `upload_stage.php` + `app/StagedUpload.php` +
  `templates/_stage_drop.php`。拖进来 → **立刻** POST 到暂存目录 → 回 `stoken` →
  放进隐藏字段 `stage[]` → 保存时在 `attach_save_files()` 里**认领**(移文件 + 建附件行)。
  `media.php` 加 `?stage=` 预览(拖完立刻看得见缩略图)。
  ⚠️ 三个必须踩准:**串行队列**(并发会让缩略图顺序乱跳)、**提交守卫**
  (还有没传完就 preventDefault,否则"拖了但没保存上"必然发生)、**卡片清单要 un-hide**。
  ⚠️ 海报/票根走**独立字段名** `stage_poster`/`stage_ticket`(各自单槽位、要按比例重裁)。
  ⚠️ 落点**就近取**(`input.closest('.field')`):一个表单里多个上传区不能串到第一个去。
- **观影编辑带出已存图片**(第 1 条):编辑页显示海报 / 票根 / 合影 + 各自的删除入口;
  新增 `movies/media_del`(只删不换也能删,槽位走白名单);`movies/update` 收编辑页
  新选的照片时**合并**而不是替换(否则一编辑就把已有合影冲掉)。
- **行程表单**(第 5 条):去掉线路名 / 承运方(**库里的值保留**:UPDATE 时不参与,
  与 seat_info/gate 同一套);出发地/到达地包进 `.loc-row` 真同一行;车辆按自驾显隐。
- **当前时间红线**(第 6 条):位置在服务端算(与 `.tg-block` 同一套"24h = 100%"坐标),
  前端每分钟自己挪(对齐到下一分钟整再起定时器),跨午夜 reload;只画在**今天那一列**。
- **重复日程删除三档**(第 7 条):与「编辑」那三档同名同序(全部 / 这一次及以后 /
  只这一次)。「只删这一次」→ `skipOnce`(**绝不删整条系列**);「这一次及以后」→
  老系列截 UNTIL,**必须清掉 COUNT**(两者并存时 COUNT 优先,不清就截不断)。
- **提醒多选**(第 8 条):同列存逗号串(`5,15,60`);Schema 把它从 **INT 改 VARCHAR**
  (INT 存逗号串会被隐式转换,多档会静默只剩第一档);ICS 导出吐**多个 VALARM**。
- **悬停统一**(第 9 条):原来 14 种互不相干的 hover 底色 → 统一成**两套手法**:
  有底色 `filter: brightness(.92)` 同色系加深 + 轻抬升;描边白底 `--hov-veil` 中性薄雾
  + 边框微显。放在 CSS **最末**用一条兜底收口(新按钮自动继承),并把十几条旧规则
  清成"交给统一段"。`person_picker.js` 同时改为按需加载。

验证(本地全套):
- e2e **1004/0**(+21 条本批断言)· 升级 149/0 · 桌面几何 428/0 ·
  **真机探针 25/0**(连续拖两次 / 红线位置与颜色与几何 / 车辆显隐 / 出发到达同行)。
- **反向验证 5/5**:①删除按钮改回嵌套 form ②卡片清单不 un-hide ③观影编辑不渲染海报
  ④车辆隐藏失效 ⑤「只删这一次」做成删全部 —— 五种坏法全部被抓住,还原后复绿 1004/0。
  过程中还**抓到两条弱断言**(只查 DOM 有无、不查可见性/是否真渲染),已一并加强。
- 部署脚本静态断言段本地空跑 **305 条全过**。
2026-10-06 03:14:18 +08:00

408 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* iCalendar(RFC 5545)的读与写 —— 日程的 ics 导入 / 导出(用户 2026-10-02 第 7 条)。
*
* 为什么自己写而不引一个库:
* 这个应用是「零依赖」的(连 Composer 都没有),为两个函数拖进一个依赖不划算;
* 而且我们只需要 VEVENT 的一个常用子集,自己写反而能把"哪些字段往返不丢"说清楚。
*
* 三条必须遵守的 RFC 规则(踩过就懂):
* ① **行要折**:一行超过 75 个**字节**(不是字符!中文一个字 3 字节)就必须折,
* 折行用 CRLF + 一个空格开头。不折的话 iOS 日历会整条拒收。
* ② **文本要转义**:\ ; , 和换行各有转义写法,不转义会把一行正文切成好几条属性。
* ③ **全天日程的 DTSTART 是 DATE 值**(20261015),不是 DATETIME ——
* 写成 DATETIME 的话,客户端会当成"这一天的 00:00",看着一样但跨时区会错。
*
* 时区:系统里存的一律是**本地时间**(不带时区),导出也不带 Z、不写 VTIMEZONE。
* 家庭应用里"几点开会"就是墙上那个钟,硬套 UTC 反而会整体偏移。
*/
declare(strict_types=1);
namespace App;
final class Ics
{
/** 导出的产品标识(RFC 5545 PRODID,客户端会拿它判断"是谁生成的") */
private const PRODID = '-//FamilyBook//FamilyBook Calendar//CN';
/**
* 把一个日历本导出成 .ics 文本。
*
* @param array $events calendar_events 的行
* @param string $calName 日历本名字(X-WR-CALNAME,客户端用它给订阅起标题)
*/
public static function export(array $events, string $calName = '日程'): string
{
$out = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:' . self::PRODID,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:' . self::escapeText($calName),
];
foreach ($events as $e) {
$out = array_merge($out, self::vevent($e));
}
$out[] = 'END:VCALENDAR';
// 折行 + 统一 CRLF(RFC 5545 要求行尾是 CRLF,虽然多数客户端也收 LF)
$lines = [];
foreach ($out as $line) {
foreach (self::fold($line) as $l) {
$lines[] = $l;
}
}
return implode("\r\n", $lines) . "\r\n";
}
/** 一条日程 → VEVENT 的若干行 */
private static function vevent(array $e): array
{
$allDay = !empty($e['all_day']);
$starts = (string)($e['starts_at'] ?? '');
$ends = (string)($e['ends_at'] ?? '');
$uid = (string)($e['uid'] ?? '');
if ($uid === '') {
$uid = Calendar::newUid();
}
$stamp = self::stampOf($e['updated_at'] ?? $e['created_at'] ?? null);
$out = ['BEGIN:VEVENT', 'UID:' . $uid, 'DTSTAMP:' . $stamp];
if ($allDay) {
// 全天:VALUE=DATE,且 DTEND 是**第二天**(RFC:DTEND 是排他边界)
$out[] = 'DTSTART;VALUE=DATE:' . self::dateValue($starts);
$d = self::dateValue($ends !== '' ? $ends : $starts);
$out[] = 'DTEND;VALUE=DATE:' . ($ends !== '' ? self::nextDay($d) : self::nextDay($d));
} else {
$out[] = 'DTSTART:' . self::dateTimeValue($starts);
// 没有结束时刻就按开始时刻算(RFC 允许只给 DTSTART + DURATION,
// 但很多客户端对 DURATION 支持得更差,给一个等于开始的 DTEND 最稳)
$out[] = 'DTEND:' . self::dateTimeValue($ends !== '' ? $ends : $starts);
}
$out[] = 'SUMMARY:' . self::escapeText((string)($e['title'] ?? ''));
if (($e['location'] ?? '') !== '') {
$out[] = 'LOCATION:' . self::escapeText((string)$e['location']);
}
if (($e['note'] ?? '') !== '') {
$out[] = 'DESCRIPTION:' . self::escapeText((string)$e['note']);
}
if (($e['rrule'] ?? '') !== '') {
$out[] = (string)$e['rrule']; // 原样带过(导入时存的就是原文)
}
$out[] = 'STATUS:' . strtoupper((string)($e['status'] ?: 'confirmed'));
$out[] = 'TRANSP:' . (empty($e['transp']) ? 'OPAQUE' : 'TRANSPARENT');
$out[] = 'SEQUENCE:' . (int)($e['sequence'] ?? 0);
if (!empty($e['created_at'])) {
$out[] = 'CREATED:' . self::stampOf($e['created_at']);
}
if (!empty($e['updated_at'])) {
$out[] = 'LAST-MODIFIED:' . self::stampOf($e['updated_at']);
}
// 提醒 = 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($rvMin),
'END:VALARM',
]);
}
$out[] = 'END:VEVENT';
return $out;
}
/**
* 解析一个 .ics 文本,返回里面的 VEVENT。
*
* @return array<int, array{uid:string,title:string,starts_at:string,ends_at:string,
* all_day:bool,location:?string,note:?string,
* reminder_minutes:?int,status:string,rrule:?string}>
*/
public static function parse(string $text): array
{
$text = str_replace(["\r\n", "\r"], "\n", $text);
// ① 反折行:以空格或制表符开头的行是上一行的续行
$text = preg_replace("/\n[ \t]/", '', $text) ?? $text;
$lines = explode("\n", $text);
$out = [];
$cur = null;
$inAlarm = false;
foreach ($lines as $raw) {
$line = trim($raw);
if ($line === '') {
continue;
}
if ($line === 'BEGIN:VEVENT') {
$cur = [];
$inAlarm = false;
continue;
}
if ($line === 'END:VEVENT') {
if ($cur !== null) {
$out[] = self::normalizeVevent($cur);
}
$cur = null;
$inAlarm = false;
continue;
}
if ($cur === null) {
continue; // VCALENDAR / VTIMEZONE 里的东西一律跳过
}
if ($line === 'BEGIN:VALARM') { $inAlarm = true; continue; }
if ($line === 'END:VALARM') { $inAlarm = false; continue; }
[$name, $params, $value] = self::splitProp($line);
if ($name === '') {
continue;
}
// VALARM 里只认 TRIGGER(提前多久);DESCRIPTION 那些是给闹钟弹窗用的,
// 混进来会覆盖日程本身的备注(实测:iOS 导出的 VALARM 里也带 DESCRIPTION)。
if ($inAlarm) {
if ($name === 'TRIGGER') {
$cur['_trigger'] = $value;
}
continue;
}
// 同名属性出现多次时保留第一个(RFC 里多数属性都是单值的)
if (!array_key_exists($name, $cur)) {
$cur[$name] = $value;
}
if (!array_key_exists($name . '_PARAMS', $cur)) {
$cur[$name . '_PARAMS'] = $params;
}
}
return $out;
}
/** 把解析出来的属性归一成我们要落库的形状 */
private static function normalizeVevent(array $p): array
{
$startRaw = (string)($p['DTSTART'] ?? '');
$endRaw = (string)($p['DTEND'] ?? '');
$params = (string)($p['DTSTART_PARAMS'] ?? '');
// 全天:VALUE=DATE,或者值本身就是 8 位的 YYYYMMDD
$allDay = stripos($params, 'VALUE=DATE') !== false
|| preg_match('/^\d{8}$/', $startRaw) === 1;
$starts = $allDay ? self::fromDateValue($startRaw) : self::fromDateTimeValue($startRaw);
$ends = $endRaw !== ''
? ($allDay ? self::fromDateValue($endRaw) : self::fromDateTimeValue($endRaw))
: null;
// 全天日程的 DTEND 是**排他**的第二天,减回一天才对得上"到哪天为止"
if ($allDay && $ends !== null && $starts !== null) {
$d = strtotime($ends . ' -1 day');
$ends = $d ? date('Y-m-d', $d) . ' 00:00:00' : null;
}
return [
'uid' => (string)($p['UID'] ?? ''),
'title' => self::unescapeText((string)($p['SUMMARY'] ?? '')),
'starts_at' => $starts,
'ends_at' => $ends,
'all_day' => $allDay,
'location' => self::nullOr(self::unescapeText((string)($p['LOCATION'] ?? ''))),
'note' => self::nullOr(self::unescapeText((string)($p['DESCRIPTION'] ?? ''))),
'rrule' => self::nullOr((string)($p['RRULE'] ?? '')),
'status' => strtolower((string)($p['STATUS'] ?? '')) ?: 'confirmed',
'reminder_minutes' => self::triggerMinutes((string)($p['_trigger'] ?? '')),
];
}
/** TRIGGER:-PT15M / TRIGGER:-P1D / TRIGGER;VALUE=DATE-TIME:... → 分钟数 */
private static function triggerMinutes(string $v): ?int
{
$v = trim($v);
if ($v === '') {
return null;
}
// 绝大多数客户端导出的是"提前多久"的持续时间(负号开头)
if (preg_match('/^-?P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/', $v, $m)) {
$mins = (int)($m[1] ?? 0) * 1440;
$mins += (int)($m[2] ?? 0) * 60;
$mins += (int)($m[3] ?? 0);
$mins += (int)($m[4] ?? 0) > 0 ? 1 : 0; // 有秒就进位到 1 分钟,别归零
return $mins > 0 ? $mins : null;
}
return null;
}
/** 分钟 → RFC 5545 的持续时间,如 90 → PT1H30M */
private static function duration(int $mins): string
{
$d = intdiv($mins, 1440);
$h = intdiv($mins % 1440, 60);
$m = $mins % 60;
$s = 'P';
if ($d > 0) {
$s .= $d . 'D';
}
if ($h > 0 || $m > 0) {
$s .= 'T';
if ($h > 0) {
$s .= $h . 'H';
}
if ($m > 0) {
$s .= $m . 'M';
}
}
return $s === 'P' ? 'PT0S' : $s;
}
/** '2026-10-16 14:30:00' → '20261016T143000' */
private static function dateTimeValue(string $dt): string
{
$t = strtotime($dt);
if ($t === false) {
return '19700101T000000';
}
return date('Ymd\THis', $t);
}
/** '2026-10-16 14:30:00' → '20261016' */
private static function dateValue(string $dt): string
{
$t = strtotime($dt);
if ($t === false) {
return '19700101';
}
return date('Ymd', $t);
}
/** '20261016T143000' / '2026-10-16 14:30:00' → '2026-10-16 14:30:00' */
private static function fromDateTimeValue(string $v): ?string
{
$v = trim($v);
if (preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/', $v, $m)) {
return sprintf('%s-%s-%s %s:%s:%s', $m[1], $m[2], $m[3], $m[4], $m[5], $m[6]);
}
$t = strtotime($v);
return $t === false ? null : date('Y-m-d H:i:s', $t);
}
/** '20261016' → '2026-10-16 00:00:00' */
private static function fromDateValue(string $v): ?string
{
if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', trim($v), $m)) {
return sprintf('%s-%s-%s 00:00:00', $m[1], $m[2], $m[3]);
}
$t = strtotime($v);
return $t === false ? null : date('Y-m-d 00:00:00', $t);
}
/** '20261016' → '20261017' */
private static function nextDay(string $ymd): string
{
$t = strtotime(substr($ymd, 0, 4) . '-' . substr($ymd, 4, 2) . '-' . substr($ymd, 6, 2) . ' +1 day');
return $t === false ? $ymd : date('Ymd', $t);
}
/** 属性行 → [名, 参数串, 值](只在**第一个**未加引号的冒号处切) */
private static function splitProp(string $line): array
{
$pos = strpos($line, ':');
if ($pos === false) {
return ['', '', ''];
}
$left = substr($line, 0, $pos);
$value = substr($line, $pos + 1);
if (strpos($left, ';') === false) {
return [strtoupper($left), '', $value];
}
[$name, $params] = explode(';', $left, 2);
return [strtoupper($name), $params, $value];
}
/** DTSTAMP / CREATED 一律写成 UTC(RFC 要求带 Z) */
private static function stampOf($dt): string
{
$t = strtotime((string)$dt);
if ($t === false) {
$t = time();
}
return gmdate('Ymd\THis\Z', $t);
}
/** 文本转义(RFC 5545 3.3.11) */
private static function escapeText(string $v): string
{
$v = str_replace('\\', '\\\\', $v);
$v = str_replace(';', '\;', $v);
$v = str_replace(',', '\,', $v);
$v = str_replace(["\r\n", "\r", "\n"], '\n', $v);
return $v;
}
private static function unescapeText(string $v): string
{
$v = str_replace('\n', "\n", $v);
$v = str_replace('\N', "\n", $v);
$v = str_replace('\,', ',', $v);
$v = str_replace('\;', ';', $v);
return str_replace('\\\\', '\\', $v);
}
private static function nullOr(string $v): ?string
{
$v = trim($v);
return $v === '' ? null : $v;
}
/**
* 折行:一行最多 75 **字节**,且不能在多字节字符中间切断。
*
* ⚠️ 按字节而不是按字符数:中文一个字 3 字节,按字符数切会切出半个 UTF-8 字,
* 客户端解出来就是乱码。续行以**一个空格**开头。
*/
private static function fold(string $line): array
{
if (strlen($line) <= 75) {
return [$line];
}
$out = [];
$cur = '';
$len = 0;
$limit = 75;
$bytes = strlen($line);
$i = 0;
while ($i < $bytes) {
// 一次取一个完整 UTF-8 字符
$ch = $line[$i];
$n = 1;
$o = ord($ch);
if ($o >= 0xF0) { $n = 4; }
elseif ($o >= 0xE0) { $n = 3; }
elseif ($o >= 0xC0) { $n = 2; }
$tok = substr($line, $i, $n);
$i += $n;
// 续行要以空格开头,所以后续每行实际只能放 74 字节
$room = $out === [] ? $limit : $limit - 1;
if ($len + $n > $room) {
$out[] = $cur;
$cur = '';
$len = 0;
}
$cur .= $tok;
$len += $n;
}
if ($cur !== '') {
$out[] = $cur;
}
// 续行补前导空格
$res = [];
foreach ($out as $k => $v) {
$res[] = $k === 0 ? $v : ' ' . $v;
}
return $res;
}
}