Compare commits

..
54 Commits
Author SHA1 Message Date
laoyang 835f2846f2 取消「票根」概念,并入观影照片(数据迁移 220 条)(#87)
用户:现在不是已经没有票根的概念了吗?全部都换成观影照片了。别管是票根
还是观影照片,都在一起。现在只分两类:一类是海报,一类是观影照片。
海报是来自豆瓣的,这个不用管它,主要就是观影照片,所有的都在观影照片里边。

事实核查:概念取消了,数据没取消。网页端 v35 只在**模板层**把它们画进
同一个网格,数据层仍是两条路:票根在 movie_details.ticket_key(单张槽位),
不在 event_photos 里 ⇒ 没有 id ⇒ 删不掉,端上得写"票根另算"特判。

线上数据:有票根 220 条,其中 209 条只有票根、没有任何照片;
event_photos 总数才 14 —— 也就是说「观影照片」里装的几乎全是票根。

① 数据迁移(已执行,220/220 成功,失败 0):
   逐条把 ticket_key 插进 event_photos(position 取比现有最小值再小 10,
   ⇒ 排第一,因为票根是"这场电影的凭证",用户认知里排最前),然后置 NULL。
   event_photos 14 → 234,旧票根残留 0。
   ⚠️ 幂等:靠"该 key 已在该 event 的 event_photos 里"判断,重复跑不插重复行;
   ⚠️ 不删存储对象:只搬引用,图片原地不动,失败也不丢图。

② 同一段逻辑写进 Schema VERSION 36→37,让新库/重装库走同一套,
   不靠"记得手工跑脚本"。对线上是空操作(已验证 234→234)。

③ 服务端停止读写票根字段(字段保留作历史审计,代码不再碰):
   - Movie::find 不再 SELECT ticket_key;
   - Movie::saveDetails UPSERT 去掉 ticket_key;
   - Movie::destroy 不再单独查/删票根对象(已并入 photos 循环);
   - index.php:上传槽位 [poster,ticket] → [poster],
     stage_ticket 不再认领,media_del 白名单只剩 poster;
   - form.php:删票根特殊格子 + mv-del-ticket 表单 + $mHasTicket;
   - show.php:删票根格子 + 灯箱 ticket 项 + $hasTicket。

验证:find(1,1)/find(1,224) 已不返回 ticket_key,photos 3/4 条票根在首位;
直接 include 模板无 Fatal,form.php 里 mv-del-ticket / stage_ticket 各 0 次;
6 个文件 md5 逐个核对一致、php -l 全过、已上线且升级到 37。
2026-11-06 11:25:47 +08:00
laoyang 94a557cd2e 观影照片支持 DELETE 单删(暴露已存在的 Movie::deletePhoto)(#86)
用户:编辑观影记录的时候,那个图片应该可以被删除吧?现在没有删除按钮。

Movie::deletePhoto() 早就存在(连存储对象一起删,含视频 thumb_key),
网页端 movies/photo_del 也一直在用 —— 缺的只是 API 那一层。

- /movies/<id>/photos 从\"只 POST\"扩为 POST 追加 + DELETE 删除;
- photo_id 走 query:Api::dispatch() 只解析到 parts[2],parts[3] 会被
  静默丢弃;与 trips/<id>/nodes/attach 同一口径;
- 删除前显式校验归属(id + event_id 同查),不匹配就 404 ——
  deletePhoto 内部遇到错配是\"删 0 行 + 静默成功\",会让调用方误以为删掉了;
- poster 仍只 POST:换海报是覆盖语义,清空走 PATCH 空 poster_key。

验证:event_id 错配查询返回 false(越权被拦);临时图入库后删除,
5→4 张、库行消失、Storage::exists true→false(驱动 OSS)。
已部署,md5 核对 b50db4dfff743c53c9e4988d694b202b,php -l 通过。
2026-11-06 11:10:07 +08:00
laoyang c6e4191bb3 观影照片支持视频:转 H.264 + 抽首帧封面 (#84)
用户:确认一下观影照片是不是允许上传视频,如果不允许需要修改,
要求能上传视频、视频需要转码,参考旅程中的行程做法。

事实核查:原来不允许 —— POST /movies/<id>/photos 走 Upload::image(),
那条管线只收 JPG/PNG/GIF/WebP(要裁剪缩转运 JPEG),传视频直接拒。
旅程走 Attachment::save(),内部对 video/* 调 Video::toH264()。

Schema VERSION 35→36:
- event_photos 加 kind / thumb_key / mime 三列;
  kind 默认 image —— 老数据全是照片,默认值即正确,不需要回填 UPDATE;
- 建表语句同步改(新库直接是新结构)。

Video.php:
- 新增 poster() 抽首帧。取第 1 秒(手机视频开头常是黑场/对焦),
  宽 480 足够列表缩略;失败返回空 path 且**不阻断上传**。

ApiHandler.php 上传分支按 MIME 分流:
- 视频 → toH264 转码 + poster 抽封面 + Storage 落盘 videos/ 与 thumbs/;
  转码失败不阻断(存原视频,至少能下载后用本地播放器看);
- 图片 → 仍走 Upload::image(),裁剪压缩行为完全不变。
- 返回值加了 kind / thumb / mime,端上靠它画播放按钮。

Movie.php:
- photos() 带出 kind/thumb_key/mime;
- addPhotos() 兼容裸键数组与结构化数组,只有一条 INSERT 路径;
- deletePhoto() / destroy() 补删 thumb_key,否则删视频留孤儿封面。

验证(服务器实测):造 H.265 视频 → needsTranscode=true,toH264 成功
转成 h264/yuv420p,poster 抽出 13KB 封面;完整上传后接口返回 5 条
(4 image + 1 video 带 thumb)。迁移已跑(35→36),字段与数据核实无误,
测试视频及封面已清理,videos/ 与 thumbs/ 目录确认为空。
2026-11-06 10:53:02 +08:00
laoyang 3d745b65b2 领证纪念日实际关联 2 人;端上只读了老列所以只带出 1 个 (#91d)
用户 2026-11-05:「你去查一下这个领证纪念日到底关联了几个人,数据库里。」

查库结果:
  anniversaries #2 「领证纪念日」
    person_id = 1                    ← **老列**,只能存一个
  anniversary_people(多对多表)
    person_id=1  杨永全
    person_id=3  洪博为
    = **2 人**                        ← 真正的事实

根因链(两处都缺一不可):

① 服务端 `occurrences()` 从来没输出 `person_ids`
   `peopleMap()`(查多对多表)只在 `split()` 里用了 —— 那是**网页端**的路径;
   `occurrences()` 是 API(鸿蒙端)用的,只透传了 `person_id` 这个老列。
   ⇒ 端上永远只拿到 1 个人。
   ⇒ occurrences 开头一次性 `peopleMap(array_column($all,'id'))`,
     输出 `person_ids`(多对多表为准,为空才退回老列)。

② 端上编辑框只读 `dayDetail.personId`(单个数字)
   ⇒ 改用 `personIds.slice()`(完整列表)。
   FbRecentDay 新增 `personIds: number[]`;`parseRecentDays` 里
   "person_ids 非空就用它,否则退回 person_id"。

⚠️ 老列 `anniversaries.person_id` 是升级到多对多之前的遗留字段。
   两边不一致时**以多对多表为准**(用户在新版界面上改的就是那张表)。
⚠️ 部署时踩了一次:第一次 scp 没生效(服务端文件 grep 不到新代码),
   导致验证结果仍是空的。重新部署并 md5 比对后才确认生效。

实测(部署后):
  领证纪念日   person_ids = [1,3]  → 杨永全、洪博为 ✅
  杨永全 的生日 person_ids = [1]    (派生条目单个人)
  其余派生条目同理,各 1 人。

编译通过,已装真机。
2026-11-05 20:57:24 +08:00
laoyang 7ae595c8c2 生日卡重排:方框放"还剩几天"、右侧放农历日期 + 阳历日期 (#88)
用户 2026-11-05:「还有5天,也就是10月2号,这个不好。因为"还有5天"这个事情
比较重要,所以方形框里面应该写几天,比如还有5天就写5天;现在还有5天,
那个位置应该写上10月2日。如果是农历的话,就要写上农历十月二日。
下面还要写上阳历:换算完之后,今年阳历是多少?比如11月10日,
都放在右侧"满42周岁"这个位置。」

上一版信息分工正好是**反的**:方框里放了「日」(几号),右侧放了「还有 N 天」。
用户要的相反。

重排后:
  方框(84x84 浅主题色块)   = **还剩几天**(大数字)+ 下方「天」/「今天」
  右侧第一行                 = 日期(农历生日「农历十月初二」;其余「11月16日」)
  右侧第二行                 = 阳历换算(**只有农历生日才画**)
  右侧第三行                 = 满 N 周岁

服务端 Anniversary::occurrences() 补两个**成品文字**字段:
  lunar_label = 「十月初二」「闰四月初五」(农历生日才有)
  solar_md    = 「11月10日」(今年实际发生那天的公历月日)

⚠️ 农历文字为什么不在端上拼:Lunar 有完整的月名/日名对照表
   (正月/冬月/腊月、初一/廿三…),端上再抄一份必然漂移,
   而且闰月(闰四月)这种特例得一起处理。
⚠️ solar_md 用 $date(今年换算后的实际日期)而不是 $a['month']/$a['day']:
   农历生日的 month/day 是**农历**月日,直接拼出来会是错的公历。
⚠️ 公历生日时 dateSolar() 返回空串 ⇒ 不画第二行:
   那时第一行已经是「11月16日」,再画一次一模一样,纯噪音。
⚠️ days = 0(就是今天)时方框写「今」而不是「0」:
   0 看着像没数据,而且底下那行写「今天」,配成「今 / 今天」很奇怪。

实测服务端输出:
  高雪玉 的农历生日  days=5  lunar=十月初二  solar=11月10日  date=2026-11-10
  领证纪念日        days=7  lunar=(空)      solar=11月12日  date=2026-11-12
  杨永全 的农历生日  days=8  lunar=十月初五  solar=11月13日  date=2026-11-13
  杨永全 的生日      days=11 lunar=(空)      solar=11月16日  date=2026-11-16
  刘巧莲 的农历生日  days=29 lunar=十月廿六  solar=12月4日   date=2026-12-04

编译通过,已装真机。
2026-11-05 20:39:45 +08:00
laoyang 90973bb71c 纪念日:生日改成只读卡、自建纪念日才可编辑 (#87)
用户 2026-11-05 四条:
  1.「如果这个纪念日来自生日,我们是不能编辑的。」
  2.「点击之后,像日程那样弹出一个框来显示就行:显示这个人的头像、姓名,
     以及生日的『日』和日期就可以了。」
  3.「如果是我们自己创建的纪念日,那么这里面就是编辑纪念日。」
  4.「这个框和刚才我们编辑任务的框是一样的做法。样式、格式全部都一样。」

🔴 先说一个**会改坏用户数据的 bug**(这次一并修掉):
   原来派生生日也走 FbDayEditSheet,而且传的是
     editingMonth: 1, editingDay: 1   ← 写死的 1 月 1 日
   结果点开任何一个生日都显示「1 月 1 日」,点保存还能把
   **成员档案里的真实生日覆盖成 1 月 1 日**。
   这不是样式问题,是数据损坏。

改动:

① 新增 components/FbDayBirthdaySheet.ets(派生生日的只读卡)
   · 外壳用 FbSheetRing + 遮罩点击关闭 + 右上角 FbSheetClose
     —— 与 FbTaskPanel / FbDayEditSheet 同一套版式(用户第 4 条)
   · 左上角「生日 / 农历生日」主题色粗体 + 一行小字
     「来自成员资料,不能在这里修改」——把"不能编辑"这条边界写明白,
     否则用户会以为卡片坏了(用户第 1 条)
   · 主体:FbAvatar(56) + 姓名(主题色粗体) + 完整日期
   · 「日」做成视觉焦点:84×84 浅主题色块里放 34 号大字数字 + 「11 月」
   · 右侧:还有几天 / 满 N 周岁 / 农历说明
   · 底部:「要修改生日,请到「家人」里打开这位成员的资料」
     —— 只读也要给出口,不然用户不知道去哪儿改

② FbHomePage 按 source 分发
   birthday_solar / birthday_lunar → FbDayBirthdaySheet(只读)
   custom                          → FbDayEditSheet(可编辑,原行为)
   ⚠️ 判据用 source 前缀而不是 id === 0:两者目前等价,但 source 是语义、
      id 是实现细节 —— 万一以后派生条目也带 id,用 id 判会静默错。

③ 服务端 Anniversary::occurrences() 补输出
   · person_name —— `birthdays()` 算出来了,但 occurrences 逐个字段挑着抄时漏了
   · avatar / avatar_color —— 只读卡要画头像(key,端上拼 URL)
   · day / month —— 用户要的「生日的『日』」
     ⚠️ 用 $a['day'] 而不是从 $date 截:农历生日的 day 是农历日
        (初五 = 5),date 是换算后的公历日 —— 两者不同,用户要看"生日那天"
   · birthdays() 同步补 avatar / avatar_color(它原来也没有)

④ Types / FbParse 补五个字段
   personName / avatar / avatarColor / day / month

编译通过,已装真机。
2026-11-05 20:32:10 +08:00
laoyang 69c47c3c43 网页端下拉框改成自绘浮层(照 FeedLite 的做法)(#85)
用户 2026-11-05:「网页版的下拉框也太丑了。你参考一下那个反馈系统里面的
下拉框到底是怎么做的?按它的做法做,它也有主题颜色,我们这个也有主题颜色,
就按照一样的做法去做。」

看了 FeedLite(~/code/FeedLite)的实现,关键结论:
  **原生 <select> 收起时能改样式,但展开的选项列表由浏览器绘制,CSS 完全改不动**
  (只能改 option 底色,各平台还不统一)⇒ 想好看只有自绘这条路。

照它的做法移植:

① CSS(assets/css/app.css)
   .sel-native  原生 select 视觉隐藏(**不能 display:none**,那样不参与提交)
   .sel         外层 inline-flex
   .sel-btn     触发按钮 —— 外观与普通输入框完全一致(40 高、同圆角同边框)
   .sel-caret   自绘箭头(CSS mask + 内联 SVG),**打开时转 180° 并变主题色**
   .sel-panel   浮层,position:fixed 挂 body
   .sel-item    列表项:34 高、圆角 7
   .sel-item.on 选中项:深一档主题底色 + 主题色文字 + 右侧对勾

② 主题色三档浅色(照 FeedLite 的比例)
   helpers.php 新增 fb_color_tint()(把 hex 往白色混)。
   layout.php 在 <body> 的 style 里输出:
     --brand-tint-1(混 91% 白)→ hover
     --brand-tint-2(混 80% 白)→ 选中项
     --brand-tint-3(混 72% 白)→ 选中项 hover
   ⚠️ 在 PHP 里算好,**不用 CSS 的 color-mix()** —— FeedLite 实测过:
      `color-mix(in srgb, var(--brand) 9%, #fff)` 在 Chrome 里解析成透明。

③ JS(assets/js/app.js 新增 fbBindSelects())
   遍历所有 select,在旁边生成按钮 + 浮层,原生 select 保留参与提交。
   · 派发原生 change → 兼容 onchange="this.form.submit()" 的内联写法
   · 浮层 fixed 定位(面板/浮层里大量 overflow:hidden,放旁边会被裁)
   · 下方空间不足且上方更宽裕时**向上翻**
   · 键盘:↑↓ / Home / End / Enter / Esc
   · 浮层 minWidth 跟随触发按钮 → "和输入框一样宽"
   · data-sel-enhanced 幂等标记

④ AJAX 路径也要增强
   task_panel.paint() 与 entry_popup.loadPanel() 里,紧跟 fbBootAntd() 之后
   调一次 window.fbBindSelects(document) —— 否则动态插入的 select 是裸的。

⚠️ app.js 整个是 IIFE 包裹的(结尾 `})();`),新函数必须插在闭包内部;
   直接追加到结尾会跑到全局作用域。

实测(Playwright + 站点真实 app.js/css):
  增强:data-sel-enhanced=1、原生 select 已隐藏、按钮文字「工作相关」✅
  尺寸:按钮 648 x 40,行宽 648 → 占满整行 ✅
  浮层:宽 648(与按钮同宽)✅ 6 项、选中项有对勾 ✅
  打开态:底色 rgb(250,242,238)=tint-1、文字 rgb(168,74,40)=brand、
          箭头 rotate 180° ✅
  选「个人事务」→ select.value=3、按钮文字同步、浮层关闭 ✅
2026-11-05 20:23:16 +08:00
laoyang 47a0027ef7 修:分类显示「未分类」—— Task::find 没 JOIN 分类表 (#84)
用户 2026-11-05:「鸿蒙端的分类那个地方没带进来。明明是有分类的,
但是它却显示的是未分类。」

根因是**两个叠在一起**:

① `Task::find()` 的 SQL **没有 JOIN `task_categories`**
   `t.*` 里有 `category_id`,但没有 `category_name`;
   端上 `category: s(d.category_name)` 永远拿空串 ⇒ 显示「未分类」。
   同一个文件里 `doneList()` / 列表页那几条 SQL 都有这个 JOIN,
   只有 find() 漏了(写它不是同一时期,属于典型的"漏一处")。
   ⇒ 补 `LEFT JOIN task_categories c ON c.id = t.category_id`、选 `c.name AS category_name`。
   实测(#49 海大信息中心评审费):category_name = [工作相关] ✅

② `taskOut()` 里 `category_id => Api::int(category_id, ...)`
   `Api::int()` 是**读请求参数**的:先看 $_REQUEST 有没有该键,
   没有才用第二个参数当默认值。
   PATCH 时用户会传 ⇒ 值对;**GET 时没有请求参数** ⇒ 走默认值。
   这次默认值本身是从 `$t` 算的,所以"看起来是对的",
   但这种写法极易被误读成"已经取到表里的值了",后续改动容易出错。
   ⇒ 直接写 `(int)($t[category_id] ?? 0)`,不绕 Api::int。

注:`repeat_rule` 在 Task::find 里没被单独 SELECT ——
   但它在 `t.*` 里,`$t[repeat_rule]` 是有的(实测任务 #53 能正确读到/写入)。
2026-11-05 20:19:36 +08:00
laoyang 50286e5327 服务端:panel_save / Task::setAttrs 支持 repeat_rule (#83)
配合网页端与鸿蒙端把「循环」改成可编辑。

① Task::setAttrs() 新增 repeat_rule 分支
   只写 repeat_rule 一列(与 due_at / category_id / title / note 同一套
   按 `array_key_exists` 判"传没传"的逻辑)。
   ⚠️ 白名单校验:只接受 `self::REPEAT` 里有的键,其余一律存 NULL。
      不校验的话前端传个乱值就进库了,`repeatWord()` 会显示成空白,
      而且 `spawnNext` 那种"按规则生成下一次"的地方会静默不工作。
   ⚠️ 空串 = 不循环,存 NULL(与 `update()` 的 `?: null` 同口径)。

② index.php 的 tasks/panel_save 接收 repeat_rule
   同一套 `array_key_exists` 语义:表单没带就不动(保留原值),
   带了才更新 —— 这样"只改标题"的调用方不会把循环规则清掉。

实测:
  设 repeat_rule=week      => [week] ✅  标题未被破坏 ✅
  设非法值 hacked_value    => [NULL] ✅ 已拦下
  只改 due_at              => repeat_rule 不变 ✅ category_id 保留 ✅
2026-11-05 20:15:18 +08:00
laoyang 255fb8a240 网页端:保存按钮整行 + 左上角「编辑任务」+ 补回分类与循环 (#83)
用户 2026-11-05:
  「保存按钮感觉太小了。要不也像鸿蒙版一样长长的一条?」
  「最左上角也是一样,应该显示"编辑任务",而不是显示任务的标题。」
  「你倒是提醒我了,分类和重复,也就是循环,都需要加上,鸿蒙端也得加上。」

① 保存按钮 → 整行长条
   原来 `.btn-sm` 是内联宽(按文字撑),在 648px 宽的面板里又小又偏。
   ⇒ `.tk-attr-actions > .btn { width:100%; height:42px; justify-content:center }`
   实测:648 x 42,占行宽 100% ✅

② 左上角改「编辑任务」
   根因:`entry_popup.js` 的 `paintDetail()` 第 142 行永远是
     titleEl().textContent = data.title || 详情
   任务面板带 `data.panel`,它本身就是编辑面板,标题栏写任务名会让用户
   以为"这是详情页,只是恰好能改"。
   ⇒ `var headText = data.panel ? 编辑任务 : (data.title || 详情);`
   ⚠️ 其它来源(观影/旅程/日程/纪念日)**保持显示名字**:它们点开的
      确实是"这条记录的详情",显示名字才是对的。

③ 补回分类与循环
   上一轮我按「不需要额外信息」把这两项删了 —— 理解偏了:
   用户指的是"不要编辑表单里那些多余字段",不是"分类/循环不要"。
   ⇒ 补回,位置放在协作人之后(用户指定的前四项顺序不动)。
   ⚠️ `tasks/panel_save` 的 `$sd` 需要能接这两个字段 —— 见服务端那侧,
      它用 `array_key_exists` 判"传没传",所以不传时保留原值、传了就更新。

实测渲染:任务标题 → 截止 → 负责人 → 备注 → 协作人 → 分类 → 循环 ✅
2026-11-05 20:14:43 +08:00
laoyang 4d04490f7f 网页端任务面板按鸿蒙版结构重排 + 输入框占满整行 (#82)
用户 2026-11-05:「网页版编辑在首页编辑任务的时候,那个输入框怎么那么短呢?
所有输入框都应该跟弹出框一样宽。它里面的信息应该和鸿蒙版一样:
1.标题是"编辑任务" 2.下面分成两行:第一行是任务标题,第二行是状态
3.后续内容也和鸿蒙版保持一致,不需要额外信息。
布局顺序也和鸿蒙版一致:1.任务标题 2.状态 3.看板
4.截止日期、负责人、备注、协作人 5.子任务,一直到子任务都和鸿蒙版一致。
颜色也是用主题颜色。」

① 输入框为什么那么短 —— 是我上一轮修"对齐"时引入的
   #80 给每行加了 `max-width: var(--tk-attr-w)`(300px),
   目的是让各个控件右缘对齐。但代价是:面板 680 宽、控件只有 300,
   右边空着 300 多像素,看起来像没做完。
   ⇒ 改成上下两行之后,**每行只有一个控件、天然左缘对齐**,
     不再需要靠限宽来对齐 ⇒ 全面 `width: 100%; max-width: none`。
   实测(Playwright 量 getBoundingClientRect):
     五行控件宽全部 648 / 行宽 648 = **100%** ✅

② 排列改成上下两行
   `.tk-attr-row` 从 `flex-direction: row` 改成 `column`:
     ① 标签独占一行(主题色 + 加粗 + 字距 .6,与鸿蒙 fieldLb 同一口径)
     ② 控件独占一行
   顺带 `.tk-sec-inline`(状态/看板那两段)也改成纵向,
   与字段行同形态 —— 否则这里横着、那里竖着,看着像两套东西。

③ 顺序按鸿蒙版重排
   改前:状态 → 看板 → 更多信息{标题/截止/负责人/协作人/分类/备注}
   改后:任务标题 → 状态 → 看板 → 截止 → 负责人 → 备注 → 协作人 → 子任务 → 附件 → 讨论
   标题从「更多信息」里提出来,单独做第一个小节。

④ 去掉"额外信息":分类、循环
   用户明确说「不需要额外信息」,鸿蒙端面板里也没有这两项。
   ⚠️ 安全性确认:`tasks/panel_save` 用 `array_key_exists` 判"传没传",
      收不到 category_id 就**保留原值**,所以删掉字段不会把分类清空
      (这条在 #76 就是按这个原则写的)。

⑤ 颜色:小节标题 + 字段标签都用主题色
   `.tk-sec-h`(附件/讨论/子任务)从 var(--ink) 改 var(--tk-accent, var(--brand)),
   `.tk-attr-lb` 从 faint 灰改主题色 + 700 粗体。

⑥ 只读态也做成"框"
   新增 `.tk-attr-val`:浅底 + 圆角 + min-height 40,
   与可编辑态同宽同高。原来只读用户看到的是一行裸文字,
   和编辑态差太多(同一位置两种长相)。

实测渲染(family 1 最新任务):
  字段标签顺序:任务标题 → 截止 → 负责人 → 备注 → 协作人 ✅
  横排段顺序:状态 → 看板 ✅
  分类 / 循环:已删 ✅
2026-11-05 20:09:02 +08:00
laoyang 33940fa730 评论附件拖放区放大:小胶囊 → 整块虚线大框 (#81)
用户 2026-11-05:「评论里面拖文件上传附件,那个地方太小了。」

原来:`padding:4px 10px` 的小胶囊(约 26px 高)+ 一行小字提示,
拖动落点只有那一条窄带 —— 触屏不好瞄,桌面也容易拖偏。

改成与「任务附件」同一套 `.upbox`(那个是站点级上传组件的标准外观):
  · 高度 89px(触屏建议下限 44px,绰绰有余)
  · 宽度占满整行(桌面 840px / 手机 330px)
  · 虚线大框 + 加号图标 + 说明文字
  · 副标题写清「点『发表』时随这条评论一起发出」——
    与上面那块「拖上去立刻上传」语义不同,不写清楚用户会以为已经传了
  · 比「任务附件」矮一点(96 → 72 min-height,实测 89px):
    评论框已经在下面,96px 会把讨论区推太长;89px 仍然很好点

顺带改的:
  · 已选文件从"逗号拼一行"改成"一行一个,带 📎",长名截断
  · 渲染用 createElement + textContent,**不拼 innerHTML**
    (文件名是用户可控的,拼 innerHTML 就是 XSS)
  · 列表为空时 hidden,不挂空灰条
  · 加键盘支持:`.upbox` 带 role=button + tabindex,Enter/Space 能打开选择框

实测(Playwright,两种视口):
  桌面 900px:840 × 89px,边框 1px dashed,触屏可点性 ✅
  手机 390px:330 × 89px,触屏可点性 ✅
  文件列表:2 项,高 56px,正常显示
2026-11-05 18:57:56 +08:00
laoyang 8e48d0b1b2 统一「更多信息」各行的控件宽度,修标题/备注不对齐 (#80)
用户 2026-11-05:「标题和备注的输入框没有和其他输入框对齐。」

根因:各行的宽度上限**不一致**,而标题/备注压根没设上限 ——
  · 截止(AntD picker)  max-width: 300px
  · 分类(select)        max-width: 300px
  · 负责人/协作人         max-width: 340px   ← 比别的宽 40px
  · **标题(input)/ 备注(textarea)无上限** ⇒ 撑满整行
于是标题那行比截止那行宽出一截,左右边缘都对不齐。

改法:收敛成一个变量 --tk-attr-w: 300px,所有行共用。
  · 同时给 width:100% + max-width:只给 max-width 时块级 input 默认
    width:auto 会按内容收缩,反而比别的行窄。
  · 去掉 pp-root 那个独立的 340px。

🔴 还修了一个更隐蔽的:`.tk-attr-row` 原本有 `flex-wrap: wrap`。
   窄屏(容器 340px,控件 300px + 标签 46px + gap 10px = 356px)放不下
   ⇒ 控件**掉到下一行**,左缘从 76 变成 0,整列看着像塌了。
   用 Playwright 量出来才发现的(宽屏下完全正常,很容易漏)。
   ⇒ 不换行,让控件按 flex:1 1 auto + min-width:0 收缩。

实测(Playwright 量 getBoundingClientRect):
  宽屏 900px:五行的控件左缘全是 76、宽 300、右缘 376 ✅
             保存按钮左缘 76 ✅(与控件对齐)
  窄屏 380px:五行左缘全是 76、宽 284(自动收缩)、右缘 360 ✅
             没有换行 ✅
2026-11-05 18:35:50 +08:00
laoyang cb846d378f 评论附件支持拖拽 + 首页卡片同步标题 (#79)
用户 2026-11-05:
  「评论框下面的附件也需要可以拖动添加。」
  「首页卡片没有刷新。」

① 评论附件拖拽
   给 `.cmt-attach-row` 挂 `data-cmt-drop`,由 task_panel.js 接管
   dragover / dragenter / dragleave / drop。

   🔴 核心技术点:`<input type=file>.files` 是**只读**的,
      不能直接 `input.files = event.dataTransfer.files`(某些内核静默失败)。
      必须用 DataTransfer 重建 FileList 再赋回去:
          var dt = new DataTransfer();
          dt.items.add(file);
          input.files = dt.files;      ← 这个赋值是允许的
      而且**必须真的赋给 input** —— 否则只是"看起来加上了",
      表单提交时浏览器一个文件都不带。

   ⚠️ 累加语义:用户可能先拖一张再拖一张,所以每次把 input.files
      现有内容 + 新拖入的合并后写回。
   ⚠️ 去重:按 name+size+lastModified 判重,否则"只拖一次却传上去两张一样的"。
   ⚠️ dragover 必须 preventDefault(不拦的话浏览器把文件当"打开",
      直接跳走、当前页面全丢)。
   ⚠️ 与「任务附件」那条 `[data-tk-drop-box]` 是**两条独立的路**:
      那个是"点了/拖了立刻上传"(任务已存在),
      这个是"挂到评论表单、点发表才一起提交"。

   实测(Playwright + 真实 DataTransfer):
     拖入 a.txt+b.png => count=2 ✅
     再拖 c.jpg      => count=3 ✅ 累加
     重复拖 a.txt    => count=3 ✅ 去重
     FormData(form)  => ["a.txt","b.png","c.jpg"] ✅ 提交时真会传

② 首页卡片同步标题
   根因:`syncRow()` 原来**只管状态徽标和复选框**,代码第一行就是
   `if (!d || !d.status) return;`,压根不碰标题。
   而 #78 之后面板里能改标题了 ⇒ 改完点保存,卡片上还是旧标题。

   改两处:
   · `task_panel_json()` 返回值补 `title`(服务端直接给,不依赖 DOM 还在)
   · `syncRow()` 补标题同步:`.dash-what` 的文本 + `title` 属性
     (`title` 是悬停提示,不同步会出现"悬停是旧标题"的怪事),
     并把 `data-fb-entry` 里的 JSON title 一并改掉
     (否则"卡片上是新标题、再点开浮层显示旧标题")。
     读不到 d.title 时兜底从 `#tkp-title` 输入框读。
2026-11-05 18:32:40 +08:00
laoyang 11aa7c6ca5 任务面板五项细化:弹窗选人、协作人、保存关窗、去编辑、评论附件 (#78)
用户 2026-11-05 五条:
  「选人的时候不能按列表,因为人太多了。参考观影模式里面选人的方式。」
  「任务其实可以共享给别人,也就是协作人,但编辑这里面没有这个选项。」
  「点击保存之后,按道理说应该直接把这个窗口关闭才对呀。」
  「现在在首页上点一个任务,直接就是编辑页面啊,所以不需要编辑按钮了。」
  「评论那个地方也需要可以添加附件。」

① 选人改弹窗
   负责人/协作人都换成 `_person_picker.php`(弹窗 + 拼音首字母联想),
   与观影表单、新建任务弹框同一套。
   负责人 single + allowEmpty(可选「不指定」);协作人 multi。
   ⚠️ 关键修复:`entry_popup.js` 的 `loadPanel()` 一直漏了
      `fbInitPickers()` —— 组件不初始化,按钮点了没反应。
      `loadEdit()` 里有这一句,`loadPanel()` 没有,面板里塞选人就是这个下场。
      同时 `task_panel.js` 的 `paint()` 后也补一次(重画后是新节点)。

② 补协作人
   数据层其实**早就完整支持**:task_shares 表 + Task::setShare/dropShare
   + 新建弹框的「共享给」+ 详情页的共享展示。只有面板和编辑表单没给入口。
   现在面板里能看当前协作人、能加、能改。
   保存语义:以提交的列表为准同步(多了就 setShare、少了就 dropShare)。
   ⚠️ 带 `shares_submitted=1` 哨兵,区分"表单里没这个控件"(不动)
      和"用户全删了"(清空)—— 否则 PHP 收不到空数组的键,无法分辨。

③ 保存后关窗
   `act()` 加 `closeOnOk`,`send()` 支持它:成功后先 `syncRow()`
   (更新背后那张首页卡片,否则关掉浮层看到旧标题更迷惑)再 `fbEntryClose()`。
   ⚠️ 只给「保存」用 —— 改状态/挪看板/加子任务/发评论/传附件都还要继续操作。
   ⚠️ 失败时不关,让用户看到错误就地改。

④ 去掉「编辑」按钮 + 面板补标题/备注
   用户说得对:点开任务就是完整编辑面板了,再给编辑按钮是重复。
   但原来面板改不了标题和备注(只在 _edit_form.php 里)
   ⇒ 先在「更多信息」里补上这两个字段,再藏编辑按钮。
   `Task::setAttrs()` 扩展支持 title/note,走 title()/textOrNull() 清洗,
   与 update() 同一口径。
   ⚠️ 只对**任务**(带 data.panel)隐藏 —— 观影/旅程/日程/纪念日仍需要那颗按钮。

⑤ 评论可加附件
   服务端**早就支持**:tasks/comment_add 里一直有
   `attach_save_files(..., task_comment, ...)`,只是表单里没有 file input。
   补上 `<input type="file" name="attach[]" multiple>` +
   `enctype="multipart/form-data"`(少了它只提交文件名、文件本体丢失)。
   顺带允许「只发附件不写字」(原来 content 空会返回"留言是空的"):
   有附件且没文字时用占位文案「(附件)」。

实测:
  setAttrs 标题/备注 => 都正确写入 ✅
  setShare / dropShare => 1 人 / 0 人 ✅
  面板渲染 => 标题框/备注框/两个选人组件(single+multi)/评论附件/
              multipart/save-attrs/antd 控件 全部在位 ✅
  选人模式 => single(allowEmpty=1), multi ✅

已知问题(另单处理):家庭有 800 人,两个选人组件各 485KB,
面板渲染 997KB。服务端只要 11ms,但传输量对移动端不友好。
这是 _person_picker.php 的既有设计(观影那边也一样),
需要改成"弹窗打开时才渲染列表"或按需加载。
2026-11-05 18:28:15 +08:00
laoyang aac7dd5974 修截止时间时/分保存不上:保存前替用户点 antd 的「确定」(#77)
用户给的方法很对:「看一下数据库里有没有记时和分。如果没有记,
就说明是修改它的时候出了问题。」查库结果:

  tasks #15 [台式机采购]
    due_at  = 2026-11-07 00:00:00
    updated = 2026-11-05 18:04:05   ← 正是用户操作的时间点

⇒ 时分在**写入时**就丢了,不是显示问题。

用 Playwright + 站点真实 antd 5 逐帧实测,找到决定性证据:

  选完日期/时间(不点弹层里的「确定」):
      vis="2026-11-16 20:34"   onChange 次数 = 0
  点面板的「保存」(这一下同时把弹层关掉):
      50ms 后 readDue = "2026-11-07 00:00:00"   ← **弹回原值!**
      onChange 次数仍然是 0

根因:antd 的 DatePicker(showTime) **只有点它内部的「确定」才提交**。
点别处(包括点我们的保存按钮)会被当成取消,值弹回上一次确认的值。
「台式机采购」原本就是 2026-11-07 00:00:00,用户改完时间直接点保存,
antd 把修改丢了,于是存进去的还是原来的 00:00:00。

修法(三步,缺一不可):
① app.js 的 writeBack():把 onChange 拿到的字符串写到
   box[data-fb-dt-value]。这是"控件刚确认的值",不经镜像、不受弹层影响。
② task_panel.js 新增 commitAntd():保存前主动点掉弹层「确定」。
   只在弹层真开着时点(:not(.ant-picker-dropdown-hidden))。
③ 读值改为**异步**:commitAntd() 之后等 60ms 再 readDue()。
   antd 的 onChange → setState → 重渲染落在微任务/下一帧,
   同步读拿到的是提交前那一帧(实测差几分钟,或干脆是旧值)。

readDue 的优先级也理顺了:
  ① data-fb-dt-value(onChange 直挂,最可靠)
  ② antd 可见框(降级兜底)
  ③ 原生隐藏字段(antd 没挂上时)
  ④ 都没有 → 空(清掉截止时间)

实测(复刻完整流程:选日期+20时+30分,不点确定,直接点保存):
  保存读到 = "2026-11-16 20:30:00" ✅
  onChange 次数 = 1
  dataset="2026-11-16 20:30"  h=20  m=30  vis="2026-11-16 20:30"

服务端侧确认无需改动:Task::dtOrNull() 对
YYYY-MM-DD HH:mm[:ss] / YYYY-MM-DD 都能正确补全,
只有带 T 分隔的会返回 null(那是整条清空,不是时分丢失)。
2026-11-05 18:19:46 +08:00
laoyang 20ba36ad62 修截止时间时/分保存不上:改为读 AntD 可见框 (#77)
用户 2026-11-05:「截止日期的时间保存有bug,时和分没保存上。」

根因:save-attrs 原来直接读 .dt-hour / .dt-minute 的 value。
那两个 <select> 是 AntD 挂上后**被 CSS 收起**的镜像(.dt-input.is-antd
> .dt-native { display:none }),它们的值依赖 mountAntdSingleDT 的
writeBack() 写回 —— 而 writeBack 走 writeDT(box, str),str 来自 antd
的 onChange 第二参数。

用 Playwright + 站点真实 app.js/antd 实测发现问题:
  · 只给 select 放一个 option 时,writeDT 写入不存在的值 → value 变空串;
  · 时序上(首屏挂载 / 面板 AJAX 换入 / 受控重渲染)隐藏字段可能滞后于
    用户看到的那个值。
⇒ 读隐藏字段本质上是"读镜像",镜像一旦滞后或缺失,时分就丢。

修法:新增 readDue(),按优先级读:
  ① AntD 的可见输入框 .ant-picker-input > input(用户实际看到/改的那一个,
     格式 YYYY-MM-DD HH:mm)—— 这是唯一权威来源;
  ② 没有 AntD(降级成原生下拉)时才读 .dt-date/.dt-hour/.dt-minute;
  ③ 都没有 → 空(= 清掉截止时间)。
AntD 的框是只读文本框(用户通过弹层选,不能直接打字),
所以解析它的 text 不会碰到"敲了半截日期"的中间态。

实测(Playwright 复刻完整 mountAntdSingleDT + readDue):
  AntD 可见框    = 2026-10-16 20:30
  隐藏 hour/min  = 20 / 30
  readDue 结果   = 2026-10-16 20:30:00  ✅

服务端侧已确认无需改动:Task::dtOrNull 会把
YYYY-MM-DD HH:mm 补秒成 YYYY-MM-DD HH:mm:ss,
YYYY-MM-DD 补成 00:00:00,格式非法才回 null。
2026-11-05 18:12:44 +08:00
laoyang 9ab7d40a3b 任务面板:加保存按钮,负责人和分类改为可编辑 (#76)
用户 2026-11-05:「时间控件是对的,但是现在没有保存按钮,只有下面一个
评论的发表按钮。另外,负责人和分类都没法修改,按道理讲都应该可以修改才对。」

三处改动:

① 取消"立即保存",改成显式「保存」按钮
   上一版(#74)做的是"选完截止时间就发请求"。用户不认可:他要的是
   "改完点一下保存"那种确定感。
   ⇒ 移除 bindDue() 的 change 监听,改成 save-attrs 动作一次性提交三项。

② 负责人 / 分类 从只读改成可编辑
   改前是只读 dl.kv 行,而且**值为空时整行不渲染**
   (if (!empty($tpT[assignee_name])) / if ($tpCatName !== ))
   ⇒ 用户不仅改不了,连"这一项存在"都看不出来。
   现在三项合成「更多信息」小节:
     截止   [ AntD 日期控件 ]
     负责人 [ 下拉 ]
     分类   [ 下拉 ]
            [ 保存 ]
   负责人用原生 select 而不是 _person_picker.php 的弹窗选人:
   面板已在浮层里,再弹一层模态 = 两层遮罩,手机上容易点错;
   且 picker 需要 fbInitPickers() 初始化,loadPanel() 目前没调它。
   家里 3~10 人 select 够用;人多了再换,那时记得把 fbInitPickers
   也补进 loadPanel()。

③ Task::setAttrs()(新增)
   只更新 due_at / assignee_id / category_id 三列。
   不复用 update():那是全字段覆盖,面板只交三项的话
   title/note/status/repeat/parent/board 会被默认值抹空
   —— 与 setDue() 记的是同一个坑。
   用 array_key_exists 判"传没传",这样可以只改其中一项
   (面板在"没有成员"等极端情况下可能不渲染负责人下拉,
   那时不该把它当成 0 去清掉原值)。

路由 tasks/panel_save:只把表单**真的提交了**的字段放进 $sd,
与 status/move 同一套约定(ajax_panel=1 回整块新面板)。

清理:bindDue / window.fbTaskPanelBindDue 全部移除
(entry_popup.js 里的调用、task_panel.js 里的定义与导出)
—— 留着会引用未定义函数,直接抛错。

实测:
  三项一起改     => due_at/assignee/category 全对 ✅
  标题/状态/备注/看板 均未被破坏 ✅
  只改 due_at    => 另两项保留、未被清空 ✅
  面板渲染       => save-url / 保存按钮 / 两个下拉 / AntD 控件 /
                    原有状态/附件/评论 全部在位 ✅
2026-11-05 18:07:17 +08:00
laoyang 7a108396ab 任务面板首次打开时截止控件没升级成 AntD (#75)
用户:「空间有了,但是不是我想要的空间,这个空间需要换。」
经确认「空间」=「控件」,现象是五个原生下拉(年/月/日/时/分),
期望 AntD 弹层日历。

真根因:首次打开面板走 entry_popup.js 的 loadPanel(),
它只 innerHTML + runInlineScripts,**没有补挂 AntD**。

上一条 #74 我在 task_panel.js 的 paint() 里加了 fbBootAntd(),
但 paint() 只在"面板里做了动作之后重画"时才跑
(点状态/挪看板/加子任务/发评论/传附件 → act() → 回来 paint())。
**首次打开不经过 paint()** ⇒ 用户第一眼看到的是从未升级过的原生下拉。
(先点一下「已完成」再点回来就会看到 AntD 挂上了 —— 这种"时好时坏"最迷惑。)

为什么 bootAntd 首屏也没兜住:
  if (!hosts.length && !dateRanges.length && !dates.length && !singles.length) return;
首页首屏任务卡里没有 [data-dt-antd-single](那控件要打开面板才有)
⇒ 首屏调用时直接 return。

修法:
① entry_popup.js loadPanel():插入 HTML 后调 fbBootAntd()
② 顺带修第二个 bug:bindDue() 原来也只在 paint() 里调,
   首次打开不绑定 ⇒ 选完截止时间 change 没人听 ⇒ **不会保存**。
   ⇒ 把 bindDue 暴露成 window.fbTaskPanelBindDue,loadPanel 显式调一次。

加载顺序已确认:两者都是 defer,entry_popup.js(253) 在前、
task_panel.js(268) 在后 ⇒ task_panel 先执行完并注册好
window.fbTaskPanelBindDue,之后 loadPanel 才在用户点击时调用。
不存在"调的时候还没定义"。

antd 样式确认:vendor 目录只有 reset.css(antd 5 是 CSS-in-JS 运行时注入),
不存在"缺样式导致看起来像原生下拉"的可能。
2026-11-05 17:59:17 +08:00
laoyang 836f6f1d2f 首页任务面板:可直接改截止时间 (#74)
用户 2026-11-05:「点开之后,就可以修改任务的所有信息,包括:
1. 上传附件;2. 添加评论;3. 添加子任务;4. 修改截止时间
(已有值要带进来);5. 改状态、挪看板。这些操作全部都在这一个页面完成。」

盘点发现:附件 / 评论 / 子任务 / 状态 / 看板 **本来就有**(_panel.php 里
早就实现了),只有「截止时间」是只读的一行 dd。所以只补这一项。

四处改动:

① Task::setDue()(新增)
   不复用 update():那是全字段覆盖(title/note/status/assignee/repeat/
   parent/board/category 一起写)。表单只交 due_at 的话,其余字段会被
   默认值抹成空 ——「只想改个日期,结果标题和备注没了」,不可恢复。
   ⇒ 单独一条只写 due_at 的 UPDATE。

② tasks/due 路由(新增,紧跟 tasks/status)
   与 status/move 同一套约定:ajax_panel=1 回整块新面板,否则 flash+302。
   空串 = 清掉截止时间。

③ _panel.php:只读「截止」行 → AntD 单点控件
   原来:
     <?php if ($tpDue !== ): ?>…<dd>截止 …</dd>…<?php endif; ?>
   **没有截止时间时整行都不显示** —— 用户根本不知道该去哪填。
   现在不管有没有值都画控件,$dtValue = $tpDue 自动带出已有值。
   有值且在逾期时,右侧补一行「还剩 N 天」小字(.tk-due-word)。

④ task_panel.js
   · paint() 后调 fbBootAntd():面板是 AJAX 换进来的,bootAntd 首屏
     扫过一次 [data-dt-antd-single],那时面板还不存在 ⇒ 必须补挂。
   · bindDue():监听 .dt-input 的 change 立即保存。
     不用按钮式 data-tk-act(面板里其它动作都是"点一下/选完就生效",
     再让用户点一次保存会与其它处不一致)。
     监听隐藏字段的 change 而不是 AntD 回调 ⇒ 覆盖 AntD 与原生下拉两种形态。
     data-fb-due-bound 幂等标记防止重复绑监听(否则改一次发好几条请求)。

⑤ CSS:.tk-sec-inline > .dt-input 加 flex/min-width/max-width ——
   .dt-input 是 flex-wrap,放进也是 flex-wrap 的 .tk-sec-inline 里
   会被当弹性项按内容撑,窄屏上可能把标签挤走或控件溢出。

实测:
  setDue 设值  => [2026-12-25 18:30:00] ✅
  标题仍为原值 ✅ 没被清空(证明没用 update())
  setDue 清空  => NULL ✅
  面板渲染:data-tk-url-due / data-dt-antd-single / due_at_date 全在,
            带出的日期值 = [2026-10-22] ✅
            状态/附件/子任务/评论 均仍在
2026-11-05 17:44:11 +08:00
laoyang a43950069d 修 React #321:loadJs 按 src 全局去重,避免 React 执行两遍 (#73)
用户 Console 决定性证据:
  AntD 日期控件已挂上 5 处   ← 首屏
  AntD 日期控件已挂上 1 处   ← 点开浮层编辑
  react-dom.min.js:121 Error: Minified React error #321 (Invalid hook call)
    at c.useContext (VM303 react.min.js:28:406)
    at e (antd-with-locales.min.js:77:5889)
    renderPicker @ app.js:2279 / mountAntdSingleDT @ app.js:2282

#321 = Invalid hook call,本次成因是"存在多份 React 副本"。

决定性线索是 VM 前缀:VM303 / VM320。
那是浏览器给 eval/new Function 动态执行的代码分配的脚本 ID;
普通 <script src> 显示的是文件 URL。同一个文件出现 VM 前缀
⇒ 它被动态执行了不止一次。

根因:app.js 里有两个独立 IIFE,各自一个 loadJs,都不去重。
  · Semi 段 boot():**无条件** loadJs(conf.react) + loadJs(conf.dom)
  · antd 段 ensure():只在 !window.React 时才加载 —— 看着谨慎,
    但依赖"当前全局有没有 React",而 UMD 执行完才赋值。

时序:Semi 的 boot() 排在 requestIdleCallback 里。
用户点开任务 → fbBootAntd() → antd 开始加载 react.min.js(第一次)
→ Semi 的 idle 回调恰好触发 → 又插一遍(第二次)
→ 同一段 UMD 执行两次 → window.React 被覆盖
→ 但 antd 内部已捕获第一次的 React 引用
→ antd 的 useContext 在第二套的 dispatcher 上调用 → #321

修法:两个 loadJs 都改为按 src 全局去重,共享 window.__fbJsLoaded。
  · 用全局而不是 IIFE 局部变量:两个 loadJs 在不同闭包里,互相看不见
  · 不用 if (!window.React) 那种判断:UMD 执行完才赋值,
    两个请求几乎同时发出时两边都还看不到 window.React,照样各插一个。
    按 URL 去重是确定的,不依赖时序。

验证(复刻逻辑跑了三种时序):
  antd 先 / Semi 后  → 第二个复用 ✅ 只有一份
  Semi 先 / antd 后  → 第二个复用 ✅ 只有一份
  不同文件           → 各自加载 ✅ 没被错误复用
2026-11-05 17:38:51 +08:00
laoyang 870a63df5a 首页任务浮层:补 ref_id,修复「编辑」按钮不显示 (#72)
用户 Console 提供决定性线索:
  AntD 日期控件已挂上 5 处
⇒ 前端控件、资源加载全部正常,问题不在那里。

真根因:
entry_popup.js 的 paintDetail() 里
  ed.hidden = !(isEvent && data.edit);
「编辑」按钮要求 data.source === event。

而 fb_entry_json() 算 source:
  $evKind = $ev[\x27kind\x27] ?? \x27custom\x27;      // 任务卡没传,默认 custom
  $evRef  = (int)($ev[\x27ref_id\x27] ?? 0);        // 任务卡没传 ⇒ 0
  $evOwn  = ($evKind === \x27custom\x27 && $evRef > 0);   // ⇒ false
  \x27source\x27 => $evOwn ? \x27event\x27 : \x27derived\x27,      // ⇒ derived

⇒ 编辑按钮恒不显示 ⇒ 用户点开任务只能看到只读面板
⇒ 压根没有进 _edit_form.php 的入口
⇒ 永远看不到「截止时间」(那控件只在那张表里)

讽刺的是:_card_tasks.php 是唯一给 panel 的卡片(注释还强调"只有任务会给"),
却也是唯一没给 ref_id 的调用方。

修法:第一个参数补 \x27ref_id\x27 => (int)$t[\x27id\x27]

不补 kind 的理由:fb_entry_json 的 kind 判据只用来区分
「日历里真正属于某本日历的条目」vs「从别的模块派生的」。
对任务来说它只关心"能不能在这里改",答案是能(有 tasks/edit),
走默认 \x27custom\x27 就对了。

副作用检查:del 仍为空(任务没有 calendar/event_del 那种路由,
面板里另有删除入口),所以「删除」按钮不显示 —— 这是正确的。
「编辑」只要 isEvent && data.edit 两者都满足就显示。

实测:
  改后(带 ref_id):source = event  ✅ 编辑显示  ref=49
  改前(不带)     :source = derived ❌ 编辑不显示(即 bug)
2026-11-05 17:35:54 +08:00
laoyang 52946e9c35 日历本默认名:旅行 → 旅程 (#71)
用户 2026-11-05:「旅程的日历本同步到日历本,它的日历本名字就叫"旅程",
观影就叫"观影"。我看现在"旅程"叫"旅行",这个不对,应该改过来。」

代码只改一处:app/Calendar.php 的 DEFAULT_BOOKS 常量
  trip => [旅行, ...] → [旅程, ...]

这段只影响**新家庭首次 seed**。已建好的家庭数据在库里,改常量不回溯
⇒ 另外跑了 UPDATE calendars SET name=旅程 WHERE kind=trip AND name=旅行
  (按值匹配,不无脑按 kind 覆盖:万一用户自己改过名,不该把他改的抹掉。
   虽然 trip 本在代码里是禁止改名的派生本,但按值匹配总是更稳)
实测改了 2 行(历史上 seedDefaults 重复跑过,有两套)。

movie 那条**没动** —— 用户明确说"观影就叫观影",本来是对的。

顺带把文件头注释里的「旅行」也改成「旅程」,
否则注释说"名字就叫观影、旅行",代码里却是"旅程",后人又要被误导。

实测:
  Calendar::mine() → #1 [观影] kind=movie
                     #2 [旅程] kind=trip
  行程条目 calname = [旅程] ✓
  观影条目 calname = [观影] ✓
2026-11-05 17:26:37 +08:00
laoyang e015181fa3 日程里的行程条目:图标按出行方式、去掉✓、带出出发地/目的地 (#67)
用户 2026-11-05 三条:

1. 「图标应该来自里面具体的交通工具,如果是火车就应该显示火车」
   原来 Calendar::month() 的 trip 派生段用 Trip::typeIcon(node_type),
   typeIcon(move) 恒为 🚗 ⇒「赶路」永远显示汽车,哪怕实际坐的是火车。
   改用 Trip::leadIcon(node_type, travel_mode, familyId) ——
   出行方式优先、节点类型兜底,与旅行详情页行程卡同一个方法。

2. 「后面这个对号是什么意思?表示它是一个实际行程吗?我感觉没必要,直接去掉」
   删掉标题拼装里的  ✓ (原来用来区分 stage=actual)。

3. 「如果行程里面有地址,也就是有出发地和目的地,应该像行程里显示的那样,
    把它显示在下面,这样一点就可以导航」
   这段 SQL 原来**完全没查** from_*/to_*,日历里只剩一个标题。
   补 n.from_place/to_place + 四个坐标字段,输出加 from_place/to_place/
   from_lng/from_lat/to_lng/to_lat。

端上:
- FbCalEvent 加 6 个字段(RawCalEvent / FbParse / FbCalStore / DemoData 同步)
- 坐标用 numStr() 转字符串(服务端 DECIMAL 可能给 number,s() 只收 string)
- FbDayEventPanel 列表卡片:时间行上面单独一行「出发地 → 目的地」,
  目的地带下划线 + 日历本色,点击调 fbOpenNavigation(有坐标带坐标,
  没有让 FbNav 自己地理编码)
- FbDayTimeline 全天块:一行文字「出发地 → 目的地」,不给点击
  (26vp 高的块切两个热区会点不准,导航入口在详情卡里)

网页端:
- fb_entry_json() 的 rows 补「出发地」「目的地」两行
- 日历网格里的条目太窄,只显示标题,不加这两行

实测(family 1 全年):
  title = [✈️ 从青岛坐飞机去哈尔滨]
  from  = [青岛胶东国际机场]
  to    = [哈尔滨太平国际机场]
  to_lng= [126.241474]  to_lat=[45.611938]
  —— 飞机显示 ✈️、没有 ✓、地址坐标齐全
2026-11-05 16:12:06 +08:00
laoyang 403ecd9dea 日程表单:全天行补 62px 标签占位,与上方输入框左对齐
用户 2026-11-05:「全天那个应该是输入框,它应该往右和那个输入框对齐。」

根因:观影式排版(`.mv-row`)里每行左侧有一个 62px 的 `.mv-lb` 标签列,
控件从 62px 之后开始,上面所有输入框左边缘都在那条线上。
而「全天」这一行没有标签(checkbox 自带"全天"二字),
`<label class="chk-inline">` 直接贴在行的最左边(x=0),
比上面的输入框往左偏了 62px,看着就是错位。

修法:补一个空的 `.mv-lb.no-colon` 占位。

不用 padding-left:62px 的原因:那条 62px 是 `.mv-row > .mv-lb`
的 `flex:0 0 62px` 算出来的,而 .mv-lb 宽度在窄屏媒体查询里会变
(app.css 6196 行附近),写死 padding 会跟它脱节。
用空标签则天然跟随同一套规则。

仅在 $evMv(观影式排版)下补 —— 整页表单是 .form-grid 两列,
没有 62px 标签列,补了反而多出缩进。
2026-11-05 16:00:19 +08:00
laoyang 306f5a61cb 图片轮播切到视频后左右键失效 (#57)
用户 2026-11-05:「行程里面图片轮播的时候,切换到视频,上下左右键就失效了。」

根因:键盘处理里有 isImage() 判断,只有当前是图片才翻页:
  if (e.key === ArrowLeft && isImage()) { step(-1); }
切到视频后 isImage() 为 false,两个分支都进不去。

当初这么写是因为「视频/PDF 里方向键另有含义,不能抢」——
顾虑是对的(视频左右键是快退/快进),但解法错了:
一刀切禁掉,而不是看焦点在哪。

修法:改成判断事件目标
  function inMediaControl(t) { return !!t.closest("video, iframe"); }
  if (inMediaControl(e.target)) { return; }   // 放行给控件
  其余情况照常翻页,不再限制只有图片

行为矩阵:
  焦点在 video 里         → 视频快退/快进(不被抢)
  焦点在 PDF iframe 里    → PDF 自己翻页(不被抢)
  焦点在别处 / 视频未聚焦  → 上一张 / 下一张

上下键保持不处理:页面滚动、视频音量另有含义。
2026-11-05 13:19:05 +08:00
laoyang bee1d077d8 严重修复:图片压缩参数被应用到了视频上,导致视频播不了 (#56)
用户报(2026-11-05):「你好像把 .mp4 后面也带上了问号,写成了 1600
这样的参数…你刚才没有进行图片压缩的时候,我自己播放好像还是好的。
是你更新了这个功能之后,出了这个问题。」

用户完全正确,这是我的错。

根因:media.php 只看 $imgW,没判是不是图片:
  Storage::read($familyId, $key, $imgW > 0 ? $OssSizes[$imgW] : );
⇒ 视频 URL 带 ?w=1600 后,给 .mp4 对象传了
  x-oss-process=image/resize,l_1600
⇒ OSS 图片处理服务对非图片返回错误内容
⇒ <video> 拿不到有效数据 ⇒ 播不了

三道防线:

1. _attachments.php(直接触发点)
   原来对所有附件都传 w=1600。改为 $attKind === image 才传。
   视频只传 v(版本号,破缓存用)。

2. media.php(核心防线)
   $imgW 与 $isImageKey 绑定,只有图片才允许压缩。
   $isImageKey 判定提前到 Storage::read 之前,
   并扩到扩展名兜底(jpg/png/webp...),
   视频/文档扩展名强制 false。

3. media_url()(前置防线)
   生成 URL 时按扩展名过滤:非图片自动去掉 w。
   这样即使调用方传错,URL 里也不会出现无效参数。

验证(模拟事故场景,URL 带 ?w=1600 请求 .mp4):
  HTTP 206,Content-Type: video/mp4
  无 hvc1,含 avc1
  ffprobe: h264/yuv420p,2.19MB  ✅ 正常

  media_url 生成视频 URL:...mp4&v=2295119(无 w)
  media_url 生成图片 URL:...jpg&w=1600&v=609017(有 w)
2026-11-05 13:14:51 +08:00
laoyang 2c44318a51 压缩只留 1600 档;视频缓存失效加版本号 (#56 #57)
一、尺寸档位(用户:「400压缩的太多了。800也不够,只保留一个1600」)
   media.php 白名单从三档收到一档:
     1600 => image/resize,l_1600/quality,q_88
   400/800 删除。质量从 q_85 提到 q_88(只留一档就该偏清楚那侧)。
   照片墙跟随改 1600,缩略图与大图共用一份缓存。
   传旧 ?w=400 的链接会落到「非法值 ⇒ 原图」分支,不报错、只是不压缩。

二、视频仍显示 HEVC(用户:「他说是H.265,还是不让播呢?」)

   实测服务端 15 个视频**全部 h264**,无一个 HEVC:
     ffprobe 逐个查过,codec=h264,pix_fmt=yuv420p/yuvj420p
     media.php 返回 Content-Type: video/mp4,body 里无 hvc1/hev1
     ETag 与实际内容完全一致

   问题在浏览器缓存:
     Cache-Control: private, max-age=604800(7 天)
     浏览器对 <video> 有自己的媒体缓存策略,不完全遵守 ETag
     Range 分段缓存会把每段当独立资源
     src 不变时直接复用已有媒体对象

   修法:让 URL 变。media_url() 加第三个参数 $ver,
   用附件 size 当版本号 → media.php?k=...&v=2295119

   为什么用 size 而不是 mtime/md5:
     mtime —— 对象在 OSS 上,本地拿不到可靠时间
     md5   —— 要读整个文件,几百 MB 视频不可接受
     size  —— 转码前后必然不同(1.22MB→2.19MB),且本就在库里,零开销

   照片墙已接入。其余调用点可逐步铺开。
2026-11-05 13:11:44 +08:00
laoyang d6af211a8e 图片走 OSS 实时压缩:取图带 x-oss-process,本地存储不受影响 (#56)
用户 2026-11-05:「如果图片是放在本地服务器的…就不管它。
如果是传到 OSS 里面的…就用 OSS 的压缩就行了。」

查证:OSS 图片处理确实存在,取图 URL 加 x-oss-process 即可实时压缩,
原图不动、不占额外存储。

三处改动(缺一个都会出错):

1. OssDriver
   - get() 新增 $process 参数
   - authorization() 新增 $subresource:x-oss-process 属于子资源,
     必须计入 CanonicalizedResource,否则 403 SignatureDoesNotMatch
   - URL 拼接带上 ?x-oss-process=...

2. Storage::read()
   - 新增 $process,仅当驱动是 oss 时透传
   - 本地驱动忽略(用户明确要求「本地不管它」)
   - 带 process 时跳过"本地优先"缓存(那个缓存里是原图,
     拿原图当压缩版返回等于压缩没生效)

3. media.php
   - 新增 ?w= 参数,白名单三档 400/800/1600
   - 按尺寸分档缓存到 _proc/<w>/<key>,避免互相覆盖
   - 无 w 时行为完全不变(300+ 处既有 img 不受影响)

白名单而非透传的理由:OSS 图片处理按次计费,
放任 ?w= 任意值会让 ?w=1、?w=9999 这类请求把处理次数打上去。

实测(一张 6602 KB 的图):
  原图  6602 KB
  w=400    26 KB   省 99.6%
  w=800   108 KB   省 98%
  w=1600  468 KB   省 93%

应用位置(保守,先动最确定的):
  _attachments.php 照片墙缩略图 → w=400
  trips/show.php   行程题图     → w=1600
  下载链接一律不加 w(用户要原文件,不能偷偷降质)
2026-11-05 13:06:29 +08:00
laoyang 3c9fb22def 视频转码:上传时自动转 H.264,已有视频批量转换 (#57)
用户 2026-11-05:「以后所有视频全部转成H.264」

新增 app/Video.php:

- isVideo / available / probe / needsTranscode / toH264
- 判据:编码不是 h264 或像素格式不是 yuv420p/nv12 就要转
  (10bit、4:2:2 的 h264 浏览器也不认,光看编码不够)
- 参数:libx264 veryfast crf23 + yuv420p + aac 128k + faststart
  + scale=min(1920,iw):-2(4K 缩到 1080p,-2 保证高度偶数)
- 转完自检编码与像素格式,不对就不采用

Attachment::save() 接入:

- needsTranscode() 先判,已合规的不重复转(不白等几十秒)
- 转码失败不阻断上传,原样保存 + error_log 记原因
- 转完改 mime/ext 为 video/mp4,否则前端判断不出来
- 转码产物是临时文件,必须清理(否则塞满 /tmp)

批量转换已有视频(15 个):

- 13 个已是 h264 跳过,2 个 hevc 转成功
- 原地覆盖同 key,数据库引用全不断
- 原文件备份到 storage/_hevc_backup/

验证:4 个抽样全部 h264/yuv420p 或 yuvj420p,浏览器可播。
2026-11-05 13:01:39 +08:00
laoyang f58a128816 行程:#47~#51 打包 —— 双轨状态、动态类型、动作按钮、去提示 (#51)
#47 编辑浮层居中、类型换行、去掉手动状态选择、按钮改胶囊
#48 计划/实际双轨:完成即转实际行程,取消≠删除,记真实开始/结束时间
#50 类型点击无反应(@Builder 值传参不建立依赖)
#51 节点类型动态加载(服务端9种,原端上硬编码5种)+ 地址结构化回调

API 新增:GET /trips/<id>/node-types、PATCH /trips/<id>/nodes/action
DB 新增:trip_nodes.actual_started_at / actual_ended_at
2026-11-05 12:40:57 +08:00
laoyang a1140437a1 视频:修 media.php 的 304/Range 冲突,HEVC 解不了时给明确提示 (#52)
304 判定没有排除 Range 请求:视频第二次打开时浏览器同时发
Range + If-None-Match,命中 304 拿到空响应 ⇒ 黑屏。
改成 $hasRange 为真时跳过 304。顺手补 Last-Modified(部分浏览器需要它才允许 seek)。

另查明用户的视频是 H.265/HEVC,Chrome/Firefox 无法解码 —— 浏览器端无解,
检测到解码失败时显示提示卡片 + 下载入口,不再给一块黑屏。
2026-11-05 12:40:52 +08:00
laoyang f92a98a3a3 子任务跟随父分类,修复父子跨本导致客户端无法显示层级
用户问:「任务是带有子任务功能的,能否通过 caldav,把任务的子任务也同步给客户端?」

## 答案:能,但实测发现父子被分到了不同本

改前数据:

    父 #12「轻舟的幼儿园发票」  category_id = 1(家庭事务)→ tasks-1.ics
    子 #29「送财务报销」        category_id = NULL(收件箱) → tasks-inbox.ics

客户端渲染「家庭事务」时看不到子任务,渲染「收件箱」时又找不到父任务,
⇒ 不可能显示成层级,只能两条并列。

## 改法

### 1. 子任务跟随父分类(TaskDav::effectiveCat)

    有效分类 = 自己的 category_id
              ↓ 为空且是子任务时
              父任务的 category_id

⚠️ **不改数据库**,只在视图层算。
   改库等于篡改用户数据,而且单向不可逆。

边界:
| 情况 | 有效分类 |
|---|---|
| 自己无分类 + 父有分类 | 跟父(父子同本) |
| 自己无分类 + 父也无分类 | 都进收件箱(父子同本) |
| 自己有分类 | 用自己的(尊重用户明确设定) |

`tasksIn()` 改为 SQL 联查 `p.category_id`(一次到位,避免 N+1),
再在内存里按有效分类过滤。

### 2. SUMMARY 去掉硬加的「└ 」前缀

原来给子任务 SUMMARY 加「└ 」。这会**污染用户数据** ——
客户端把标题写回来(PUT)时前缀被存进库,用户的标题就多了一坨。

层级改由标准属性表达:
- `RELATED-TO:task-12` —— RFC 5545 §3.8.4.5 标准父子
- `X-FAMILYBOOK-PARENT:轻舟的幼儿园发票` —— 展示用补充,不支持的客户端无视

### 3. 跨本时的降级提示

用户明确给子任务设了别的分类时(尊重其选择),父子仍会跨本。
那种情况在 DESCRIPTION 尾部补一句人话说明,避免客户端把
「有 RELATED-TO 但找不到父」当成数据损坏而不显示这条。

## 验证

改后分布:

| 本 | 总数 | 父 | 子 |
|---|---|---|---|
| 家庭事务 | 7 | 6 | 1(#29) |
| 工作相关 | 19 | 19 | 0 |
| 个人事务 | 2 | 2 | 0 |
| 党支部工作 | 1 | 1 | 0 |
| 轻舟相关 | 1 | 1 | 0 |
| 收件箱 | 2 | 2 | 0 |

- 总数仍 32 条,没丢没重
- **所有父子都在同一本里**
- HTTP 端到端 39 项全过(含跨本隔离、鉴权、日历本回归)

## 客户端支持度说明(服务端无法改变)

| 客户端 | 层级显示 |
|---|---|
| DAVx⁵ + Tasks.org | 认 RELATED-TO,显示缩进子项 |
| 苹果提醒事项 | 有原生子任务 UI,对 RELATED-TO 支持有限 |
| Thunderbird | 不显示层级,两条并列 |

数据是正确的,显示成什么样取决于客户端能力。
想在所有客户端都看到层级,只能靠前端自己渲染 —— 那是网页端的事。
2026-11-02 16:07:45 +08:00
laoyang 610d524e68 CalDAV 待办改为按分类拆成多本(对齐参照 DAViCal)
用户提供参照服务器 nas.yangyq.net/caldav/synccalendartest(DAViCal 1.1.9)并指出
「应该可以直接显示那个家庭的待办分类」。

## 参照实测结果

那台服务器的待办本结构:

    /caldav/<user>/tfxnz/     家庭计划   VTODO
    /caldav/<user>/zlqlzm/    工作任务   VTODO
    /caldav/<user>/tdotbsnl/  个人成长   VTODO
    /caldav/<user>/home_todo/ Inbox     VTODO

⇒ 每个分类是**独立的 calendar 集合**,各自 supported-calendar-component-set
只含 VTODO,客户端里显示成并列的多个待办列表。

VTODO 正文还带 CREATED / LAST-MOD / DTSTAMP / SEQUENCE / PRIORITY /
PERCENT-COMPLETE / STATUS / DESCRIPTION / LOCATION / TRANSP。

## 上一版的问题

第一版做成「一个「家庭待办」本装全部任务」,所有分类混在一起,
客户端里只有一个列表 —— 用户要的是分类可见。

## 这一版

### 本的组织

    /dav/tasks-<categoryId>.ics/   每个 task_categories 一行 = 一本
    /dav/tasks-inbox.ics/          category_id 为空的任务(Inbox)

⚠️ 用 `tasks-<id>` 而不是裸 id:真实日历本是 `/dav/<id>.ics/`,
裸 id 会与它们撞号(calendar_events 与 task_categories 是两套独立序列)。

### 分类跟随「本」

客户端把资源 PUT 进哪一本,任务就归哪个分类。
这是「按分类拆本」的自然语义,也让「在客户端里移动任务到另一本」等价于改分类。

### 跨本隔离

GET/DELETE 单条时会校验「这条任务确实属于这个本」,
否则改一下 href 就能用 A 本读到 B 本的任务。

## 验证(39 项全过)

真实数据(5 个分类 / 32 条任务):

| 本 | 条数 |
|---|---|
| 家庭事务 | 6 |
| 工作相关 | 19 |
| 个人事务 | 2 |
| 党支部工作 | 1 |
| 轻舟相关 | 1 |
| 收件箱 | 3 |

每本 supported-calendar-component-set 均只含 VTODO,无 VEVENT。
PUT 到 tasks-1.ics 后 Location 返回 /dav/tasks-1.ics/task-40.ics,
GET 读回、更新、完成、删除全部正常。

回归:原有日历本(观影等)PROPFIND/GET 正常,组件类型仍是 VEVENT。
鉴权:无口令 401、不存在的分类本 404、非法段 404。

测试数据已清理,tasks 仍为 32 条。

## 文件

- app/TaskDav.php     重写为多本模型
- app/Ics.php         VTODO 导出/解析(上一版已加,本版未改)
- dav.php             五处拦截改多本

## 已知限制

看板名(正在进行/等待反馈/等待结果)通过 CATEGORIES 输出给客户端,
但客户端改它不会反向改看板 —— 标准 VTODO 没有「看板列」概念。
分类则双向(由「在哪一本」决定)。
2026-11-02 15:41:54 +08:00
laoyang c86658966c CalDAV 增加待办(VTODO)双向同步
用户原话:「网页端的 caldav 功能,除了能够同步日程外,增加可以同步任务(待办)的功能」

## 三个设计选择(用户已定)

1. 存放位置:作为 /dav/ 内的一个日历本,不另开 /taskdav/ 入口
2. 同步方向:直接双向读写
3. 字段映射:标准映射

## 字段映射

| VTODO | tasks 列 | 说明 |
|---|---|---|
| UID | 派生 task-<id> | 与日历 UID 空间隔离 |
| SUMMARY | title | 子任务加「└ 」前缀(纯展示) |
| DESCRIPTION | note | |
| DUE | due_at | 零点视为整天,用 VALUE=DATE |
| STATUS | status | todo→NEEDS-ACTION,doing→IN-PROCESS,done→COMPLETED |
| COMPLETED | done_at | 仅 done 时输出 |
| PERCENT-COMPLETE | status | doing=50,done=100 |
| CATEGORIES | 看板名 | 让客户端能归类 |
| RELATED-TO | parent_id | 标准父子表达 |

## 改动

### app/Ics.php:并列增加 VTODO 能力

不改 parse()/export() 的任何行为(VEVENT 路径注释里记着多轮踩坑史),
新增 exportTodos() / vtodo() / parseTodos() / normalizeTodo(),复用底层工具。

### app/TaskDav.php(新建,约 380 行)

虚拟日历本:href 固定 /dav/tasks.ics/,BOOK_ID = -1
(calendar_events.calendar_id 是 INT UNSIGNED 恒 ≥1,负数永不撞)。

权限口径与网页端完全一致,复用 Task::visibleTo / Task::canWrite /
Task::create / Task::update / Task::destroy,不另写一套。

### dav.php:前置拦截,原有日历逻辑零改动

PROPFIND / REPORT / GET / PUT / DELETE 各分支开头判断是否待办本,
是则走 TaskDav 并 exit。另外在 /dav/ 的本清单里追加待办本
(否则客户端根本不知道有这一本)。

## 过程中修掉的 4 个真 bug

1. **Class "TaskDav" not found** → 缺 use App\TaskDav;
   autoload 要求带上命名空间前缀,漏了会让整个 dav.php 全线 500。

2. **PUT/GET单条/DELETE 全 404** → 原来全用 isTaskHref(),
   它只比「整条路径 == /dav/tasks.ics/」,带子资源时必然 false。
   新增 isTaskPath() 做前缀匹配(且要求前缀后必须是 / 或结束,
   避免 /dav/tasks.icsXXX 被误判)。

3. **PUT 到不存在的资源名返回 403** → 原实现直接抛「任务不存在」。
   按 RFC 4918 §9.7,PUT 到不存在的 URI 就是创建资源。
   改为:只有确实存在且有权访问时才走更新分支,否则新建。

4. **注释里的 markdown 加粗导致 PHP 语法错**
   `作为 **/dav/ 内的一个日历本**` 里 `**/` 形成 `*/`,提前闭合块注释。
   PHP 注释里不要写 markdown 加粗。

## 验证

### Ics 层(34 项全过)
生成 → 解析 → 归一往返,含三种客户端完成态表达
(STATUS:COMPLETED / 只有 COMPLETED 时间戳 / PERCENT-COMPLETE:100)。

### TaskDav 层(45 项全过,真实 32 条任务)
读写、ETag 稳定性、权限、事务回滚。
写操作全在事务里做并回滚,不污染真实数据。

### HTTP 端到端(37 项全过)
OPTIONS → PROPFIND 发现 → REPORT → GET 整本 → PUT 新建
→ GET 读回 → PUT 更新 → DELETE,另含回归与越权检查:
- 原有日历本 PROPFIND/GET 仍正常,组件类型仍是 VEVENT
- 无口令 401、错口令 401、不存在的任务 404

测试按 RFC 跟随 Location(新建时服务端分配 id 并告知真实 href),
这也是真实客户端的行为。

### 数据完整性
测试前后 tasks 均为 32 条,测试数据已清理。
测试用 DAV 口令已 revoke(不影响用户原有口令)。

## 部署

app/Ics.php、app/TaskDav.php、dav.php 三个文件已上传并验证。

⚠️ 服务端文件不在客户端仓库,以后改这些要先比对服务器 md5 再验
(本次踩过:改了没上传,拿旧代码验出假结果)。
2026-11-02 15:29:36 +08:00
laoyang 821cfd1542 Holiday:去掉三处 400 天上限,农历/节日/节气在全量范围下不再丢失
## 问题

lunarRange / traditionalRange 都有 `$i < 400` 硬上限。
2026-10-30 客户端改成全量同步(1970~2100,47846 天)后,
这个「防异常」的保险变成「削正常功能」:
- 只算前 400 天 ⇒ **今天的农历是空的**
- 传统节日同样只剩前 400 天 ⇒ 今年的春节/中秋全丢

solarTermRange 另有一个问题:只取首末两年,
130 年范围下只算 1970 和 2100 的节气,中间 128 年全丢。

## 修法

- 三处统一改为:夹到 Lunar::MIN_YEAR~MAX_YEAR,再按
  MAX_LUNAR_YEARS(30 年)截断,去掉按天计数上限
- solarTermRange 改为逐年遍历

## 实测(部署后)

今天 2026-11-02 → 廿四(修复前为空)
今年 11 个传统节日、24 个节气,齐全
2021~2031 农历 4017 条(修复前 400),节气 264 条(修复前 48)
1970~2100 农历 10958 条 / 0.47 秒(按 30 年上限截断,符合设计)

## 注意

本文件不在客户端仓库,需单独上传服务器。
验证前必须比对服务器 md5,否则会拿旧代码验出假结果(本次踩过)。
2026-11-02 08:51:34 +08:00
laoyang ce752073db 品牌改名:index.php 里 site_name 兜底值 家书 → 岁月家(共 2 处) 2026-11-01 19:57:38 +08:00
laoyang 0b8012f5dd 品牌改名:家书 → 岁月家(网页端标题/logo/文案;首页标题改「某某某的家庭岁月」) 2026-11-01 19:55:40 +08:00
laoyang 7f3df7a40b 日程新建/修改不再刷新页面:服务端片段 + 就地重绘 (#37)
用户原话(2026-11-01):
  「修改和新建,都不应该刷新页面,而是使用js动态调整。」

## 改法:不再手写复刻渲染,改成"服务端渲染片段 → 前端整块替换"

历史上「新建不刷新」是靠手写复刻服务端渲染做的(`putMonth` / `putTimed` /
`relayoutColumn`),它自己的注释里就记着一次真实事故:新加一条 07:00 的日程被
append 到末尾 ⇒ 落进「+N 条」看不见,刷新后又跑到第一位。修改要处理的情况更多
(跨天 / 全天状态 / 换日历本改颜色 / 改重复规则 / 挪到别的月),复刻必然出错。

⇒ 现在只有**一处渲染**:前端取服务端画好的视图片段整块替换。
   "插进去的样子"与"刷新一次的样子"必然相同。

## 服务端

- `calendar/event_update` 认 `X-Requested-With`(与 event_add / event_del 同一套约定)
  回 JSON;失败也回 JSON(可预期的拒绝不该把用户甩到一个刷新过的页面上)。
  三处失败出口 + 四处成功出口收口成两个闭包,避免"某条路径仍刷页"。
- 抽出 `calendar_view_data()`:整页渲染与局部重绘共用同一份取数/建网格逻辑。
- 新增 `calendar/view` 片段路由(月/周/日视图片段)。
- 抽出 `home_agenda_events()` + 新增 `home/agenda` 片段路由(首页卡片)。
- 抽出 `templates/calendar/_view.php`:视图区从页面模板里搬出来(原文一字未改,
  已用脚本逐行比对证明 270 行零差异)。
- `templates/layout.php` 加载 `cal_sync.js`。

## 前端

- 新增 `assets/js/cal_sync.js`:`refresh()` 按当前视图取片段替换;
  只换内容不换容器;保首页卡片滚动位置;重绘后补跑红线定时器;
  失败退回整页刷新(宁可刷一次,也不要"没刷新却也没变")。
- `entry_popup.js` 的「保存」改 fetch 提交 → 关浮层 + 轻提示 + 局部重绘。
  名册/观影/行程/任务四个来源保持原生提交,范围不收不必要的回归面。
- 删掉日历页那 ~270 行手写插入,新建统一走同一个 `refresh()`。
- 「点格子新建」的 per-element 绑定改成 document 委托(重绘后新节点才有监听)。

## 本轮实测抓到并修掉的真 bug

`form.id` 被同名 input 遮蔽:日程编辑表里有 `<input name="id">`,
HTMLFormElement 的具名访问优先级高于原型上的 `id` ⇒ `form.id === 'calEditForm'`
恒为 false ⇒ 日程也走原生提交、整页刷新。
改用 `form.getAttribute('id')`。

## 验证(全部做过反向验证)

- 浏览器端到端 28 项:编辑/新建都不导航、改动原地可见、视图不跳、数据岛同步、
  重绘后点格子仍有效、红线定时器重建、无 JS 异常
- 反向验证 7 项:把 refresh 置空 / 改成 reload / 不调 reinit / 抹掉上下文属性,
  判据逐条转红(证明判据有牙齿)
- DOM 等价性 4 项:月/周/日视图与首页卡片,重绘后与刷新后的 DOM **逐字节相同**
  (这条判据本身也做了反向验证:删一个字、多一个节点都能抓到)
- 服务端 11 项:非 AJAX 仍 302 且真存库、AJAX 回 JSON、失败也回 JSON、
  片段路由可用、未登录被拒
- #35 跨天回归 11 项:三段的 seg/cont/is-seg-* 标记全在、贴满整列、不进全天行
- 静态检查:37 个模板片段无作用域违规;20 块内联 JS 语法全过;运行期零 Warning
2026-11-01 09:04:35 +08:00
laoyang 01d5bc262f 跨天日程:周/日视图按列分段画成一条,不再拆成碎片 laoyang/FamilyBook#35
用户 2026-10-30:「网页端,跨天日程显示还是有问题…如果一个日程跨了三天,
应该在日历本上怎么显示呢?在周视图和日视图,应该怎么显示?现在显示的有问题。」

## 修复前是什么样(实测,不是推断)

一个「11/10 20:00 → 11/12 02:00」的跨三天日程,服务端 month() 展开成三条
(首日带 time、中间天两者皆空、末日带 end_time),喂给 build_time_grid 后:

    全天行:11/11、11/12 各一条     ← 中间两天变成"全天日程"
    时间网格:11/10 一个小块,高度仅 2.08%(≈30 分钟)

三个问题叠加:
  · 开始日的结束时刻不在同一天 ⇒ 判成"没有结束" ⇒ 退化成 30 分钟
  · 中间/末日既无 time 又不是全天 ⇒ 被"按全天处理"的兜底塞进全天行
  · 三条互不相干,用户看不出"这是同一个日程"

## 方案(用户已确认)

  1. 周视图:跨列连续长条 —— 毎列各画一段,视觉连成一条
  2. 日视图:铺满当天并标延续
  3. 中间天归时间网格,不进全天行

## 改了什么

**app/Calendar.php** —— 服务端补两个字段
  · `span_days`:整条日程跨了几天(0 = 当天来回)
  · `seg`:start / mid / end / only
  同时修掉一个真 bug:原来 `$first` 是"窗口内遍历的第一个",
  日视图只查一天时,中间天会变成 `$first` 被当开始日处理。
  ⇒ 一律用**真实日期**跟 `$base`/`$base+span` 比较,不看遍历顺序。

**index.php::build_time_grid** —— 核心重构
  · 先按 `kind:ref_id:occ` 归堆,把同一日程的逐天条目合成一条
    (带 occ 是因为重复日程不同次发生的 ref_id 相同;带 kind 是因为
     观影/旅程的 ref_id 是各自表里的 id,`custom:5` 和 `movie:5` 是两回事)
  · 跨度以服务端 `span_days` 为准,不再用"窗口内铺了几列"推断
    (日视图下那个推断必然不成立)
  · 跨天条目拆成每列一段,按 `seg` 决定纵坐标:
    start = 开始时刻→24:00,mid = 整列,end = 0→结束时刻
  · 跨天条目**不参与 conflictLanes** —— 它必然占满整列,
    拿去分列会把同一天所有日程都挤窄

**templates/calendar/index.php** —— 分段渲染
  · `is-seg-start/mid/end` 类;延续段前面加 `↳` 标记,不重复显示时间
  · 跨天段用 `left:0; width:100%`,单天块保持原来的 `calc(2px + N%)`

**assets/css/app.css** —— 让三段看着连成一条
  · start 下圆角归零、下边框透明;mid 上下都平;end 上圆角归零、上边框透明
  · 段与段之间原本有 1px 白边 + 左右内缩,实测缝隙 10.9px,看着像断成三段
    ⇒ 跨天段贴满整列后降到 1.0px(只剩列边框)

## 为什么在列内画段,不做跨列绝对定位层

`.tg-body` 左边有 56px 固定 gutter,窄屏还会横向滚动
(`.cal-tg-card{overflow:auto}` + 每列 min-width 92px),`.tg-col` 又是
`overflow:hidden`。跨列层要自己算 gutter 偏移、跟着横向滚动跑、还不能被裁 ——
每个都是新坑。在列内画段天然对齐,代价只是段间有 1px 列边框缝,用 CSS 糊掉。

## 验证

**逻辑层(PHP,用真实 build_time_grid)**
  · 周视图跨三天:3 段(start/mid/end),全天行为空 ✓
  · 日视图三个位置(起始/中间/结束天)各自正确 ✓
  · 回归:单天 + 真全天仍正常;冲突分列仍生效;跨天与普通日程共存正常 ✓
  · 反向验证:用修复前的代码跑同一判据**正确转红**
    (旧版 timed=1 all_day=2,高度只有 2.08%),证明判据非恒真 ✓

**视觉层(Playwright + 真实 CSS + include 真实模板)32 项全过**
  · 横向连续:start→mid 缝隙 1.0px,mid→end 1.0px(判据 ≤2.5px)✓
  · 纵向:start 底边贴列底 0px;mid 占满整列(顶 0 / 底 0);
    end 顶边贴列顶 0px 且只占开头一小段 ✓
  · 样式:start 下圆角 0px + 下边框透明;mid 上下圆角 0;end 上圆角 0 +
    上边框透明 ✓
  · 无裁切:三段都落在列内 ✓
  · 回归:真全天仍在全天行;跨天不再出现在全天行;单天日程正常 ✓
  · 窄屏 375px:三段仍可见(w=91)✓

**反向验证 4/4 全部抓到**
  故意做坏四个变体(段留单天内缩 / 保留圆角 / 中间段只画 30% /
  下边框不透明),判据全部转红 —— 说明"32 项全过"不是因为判据恒真。

## 已知取舍

跨天段占满整列,同一天的普通日程与它在视觉上会重叠。
这是日历的固有取舍(Google 日历同样如此),用户确认的方案里也接受。
2026-10-30 23:43:10 +08:00
laoyang 73c84c1910 geo:移除地址联想代理接口(端上改用鸿蒙自带 Map Kit)laoyang/FamilyBook#33
用户指示「端侧不用高德,用鸿蒙自带的接口」。
端上已改用 @kit.MapKit 的 site.queryAutoComplete(),这个服务端代理随之无用:

- ApiHandler:删 case 'geo' 与 geo() 方法
- Geo:删 TIPS_URL 常量与 tips() 方法

网页端不受影响:它走浏览器端 JS API(AMap.AutoComplete),
从来不经过 /api/v1/geo/tips(已确认 addr_suggest.js 零引用)。

验证:接口返回 404(已删),calendar/books 等其它接口仍 401(正常鉴权)。
2026-10-30 08:11:47 +08:00
laoyang 29b2ca0e3c geo:新增地址联想接口 /api/v1/geo/tips(高德 inputtips 代理)laoyang/FamilyBook#36
鸿蒙端地点输入要「输入简单地址 → 下拉带详细地址」,但高德 Web 服务 key
不能进客户端(反编译/抓包即得,配额会被跑光、key 可能被封)。
网页端用的 JS API key + 安全密钥是域名白名单那套,原生端没有等价机制
⇒ 走服务端代理,与网页端 addr_suggest.js 同一个上游(/v3/assistant/inputtips)。

- Geo::tips():调高德 inputtips,把三套不齐的字段归一成
  name / detail(省市区+门牌) / district / address / lng / lat
- 过滤公交地铁线路(typecode 15xx)—— 对填地点没用
- 只看 body 里的 status 而不是 HTTP 码(配额用完高德回 200 + status=0)
- 少于 2 字不查,返回空数组而不是报错(阈值与网页端一致)
- ApiHandler::geo():GET only、要求登录(这是花配额的接口)

⚠️ 现有 key 是 JS API 类型,调 Web 服务接口返回 USERKEY_PLAT_NOMATCH(10009),
   需另配 Web 服务 key 才能真正工作。同样原因,既有 Geo::regeo() 一直是失效的
   (cityFromPoint 在走离线兜底),此问题另行处理。

解析逻辑已用 4 组模拟高德响应验证(同名可区分/行政区划兜底/线路过滤/
空 name 跳过/失败返回空),19 项全通过,含源码一致性检查。
2026-10-30 08:03:20 +08:00
laoyang df657e5e5a 地址联想:下拉候选补详细地址,同名地点可区分 laoyang/FamilyBook#32
用户 2026-10-30:「输入一个简单地址、下拉显示详细地址时,不能只把地点告诉我,
一定要把详细地址也放在后面。因为同一个地址可能有好几个,我根本不知道应该选哪一个。」

原来用的是 AMap.AutoComplete 的内置下拉 —— 它只画 name,同名地点
(多个「星巴克」、同一商场的不同入口)长得一模一样,无法判断该选哪个。
⇒ 改成「高德只当查询器、下拉自己画」:
  · 构造时不传 input,高德不再塞自己的面板
  · 输入防抖 260ms → ac.search(kw) → 自己渲染 result.tips
  · 每行 = 地名(14px 深色)+ 省市区/详细地址(12px 灰字)

选中后的既有行为原样保留(只是入口从高德的 select 事件换成自建候选的点击):
data-addr-fill 回填、data-addr-detail 写门牌、框下 .fb-addr-hint 提示、
fb-addr-picked 事件。

几处刻意的取舍:
  · 面板挂 body + position:fixed,不挂输入框父节点 —— 否则会被 .mv-row
    的两列网格挤掉、或被子表单弹框的 overflow:hidden 裁掉
  · placePanel 量不到尺寸(0x0)时**只跳过定位、不收起**。原来收起了,
    导致候选查到却一显示就消失(实测踩到)
  · 少于 2 字不查,并收起面板,避免残留旧候选被误点
  · 候选行用 textContent 逐个填,不拼 HTML(地点名含 < & 会破版/注入)

验证:jsdom 27 项(含 from/to 不串、SDK 未就绪降级、自愈)+
真实浏览器 54 项(桌面/手机视口 × 表单/弹框/贴底部三种布局)全通过;
反向验证 3 个坏变体(不渲染副标题/副标题为空/不挂 body)判据全部正确转红。
2026-10-30 07:37:55 +08:00
laoyang 42aff02687 读书上传+在线阅读+笔记导入:补 books/{upload,chapters,chapter,import_notes} #28
用户原话(2026-10-28):「鸿蒙端对齐网页端」。读书端上只有书架(状态/评分/进度/
笔记),上传解析、在线读、笔记导入都在网页端。

补四个 API 端点(模型 BookParser / Book::chapters/chapter/replaceChapters /
BookNoteImport 早已齐,纯粹没暴露):
· POST /books/upload       上传 EPUB/TXT 等 → 存文件 → BookParser 解析 →
                            建书 + replaceChapters + EPUB 自带封面
· GET  /books/<id>/chapters  章节列表(无正文)
· GET  /books/<id>/chapter?seq=N  单章正文
· POST /books/import_notes  粘贴读书笔记原文 → 解析 → 导入(不信前端结果)

判据:_i28_book_api.py 11/0(上传建书+落章节 · 章节列表 · 单章正文 ·
笔记导入),_i28_falsify.py 注掉 upload 分支变红。
2026-10-28 22:47:38 +08:00
laoyang b6dc60452a 电影影评+评论:修半成品并补全 /movies/<id>/{reviews,review,comments} #27
用户原话(2026-10-28):「鸿蒙端对齐网页端」。观影详情只能看平均分,
不能写影评/评论。盘点发现服务端其实有份**半成品且带 bug**:

· comments GET 写死"只有我写过影评才返回" —— 错,改为独立 reviews 端点;
· comments POST 调 addComment 参数**完全错位**(parent_id 传给了 feelings、
  target_person 传给了 parentId)—— 评论实际存不进;
· 缺影评/评论的删除端点。

现在对齐 Web 端 reviews 四条路由:
· GET    /movies/<id>/reviews   全部影评(含评论树)+ 我的影评 + 统计 + 感受目录
· POST   /movies/<id>/review    写/改我的影评
· DELETE /movies/<id>/review    删我的影评
· POST   /movies/<id>/comments  评论/回复(review_id/content/parent_id)
· DELETE /movies/<id>/comments?comment_id=N  删我的评论

判据:_i27_movie_review_api.py 22/0(写/改影评 · 列表含 mine · 评论/回复 ·
删评论级联 · 删影评 · 越权删别人影评被拒),_i27_falsify.py 注掉写分支变红。
2026-10-28 22:29:55 +08:00
laoyang ad71c80f48 任务共享:补 /tasks/<id>/shares API 端点 #26
用户原话(2026-10-28):「鸿蒙端对齐网页端」。任务端上无「共享给谁」入口,
Task::shares/setShare/dropShare 模型早就有(Web 端 tasks/share、tasks/unshare 在用),
只缺 API 子端点。

· GET/POST/DELETE /api/v1/tasks/<id>/shares —— 共享列表 / 加 / 取消
  (canWrite 才允许;Task::find 本来就带 shares 字段,详情一次就能拿到)

判据:_i26_task_share_api.py 13/0(共享增/列/删 · 越权被拒),
_i26_falsify.py 注掉 shares 分支变红。
2026-10-28 22:10:15 +08:00
laoyang 47247e39b9 资源附件+共享:补 /resources/<id>/{attach,shares} API 端点 #25
用户原话(2026-10-28):「鸿蒙端对齐网页端」。资源端上明说「附件与共享
还在网页端」,根因是资源 API 只有 title/content,附件与共享没接口。

补两组子端点(模型 Resource::ATTACH=resource、shares/setShare/dropShare 早就有):
· GET/POST/DELETE /api/v1/resources/<id>/attach —— 附件列表 / 传 / 删
  (删走 attachment_id query,与 tasks 同招;可写者才允许,别人家的 404)
· GET/POST/DELETE /api/v1/resources/<id>/shares —— 共享列表 / 加 / 取消
  (canWrite 才允许,越权 404 —— find 对看不到的资源返 null,不泄露存在性)

判据:_i25_resource_api.py 21/0(传/列/删附件 · 共享增/列/删 · 越权被拒且库里没变)
· _i25_falsify.py 注掉 attach 列表与 shares 分支各自变红。
2026-10-28 22:00:14 +08:00
laoyang e44f37e902 财务折线:补 /ledger/<id>/timeline API 端点 #24
用户原话(2026-10-28):「鸿蒙端对齐网页端」。财务概览卡「净资产变化」在
端上是空占位,数据源 Web 端早有(Ledger::timeline + ledger/total_chart),
只缺一个 API 给端上。

· GET /api/v1/ledger/<id>/timeline —— 返回时序点数组(date/net/asset/debt)
  加当前汇总。与 Web 端 total_chart 用**同一份 Ledger::timeline($id)**,
  两端曲线必然一致。

判据:_i24_timeline_api.py 8/0(2xx · items 数组 · 每点 net=asset-debt ·
有数据源),_i24_falsify.py 注掉 timeline → ④ 变红。
2026-10-28 21:47:27 +08:00
laoyang cd56a9d8fe 笔记图片块:补 API 上传子端点(图文混排的前半程)#23
用户原话(2026-10-28):「鸿蒙端对齐网页端」,笔记端上只支持纯文本,
图片块显示成「[图片]」,且编辑带图笔记会静默删光图片块。

这半程只改服务端、补一个缺口:
· POST /api/v1/notes/upload —— 笔记图片上传,返回附件 key。
  放在 notes() 的 $id>0 分支之前(上传是草稿态,不依赖笔记 id,
  新建笔记插图时还没有 id)。图片先挂 target_id=0,等 blocks 带
  key 提交时由 Note::claimUploads 认领(与 Web 端 notes/upload 同一套)。
  Attachment::save 只回 id 不回 key ⇒ 反查 Note::ATTACH=0 的 list 拿 key。
· dispatch 里 notes case 从 notes($id,$method) 改成 notes($id,$sub,$method),
  否则 $sub='upload' 根本传不进去(405)。

判据:
· _i23_note_image_api.py  11/0(上传返 key · 带 blocks 新建后 text+image
  两块都在 · PATCH 后图片块不被删 —— 这是 P0 数据丢失修复的核心证据)
· _i23_falsify.py 注掉 upload 分支 → ①② 变红(命中 2/2)
2026-10-28 21:34:43 +08:00
laoyang 7c86859ab9 笔记附件:修好拖放、传完回编辑态、选中即上传 #21
用户原话:「网页端,笔记应该可以同时上传多个附件。」

诊断后确认「多选」本来就是好的(multiple + name=file[] + 后端数组分支,
一次选 3 个混合类型真的存了 3 行)。真正坏的是另外两件事:

1) 拖放是假承诺。界面写「把文件拖到这里」,但没有任何 drop 监听器:
   拖完 input.files 仍是 0、POST 0 个文件。更糟的是 dragover 没有
   preventDefault ⇒ 浏览器按默认行为直接打开那个文件,正在写的笔记
   连未保存正文一起丢。
   修法:给框加 data-sd-for="noteAttInput",复用 app.js 里既有的
   bindInput() → bindZone()。一行属性,不另写一套拖放。
   踩过的坑:data-sd-for 写的是**裸 id**,第一版带了个 `#`,选择器
   变成 [data-sd-for="#noteAttInput"],一个都匹配不上,拖放照样没人接。

2) 传完跳回阅读态(redirect 不带 edit=1),附件区在 $editMode 分支里
   根本不渲染 ⇒ 用户看到的是"没传上"。attach_add / attach_del 两处
   都补 edit => 1。

另外补了即时反馈:change 一来就把提示换成「已选择 N 个文件:…,正在
上传…」并自动提交。用 requestSubmit(btn) 而不是 form.submit() ——
后者绕过 submit 事件、也不带按钮的 formaction/formenctype,提交会跑去
notes/save 且非 multipart,文件直接丢。

判据:
- _b40_note_multi_attach.py  16/0(点选多选 / 拖放 / 回编辑态 / 单选回归 / 提示文案)
- _b40_falsify21.py  三处注入各自红在预期编号(data-sd-for 带# → ③④a-d;
  requestSubmit→submit → ①-b;去掉 edit=1 → ③-a/b)
- _b40_regress21.py  20/0(删除生效且回编辑态 / 越权删除被拒 / 公共绑定器
  未受影响 / MAX_PER_HOST=60 端到端仍生效)
- _b40_regress21_falsify.py  关掉上限与归属校验各自变红
2026-10-28 20:13:22 +08:00
laoyang 4941e9db9c fix(home): 从浮层把任务标成完成时该行不消失(只修了复选框那条路)(#20)
用户第二次提同一条需求。核了线上代码:4 文件 md5 与本地一致、mtime 18:14,
部署没问题;复跑原判据仍 20/0 全绿。判据绿 + 用户能复现 ⇒ 判据有盲区。

盲区:首页把任务标成完成有**两个入口**
  ① 每行前面那颗复选框 —— home.php 末尾的脚本(上一版已修)
  ② 点整行弹浮层 → 面板里「状态」三段按钮 → assets/js/task_panel.js 的 syncRow()
上一版只改了 ①。syncRow() 只改徽章与复选框的 aria 状态,**从不删行**,
所以从面板点「已完成」后行留在原地,只把徽标换成「已完成」。
用户点的就是 ②,而原判据 grep 全文没有 panel / 浮层 / data-fb-entry —— 完全没测这条路。

改法:
- 把删行逻辑抽成 window.fbHomeTaskDrop(row, wasStatus) 放 assets/js/app.js,
  两个入口共用一份。作用域限定 [data-tk-home-host]:任务**看板**页的行也带
  data-task-row,那里勾完成只改状态、**必须保留该行**(看板按状态分栏,删了就没处看)。
- task_panel.js 的 syncRow() 在 d.status === 'done' 时调它;
  wasStatus 必须在改写 data-tk-status **之前**读,否则概述卡不知道该减「在做」还是「待办」。
- home.php 原来那份本地实现改成薄转发,避免两份实现改一处忘一处。
- 顺带修掉一条说谎的注释:原注释写"补偿了一个可撤回的提示",但代码里从来没有
  撤回按钮,只有一句 3.2 秒自动消失的 say()。
- _card_tasks.php 补记"两个入口"这件事。

子任务安全:task_panel_json() 用 panel_of 找**父任务**,回的 status 是父任务的,
所以面板里勾子任务不会把父任务那一行删掉(判据 ⑱ 专门守这条)。

判据:
- 新增 _b38_panel_done_drop.py(21 条,走真实浮层点击):
  修复前 **16 通过 / 5 失败**(⑥⑦⑧⑬⑭)→ 修复后 **21 / 0**
- 注入反证 _b38_falsify.py:把删行那两行用 if(false) 挡掉,锚点断言 count==1,
  立刻红回同样的 5 条、关键条目 ⑥⑧⑬⑭ 命中 4/4,副本自动还原
- 原 _b37_home_task_drop.py(复选框路径)仍 **20 / 0**
- _b37_regress.py **11 / 0**:看板仍改状态且**不删行**、详情页子任务复选框正常、
  空态 display 真的是 none
- home.php 内联 JS 0 块坏;app.js / task_panel.js node --check 全过
2026-10-28 18:29:59 +08:00
laoyang ea6e42c27f fix(home): 近期任务勾选完成后该行不消失(连概述卡计数也不减)(#20)
用户原话:「首页,近期任务,如果勾选了完成,应该刷新一下这个列表,把完成的删掉。」

## 原来的行为

首页那段复选框脚本 AJAX 成功后只改徽章文字(`kb-st` → 「已完成」),
**没有任何"移除这一行"的逻辑**。而且 templates/home.php:160 那条注释还写着
这是**当年的刻意设计**:

> ⚠️ 改完**不摘掉这一行**:用户点完正想看自己刚完成什么,立刻消失会让人怀疑
>    "是不是点到删除了"。刷新时由服务端决定它还在不在。

用户现在明确推翻这条:完成的就该从列表里删掉。

## 服务端本身是对的

首页路由只取 `doing` + `todo`(index.php:839),`done` 本来就不在列表里
⇒ 手动刷新这一行确实会消失,问题纯粹在"AJAX 完成后前端没同步"。
所以这次一行服务端代码都不用改。

## 改法

新增 `dropTaskRow(row, wasStatus)`,AJAX 成功后(**按服务端返回的 `d.status` 判断**,
不是拿点击前的 `next`)做三件事:

1. **删掉这一行**;
2. **同步「概述」卡右下的「N 件在做 / N 件待办」** —— 那两个数字与这份列表
   **同源**(同一个 `$tasks`,见 _card_brief.php:53),只删行不减去数字
   ⇒ 列表 2 条、概述说 3 件在做,自相矛盾;
3. **一条不剩时把空态提示露出来**(服务端渲染的 `[data-tk-empty]` 原来一直 hidden)
   —— 否则卡片变成一片空白,用户以为加载坏了。

配套的小改动:

- `_card_tasks.php`:空态提示改成**一直渲染、靠 `hidden` 切换**,加 `data-tk-empty`
  钩子(不靠匹配文案 —— 文案改一个字就失效)。外层加 `data-tk-home-host`。
- `_card_brief.php`:两个统计 `<a>` 加 `data-hbs="doing|todo"` 钩子,
  同样**不靠中文文案定位**(这是 JS 要读的东西,不能把展示文案变成接口)。
- `app.css`:补 `.empty-hint[hidden] { display: none !important; }` ——
  浏览器默认的 `[hidden]{display:none}` 优先级最低,哪天有人给它加 `display:flex`
  这句提示就会在"有任务时"也冒出来(与 app.css:601 那条注释是同一约定)。

## 几个判据上的讲究

- **按 `d.status` 判断而不是 `next`**:`tasks/status` 是幂等接口,
  拿服务端返回的状态才不会"手快连点两下,第二下把行删了"。
- **`wasStatus` 要在改 `data-tk-status` 之前存**:删行时要靠它决定减「在做」还是「待办」。
  第一版直接在删行时读 `data-tk-status`,读到的已经是 `done` —— 自己写的时候就踩了。
- **提示文案补一句"已从近期任务移出,去「全部」里能看到"**:
  删行的原罪是"用户以为点到删除了",所以必须**明说它去哪了**,
  而不是沿用服务端那句「已标成「已完成」」(那会让人以为行还在,下一眼发现没了更慌)。

## 验证

**主判据 `_tooling/_b37_home_task_drop.py` —— 20 通过 / 0 失败**

夹具:把库里历史 doing/todo 任务临时改状态腾空卡片(**不能靠 `deleted_at`** ——
`Task::byStatus()` 的 SQL 里根本没有 `deleted_at IS NULL`,软删对首页无效,实测过),
造 2 条 doing + 1 条 todo,跑完逐个恢复。

覆盖:勾「进行中」→ 行移除 + 「件在做」2→1 + 「件待办」不误减;
勾「待办」→ 行移除 + 「件待办」1→0 且链接藏起;
删到最后一条 → 0 行 + 空态露出;刷新后与服务端事实一致;库里状态真的是 done。

**注入反证 `_tooling/_b37_falsify.py` —— 三次独立注入,各自红在对应处**

| 注入 | 结果 | 命中的判据 |
|---|---|---|
| ① 不删行(原始 bug) | 通过 12 / **失败 8** | ③④⑤⑪⑭⑮ |
| ② 删行但不减概述卡计数 | 通过 17 / **失败 3** | ⑤⑫⑯ |
| ③ 不限空态(删完一片空白) | 通过 19 / **失败 1** | ⑮ |

每次注入前断言锚点唯一(#474),恢复后回到 20/0。

**回归 `_tooling/_b37_regress.py` —— 11 通过 / 0 失败**

看板勾选仍能改状态且**不删行**(与首页行为不同是有意的)、
详情页子任务复选框仍能改状态、`[hidden]` 的 `display` 真的是 `none`、
`data-hbs` 钩子在、卡片「全部」入口仍指向 tasks。

⚠️ 回归里踩了一个判据假设错:我一开始按"详情页也该有父任务复选框"写,
直接 FAIL。真实情况是详情页的复选框**只给子任务**(show.php:181 在子任务循环里),
父任务没有 —— 是判据错了不是产品坏了(#429 同族)。

**内联 JS 校验 `_tooling/_b37_inline_js_check.py`**:把渲染后 HTML 的 `<script>`
抽出来逐个 `node --check`(`php -l` 不看 HTML 里的 JS)。
并做了反证:喂一段坏 JS,它确实报 1 块坏,不是恒绿。

工单 #20
2026-10-28 18:13:49 +08:00
laoyang e733c4f057 fix(resources): 新建资源时带着上一次的附件(暂存清单从不复位)(#19)
用户原话:「资源模块,新建资源,会把之前的附件带进来,就是说,附件那里不是空的。」

## 根因

`#resAddCard` 是新建/编辑**共用**同一个弹窗(v42 第 7 条),
复位函数 `resetToCreate()` 清了标题/正文/归属人/共享人,**唯独漏了附件**:

- 缩略图卡片与隐藏令牌 `stage_token[]` 是 queueDrop() 上传成功后
  **动态插进 `.stage-list`** 的(assets/js/app.js:668),只有点卡片上的「×」才移除;
- 关弹窗只做 `addCard.hidden = true`,DOM 原样留着 ⇒ 下次打开就看见上一次的附件。

## 不只是"看着脏"

上传后**没保存就关掉**的那批,暂存目录还在(没被认领),令牌残留在表单里
⇒ 下次点「确定」时被 `StagedUpload::claim()` 认领到**新资源**上。

实测(`_tooling/_b36_res_attach_reset.py` 的 D1):
修复前新建的资源 id=3 附件数 **=1** —— 用户会看到"新建的资源莫名多了张照片"。
修复后同一路径附件数 **=0**。

## 改法

新增 `clearStaged()`:清 `.stage-cell` 卡片 + 兜底清 `input[name="stage_token[]"]`
+ 把 `.stage-list` 藏回 hidden(与模板初始状态一致)。
两处调用:

- `resetToCreate()` —— 点「新建资源」时
- `openEdit()` —— 点「编辑」时(openEdit 并不灌附件,已有附件走列表里独立的
  「附件」弹窗,所以此刻表单里的卡片一定是"上一次没做完的残留")

⚠️ 兜底清令牌这步不能省:用户看不见令牌,但它才是真正会被提交的东西
(DOM 删了、数据还在 —— #456 同族)。

## 验证

`_tooling/_b36_res_attach_reset.py`(走真实 UI,三条关闭路径 A/B/C + 数据层 D):

| | 修复前 | 修复后 |
|---|---|---|
| A 取消后再新建(卡片/令牌) | 1 / 1 | **0 / 0** |
| B 保存成功后再新建 | 1 / 1 | **0 / 0** |
| C Esc 关闭后再新建 | 1 / 1 | **0 / 0** |
| D 新建资源的实际附件数 | **1**(带进来了) | **0** |
| 合计 | 通过 6 / **失败 7** | **通过 13 / 失败 0** |

**判据有牙齿**:`_tooling/_b36_falsify.py` 把 `clearStaged()` 注入成第一行 `return;`
(等价修复前),判据立刻红回 7 条,恢复后 13/0。锚点命中数断言 =1。

`_tooling/_b36_res_regress.py` —— 回归 13/0,确认没弄坏既有能力:
编辑仍能灌入标题/正文/id、底栏「共享」「附件」两颗按钮仍显示、
「新建+上传+保存」附件照旧真的挂上(附件数=1)、
列表里独立的附件面板组件(`sd-attach-panel`)仍在。

顺带查了其它用 `_stage_drop.php` 的模块(books/calendar/movies/people/tasks/trips),
都没有"共用弹窗 + 复位"这套结构,同一个坑不存在。

工单 #19
2026-10-28 17:50:51 +08:00
laoyang fc81347e8d fix(notes): 调过宽度的图片保存后变孤儿(#18)
用户报「笔记功能,上传图片失败」。实际不是上传失败,是上传成功后
保存那一步把图片弄丢了。

根因:图片块 content 有两种形态
  - `photos/xxx.jpg`      —— 宽度 100% 时,前端不写后缀
  - `photos/xxx.jpg|50`   —— 宽度调到 20~99% 时,serialize() 加上宽度

但 Note::claimUploads() 与 Note::files() 直接拿 content 去匹配
attachments.key(`WHERE key IN (...)` / `isset($used[$key])`),
带后缀的那个**永远匹配不到**,于是:

  1. 草稿附件(target_id=0)认领失败 ⇒ 图片变孤儿,笔记里图没了
  2. files() 排除不掉已用作图片块的附件 ⇒ 同一张图既当图片块、
     又在底部附件区重复出现

默认 100% 不写后缀,所以表现为"有时好有时坏",
极易被误判成上传环节的问题。

修法:新增 Note::imageKey() 统一剥 `|宽度` 后缀,
claimUploads() 与 files() 都先过它。

刻意修在**服务端**、而不是"前端别写后缀":后者会把宽度功能废掉
—— 渲染时要按 content 里的宽度出图(见 templates/notes/form.php
的 $imgParts 与 templates/notes/index.php:98,那两处本来就剥了,
所以只有服务端这两处漏了,属于不对称实现)。

updateBlock() 走同一条 claimUploads(),自动被覆盖。

验证(本地 PHP 8.5 + MariaDB,真实 UI 操作,非手搓 DOM):
- 主判据 _tooling/_b35_width_falsify.py:修复前 100%→1712 成功 /
  50%→target_id=0;修复后两条路径都认领成功,7 通过 0 失败,
  且 content 仍带 `|50`(宽度功能没被牺牲)
- 附件不重复 _tooling/_b35_dup_attach.py:阅读态底部附件 0 条、
  图片块 1 张、img 61px vs figure 990px(宽度真的生效),5 通过 0 失败
- 判据有牙齿:把 claimUploads() 注入回旧形态后立刻 2 条 FAIL,
  恢复后回到 7 通过 0 失败

工单 #18
2026-10-28 16:03:36 +08:00
51 changed files with 7838 additions and 970 deletions
+77 -1
View File
@@ -282,6 +282,9 @@ final class Anniversary
'is_leap' => 0, 'is_leap' => 0,
'person_id' => $pid, 'person_id' => $pid,
'person_name' => (string)($p['name'] ?? ''), 'person_name' => (string)($p['name'] ?? ''),
/* #87:头像给端上画"生日只读卡"用(key,端上拼 URL) */
'avatar' => (string)($p['avatar'] ?? ''),
'avatar_color' => (string)($p['avatar_color'] ?? ''),
'source' => 'birthday_solar', 'source' => 'birthday_solar',
'born' => $solar, 'born' => $solar,
]; ];
@@ -299,6 +302,8 @@ final class Anniversary
'is_leap' => (int)($p['birthday_lunar_leap'] ?? 0), 'is_leap' => (int)($p['birthday_lunar_leap'] ?? 0),
'person_id' => $pid, 'person_id' => $pid,
'person_name' => (string)($p['name'] ?? ''), 'person_name' => (string)($p['name'] ?? ''),
'avatar' => (string)($p['avatar'] ?? ''),
'avatar_color' => (string)($p['avatar_color'] ?? ''),
'source' => 'birthday_lunar', 'source' => 'birthday_lunar',
'born' => $lunar, 'born' => $lunar,
]; ];
@@ -586,7 +591,24 @@ final class Anniversary
public static function occurrences(int $familyId, int $viewerId, int $year): array public static function occurrences(int $familyId, int $viewerId, int $year): array
{ {
$out = []; $out = [];
foreach (self::all($familyId, $viewerId) as $a) { $all = self::all($familyId, $viewerId);
/* 🔴🔴 #91d(用户 2026-11-05):「你去查一下这个领证纪念日到底关联了几个人,
数据库里。」
查库结果:`anniversaries #2` 的**老列** `person_id = 1`(只能存一个),
但 `anniversary_people` 里有 **2 人**(杨永全 + 洪博为)。
端上编辑框只读老列 ⇒ 只带出 1 个人,另一个被静默丢掉。
⇒ 在这里一次性把多对多关系查出来。
⚠️ **一次查全表**而不是逐条查:`peopleMap()` 正是为此写的
(`split()` 早就这么用了;这里漏了)。
⚠️ 不能直接用老列兜底就完事 —— 老列是升级前的遗留字段,
两边不一致时**以多对多表为准**(用户在新版界面上改的是那张表)。
⚠️ 多对多表里没有行时才退回老列(升级过渡期还没回填的条目)。 */
$pmap = self::peopleMap(array_column($all, 'id'));
foreach ($all as $a) {
$repeat = (string)($a['repeat_type'] ?? 'solar'); $repeat = (string)($a['repeat_type'] ?? 'solar');
$m = (int)($a['month'] ?? 0); $m = (int)($a['month'] ?? 0);
$d = (int)($a['day'] ?? 0); $d = (int)($a['day'] ?? 0);
@@ -644,6 +666,60 @@ final class Anniversary
在这里一次性算好,别让每个渲染点各自去查一遍配置。 */ 在这里一次性算好,别让每个渲染点各自去查一遍配置。 */
'icon' => self::iconOf($familyId, (string)($a['kind'] ?? '')), 'icon' => self::iconOf($familyId, (string)($a['kind'] ?? '')),
'person_id' => (int)($a['person_id'] ?? 0), 'person_id' => (int)($a['person_id'] ?? 0),
/* #91d:**完整关联人列表**(多对多表为准,空则退回老列)。
端上编辑纪念日要带出全部关联人、只读卡要显示"关联了谁"。 */
'person_ids' => (function () use ($pmap, $a): array {
$aid = (int)($a['id'] ?? 0);
if ($aid > 0 && !empty($pmap[$aid])) {
return array_map('intval', $pmap[$aid]);
}
$one = (int)($a['person_id'] ?? 0);
return $one > 0 ? [$one] : [];
})(),
/* 🔴 #87(用户 2026-11-05):「如果这个纪念日来自生日…
点击之后,显示这个人的头像、姓名,以及生日的『日』和日期就可以了。」
⇒ 端上要画头像,所以这里必须带出来。
原来 `birthdays()` 算出来的 `person_name` 在这里**被丢掉了**
—— `occurrences()` 逐个字段挑着抄,漏了它。
⇒ 一并补 `person_name` + `avatar` + `avatar_color`。
`avatar` 是 key(如 `avatars/2026xxx.jpg`),
端上自己拼完整 URL(与任务负责人头像同一口径)。
⚠️ `all()` 里自建纪念日那批的 `person_name` 是 `''`
(自建纪念日可能关联多人、也可能不关联人),
这里原样透传即可,端上"名字为空就不画头像"。 */
'person_name' => (string)($a['person_name'] ?? ''),
'avatar' => (string)($a['avatar'] ?? ''),
'avatar_color' => (string)($a['avatar_color'] ?? ''),
/* 「日」—— 用户明确要"生日的『日』"。
直接给整数,端上做"大字显示"(如「17」)。
⚠️ 用 `$a['day']` 而不是从 `$date` 里截:
`$a['day']` 是**用户填的那一天**(农历生日时是农历日),
`$date` 是换算出来的公历日期 —— 两者在农历生日上不同,
用户要看的是"生日那天"(农历初五就该显示 5)。 */
'day' => (int)($a['day'] ?? 0),
'month' => (int)($a['month'] ?? 0),
/* 🔴 #88(用户 2026-11-05):卡片右侧要显示两种日期写法 ——
「如果是农历的话,就要写上农历十月二日。下面还要写上阳历:
换算完之后,今年阳历是多少?比如11月10日」
⇒ 服务端直接给**成品文字**,端上不自己拼:
· `lunar_label` = 「十月二日」/「闰四月初五」(农历生日才有;其余空)
· `solar_md` = 「11月10日」(今年实际发生那天的公历月日)
⚠️ 农历文字为什么不在端上拼:`Lunar` 有完整的月名/日名对照表
(正月/冬月/腊月、初一/廿三…),端上再抄一份必然漂移,
而且闰月(闰四月)这种特例得一起处理。
⚠️ `solar_md` 用 `$date`(今年换算后的实际日期)而不是 `$a['month']/$a['day']`:
农历生日的 month/day 是**农历**月日,直接拼出来会是错的公历。
⚠️ 农历生日时 `lunar_label` 用 `$a['month']/$a['day']`(才是农历月日);
公历生日时留空 —— 那时"农历"这一行没有意义。 */
'lunar_label' => ($repeat === 'lunar' && $m >= 1 && $d >= 1)
? (\App\Lunar::monthName($m, !empty($a['is_leap'])) . \App\Lunar::dayName($d))
: '',
'solar_md' => (strlen((string)$date) >= 10)
? ((int)substr((string)$date, 5, 2)) . '月' . ((int)substr((string)$date, 8, 2)) . '日'
: '',
'source' => (string)($a['source'] ?? 'custom'), 'source' => (string)($a['source'] ?? 'custom'),
'note' => (string)($a['note'] ?? ''), 'note' => (string)($a['note'] ?? ''),
'id' => (int)($a['id'] ?? 0), 'id' => (int)($a['id'] ?? 0),
+865 -40
View File
File diff suppressed because it is too large Load Diff
+43 -1
View File
@@ -83,8 +83,45 @@ final class Attachment
} }
$key = (string)$up['file']; $key = (string)$up['file'];
} else { } else {
/* 🔴🔴 #57(用户 2026-11-05:「以后所有视频全部转成 H.264」)
视频先转码再存。
为什么必须转:用户的视频是手机拍的 H.265/HEVC,
Chrome/Firefox **无法解码**(不存在能解码 HEVC 的前端 JS 库)。
只能在服务端转成 H.264。
⚠️ `needsTranscode()` 先判:已经符合 H.264 + yuv420p 的**不重复转**
—— 用户传合规视频时不该白等几十秒。
⚠️ **转码失败不阻断上传**:原样存。转码可能因磁盘满/编码异常失败,
不该因此让用户传不了;原文件还能下载后用本地播放器看。
⚠️ 转完要改 `$mime` / `$ext`,否则入库的 MIME 还是 video/quicktime
之类,前端又判断不出来。
⚠️ 转码产物是临时文件,**必须清掉**(撒了会塞满 /tmp)。 */
$transcodeNote = '';
if (Video::isVideo($mime) && Video::needsTranscode($tmp)) {
$tr = Video::toH264($tmp);
if (!empty($tr['ok'])) {
$newPath = (string)$tr['path'];
@unlink($tmp); // 原文件不要了
$tmp = $newPath;
$mime = 'video/mp4';
$ext = 'mp4';
$transcodeNote = (string)($tr['note'] ?? '');
$size = (int)@filesize($tmp);
} else {
/* 转码失败:保留原文件,记一笔原因(不阻断上传) */
$transcodeNote = '转码失败,已保存原文件:' . (string)($tr['error'] ?? '');
error_log('[Attachment] 视频转码失败 target=' . $type . '/' . $targetId
. ' err=' . (string)($tr['error'] ?? ''));
}
}
$key = 'files/' . bin2hex(random_bytes(12)) . ($ext !== '' ? '.' . preg_replace('/[^a-z0-9]/', '', $ext) : ''); $key = 'files/' . bin2hex(random_bytes(12)) . ($ext !== '' ? '.' . preg_replace('/[^a-z0-9]/', '', $ext) : '');
$res = Storage::putFile($familyId, $tmp, $key, $mime !== '' ? $mime : 'application/octet-stream'); $res = Storage::putFile($familyId, $tmp, $key, $mime !== '' ? $mime : 'application/octet-stream');
/* 转码产物用完即删 —— 它是临时文件,不能留在 /tmp */
if ($transcodeNote !== '' && strpos($tmp, '.h264.mp4') !== false) {
@unlink($tmp);
}
if (empty($res['ok'])) { if (empty($res['ok'])) {
return ['ok' => false, 'error' => '保存失败:' . (string)($res['error'] ?? '')]; return ['ok' => false, 'error' => '保存失败:' . (string)($res['error'] ?? '')];
} }
@@ -104,7 +141,12 @@ final class Attachment
self::nextPosition($type, $targetId), self::nextPosition($type, $targetId),
$uploaderId > 0 ? $uploaderId : null, $uploaderId > 0 ? $uploaderId : null,
]); ]);
return ['ok' => true, 'id' => (int)$db->lastInsertId()]; return [
'ok' => true,
'id' => (int)$db->lastInsertId(),
/* 转码说明(空串 = 没转,不需要提示) */
'note' => $transcodeNote ?? '',
];
} }
/** 删一个附件(连存储对象一起) */ /** 删一个附件(连存储对象一起) */
+2 -2
View File
@@ -195,12 +195,12 @@ final class Auth
/** /**
* 首页标题 —— 「以我为中心」最直接的一处落点。 * 首页标题 —— 「以我为中心」最直接的一处落点。
* 登录后看到的不是「家书」,而是「<我的名字>的家书」。 * 登录后看到的不是品牌名「岁月家」,而是「<我的名字>的家庭岁月」。
*/ */
public static function homeTitle(): string public static function homeTitle(): string
{ {
$name = self::name(); $name = self::name();
return $name === '' ? '家书' : $name . '的家书'; return $name === '' ? '岁月家' : $name . '的家庭岁月';
} }
/** 未登录跳登录页 */ /** 未登录跳登录页 */
+68 -13
View File
@@ -4,12 +4,12 @@
* *
* 三条核心设计(用户 2026-10-01 第 7 条逐条对应): * 三条核心设计(用户 2026-10-01 第 7 条逐条对应):
* ① **每人可建日历本**(自定义名字 + 自定义颜色),并能共享给别人(只读 / 可写)。 * ① **每人可建日历本**(自定义名字 + 自定义颜色),并能共享给别人(只读 / 可写)。
* ② **观影与旅行是每人默认的两本**,名字就叫「观影」「旅行」,内容**从 events 派生** —— * ② **观影与旅程是每人默认的两本**,名字就叫「观影」「旅程」,内容**从 events 派生** ——
* 不复制一份进 calendar_events,否则"改了观影记录、日历还是旧的"。 * 不复制一份进 calendar_events,否则"改了观影记录、日历还是旧的"。
* 反过来,在日历里给观影本新增日程 = 写一条 events(kind='movie')(真的成为一条观影记录)。 * 反过来,在日历里给观影本新增日程 = 写一条 events(kind='movie')(真的成为一条观影记录)。
* ③ **没有具体时分的日程 = 全天日程**(all_day=1)。 * ③ **没有具体时分的日程 = 全天日程**(all_day=1)。
* *
* 日历其实是"综合视图":自定义日程 + 观影 + 旅行 + 有截止时间的任务, * 日历其实是"综合视图":自定义日程 + 观影 + 旅程 + 有截止时间的任务,
* 各模块各自出数据,日历只负责按日期摆出来(见 Calendar::month())。 * 各模块各自出数据,日历只负责按日期摆出来(见 Calendar::month())。
*/ */
@@ -49,7 +49,15 @@ final class Calendar
custom 排最前,所以它天然就是"新建日程"的默认本。 */ custom 排最前,所以它天然就是"新建日程"的默认本。 */
'custom' => ['我的日程', '#4A7C82'], 'custom' => ['我的日程', '#4A7C82'],
'movie' => ['观影', '#7A4E8C'], 'movie' => ['观影', '#7A4E8C'],
'trip' => ['旅行', '#3F7D5A'], /* 🔴 #71(用户 2026-11-05):「旅程的日历本同步到日历本,它的日历本名字
就叫"旅程",观影就叫"观影"。我看现在"旅程"叫"旅行",这个不对,应该改过来。」
⇒ 「旅行」→「旅程」。模块本身一直叫"旅程"(页面/菜单/接口全是旅程),
只有这本默认日历的名字是"旅行" —— 用户进日历看到"旅行"会觉得是另一个东西。
⚠️ 改这里只影响**新家庭首次 seed**;已有家庭的库里数据要单独 UPDATE
(见同批部署的 SQL)。
⚠️ `movie` 那条**不要动** —— 用户明确说"观影就叫观影",本来就是对的。 */
'trip' => ['旅程', '#3F7D5A'],
'lunar' => ['农历', '#8C6D3F'], 'lunar' => ['农历', '#8C6D3F'],
'holiday' => ['节假日', '#B0342C'], 'holiday' => ['节假日', '#B0342C'],
// v24 第 12 批 #4(用户原话:「增加系统日历本,一个是传统节日…再增加一个 // v24 第 12 批 #4(用户原话:「增加系统日历本,一个是传统节日…再增加一个
@@ -1526,9 +1534,29 @@ final class Calendar
$vetm = $vd['ends_at'] ? substr((string)$vd['ends_at'], 11, 5) : ''; $vetm = $vd['ends_at'] ? substr((string)$vd['ends_at'], 11, 5) : '';
// 跨天日程:从这次发生的当天起,按事件本身的长度铺开 // 跨天日程:从这次发生的当天起,按事件本身的长度铺开
$span = (int)floor((strtotime($bed) - strtotime($bsd)) / 86400); $span = (int)floor((strtotime($bed) - strtotime($bsd)) / 86400);
$first = true; /* 🔴 v88(工单 #35):`$first` 原来是"窗口内遍历的第一个",**这是错的**。
foreach (self::eachDay($base, date('Y-m-d', strtotime($base . ' +' . $span . ' day')), $from, $to) as $d2) { 日视图只查一天时,如果那天正好是跨天日程的**中间天**,
$isLast = ($d2 === date('Y-m-d', strtotime($base . ' +' . $span . ' day'))); 它就成了 `$first` ⇒ 被当作"开始日"处理(但 `time` 是空的),
于是落到全天行;同理 `$isLast` 也会把中间天当结束日。
⇒ 一律用**真实日期**跟 `$base`/`$base+span` 比较,不看遍历顺序。
另外把「这条跨度」本身也带出去(`span_days` / `seg`),
让端上/网页端不必再靠"同一次发生铺了几条"去猜 ——
那个推断在窗口被裁剪时(日视图)必然不成立。 */
$baseEnd = date('Y-m-d', strtotime($base . ' +' . $span . ' day'));
foreach (self::eachDay($base, $baseEnd, $from, $to) as $d2) {
$isFirst = ($d2 === $base);
$isLast = ($d2 === $baseEnd);
// 段形态:整天(span=0)就是 only
if ($span === 0) {
$segKind = 'only';
} elseif ($isFirst) {
$segKind = 'start';
} elseif ($isLast) {
$segKind = 'end';
} else {
$segKind = 'mid';
}
$out[] = [ $out[] = [
'cal' => (int)$e['calendar_id'], 'cal' => (int)$e['calendar_id'],
'color' => (string)($c['color'] ?? '#4A7C82'), 'color' => (string)($c['color'] ?? '#4A7C82'),
@@ -1536,12 +1564,17 @@ final class Calendar
'title' => (string)$vd['title'], 'title' => (string)$vd['title'],
'date' => $d2, 'date' => $d2,
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻 // 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
'time' => ($first && empty($vd['all_day'])) ? $vtm : '', 'time' => ($isFirst && empty($vd['all_day'])) ? $vtm : '',
// 结束时刻同理:只落在**最后一天**上(那才是它真正结束的那天) // 结束时刻同理:只落在**最后一天**上(那才是它真正结束的那天)
'end_time'=> ($isLast && $vetm !== '' && empty($vd['all_day'])) ? $vetm : '', 'end_time'=> ($isLast && $vetm !== '' && empty($vd['all_day'])) ? $vetm : '',
'all_day' => !empty($vd['all_day']), 'all_day' => !empty($vd['all_day']),
'kind' => 'custom', 'kind' => 'custom',
'ref_id' => (int)$e['id'], 'ref_id' => (int)$e['id'],
/* 跨度信息(v88):网页端据此把一条画成多列分段;
日视图下窗口只给一天也不会判错。
`span_days` = 跨了几天(0 表示当天来回)。 */
'span_days' => $span,
'seg' => $segKind,
// 🔴 v43(用户第 6 条):条目详情浮层要显示"更多信息", // 🔴 v43(用户第 6 条):条目详情浮层要显示"更多信息",
// 所以地点/备注/状态也要带出去(原来模板只用得到 title/time)。 // 所以地点/备注/状态也要带出去(原来模板只用得到 title/time)。
'location' => (string)($vd['location'] ?? ''), 'location' => (string)($vd['location'] ?? ''),
@@ -1553,7 +1586,6 @@ final class Calendar
'_can_write' => $canW, '_can_write' => $canW,
'href' => url('calendar/event', ['id' => (int)$e['id'], 'd' => $day]), 'href' => url('calendar/event', ['id' => (int)$e['id'], 'd' => $day]),
]; ];
$first = false;
} }
} }
} }
@@ -1612,6 +1644,9 @@ final class Calendar
$st = Database::get()->prepare( $st = Database::get()->prepare(
"SELECT n.id AS node_id, n.title AS node_title, n.starts_at, n.ends_at, "SELECT n.id AS node_id, n.title AS node_title, n.starts_at, n.ends_at,
n.node_type, n.status AS node_status, n.stage AS node_stage, n.node_type, n.status AS node_status, n.stage AS node_stage,
n.travel_mode,
n.from_place, n.to_place,
n.from_lng, n.from_lat, n.to_lng, n.to_lat,
e.id AS event_id, e.title AS trip_title e.id AS event_id, e.title AS trip_title
FROM trip_nodes n FROM trip_nodes n
JOIN events e ON e.id = n.event_id AND e.kind = 'trip' JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
@@ -1637,11 +1672,19 @@ final class Calendar
'cal' => (int)$c['id'], 'cal' => (int)$c['id'],
'color' => (string)$c['color'], 'color' => (string)$c['color'],
'calname' => (string)$c['name'], 'calname' => (string)$c['name'],
// 用户要"一眼看出这是计划还是实际":实际行程加个 ✓ 前缀。 /* 🔴 #67(用户 2026-11-05,两条):
// 计划行程保持原样(日历里大多数是它,加前缀反而吵)。 ① 「这个图标应该来自里面具体的交通工具,也就是说,如果是火车,
'title' => Trip::typeIcon((string)$n['node_type']) 就应该显示火车。」
. ((string)($n['node_stage'] ?? 'plan') === 'actual' ? ' ✓ ' : ' ') ⇒ 原来只按 `node_type` 取,`typeIcon('move')` 恒为 🚗 ——
. (string)$n['node_title'], 「赶路」永远显示汽车,哪怕实际坐的是火车。
改用 `leadIcon()`(出行方式优先、节点类型兜底),
它正是旅行详情页行程卡左侧用的同一个方法。
② 「后面这个对号是什么意思?…我感觉没必要,直接把这个对号去掉。」
⇒ 删掉原来插在标题里的 `' ✓ '`。 */
'title' => Trip::leadIcon((string)$n['node_type'],
(string)($n['travel_mode'] ?? ''),
$familyId)
. ' ' . (string)$n['node_title'],
'date' => $day, 'date' => $day,
'time' => $first ? ($tm === '00:00' ? '' : $tm) : '', 'time' => $first ? ($tm === '00:00' ? '' : $tm) : '',
// 结束时刻只落在节点跨天的最后一天(与自定义日程同一口径) // 结束时刻只落在节点跨天的最后一天(与自定义日程同一口径)
@@ -1649,6 +1692,18 @@ final class Calendar
'all_day' => $first && $tm === '00:00', 'all_day' => $first && $tm === '00:00',
'kind' => 'trip', 'kind' => 'trip',
'ref_id' => (int)$n['node_id'], 'ref_id' => (int)$n['node_id'],
/* 🔴 #67(用户:「如果行程里面有地址,也就是有出发地和目的地,
应该像行程里显示的那样,把它显示在下面,这样一点就可以导航。」)
⇒ 原来这一段的 SQL **完全没查** from_* / to_*,
日历里只剩一个标题,用户看不出这段是从哪到哪。
带坐标是因为导航要用:没有 lat/lng 只能搜地名,
同名的地点会搜错城市(这个坑在旅程模块已经踩过一次)。 */
'from_place' => (string)($n['from_place'] ?? ''),
'to_place' => (string)($n['to_place'] ?? ''),
'from_lng' => (string)($n['from_lng'] ?? ''),
'from_lat' => (string)($n['from_lat'] ?? ''),
'to_lng' => (string)($n['to_lng'] ?? ''),
'to_lat' => (string)($n['to_lat'] ?? ''),
'href' => url('trips/show', ['id' => (int)$n['event_id']]) 'href' => url('trips/show', ['id' => (int)$n['event_id']])
. '#node-' . (int)$n['node_id'], . '#node-' . (int)$n['node_id'],
]; ];
+225
View File
@@ -474,6 +474,101 @@ final class Geo
* 注意「青岛胶东国际机场」能提出来是因为"青岛"是**已知城市名**(离线表里有), * 注意「青岛胶东国际机场」能提出来是因为"青岛"是**已知城市名**(离线表里有),
* 不是靠猜。 * 不是靠猜。
*/ */
/**
* 把任意「行政区划串」归一化为**纯市名**(「青岛市」)。
*
* 🔴 2026-11-05 新增。用户原话:
* 「它那个城市原来我说的是指定城市就可以,比如北京市、青岛市。
* 现在弄得很乱,什么省也带上了,市也带上了,区甚至都带上了,
* 这没必要,直接城市就可以。」
*
* ## 为什么必须在服务端做
*
* 前端(网页 `addr_suggest.js`、鸿蒙端)都可能传进「山东省青岛市市南区」,
* 这是历史行为。靠"前端自觉"不可靠:
* · 老版本 App 还在用;
* · 第三方/脚本调用不经过我们的前端;
* · 老数据已经脏了。
* ⇒ 服务端统一归一化,前端传什么都剥成市名。**这是唯一的真防线。**
*
* ## 取值顺序
*
* ① 已经就是纯市名(「青岛市」)→ 原样返回
* ② 含「XX市」→ 取**第一个**(中文地址是 省→市→区 层级顺序)
* ⚠️ 不能取最后一个:「黑龙江省黑河市五大连池市」会得到县级市
* 「五大连池市」;「山东省青岛市市南区」的"市南区"里还有"市"字
* ③ 直辖市不带"市"字(「北京朝阳区」)→ 开头匹配四直辖市
* ④ 都没有 → 返回空串(**不猜**)
*
* @param string $raw 可能带省/区/空格/「·」分隔的行政区划串
* @return string 纯市名;认不出返回空串
*/
public static function cityOnly(string $raw): string
{
$s = trim($raw);
if ($s === '') {
return '';
}
/* 已经是纯市名(最常见的正常情况)→ 直接返回,不做正则 */
if (mb_substr($s, -1, 1, 'UTF-8') === '市' && mb_strpos($s, ' ') === false
&& mb_strpos($s, '·') === false && mb_strpos($s, '省') === false
&& mb_strpos($s, '区') === false && mb_strpos($s, '县') === false) {
return $s;
}
/* 带「·」的(老格式「山东·青岛」)→ 取后半段再走一遍 */
$dot = mb_strpos($s, '·');
if ($dot !== false) {
$s = trim(mb_substr($s, $dot + 1, null, 'UTF-8'));
if ($s !== '') {
/* 「青岛」这种不带市的也要认 —— 补上市字再返回 */
if (mb_substr($s, -1, 1, 'UTF-8') === '市') {
return $s;
}
if (!preg_match('/[省区县旗盟]/u', $s)) {
return $s . '市';
}
}
}
/* 空格分隔的(「山东省 青岛市 市南区」)→ 逐段找第一个「XX市」 */
$segs = preg_split('/\s+/u', $s) ?: [];
foreach ($segs as $seg) {
$got = self::firstCityWord($seg);
if ($got !== '') {
return $got;
}
}
/* 整串里找第一个「XX市」 */
$got = self::firstCityWord($s);
if ($got !== '') {
return $got;
}
/* 直辖市不带"市"字(「北京朝阳区」) */
foreach (self::MUNICIPALITIES as $m) {
$short = rtrim($m, '市');
if ($short !== '' && mb_strpos($s, $short) === 0) {
return $m;
}
}
return '';
}
/**
* 从一个片段里取**第一个**「XX市」,XX 中不允许出现层级字眼。
*
* ⚠️ `[^省市县区旗盟]` 这个字符类不能省:
* 否则「山东省青岛市」会匹配到「山东省市」(把"山东省"当成了城市名前缀)。
*/
private static function firstCityWord(string $s): string
{
if ($s === '') {
return '';
}
if (preg_match('/([^\s省市县区旗盟的了吧呢啊个这那有字方地]{2,6})市/u', $s, $m)) {
return $m[1] . '市';
}
return '';
}
public static function cityFromText(string $text): string public static function cityFromText(string $text): string
{ {
$t = trim($text); $t = trim($text);
@@ -636,6 +731,136 @@ final class Geo
} }
/** 高德 **Web 服务** key(服务端逆地理用;没配返回空串 → 自动走离线兜底) */ /** 高德 **Web 服务** key(服务端逆地理用;没配返回空串 → 自动走离线兜底) */
/**
* 地址联想(高德输入提示)。
*
* 🔴 2026-11-03 新增。鸿蒙端 Map Kit 报 1002600003(AGC 认证失败),
* 而 AGC 那条链路需要开地图服务 + 重新生成 Profile,可能等 24 小时。
* ⇒ 加这个接口:端上走我们的服务器,服务器用**高德 Web 服务 key** 调 inputtips。
*
* 好处(不只是绕过 AGC):
* · **数据源与网页端完全一致** —— 网页 `addr_suggest.js` 也用高德,
* 两端选中的地址是同一套 POI,不会再出现"网页能搜到、手机搜不到";
* · inputtips 直接给 `district`(省市区)+ `address`(门牌)+ `location`(坐标),
* 地址天生完整,不用像 Map Kit 那样自己拼。
*
* @param string $kw 关键词
* @param int $limit 最多几条
* @return array<int,array{name:string,detail:string,district:string,address:string,lng:string,lat:string}>
* **任何失败都返回空数组**,不抛错(联想挂掉不能拖垮"记一条日程")
*/
public static function suggest(string $kw, int $limit = 8): array
{
$q = trim($kw);
if ($q === '' || mb_strlen($q, 'UTF-8') < 2) {
return [];
}
$key = self::webKey();
if ($key === '') {
return [];
}
$url = 'https://restapi.amap.com/v3/assistant/inputtips'
. '?key=' . urlencode($key)
. '&keywords=' . urlencode($q)
. '&datatype=all'
. '&citylimit=false';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 6,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_SSL_VERIFYPEER => true,
]);
$body = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err !== '' || !is_string($body) || $body === '') {
return [];
}
$j = json_decode($body, true);
if (!is_array($j) || (string)($j['status'] ?? '') !== '1' || empty($j['tips'])) {
return [];
}
$out = [];
foreach ($j['tips'] as $t) {
/* ⚠️ 高德的字段类型**不保证是字符串**:
实测 `district`/`address`/`location` 在部分条目里是**数组**(空数组),
直接当字符串用会触发 "Array to string conversion" 警告。
⇒ 统一过一遍 toStr()。 */
$name = self::toStr($t['name'] ?? '');
$dist = self::toStr($t['district'] ?? '');
$addr = self::toStr($t['address'] ?? '');
$loc = self::toStr($t['location'] ?? '');
/* ⚠️ 有些条目是"纯行政区划"(如 name=招商银行、district=空、address=空),
没有地址也没有坐标 ⇒ 面板上就是一行空白,跳过。 */
if ($name === '' || ($addr === '' && $dist === '' && $loc === '')) {
continue;
}
$lng = '';
$lat = '';
if ($loc !== '' && strpos($loc, ',') !== false) {
$seg = explode(',', $loc);
if (count($seg) === 2) {
// 高德给的是 "经度,纬度"
$lng = (string)$seg[0];
$lat = (string)$seg[1];
}
}
// 详细地址 = 省市区 + 门牌(用户要的"参考信息")
$detail = $dist;
if ($addr !== '') {
$detail = $detail === '' ? $addr : ($detail . $addr);
}
$out[] = [
'name' => $name,
'detail' => $detail,
'district' => $dist,
'address' => $detail,
'lng' => $lng,
'lat' => $lat,
];
if (count($out) >= $limit) {
break;
}
}
return $out;
}
/**
* 把高德返回的值统一成字符串。
*
* 高德的 PHP 侧字段类型不稳定(字符串 / 空数组 / 数组都见过),
* 直接 `(string)` 会触发 "Array to string conversion" 警告并得到 "Array"。
*/
private static function toStr($v): string
{
if (is_string($v)) {
return trim($v);
}
if (is_int($v) || is_float($v)) {
return (string)$v;
}
// 数组/对象/null 一律当空(高德用 [] 表示"没这个字段")
return '';
}
/** 端上地址联想用的 key(Web 服务类型) */
public static function webKey(): string
{
$a = fb_config('amap_web');
return is_array($a) ? trim((string)($a['key'] ?? '')) : '';
}
public static function apiKey(): string public static function apiKey(): string
{ {
$a = fb_config('amap'); $a = fb_config('amap');
+71 -8
View File
@@ -399,9 +399,24 @@ final class Holiday
if ($t === false || $end === false || $end < $t) { if ($t === false || $end === false || $end < $t) {
return []; return [];
} }
$years = [(int)substr($from, 0, 4)]; // 🔴 2026-11-02:原来只取「首年 + 末年」两年。
if ((int)substr($to, 0, 4) !== $years[0]) { // 全量范围 1970~2100 下 ⇒ 只算了 1970 和 2100 的节气,
$years[] = (int)substr($to, 0, 4); // 中间 128 年全丢(用户会觉得"日历上没节气")。
// 改成逐年遍历,并夹紧到农历算法支持范围 + 年数上限。
$yFrom = (int)substr($from, 0, 4);
$yTo = (int)substr($to, 0, 4);
if ($yFrom < Lunar::MIN_YEAR) {
$yFrom = Lunar::MIN_YEAR;
}
if ($yTo > Lunar::MAX_YEAR) {
$yTo = Lunar::MAX_YEAR;
}
if ($yTo - $yFrom + 1 > self::MAX_LUNAR_YEARS) {
$yTo = $yFrom + self::MAX_LUNAR_YEARS - 1;
}
$years = [];
for ($yy = $yFrom; $yy <= $yTo; $yy++) {
$years[] = $yy;
} }
foreach ($years as $y) { foreach ($years as $y) {
foreach (self::SOLAR_TERMS as $idx => $name) { foreach (self::SOLAR_TERMS as $idx => $name) {
@@ -435,9 +450,25 @@ final class Holiday
if ($t === false || $end === false || $end < $t) { if ($t === false || $end === false || $end < $t) {
return []; return [];
} }
// 逐日扫区间,转农历后查表 —— 区间很短(月视图 ≤42 天),成本可忽略。 // 逐日扫区间,转农历后查表 —— 天然处理"跨月/跨年"
// 这样天然处理"跨月/跨年"(如腊月二十三可能落在次年 1 月)。 // (如腊月二十三可能落在次年 1 月)。
for ($i = 0; $t <= $end && $i < 400; $i++, $t = strtotime('+1 day', $t)) { //
// 🔴 2026-11-02:原来有 `$i < 400` 的硬上限。改成全量同步后请求范围是
// 1970~2100(47846 天)⇒ 只算前 400 天,**今年的春节/中秋全丢**。
// 改为与 lunarRange 一致的「夹范围 + 年数上限」。
$minT = (int)strtotime(sprintf('%04d-01-31', Lunar::MIN_YEAR));
$maxT = (int)strtotime(sprintf('%04d-12-31', Lunar::MAX_YEAR));
if ($t < $minT) {
$t = $minT;
}
if ($end > $maxT) {
$end = $maxT;
}
$yearsLimitT = (int)strtotime('+' . self::MAX_LUNAR_YEARS . ' years', $t);
if ($end > $yearsLimitT) {
$end = $yearsLimitT;
}
for (; $t <= $end; $t = strtotime('+1 day', $t)) {
$date = date('Y-m-d', $t); $date = date('Y-m-d', $t);
$y = (int)date('Y', $t); $m = (int)date('n', $t); $d = (int)date('j', $t); $y = (int)date('Y', $t); $m = (int)date('n', $t); $d = (int)date('j', $t);
$l = Lunar::solarToLunar($y, $m, $d); $l = Lunar::solarToLunar($y, $m, $d);
@@ -695,6 +726,19 @@ final class Holiday
* *
* ⚠️ 每天的农历都要出,不能只出初一十五 —— 用户要的是"看日历就知道今天农历几号"。 * ⚠️ 每天的农历都要出,不能只出初一十五 —— 用户要的是"看日历就知道今天农历几号"。
*/ */
/**
* 单次请求最多算多少年的农历。
*
* 🔴 2026-11-02:原来是「最多 400 天」的硬上限,而客户端改成全量同步后
* 请求范围是 1970~2100(47846 天)⇒ 只返回前 400 天的农历,
* 导致**今天的农历是空的**。
*
* 改成按年计:客户端实际只需「今天 ±5 年」,
* 留 30 年余量足够,同时防止有人手拼 1900~2100 把服务器拖死
* (算 7 万天农历约需数十秒)。
*/
private const MAX_LUNAR_YEARS = 30;
public static function lunarRange(string $from, string $to): array public static function lunarRange(string $from, string $to): array
{ {
$out = []; $out = [];
@@ -703,8 +747,27 @@ final class Holiday
if ($t === false || $end === false || $end < $t) { if ($t === false || $end === false || $end < $t) {
return []; return [];
} }
// 上限保险:范围异常大时不至于把请求拖死(正常月视图最多 42 天)
for ($i = 0; $t <= $end && $i < 400; $i++, $t = strtotime('+1 day', $t)) { // ① 夹到农历算法支持的范围(1900-01-31 ~ 2100-12-31),
// 否则 solarToLunar 会一路返回 null,白跑空转。
$minT = (int)strtotime(sprintf('%04d-01-31', Lunar::MIN_YEAR));
$maxT = (int)strtotime(sprintf('%04d-12-31', Lunar::MAX_YEAR));
if ($t < $minT) {
$t = $minT;
}
if ($end > $maxT) {
$end = $maxT;
}
// ② 年数上限:超出就截断到 MAX_LUNAR_YEARS 年末
$yearsLimitT = (int)strtotime('+' . self::MAX_LUNAR_YEARS . ' years', $t);
if ($end > $yearsLimitT) {
$end = $yearsLimitT;
}
// ③ 逐日生成(农历必须逐日算,无法按年跳)
// 原来的 `$i < 400` 计数制已由上面的年数上限取代。
for (; $t <= $end; $t = strtotime('+1 day', $t)) {
$date = date('Y-m-d', $t); $date = date('Y-m-d', $t);
$y = (int)date('Y', $t); $m = (int)date('n', $t); $d = (int)date('j', $t); $y = (int)date('Y', $t); $m = (int)date('n', $t); $d = (int)date('j', $t);
$l = Lunar::solarToLunar($y, $m, $d); $l = Lunar::solarToLunar($y, $m, $d);
+307
View File
@@ -57,6 +57,187 @@ final class Ics
'RECURRENCE-ID', 'RECURRENCE-ID',
]; ];
/**
* 把一个待办集合导出成 .ics 文本(VTODO 版本)。
*
* 🔴 2026-11-02 新增:用户「网页端 caldav 除了同步日程,增加同步任务(待办)」。
*
* ⚠️ 为什么不复用 `export()`:`export()` 里每条都走 `vevent()`,
* 而 VEVENT 与 VTODO 是**两种组件**(RFC 5545 §3.6.1 / §3.6.2):
* VEVENT 必须有 DTSTART、VTODO 不能有 DTEND。混用会让客户端解析失败。
* ⇒ 单独一个入口,但**共用** fold/escape 等底层工具,风格保持一致。
*
* @param array $tasks tasks 表的行(已补好 uid / board_name)
* @param string $calName X-WR-CALNAME 用的名字
*/
public static function exportTodos(array $tasks, string $calName = '待办'): string
{
$out = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:' . self::PRODID,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:' . self::escapeText($calName),
/* VTODO 是 CalDAV 的扩展能力。这两个 non-standard 头是
iOS / 苹果提醒事项认「这是个待办本」的依据之一;
不认识它们的客户端会忽略,不影响 VEVENT 本。 */
'X-WR-RELCALID:familybook-tasks',
'X-PUBLISHED-TTL:PT1H',
];
foreach ($tasks as $t) {
$out = array_merge($out, self::vtodo($t));
}
$out[] = 'END:VCALENDAR';
$lines = [];
foreach ($out as $line) {
foreach (self::fold($line) as $l) {
$lines[] = $l;
}
}
return implode("\r\n", $lines) . "\r\n";
}
/**
* 一条任务 → VTODO 的若干行。
*
* ⚠️ VTODO **不能有 DTSTART 之外的时间边界**:`DTEND` 是 VEVENT 的属性,
* 用在 VTODO 上会被严格客户端判为非法而整条丢弃。
* 截止时间只走 `DUE`。
*/
private static function vtodo(array $t): array
{
$uid = (string)($t['uid'] ?? '');
if ($uid === '') {
$uid = 'task-' . (int)($t['id'] ?? 0);
}
$stamp = self::stampOf($t['updated_at'] ?? $t['created_at'] ?? null);
$status = (string)($t['status'] ?? 'todo');
$out = ['BEGIN:VTODO', 'UID:' . $uid, 'DTSTAMP:' . $stamp];
/* ---- 完成时间:必须在 STATUS:COMPLETED 之前给,
苹果提醒事项要求 COMPLETED 与 STATUS 同时出现才认"已完成" ---- */
$doneAt = (string)($t['done_at'] ?? '');
if ($status === self::DONE_STATUS && $doneAt !== '') {
$out[] = 'COMPLETED:' . self::dateTimeValue($doneAt);
}
if (($t['created_at'] ?? '') !== '') {
$out[] = 'CREATED:' . self::dateTimeValue((string)$t['created_at']);
}
if (($t['updated_at'] ?? '') !== '') {
$out[] = 'LAST-MODIFIED:' . self::dateTimeValue((string)$t['updated_at']);
}
/* ---- 标题 ----
🔴 2026-11-02 改:原来给子任务硬加「└ 」前缀。那会**污染用户数据** ——
客户端把标题写回来(PUT)时,前缀会被存进数据库,
用户的任务标题就凭空多了一坨。
层级改由 RELATED-TO 表达(标准属性),
另给 X-FAMILYBOOK-PARENT 做展示用的补充,不碰 SUMMARY。 */
$title = (string)($t['title'] ?? '');
$out[] = 'SUMMARY:' . self::escapeText($title);
$parentId = (int)($t['parent_id'] ?? 0);
$note = (string)($t['note'] ?? '');
/* 跨本父子:客户端会看到 RELATED-TO 指向一个本里不存在的 UID,
部分客户端会**直接不显示这条**(当成孤立子项)。
补一句人话说明,让用户知道这是"父在别的分类里",不是数据损坏。 */
if (!empty($t['parent_in_other_book']) && ($t['parent_title'] ?? '') !== '') {
$hint = '(此任务的上级「' . (string)$t['parent_title']
. '」在另一个分类里,网页端可以看到两级的完整关系)';
$note = $note === '' ? $hint : $note . "\n\n" . $hint;
}
if ($note !== '') {
$out[] = 'DESCRIPTION:' . self::escapeText($note);
}
/* ---- 截止时间 ----
库里 `due_at` 是 DATETIME。判断"是否整天"的方式:时分秒全 00:00:00
就按 DATE 输出(用户在网页上选"某天截止"通常就是这个形态),
否则按 DATE-TIME 输出。这样客户端显示的粒度与网页端一致。 */
$due = (string)($t['due_at'] ?? '');
if ($due !== '') {
if (self::isMidnight($due)) {
$out[] = 'DUE;VALUE=DATE:' . self::dateValue($due);
} else {
$out[] = 'DUE:' . self::dateTimeValue($due);
}
}
// ---- 状态 ----
$out[] = 'STATUS:' . self::statusOf($status);
$out[] = 'PERCENT-COMPLETE:' . self::percentOf($status);
/* ---- 看板名进 CATEGORIES ----
客户端大多支持按 CATEGORIES 分组/打标签,把看板名放这里
是"让客户端也能看出这条属于哪个看板"的最低成本方式。
⚠️ 派生只读,不参与反向写回(PUT 时不从 CATEGORIES 改看板)。 */
$board = (string)($t['board_name'] ?? '');
if ($board !== '') {
$out[] = 'CATEGORIES:' . self::escapeText($board);
}
/* ---- 父子关系 ----
`RELATED-TO` 是 RFC 5545 §3.8.4.5 的标准属性,值是**父资源的 UID**。
本模块的 UID 规范是 `task-<id>`,所以这里拼 `task-<parentId>`
正好对上同一本里那个资源的 UID。
⚠️ 各客户端的支持度:
· DAVx⁵ + Tasks.org:认,显示成缩进的子项
· 苹果提醒事项:认自己的 parent 语义,对 RELATED-TO 支持有限
· Thunderbird:完全不显示层级,两条并列
这是客户端能力差异,服务端无法改变 —— 数据是正确的。 */
if ($parentId > 0) {
$out[] = 'RELATED-TO:task-' . $parentId;
/* 展示用的补充属性:值 = 父标题。
支持的客户端能直接显示"上级是谁",不支持的无视。
用 X- 前缀是因为这是本服务特有的,不进标准语义。 */
if (($t['parent_title'] ?? '') !== '') {
$out[] = 'X-FAMILYBOOK-PARENT:' . self::escapeText((string)$t['parent_title']);
}
}
$out[] = 'END:VTODO';
return $out;
}
/** 任务的三种状态 → VTODO 的 STATUS 值 */
private static function statusOf(string $st): string
{
if ($st === self::DONE_STATUS) {
return 'COMPLETED';
}
if ($st === 'doing') {
return 'IN-PROCESS';
}
return 'NEEDS-ACTION';
}
/** STATUS 的通用信号:不认 IN-PROCESS 的客户端也能看出进度 */
private static function percentOf(string $st): int
{
if ($st === self::DONE_STATUS) {
return 100;
}
return $st === 'doing' ? 50 : 0;
}
/** 这个 DATETIME 是不是"零点"(= 用户表达的是"某天"而不是"某时刻") */
private static function isMidnight(string $dt): bool
{
$t = strtotime($dt);
if ($t === false) {
return false;
}
return date('H:i:s', $t) === '00:00:00';
}
/** 任务完成状态常量(与 App\\Task::DONE_STATUS 一致,这里独立一份避免循环依赖) */
private const DONE_STATUS = 'done';
/** /**
* 把一个日历本导出成 .ics 文本。 * 把一个日历本导出成 .ics 文本。
* *
@@ -362,6 +543,132 @@ final class Ics
return $out; return $out;
} }
/**
* 解析 VTODO(客户端 PUT 上来的待办)。
*
* 🔴 2026-11-02 新增。**独立于** `parse()`:
* `parse()` 只收 VEVENT,改它会波及日历写路径(那里注释记了多轮踩坑史)。
*
* @return array<int,array> 每条的字段:
* uid / summary / description / due / status / percent / completed /
* priority / categories / parent_uid / extra
*
* ⚠️ 只取**第一个** VTODO:一个 CalDAV 资源对应一个待办,
* 正文里出现多个说明客户端行为异常,宁可少存不可乱存
* (与 PUT 里 VEVENT 的处理口径一致)。
*/
public static function parseTodos(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;
foreach ($lines as $raw) {
$line = trim($raw);
if ($line === '') {
continue;
}
if ($line === 'BEGIN:VTODO') {
$cur = [];
continue;
}
if ($line === 'END:VTODO') {
if ($cur !== null) {
$out[] = $cur;
}
$cur = null;
continue;
}
if ($cur === null) {
continue; // VCALENDAR / VALARM / VEVENT 里的东西跳过
}
[$name, $params, $value] = self::splitProp($line);
if ($name === '') {
continue;
}
// 同名只留第一个(VTODO 里这些属性都是单值的)
if (!array_key_exists($name, $cur)) {
$cur[$name] = $value;
$cur[$name . '_PARAMS'] = $params;
}
}
return $out;
}
/**
* 把解析出来的 VTODO 属性归一成能落库的形状。
*
* 与 `normalizeVevent()` 并列,互不影响。
*
* @return array{uid:string, summary:string, description:string,
* due:?string, status:string, completed:?string,
* percent:?int, priority:?int, categories:string,
* parent_uid:string}
*/
public static function normalizeTodo(array $p): array
{
$summary = self::unescapeText((string)($p['SUMMARY'] ?? ''));
$desc = self::unescapeText((string)($p['DESCRIPTION'] ?? ''));
// ---- DUE:可能是 DATE 也可能 DATE-TIME ----
$due = null;
$dueRaw = (string)($p['DUE'] ?? '');
if ($dueRaw !== '') {
$dueParams = (string)($p['DUE_PARAMS'] ?? '');
if (stripos($dueParams, 'VALUE=DATE') !== false && stripos($dueParams, 'DATE-TIME') === false) {
// 整天:`fromDateValue()` 返回的已经是 `Y-m-d 00:00:00` 完整形态,
// ⚠️ 不要再拼 ` 00:00:00` —— 会变成 `... 00:00:00 00:00:00`(踩过)
$due = self::fromDateValue($dueRaw);
} else {
$due = self::fromDateTimeValue($dueRaw, $dueParams);
}
}
// ---- STATUS / COMPLETED ----
/* 完成态的判定优先级:
① `STATUS:COMPLETED` —— 最明确
② 有 `COMPLETED` 时间戳 —— 苹果提醒事项勾选时只加这个
③ `PERCENT-COMPLETE:100` —— 少数客户端只用这个
三者任一成立就算已完成,否则再看 IN-PROCESS。 */
$stRaw = strtoupper(trim((string)($p['STATUS'] ?? '')));
$compRaw = (string)($p['COMPLETED'] ?? '');
$pctRaw = (string)($p['PERCENT-COMPLETE'] ?? '');
$completed = $compRaw !== ''
? self::fromDateTimeValue($compRaw, (string)($p['COMPLETED_PARAMS'] ?? ''))
: null;
$isDone = $stRaw === 'COMPLETED'
|| $completed !== null
|| (trim($pctRaw) !== '' && (int)$pctRaw >= 100);
if ($isDone) {
$status = self::DONE_STATUS;
} elseif ($stRaw === 'IN-PROCESS' || $stRaw === 'IN_PROGRESS') {
$status = 'doing';
} elseif (trim($pctRaw) !== '' && (int)$pctRaw > 0 && (int)$pctRaw < 100) {
$status = 'doing';
} else {
$status = 'todo';
}
$prio = trim((string)($p['PRIORITY'] ?? ''));
return [
'uid' => trim((string)($p['UID'] ?? '')),
'summary' => $summary,
'description' => $desc,
'due' => $due,
'status' => $status,
'completed' => $completed,
'percent' => $pctRaw === '' ? null : (int)$pctRaw,
'priority' => $prio === '' ? null : (int)$prio,
'categories' => self::unescapeText((string)($p['CATEGORIES'] ?? '')),
'parent_uid' => trim((string)($p['RELATED-TO'] ?? '')),
];
}
/** /**
* 把解析出来的属性归一成我们要落库的形状。 * 把解析出来的属性归一成我们要落库的形状。
* *
+83 -22
View File
@@ -95,8 +95,12 @@ final class Movie
/** 一条完整记录(详情/编辑页用):事件 + 卫星 + 影院 + 同行人 + 照片 */ /** 一条完整记录(详情/编辑页用):事件 + 卫星 + 影院 + 同行人 + 照片 */
public static function find(int $familyId, int $id, int $viewerId = 0): ?array public static function find(int $familyId, int $id, int $viewerId = 0): ?array
{ {
/* 🔴 2026-11-06:**不再 SELECT `ticket_key`**。
用户要求"只分两类:海报、观影照片",旧票根已经一次性迁进
`event_photos`(见迁移脚本与 Schema VERSION 37 的说明),
字段本身也置 NULL 了。继续查它出来只会让上层又长出第三个概念。 */
$st = Database::get()->prepare( $st = Database::get()->prepare(
'SELECT e.*, d.info, d.poster_key, d.ticket_key, d.cinema_id, d.douban_meta, 'SELECT e.*, d.info, d.poster_key, d.cinema_id, d.douban_meta,
c.place AS cinema_place, c.name AS cinema_name, c.place AS cinema_place, c.name AS cinema_name,
c.address AS cinema_address, c.color AS cinema_color c.address AS cinema_address, c.color AS cinema_color
FROM events e FROM events e
@@ -133,8 +137,16 @@ final class Movie
public static function photos(int $eventId): array public static function photos(int $eventId): array
{ {
/* 🔴 2026-11-06:带上 `kind` / `thumb_key` / `mime`(观影照片支持视频)。
⚠️ 用 `COALESCE` 兜底 `kind`:极老的库(迁移没跑过)可能没这列,
但这种情形会直接报 1054 —— 迁移一定会先跑,这里不加 COALESCE。
真正需要兜底的是**值为 NULL** 的历史行(迁移加列时默认 'image',
但手写过 INSERT 的行可能是 NULL)。 */
$st = Database::get()->prepare( $st = Database::get()->prepare(
'SELECT id, `key` FROM event_photos WHERE event_id = ? ORDER BY position, id' "SELECT id, `key`,
COALESCE(`kind`, 'image') AS kind,
`thumb_key`, `mime`
FROM event_photos WHERE event_id = ? ORDER BY position, id"
); );
$st->execute([$eventId]); $st->execute([$eventId]);
return $st->fetchAll(); return $st->fetchAll();
@@ -143,7 +155,8 @@ final class Movie
/** /**
* 新建一条观影记录,返回事件 id。 * 新建一条观影记录,返回事件 id。
* $d 字段:title, starts_at(Y-m-d H:i:s), ends_at, location, note, info, * $d 字段:title, starts_at(Y-m-d H:i:s), ends_at, location, note, info,
* cinema_id(int|null), poster_key, ticket_key, attendees(int[]), photos(string[]) * cinema_id(int|null), poster_key, attendees(int[]), photos(string[])
* ⚠️ 2026-11-06:`ticket_key` 已取消(票根并入 photos)。
*/ */
public static function create(int $familyId, ?int $creatorId, array $d): int public static function create(int $familyId, ?int $creatorId, array $d): int
{ {
@@ -203,17 +216,20 @@ final class Movie
$row = [ $row = [
self::textOrNull($d['info'] ?? ''), self::textOrNull($d['info'] ?? ''),
self::keyOrNull($d['poster_key'] ?? ''), self::keyOrNull($d['poster_key'] ?? ''),
self::keyOrNull($d['ticket_key'] ?? ''),
$cid > 0 ? $cid : null, $cid > 0 ? $cid : null,
$meta, $meta,
]; ];
$has = $db->prepare('SELECT COUNT(*) FROM movie_details WHERE event_id = ?'); $has = $db->prepare('SELECT COUNT(*) FROM movie_details WHERE event_id = ?');
$has->execute([$eventId]); $has->execute([$eventId]);
/* 🔴 2026-11-06:`ticket_key` 从这条 UPSERT 里**移除**了。
票根概念已取消(迁进 `event_photos`),再写它等于把它复活。
⚠️ 列还留在表里(不清字段,留作历史审计),但代码不再碰它;
迁移已把存量置 NULL,所以它现在是全 NULL 的死列。 */
if ((int)$has->fetchColumn() > 0) { if ((int)$has->fetchColumn() > 0) {
$db->prepare('UPDATE movie_details SET info=?, poster_key=?, ticket_key=?, cinema_id=?, douban_meta=? WHERE event_id=?') $db->prepare('UPDATE movie_details SET info=?, poster_key=?, cinema_id=?, douban_meta=? WHERE event_id=?')
->execute([...$row, $eventId]); ->execute([...$row, $eventId]);
} else { } else {
$db->prepare('INSERT INTO movie_details (info, poster_key, ticket_key, cinema_id, douban_meta, event_id) VALUES (?,?,?,?,?,?)') $db->prepare('INSERT INTO movie_details (info, poster_key, cinema_id, douban_meta, event_id) VALUES (?,?,?,?,?)')
->execute([...$row, $eventId]); ->execute([...$row, $eventId]);
} }
} }
@@ -294,14 +310,34 @@ final class Movie
*/ */
public static function addPhotos(int $eventId, array $keys): void public static function addPhotos(int $eventId, array $keys): void
{ {
$clean = []; /* ⚠️ 兼容两种入参(2026-11-06 起):
foreach ($keys as $k) { · `['a.jpg', 'b.jpg']` —— 老调用方(网页表单、端上旧版)
$k = trim((string)$k); · `[['key'=>'a.mp4','kind'=>'video','thumb_key'=>'…','mime'=>'video/mp4']]`
if ($k !== '') { —— 新调用方(带类型信息)
$clean[] = $k; 统一归一成"每项一个数组",下面只有一条 INSERT 路径,
不会出现"有的走老分支有的走新分支"那种分叉。 */
$rows = [];
foreach ($keys as $item) {
if (is_array($item)) {
$k = trim((string)($item['key'] ?? ''));
if ($k === '') {
continue;
}
$rows[] = [
'key' => $k,
'kind' => ((string)($item['kind'] ?? 'image')) === 'video' ? 'video' : 'image',
'thumb_key' => (string)($item['thumb_key'] ?? ''),
'mime' => (string)($item['mime'] ?? ''),
];
} else {
$k = trim((string)$item);
if ($k === '') {
continue;
}
$rows[] = ['key' => $k, 'kind' => 'image', 'thumb_key' => '', 'mime' => ''];
} }
} }
if ($clean === []) { if ($rows === []) {
return; return;
} }
$db = Database::get(); $db = Database::get();
@@ -309,24 +345,41 @@ final class Movie
$st = $db->prepare('SELECT COALESCE(MAX(position), 0) FROM event_photos WHERE event_id = ?'); $st = $db->prepare('SELECT COALESCE(MAX(position), 0) FROM event_photos WHERE event_id = ?');
$st->execute([$eventId]); $st->execute([$eventId]);
$pos = (int)$st->fetchColumn(); $pos = (int)$st->fetchColumn();
$ins = $db->prepare('INSERT INTO event_photos (event_id, `key`, position) VALUES (?, ?, ?)'); $ins = $db->prepare(
foreach ($clean as $k) { 'INSERT INTO event_photos (event_id, `key`, kind, thumb_key, mime, position)
VALUES (?, ?, ?, ?, ?, ?)'
);
foreach ($rows as $r) {
$pos += 10; $pos += 10;
$ins->execute([$eventId, $k, $pos]); $ins->execute([
$eventId, $r['key'], $r['kind'],
$r['thumb_key'] !== '' ? $r['thumb_key'] : null,
$r['mime'] !== '' ? $r['mime'] : null,
$pos,
]);
} }
} }
/** 删一张现场照片(连带删存储对象) */ /** 删一张现场照片(连带删存储对象) */
public static function deletePhoto(int $familyId, int $eventId, int $photoId): void public static function deletePhoto(int $familyId, int $eventId, int $photoId): void
{ {
$st = Database::get()->prepare('SELECT `key` FROM event_photos WHERE id = ? AND event_id = ?'); /* 🔴 2026-11-06:视频的**封面**也要一起删(`thumb_key`),
否则每删一个视频就在存储里留一张孤儿封面。 */
$st = Database::get()->prepare(
'SELECT `key`, `thumb_key` FROM event_photos WHERE id = ? AND event_id = ?'
);
$st->execute([$photoId, $eventId]); $st->execute([$photoId, $eventId]);
$key = (string)($st->fetchColumn() ?: ''); $row = $st->fetch();
$key = $row ? (string)($row['key'] ?? '') : '';
$thumb = $row ? (string)($row['thumb_key'] ?? '') : '';
Database::get()->prepare('DELETE FROM event_photos WHERE id = ? AND event_id = ?') Database::get()->prepare('DELETE FROM event_photos WHERE id = ? AND event_id = ?')
->execute([$photoId, $eventId]); ->execute([$photoId, $eventId]);
if ($key !== '') { if ($key !== '') {
Storage\Storage::delete($familyId, $key); Storage\Storage::delete($familyId, $key);
} }
if ($thumb !== '' && $thumb !== $key) {
Storage\Storage::delete($familyId, $thumb);
}
} }
/** 删除整条观影记录(events 级联带走卫星/同行人/照片行;存储对象手动清) */ /** 删除整条观影记录(events 级联带走卫星/同行人/照片行;存储对象手动清) */
@@ -335,16 +388,24 @@ final class Movie
$keys = []; $keys = [];
foreach (self::photos($id) as $p) { foreach (self::photos($id) as $p) {
$keys[] = (string)$p['key']; $keys[] = (string)$p['key'];
/* 视频的封面也要清(2026-11-06) */
$tk = (string)($p['thumb_key'] ?? '');
if ($tk !== '') {
$keys[] = $tk;
}
} }
/* 🔴 2026-11-06:票根已并入 `event_photos`,上面的 photos() 循环就把
它收集进来了 —— 原来这里还要单独查一次 `ticket_key` 并删对象,
现在**不需要且不该要**(字段已置 NULL,查出来也是空)。
⚠️ 海报仍单独存在 `movie_details.poster_key`,所以要留这一段。 */
$st = Database::get()->prepare( $st = Database::get()->prepare(
'SELECT poster_key, ticket_key FROM movie_details WHERE event_id = ?' 'SELECT poster_key FROM movie_details WHERE event_id = ?'
); );
$st->execute([$id]); $st->execute([$id]);
if ($row = $st->fetch()) { if ($row = $st->fetch()) {
foreach ([$row['poster_key'], $row['ticket_key']] as $k) { $k = (string)($row['poster_key'] ?? '');
if ($k) { if ($k !== '') {
$keys[] = (string)$k; $keys[] = $k;
}
} }
} }
Database::get()->prepare('DELETE FROM events WHERE id = ? AND family_id = ? AND kind = "movie"') Database::get()->prepare('DELETE FROM events WHERE id = ? AND family_id = ? AND kind = "movie"')
+40 -3
View File
@@ -298,7 +298,12 @@ final class Note
$used = []; $used = [];
foreach (self::blocks($noteId) as $b) { foreach (self::blocks($noteId) as $b) {
if ((string)$b['kind'] === 'image') { if ((string)$b['kind'] === 'image') {
$used[(string)$b['content']] = true; // 🔴 工单 #18:content 可能带 `|宽度`,必须剥掉再比;
// 否则"调过宽度的图"排除不掉,会既当图片块又在附件区重复出现。
$k = self::imageKey((string)$b['content']);
if ($k !== '') {
$used[$k] = true;
}
} }
} }
$out = []; $out = [];
@@ -452,6 +457,34 @@ final class Note
return true; return true;
} }
/**
* 从图片块的 content 里取出**真正的附件 key**(剥掉 `|宽度` 后缀)。
*
* 🔴 工单 #18(用户 2026-10-28「笔记功能,上传图片失败」):图片块 content 有两种形态
* —— `photos/xxx.jpg` 与 `photos/xxx.jpg|50`(前端 `serialize()` 在宽度不是 100 时写后缀)。
* **凡是拿 content 当 key 用的地方都必须先过这里**,否则带后缀的那个永远匹配不到。
*
* ⚠️ 曾经的 bug:`claimUploads()` 与 `files()` 直接拿 content 匹配 `attachments.key`,
* 于是"把图片调成 50% 再保存" ⇒ 草稿附件(target_id=0)认领不到 ⇒ 图片变孤儿,
* 用户看到的就是**图片上传失败 / 保存后图没了**。
* 默认 100% 不写后缀,所以表现为"有时好有时坏",极易被误判成上传问题。
*
* ⚠️ 修在**服务端**而不是"前端别写后缀":后者会把宽度功能废掉
* (渲染时要按 content 里的宽度出图,见 templates/notes/form.php 的 $imgParts)。
*
* @param string $content 图片块 content,形如 `abc` 或 `abc|50`
* @return string 纯 key(可能为空串)
*/
public static function imageKey(string $content): string
{
$c = trim($content);
if ($c === '') {
return '';
}
$p = explode('|', $c, 2);
return trim($p[0]);
}
/** /**
* 把本次上传的图片块附件从"草稿态"认领到这条笔记上。 * 把本次上传的图片块附件从"草稿态"认领到这条笔记上。
* *
@@ -469,8 +502,12 @@ final class Note
{ {
$keys = []; $keys = [];
foreach ($blocks as $b) { foreach ($blocks as $b) {
if ($b['kind'] === 'image' && $b['content'] !== '') { if ($b['kind'] === 'image') {
$keys[] = $b['content']; // 🔴 工单 #18:必须先剥 `|宽度` 后缀,否则带后缀的 key 永远匹配不到
$k = self::imageKey((string)$b['content']);
if ($k !== '') {
$keys[] = $k;
}
} }
} }
$db = Database::get(); $db = Database::get();
+99 -3
View File
@@ -188,7 +188,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/ */
public const VERSION = '35'; public const VERSION = '37';
/** /**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -539,12 +539,15 @@ CREATE TABLE IF NOT EXISTS `event_photos` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`event_id` INT UNSIGNED NOT NULL, `event_id` INT UNSIGNED NOT NULL,
`key` VARCHAR(255) NOT NULL COMMENT '照片(存储相对键)', `key` VARCHAR(255) NOT NULL COMMENT '照片(存储相对键)',
`kind` ENUM('image','video') NOT NULL DEFAULT 'image' COMMENT '图片还是视频(老数据全是图片,默认值即正确)',
`thumb_key` VARCHAR(255) DEFAULT NULL COMMENT '视频首帧封面(存储相对键;图片为空)',
`mime` VARCHAR(100) DEFAULT NULL COMMENT 'MIME(视频转码后是 video/mp4;图片可空)',
`position` INT NOT NULL DEFAULT 100 COMMENT '展示先后', `position` INT NOT NULL DEFAULT 100 COMMENT '展示先后',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `idx_ep_event` (`event_id`, `position`), KEY `idx_ep_event` (`event_id`, `position`),
CONSTRAINT `fk_ep_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE CONSTRAINT `fk_ep_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='事件的现场照片(可多张)' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='事件的现场照片/视频(可多张)'
SQL, SQL,
// ---- 统一附件(v12)---- // ---- 统一附件(v12)----
// 旅程照片 / 资源附件 / 任务附件 / 评论附件,全都走这一张表: // 旅程照片 / 资源附件 / 任务附件 / 评论附件,全都走这一张表:
@@ -965,7 +968,7 @@ SQL,
<<<'SQL' <<<'SQL'
CREATE TABLE IF NOT EXISTS `nav_groups` ( CREATE TABLE IF NOT EXISTS `nav_groups` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT, `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`person_id` int(10) unsigned NOT NULL COMMENT '这份导航属于谁(家书 person id)', `person_id` int(10) unsigned NOT NULL COMMENT '这份导航属于谁(岁月家 person id)',
`name` VARCHAR(60) NOT NULL, `name` VARCHAR(60) NOT NULL,
`slug` VARCHAR(60) NOT NULL DEFAULT '', `slug` VARCHAR(60) NOT NULL DEFAULT '',
`display` ENUM('grid','tabs') NOT NULL DEFAULT 'grid' COMMENT 'grid=普通网格 tabs=合集标签页', `display` ENUM('grid','tabs') NOT NULL DEFAULT 'grid' COMMENT 'grid=普通网格 tabs=合集标签页',
@@ -1542,6 +1545,22 @@ SQL,
"ADD COLUMN `revised_at` DATETIME DEFAULT NULL COMMENT '最后一次修正时间' AFTER `stage`"); "ADD COLUMN `revised_at` DATETIME DEFAULT NULL COMMENT '最后一次修正时间' AFTER `stage`");
self::addColumn($db, 'trip_nodes', 'revision_n', self::addColumn($db, 'trip_nodes', 'revision_n',
"ADD COLUMN `revision_n` INT NOT NULL DEFAULT 0 COMMENT '修正次数' AFTER `revised_at`"); "ADD COLUMN `revision_n` INT NOT NULL DEFAULT 0 COMMENT '修正次数' AFTER `revised_at`");
/* 🔴 #48(2026-11-05):**真实开始 / 结束时间**。
用户:「我计划今天 8:00 到 10:00 开车去公园,但我可能出门晚了,
8:10 或 8:30 才点『开始』按钮。这时候就要记录住 8:30 这个时间,
因为 8:30 会成为实际行程的开始时间。也就是说,
**原来计划的时间和现在真正开始的时间都要记录**。」
⚠️ 不能复用 `starts_at` / `ends_at`(那是**计划**时间),
改掉等于把计划丢了。两者并存才能"计划 8:00–10:00 / 实际 8:30–10:30"。
⚠️ 列名带 `actual_` 前缀,与 `starts_at` 形成清晰对照。 */
self::addColumn($db, 'trip_nodes', 'actual_started_at',
"ADD COLUMN `actual_started_at` DATETIME DEFAULT NULL
COMMENT '真实开始时间(点「开始」那一刻;NULL=还没开始)' AFTER `ends_at`");
self::addColumn($db, 'trip_nodes', 'actual_ended_at',
"ADD COLUMN `actual_ended_at` DATETIME DEFAULT NULL
COMMENT '真实结束时间(点「完成」那一刻;NULL=还没完成)' AFTER `actual_started_at`");
// ② 建修正历史表(老库没有) // ② 建修正历史表(老库没有)
self::tryStep('建行程修正历史表', static function () use ($db) { self::tryStep('建行程修正历史表', static function () use ($db) {
$db->exec(<<<'SQL' $db->exec(<<<'SQL'
@@ -1842,6 +1861,83 @@ SQL);
self::addColumn($db, 'trip_details', 'is_public', self::addColumn($db, 'trip_details', 'is_public',
"ADD COLUMN `is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(用户 2026-10-03 第 4 条):0=不显示在旅程列表与日历' AFTER `summary`"); "ADD COLUMN `is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(用户 2026-10-03 第 4 条):0=不显示在旅程列表与日历' AFTER `summary`");
// ---- 观影照片支持视频(版本 36,2026-11-06)----
/* 🔴 用户:「确认一下观影照片是不是允许上传视频,如果不允许,需要修改,
要求能够上传视频,视频需要转码,参考旅程中的行程做法。」
事实核查:**原来不允许**。`POST /movies/<id>/photos` 走的是
`Upload::image()`,那条管线只收 JPG/PNG/GIF/WebP(要裁剪缩放转 JPEG),
传视频会被直接拒("只支持 JPG / PNG / GIF / WebP 图片")。
旅程那边走的是 `Attachment::save()`,它内部对视频调 `Video::toH264()`
转成 H.264 再存 —— 所以观影要支持视频,得把照片也改走那条路。
改走 Attachment 后有三件事要落到 `event_photos` 上:
· `kind` —— 是图片还是视频。端上要靠它决定"显示图"还是"显示播放按钮";
(不靠扩展名猜:`.mp4` 也可能是图片的误命名,MIME 才靠谱)
· `thumb_key` —— 视频首帧封面。列表里不可能为每个视频解一次码
(一屏 9 个视频 × 解码 = 卡死),必须服务端抽一帧存成图;
· `mime` —— 冗余存一份,免得每次都要回查 Storage 或猜。
⚠️ 三列都给默认值:`kind` 默认 `'image'`,老数据(全是照片)自动正确,
不需要回填 UPDATE —— 这是最安全的一种迁移默认值。 */
self::addColumn($db, 'event_photos', 'kind',
"ADD COLUMN `kind` ENUM('image','video') NOT NULL DEFAULT 'image' COMMENT '图片还是视频(老数据全是图片,默认值即正确)' AFTER `key`");
self::addColumn($db, 'event_photos', 'thumb_key',
"ADD COLUMN `thumb_key` VARCHAR(255) DEFAULT NULL COMMENT '视频首帧封面(存储相对键;图片为空)' AFTER `kind`");
self::addColumn($db, 'event_photos', 'mime',
"ADD COLUMN `mime` VARCHAR(100) DEFAULT NULL COMMENT 'MIME(视频转码后是 video/mp4;图片可空)' AFTER `thumb_key`");
// ---- 「票根」概念取消,并入观影照片(版本 37,2026-11-06)----
/* 🔴 用户原话:「现在不是已经没有票根的概念了吗?全部都换成观影照片了。
别管是票根还是观影照片,都在一起。现在只分两类:一类是海报,
一类是观影照片。海报是来自豆瓣的,这个不用管它,主要就是观影照片,
所有的都在观影照片里边。」
背景:早年「票根」是 `movie_details.ticket_key` 的**单张**槽位,
和 `event_photos`(现场照片)是两条并行的路径。v35 用户在网页端就说过
"不分类别,放一块",但当时只在**模板层**把它画进同一个网格,
数据层仍然两条路 —— 结果是端上永远得写"票根另算"的特判,
而且票根没有 `event_photos` 行 id,**删不掉**。
这一版把数据也统一:
① 把每条记录的 `ticket_key` 插进 `event_photos`(position 排最前,
因为票根是"这场电影的凭证",用户认知里它排第一);
② `ticket_key` 置 NULL(字段保留,仅作历史审计,代码不再读写)。
⚠️ **幂等**:靠"该 key 是否已在该 event 的 event_photos 里"判断,
重复跑不会插重复行 —— 迁移在每次升级检查时都可能被调到。
⚠️ **不删存储对象**:只是搬引用,文件原地不动,失败也不会丢图。
⚠️ 2026-11-06 线上已手工跑过同一段逻辑(220 条全部搬完,
复核剩余旧票根=0、event_photos 234 条),所以这里对老库是空操作;
写进来是为了让**新库/重装库**走同一套,不靠"记得手工跑脚本"。 */
$tkRows = $db->query(
"SELECT event_id, ticket_key FROM movie_details
WHERE ticket_key IS NOT NULL AND ticket_key <> ''"
)->fetchAll();
if (count($tkRows) > 0) {
$hasPhoto = $db->prepare('SELECT COUNT(*) FROM event_photos WHERE event_id = ? AND `key` = ?');
$minPos = $db->prepare('SELECT COALESCE(MIN(position), 100) FROM event_photos WHERE event_id = ?');
$insPhoto = $db->prepare(
'INSERT INTO event_photos (event_id, `key`, position, kind, thumb_key, mime)
VALUES (?, ?, ?, ?, ?, ?)'
);
foreach ($tkRows as $tk) {
$eid = (int)$tk['event_id'];
$key = (string)$tk['ticket_key'];
$hasPhoto->execute([$eid, $key]);
if ((int)$hasPhoto->fetchColumn() > 0) {
continue;
}
$minPos->execute([$eid]);
$pos = max(0, (int)$minPos->fetchColumn() - 10);
$insPhoto->execute([$eid, $key, $pos, 'image', '', 'image/jpeg']);
}
/* 搬完清空旧槽位 —— 「只分两类」必须在数据层成立,
否则下一版又会有人写"票根另算"的特判。 */
$db->exec("UPDATE movie_details SET ticket_key = NULL
WHERE ticket_key IS NOT NULL AND ticket_key <> ''");
}
// ---- 纪念日(版本 13,用户第 13 条)---- // ---- 纪念日(版本 13,用户第 13 条)----
// ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的 // ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的
// (见 Anniversary::birthdays),改了生日纪念日就跟着变。 // (见 Anniversary::birthdays),改了生日纪念日就跟着变。
+35 -8
View File
@@ -168,13 +168,19 @@ final class OssDriver implements StorageDriver
// ---------------- 签名 ---------------- // ---------------- 签名 ----------------
private function authorization(string $method, string $key, string $contentType, string $date, array $cred): string /**
* @param string $subresource 子资源串(如 `?x-oss-process=image/resize,w_400`),
* **必须计入 CanonicalizedResource**,否则 403。
*/
private function authorization(string $method, string $key, string $contentType, string $date,
array $cred, string $subresource = ''): string
{ {
$canonicalHeaders = ''; $canonicalHeaders = '';
if ((string)($cred['token'] ?? '') !== '') { if ((string)($cred['token'] ?? '') !== '') {
$canonicalHeaders = "x-oss-security-token:{$cred['token']}\n"; $canonicalHeaders = "x-oss-security-token:{$cred['token']}\n";
} }
$canonicalResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim($key, '/'); $canonicalResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim($key, '/')
. $subresource;
$stringToSign = $method . "\n" $stringToSign = $method . "\n"
. "\n" // Content-MD5(未使用) . "\n" // Content-MD5(未使用)
. $contentType . "\n" . $contentType . "\n"
@@ -200,12 +206,13 @@ final class OssDriver implements StorageDriver
} }
/** 读请求刻意不发 Content-Type,签名串里传空串 */ /** 读请求刻意不发 Content-Type,签名串里传空串 */
private function readHeaders(string $key, array $cred, array $extra = [], string $method = 'GET'): array private function readHeaders(string $key, array $cred, array $extra = [],
string $method = 'GET', string $subresource = ''): array
{ {
$date = gmdate('D, d M Y H:i:s \G\M\T'); $date = gmdate('D, d M Y H:i:s \G\M\T');
$h = [ $h = [
'Date: ' . $date, 'Date: ' . $date,
'Authorization: ' . $this->authorization($method, $key, '', $date, $cred), 'Authorization: ' . $this->authorization($method, $key, '', $date, $cred, $subresource),
]; ];
if ((string)($cred['token'] ?? '') !== '') { if ((string)($cred['token'] ?? '') !== '') {
$h[] = 'x-oss-security-token: ' . $cred['token']; $h[] = 'x-oss-security-token: ' . $cred['token'];
@@ -327,7 +334,19 @@ final class OssDriver implements StorageDriver
} }
/** 服务端读取(内网优先 + 自动回退外网) */ /** 服务端读取(内网优先 + 自动回退外网) */
public function get(string $key, array $extra = []): array /**
* 取对象。
*
* @param string $process OSS 图片处理参数(如 `image/resize,l_400/quality,q_80`)。
* **空 = 取原图**。非空时:
* ① URL 追加 `?x-oss-process=...`
* ② 签名把它计入 CanonicalizedResource(不然 403)
*
* 🔴 #56(用户 2026-11-05):「OSS在获取图片的时候,好像可以传一个参数,
* 它会给你返回一张压缩过的图片文件。」—— 就是 `x-oss-process`。
* **实时处理,原图不动**,所以不用改上传流程、不用二次存储。
*/
public function get(string $key, array $extra = [], string $process = ''): array
{ {
if ($err = $this->validateConfig()) { if ($err = $this->validateConfig()) {
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $err]; return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $err];
@@ -336,8 +355,14 @@ final class OssDriver implements StorageDriver
if (empty($cred['ok'])) { if (empty($cred['ok'])) {
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $cred['error']]; return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $cred['error']];
} }
$key = $this->objectKey($key); $key = $this->objectKey($key);
$headers = $this->readHeaders($key, $cred, $extra);
/* 子资源串:签名与 URL 必须**用同一个值**,否则签名对不上 */
$sub = '';
if ($process !== '') {
$sub = '?x-oss-process=' . $process;
}
$headers = $this->readHeaders($key, $cred, $extra, 'GET', $sub);
$hosts = []; $hosts = [];
$internal = $this->internalEndpoint(); $internal = $this->internalEndpoint();
@@ -349,8 +374,10 @@ final class OssDriver implements StorageDriver
$res = null; $res = null;
foreach ($hosts as $i => $host) { foreach ($hosts as $i => $host) {
$isInternal = ($internal !== '' && $host === $internal); $isInternal = ($internal !== '' && $host === $internal);
/* ⚠️ process 里的逗号、下划线不需要 URL 编码(OSS 认原样),
但 `?` 与 `=` 必须保留为分隔符 —— 所以直接拼,不 encode。 */
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $host $url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $host
. '/' . Http::encodeKey($key); . '/' . Http::encodeKey($key) . $sub;
$res = Http::send('GET', $url, $headers, null, 60, $isInternal ? 2 : null); $res = Http::send('GET', $url, $headers, null, 60, $isInternal ? 2 : null);
if ((int)$res['status'] === 0 && $isInternal && isset($hosts[$i + 1])) { if ((int)$res['status'] === 0 && $isInternal && isset($hosts[$i + 1])) {
continue; // 内网连不上,换外网 continue; // 内网连不上,换外网
+36 -6
View File
@@ -153,14 +153,44 @@ final class Storage
* 从本地搬到云端(或反过来)时,历史文件可能只在一边, * 从本地搬到云端(或反过来)时,历史文件可能只在一边,
* 本地优先能保证迁移期的老文件照样打得开,也让回滚随时可行。 * 本地优先能保证迁移期的老文件照样打得开,也让回滚随时可行。
*/ */
public static function read(int $familyId, string $key): array /**
* 读一个对象。
*
* @param string $process OSS 图片处理参数(如 `image/resize,l_400/quality,q_80`)。
*
* 🔴 #56(用户 2026-11-05):「如果图片是放在本地服务器的…就不管它。
* 如果是传到 OSS 里面的…就用 OSS 的压缩就行了。」
*
* ## 两种驱动下的行为
*
* | 驱动 | `$process` 非空时 |
* |---|---|
* | `oss` | 透传给 `OssDriver::get()` ⇒ OSS 实时压缩 |
* | `local` | **忽略** —— 本地没有图片处理服务,不该假装有 |
*
* ⚠️ 带 `$process` 时**跳过本地缓存**:那个缓存里是**原图**,
* 拿原图当压缩版返回,等于压缩没生效而且用户以为生效了。
* (缓存由 media.php 自己按尺寸分目录管理,见那里的说明。)
*/
public static function read(int $familyId, string $key, string $process = ''): array
{ {
$local = FB_UPLOADS . '/' . ltrim($key, '/'); /* 只有无 process 时才走"本地优先":原图有缓存就直接读,
if ($key !== '' && is_file($local)) { 省掉整个 OSS round-trip。带 process 的必须去 OSS 要压缩版。 */
$body = (string)@file_get_contents($local); if ($process === '') {
return ['ok' => true, 'status' => 200, 'headers' => [], 'body' => $body, 'error' => '', 'from' => 'local']; $local = FB_UPLOADS . '/' . ltrim($key, '/');
if ($key !== '' && is_file($local)) {
$body = (string)@file_get_contents($local);
return ['ok' => true, 'status' => 200, 'headers' => [], 'body' => $body,
'error' => '', 'from' => 'local'];
}
} }
return self::disk($familyId)->get($key);
$disk = self::disk($familyId);
/* 本地驱动不支持图片处理 ⇒ 忽略 process(用户明确要求"本地不管它") */
if ($process === '' || $disk->id() !== 'oss') {
return $disk->get($key);
}
return $disk->get($key, [], $process);
} }
public static function exists(int $familyId, string $key): bool public static function exists(int $familyId, string $key): bool
+112
View File
@@ -557,12 +557,22 @@ final class Task
// 抛 PDOException: Unknown column 'p.name',而它又被 PHP 以 // 抛 PDOException: Unknown column 'p.name',而它又被 PHP 以
// "200 + Fatal error 正文"吐出来,看着像页面正常返回。 // "200 + Fatal error 正文"吐出来,看着像页面正常返回。
// 改 SQL 之后一定要用「真实库表结构」校验列名(见 sql-schema-column-check)。 // 改 SQL 之后一定要用「真实库表结构」校验列名(见 sql-schema-column-check)。
/* 🔴 #84(用户 2026-11-05):「鸿蒙端的分类那个地方没带进来。
明明是有分类的,但是它却显示的是未分类。」
⇒ 补 `LEFT JOIN task_categories`。
原来这条 SQL **没有 JOIN 分类表**,虽然 `t.*` 里有 `category_id`,
但没有 `category_name` —— 端上 `category: s(d.category_name)`
永远拿到空串,于是显示成「未分类」。
(`doneList` / 详情页那几条 SQL 都有这个 JOIN,只有 find() 漏了。) */
'SELECT t.*, p.title AS parent_title, 'SELECT t.*, p.title AS parent_title,
c.name AS category_name,
a.name AS assignee_name, a.avatar AS assignee_avatar, a.avatar_color AS assignee_color, a.name AS assignee_name, a.avatar AS assignee_avatar, a.avatar_color AS assignee_color,
na.nickname AS assignee_nick, na.nickname AS assignee_nick,
cr.name AS creator_name, cr.avatar AS creator_avatar, cr.avatar_color AS creator_color, cr.name AS creator_name, cr.avatar AS creator_avatar, cr.avatar_color AS creator_color,
nc.nickname AS creator_nick nc.nickname AS creator_nick
FROM tasks t FROM tasks t
LEFT JOIN task_categories c ON c.id = t.category_id
LEFT JOIN tasks p ON p.id = t.parent_id LEFT JOIN tasks p ON p.id = t.parent_id
LEFT JOIN persons a ON a.id = t.assignee_id LEFT JOIN persons a ON a.id = t.assignee_id
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ? LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
@@ -721,6 +731,108 @@ final class Task
* *
* @return int 因循环而新生成的任务 id(没有则 0) * @return int 因循环而新生成的任务 id(没有则 0)
*/ */
/**
* 只改「截止时间」(#74)。
*
* 🔴 用户 2026-11-05:「点开之后,就可以修改任务的所有信息,包括…
* 修改截止时间。当然,如果现在已经有截止时间,就把它带进来。」
*
* ⚠️ **不要复用 `update()`**:那个方法是全字段覆盖(title/note/status/
* assignee/repeat/parent/board/category 一起写),表单只交了 `due_at`
* 的话,其余字段会被 `title('')`、`textOrNull(null)` 之类的默认值抹成空 ——
* "只想改个日期,结果标题和备注没了",而且这种破坏不可恢复。
* ⇒ 单独一条只写 `due_at` 的 UPDATE。
*
* 传空串 = 清掉截止时间(`dtOrNull` 会转成 NULL)。
*
* @return bool 是否真的更新了(任务不存在 / 不属于这个家庭时为 false)
*/
public static function setDue(int $familyId, int $id, string $dueAt): bool
{
$old = self::row($familyId, $id);
if (!$old) {
return false;
}
Database::get()->prepare(
'UPDATE tasks SET due_at = ? WHERE id = ? AND family_id = ?'
)->execute([self::dtOrNull($dueAt), $id, $familyId]);
return true;
}
/**
* 只更新「截止时间 / 负责人 / 分类」三项(#76)。
*
* 🔴 用户 2026-11-05:「负责人和分类都没法修改,按道理讲都应该可以修改才对。」
* 上一版面板里这三项都是只读的 `dl.kv` 行。
*
* ⚠️ **不要复用 `update()`**:那是全字段覆盖。面板只交这三项的话,
* title / note / status / repeat / parent / board 会被默认值抹空 ——
* 与 `setDue()` 记的是同一个坑。
*
* ⚠️ 三个参数都「显式传」才算改:`due_at` 传 `null` = 不动;
* 传 `''` = 清掉。这样调用方可以只改其中一项。
* (用 `array_key_exists` 判"传没传",而不是 `isset` ——
* `isset(null)` 为 false,会把"显式传 null"误判成"没传"。)
*
* @param array $d 可含 due_at / assignee_id / category_id
*/
public static function setAttrs(int $familyId, int $id, array $d): bool
{
$old = self::row($familyId, $id);
if (!$old) {
return false;
}
$sets = [];
$args = [];
if (array_key_exists('due_at', $d)) {
$sets[] = 'due_at = ?';
$args[] = self::dtOrNull((string)($d['due_at'] ?? ''));
}
if (array_key_exists('assignee_id', $d)) {
/* 0 / 空 = 不指定(存 NULL,与 update() 里 `?: null` 同一口径) */
$sets[] = 'assignee_id = ?';
$aid = (int)($d['assignee_id'] ?? 0);
$args[] = $aid > 0 ? $aid : null;
}
if (array_key_exists('category_id', $d)) {
$sets[] = 'category_id = ?';
$args[] = self::categoryIdOf($familyId, (int)($d['category_id'] ?? 0));
}
/* 🔴 #83(用户 2026-11-05):「分类和重复,也就是循环,都需要加上。」
⇒ 面板里改成可编辑之后,得有一条能只写 repeat_rule 的路径。
⚠️ 空串 = 不循环,存 NULL(与 `Task::update()` 的 `?: null` 同一口径)。
⚠️ 白名单校验:只接受 `REPEAT` 里有的键,其余一律当"不循环"。
不校验的话,前端传个乱值会进库,`repeatWord()` 那边就显示成空白。 */
if (array_key_exists('repeat_rule', $d)) {
$sets[] = 'repeat_rule = ?';
$rr = trim((string)($d['repeat_rule'] ?? ''));
$args[] = ($rr !== '' && isset(self::REPEAT[$rr])) ? $rr : null;
}
/* 🔴 #78(用户 2026-11-05):「现在在首页上点一个任务,直接就是编辑页面啊。
所以不需要再加一个编辑按钮了。」
⇒ 删掉「编辑」按钮后,标题/备注也得能在这里改。
⚠️ 走 `self::title()` / `self::textOrNull()` 而不是直接塞原值 ——
与 `update()` 同一套清洗口径(去首尾空白、空备注存 NULL),
否则同一条任务从两个入口改会得到不同形态的数据。 */
if (array_key_exists('title', $d)) {
$sets[] = 'title = ?';
$args[] = self::title((string)($d['title'] ?? ''));
}
if (array_key_exists('note', $d)) {
$sets[] = 'note = ?';
$args[] = self::textOrNull((string)($d['note'] ?? ''));
}
if (!$sets) {
return true; // 三个都没传:什么都不做,但也算"成功"(幂等)
}
$args[] = $id;
$args[] = $familyId;
Database::get()->prepare(
'UPDATE tasks SET ' . implode(', ', $sets) . ' WHERE id = ? AND family_id = ?'
)->execute($args);
return true;
}
public static function setStatus(int $familyId, int $id, string $status, int $position = 0): int public static function setStatus(int $familyId, int $id, string $status, int $position = 0): int
{ {
$old = self::row($familyId, $id); $old = self::row($familyId, $id);
+500
View File
@@ -0,0 +1,500 @@
<?php
/**
* TaskDAV —— 待办(VTODO)走的 CalDAV 扩展层(多本模型)。
*
* 2026-11-02 新增。用户原话:
* 「网页端的 caldav 功能,除了能够同步日程外,增加可以同步任务(待办)的功能」
*
* 2026-11-02 重构(第二版):
* 用户给了参照服务器 nas.yangyq.net/caldav/synccalendartest(DAViCal 1.1.9),
* 并指出「应该可以直接显示那个家庭的待办分类」。
* 参照实测:待办按分类拆成多个独立 calendar 集合,各自 supported-calendar-component-set
* 只含 VTODO。客户端里显示成多个并列的待办列表。
* ⇒ 本版从「一个家庭待办本」改为「每个 task_categories 一行 = 一个 VTODO 本」,
* 外加一个 Inbox 本收未分类任务(与参照的 home_todo 同构)。
*
* ## 本的组织(用户已定)
*
* /dav/tasks-<categoryId>.ics/ 每个分类一本,名字就是分类名
* /dav/tasks-inbox.ics/ 未分类(category_id 为空)的任务
*
* ⚠️ 为什么用 `tasks-<id>` 而不是裸 id:
* 真实日历本是 `/dav/<id>.ics/`,用裸 id 会与它们撞号
* (calendar_events 的 id 与 task_categories 的 id 是两套独立序列)。
* 加 `tasks-` 前缀后永远不会冲突,也不需要负数那种 trick。
*
* ## 权限口径
*
* 与网页端「任务」模块完全一致(User 可见 = Task::visibleTo)。
* 不在 DAV 层另写一套权限判断,否则"网页上不能改的,手机能改"会出问题。
*
* ## UID 方案
*
* `task-<id>`。前缀 `task-` 与日历的 UID 空间隔离(日历 UID 可能是
* `syncal-...`、`movie-100_familybook`,或客户端自造的任意串)。
*/
declare(strict_types=1);
namespace App;
final class TaskDav
{
/**
* 本 href 前缀:`/dav/tasks-<id>.ics/`。
*
* Inbox 本用 `tasks-inbox.ics`(id 段是字符串 `inbox` 而不是数字)。
*/
public const HREF_PREFIX = '/dav/tasks-';
/** Inbox(未分类)本的 id 段 */
public const INBOX_SEG = 'inbox';
/** Inbox 本的显示名(与参照服务器的 home_todo 一个意思) */
public const INBOX_NAME = '收件箱';
/** 资源名前缀(与日历 UID 空间隔离) */
public const UID_PREFIX = 'task-';
/** ctag 版本,改了导出格式就 +1,让客户端重拉 */
private const CTAG_VERSION = 2;
// ============================================================
// 本的识别
// ============================================================
/**
* 列出「这个人能看到的待办本」。
*
* @return array<int,array{seg:string, id:int, name:string, href:string, color:string}>
* id 为 -1 表示 Inbox
*/
public static function books(int $familyId, int $personId): array
{
$out = [];
// ---- 各分类本 ----
/* ⚠️ 不直接用 Task::categories():它带 used_count 子查询,
对 DAV 每次发现请求都算一遍是浪费。这里只要 id/name/position。 */
$st = Database::get()->prepare(
'SELECT id, name FROM task_categories WHERE family_id = ? ORDER BY position, id'
);
$st->execute([$familyId]);
foreach ($st->fetchAll() as $c) {
$cid = (int)$c['id'];
$out[] = [
'seg' => (string)$cid,
'id' => $cid,
'name' => (string)$c['name'],
'href' => self::HREF_PREFIX . $cid . '.ics/',
'color' => self::colorOf($cid, (string)$c['name']),
];
}
// ---- Inbox(未分类)----
/* ⚠️ 即使用户当前没有未分类任务,也要列出来 ——
否则客户端建不出"没选分类"的待办,且新任务会无处可去。
参照服务器也是恒定有 home_todo/Inbox 的。 */
$out[] = [
'seg' => self::INBOX_SEG,
'id' => -1,
'name' => self::INBOX_NAME,
'href' => self::HREF_PREFIX . self::INBOX_SEG . '.ics/',
'color' => '#8A8A8A',
];
return $out;
}
/**
* 一条任务的**有效分类**(决定它出现在哪个本里)。
*
* 2026-11-02 新增。用户问「任务的子任务能否同步给客户端」,
* 实测发现父子跨本(父分类=1,子分类=NULL)⇒ 客户端无法显示层级。
* 用户已定:**子任务跟随父的分类**。
*
* 规则:
* · 自己有分类 → 用自己的(尊重用户明确设定)
* · 自己无分类 + 有父 → 跟父的分类(保证父子同本,层级才可显示)
* · 都没有 → 收件箱(-1)
*
* ⚠️ **不改数据库**:这是视图层算出来的。改库就等于篡改用户数据,
* 而且单向不可逆(以后想让子任务独立分类就回不去了)。
*
* @param array $row tasks 行(可用 `parent_cat_id` 预联查好的父分类)
*/
public static function effectiveCat(array $row): int
{
$own = (int)($row['category_id'] ?? 0);
if ($own > 0) {
return $own;
}
$pid = (int)($row['parent_id'] ?? 0);
if ($pid > 0) {
// 优先用预联查的父分类(避免 N+1)
if (array_key_exists('parent_cat_id', $row)) {
$pc = (int)($row['parent_cat_id'] ?? 0);
return $pc > 0 ? $pc : -1;
}
$st = Database::get()->prepare(
'SELECT category_id FROM tasks WHERE id = ? LIMIT 1'
);
$st->execute([$pid]);
$v = $st->fetchColumn();
$pc = $v === false || $v === null ? 0 : (int)$v;
return $pc > 0 ? $pc : -1;
}
return -1;
}
/**
* 从一个 href 或路径解析出待办本的段(`<id>` 或 `inbox`)。
*
* @return array{seg:string, sub:string}|null
* seg = 本段(如 `3` 或 `inbox`),sub = 资源段(如 `task-12`),
* 不是待办本则返回 null。
*/
public static function parse(string $href): ?array
{
$p = rtrim((string)parse_url($href, PHP_URL_PATH), '/');
$prefix = rtrim(self::HREF_PREFIX, '/'); // /dav/tasks-
if (strpos($p, $prefix) !== 0) {
return null;
}
$rest = substr($p, strlen($prefix)); // 如 `3.ics` 或 `3.ics/task-12.ics`
if ($rest === '') {
return null;
}
$parts = explode('/', $rest);
$bookSeg = (string)preg_replace('/\.ics$/', '', $parts[0]);
if ($bookSeg === '') {
return null;
}
$sub = '';
if (isset($parts[1]) && $parts[1] !== '') {
$sub = (string)preg_replace('/\.ics$/', '', $parts[1]);
}
return ['seg' => $bookSeg, 'sub' => $sub];
}
/** 这个路径是不是待办本(含子资源) */
public static function isTaskPath(string $href): bool
{
return self::parse($href) !== null;
}
/** 段 → 分类 id(Inbox 返回 -1;认不出返回 -2 表示非法) */
public static function categoryIdOfSeg(string $seg): int
{
if ($seg === self::INBOX_SEG) {
return -1;
}
if (preg_match('/^\d+$/', $seg) === 1) {
return (int)$seg;
}
return -2;
}
/**
* 校验这个段确实是**这个人能访问的**待办本。
*
* ⚠️ 必须校验:否则改一下 URL(`/dav/tasks-999.ics/`)就能探到
* 别的家庭的分类名,属于越权。
*/
public static function resolveBook(int $familyId, int $personId, string $seg): ?array
{
foreach (self::books($familyId, $personId) as $b) {
if ($b['seg'] === $seg) {
return $b;
}
}
return null;
}
/** 本名配色(稳定:同一个分类每次都是同一色) */
private static function colorOf(int $id, string $name): string
{
$palette = ['#B0763A', '#7A4E8C', '#3A7AB0', '#4E8C5A', '#B05A5A', '#8C7A4E', '#5A6B8C'];
return $palette[$id % count($palette)];
}
// ============================================================
// 资源名
// ============================================================
/**
* 一条 tasks 行 → 资源名(href 最后一段,不含 .ics)。
*
* ⚠️ 与 CalDav::segmentOf() 同口径:一律用 UID。
* 客户端 PUT 到 `/dav/tasks-3.ics/task-5.ics` 后必须能按同名 GET 回来,
* 否则会表现成"写进去了又没了"(CalDav 那边踩过)。
*/
public static function segmentOf(array $t): string
{
return self::UID_PREFIX . (int)($t['id'] ?? 0);
}
/** 从资源段反解任务 id(`task-5` → 5;认不出返回 0) */
public static function idOfSegment(string $seg): int
{
$seg = trim($seg);
if ($seg === '') {
return 0;
}
if (preg_match('/^' . preg_quote(self::UID_PREFIX, '/') . '(\d+)/', $seg, $m)) {
return (int)$m[1];
}
return 0;
}
// ============================================================
// 读取
// ============================================================
/**
* 某一本里的任务。
*
* @param int $catId 分类 id;-1 表示 Inbox(未分类)
*/
public static function tasksIn(int $familyId, int $personId, int $catId): array
{
/* ⚠️ 不能再用 SQL 直接按 `t.category_id` 过滤:
子任务要**跟随父的分类**(见 effectiveCat),
父分类在另一张行上,必须联查出来再在内存里判。
`parent_cat_id` 一次联查解决,避免逐条查父任务(N+1)。 */
$sql = 'SELECT t.*, b.name AS board_name, c.name AS cat_name,
p.category_id AS parent_cat_id,
pc.name AS parent_cat_name,
p.title AS parent_title
FROM tasks t
LEFT JOIN task_boards b ON b.id = t.board_id
LEFT JOIN task_categories c ON c.id = t.category_id
LEFT JOIN tasks p ON p.id = t.parent_id
LEFT JOIN task_categories pc ON pc.id = p.category_id
WHERE t.family_id = ?
ORDER BY t.status <> \'done\', t.due_at IS NULL, t.due_at, t.position, t.id';
$st = Database::get()->prepare($sql);
$st->execute([$familyId]);
$out = [];
foreach ($st->fetchAll() as $r) {
// 先按"有效分类"判它属不属于这一本
if (self::effectiveCat($r) !== $catId) {
continue;
}
if (!Task::visibleTo($r, $personId)) {
continue;
}
$r['uid'] = self::UID_PREFIX . (int)$r['id'];
/* ⚠️ 跨本父任务的提示:子任务跟自己的分类、而父在别的本时,
客户端会看到一条"有 RELATED-TO 但找不到那个父"的条目。
多给一个说明性字段,导出时写进 DESCRIPTION 尾部,
让用户至少知道"它的父在别的分类里",而不是以为数据坏了。 */
$r['parent_in_other_book'] = self::isParentInOtherBook($r);
$out[] = $r;
}
return $out;
}
/**
* 这条任务的父是否在**别的本**里(跨本父子)。
*
* 用于导出时加一句提示。只在"子任务自己有分类、父分类不同"时成立 ——
* 那种情况是用户明确给子任务设了别的分类,我们尊重它,但要让客户端可见。
*/
private static function isParentInOtherBook(array $r): bool
{
$pid = (int)($r['parent_id'] ?? 0);
if ($pid <= 0) {
return false;
}
$own = (int)($r['category_id'] ?? 0);
if ($own <= 0) {
return false; // 自己没分类 → 已跟随父,必定同本
}
$pc = array_key_exists('parent_cat_id', $r)
? (int)($r['parent_cat_id'] ?? 0)
: 0;
$pcNorm = $pc > 0 ? $pc : -1;
return $pcNorm !== $own;
}
/** 单条任务(含权限)。取不到或无权看 → null。 */
public static function one(int $familyId, int $personId, int $taskId): ?array
{
if ($taskId <= 0) {
return null;
}
$st = Database::get()->prepare(
'SELECT t.*, b.name AS board_name, c.name AS cat_name
FROM tasks t
LEFT JOIN task_boards b ON b.id = t.board_id
LEFT JOIN task_categories c ON c.id = t.category_id
WHERE t.id = ? AND t.family_id = ?'
);
$st->execute([$taskId, $familyId]);
$r = $st->fetch();
if (!$r) {
return null;
}
if (!Task::visibleTo($r, $personId)) {
return null;
}
$r['uid'] = self::UID_PREFIX . (int)$r['id'];
return $r;
}
// ============================================================
// 导出
// ============================================================
/**
* 一条任务 → 资源(ics 正文 + ETag)。
*
* ⚠️ ETag 由内容哈希算出,不用"当前时间"。
* CalDav 那边为 ETag 潮汐踩过坑(客户端每轮全量重拉),这里从一开始就用正确做法。
*/
public static function resourceOf(array $t, string $calName = '待办'): array
{
$ics = Ics::exportTodos([$t], $calName);
return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"'];
}
/** 整本导出 */
public static function ics(int $familyId, int $personId, int $catId, string $calName): string
{
return Ics::exportTodos(self::tasksIn($familyId, $personId, $catId), $calName);
}
/**
* ctag:这一本"变没变"的指纹。
*
* ⚠️ 必须覆盖任何会影响导出的变化,且不能随时间变。
* 用 COUNT + MAX(updated_at) + SUM(done_at) 三个一起,
* 单用 MAX(updated_at) 在同一秒改两条时会漏。
*/
public static function ctag(int $familyId, int $personId, int $catId): string
{
$sql = 'SELECT COUNT(*) AS n,
COALESCE(MAX(updated_at), \'\') AS mx,
COALESCE(SUM(UNIX_TIMESTAMP(COALESCE(done_at, \'1970-01-01\'))), 0) AS dn
FROM tasks WHERE family_id = ?';
$params = [$familyId];
if ($catId === -1) {
$sql .= ' AND category_id IS NULL';
} else {
$sql .= ' AND category_id = ?';
$params[] = $catId;
}
$st = Database::get()->prepare($sql);
$st->execute($params);
$r = $st->fetch() ?: [];
$seed = self::CTAG_VERSION . '|' . $catId . '|'
. (int)($r['n'] ?? 0) . '|'
. (string)($r['mx'] ?? '') . '|'
. (int)($r['dn'] ?? 0);
return '"' . md5($seed) . '"';
}
// ============================================================
// 写入
// ============================================================
/**
* 客户端 PUT 一条待办 → 落库。
*
* @param int $catId 本对应的分类 id(-1 = Inbox)
* @param int $taskId URL 段解析出的 id(0 = 新建)
* @param string $body text/calendar 正文
*
* @return array{id:int, created:bool}
* @throws \RuntimeException
*/
public static function put(int $familyId, int $personId, int $catId,
int $taskId, string $body): array
{
$parsed = Ics::parseTodos($body);
if (!$parsed) {
throw new \RuntimeException('正文里没有找到 VTODO。');
}
$n = Ics::normalizeTodo($parsed[0]);
if ($n['summary'] === '') {
throw new \RuntimeException('VTODO 缺少 SUMMARY(任务标题),拒绝写入。');
}
/* 只有"确实存在且有权访问"时才走更新分支。
否则(客户端 PUT 到 task-99999.ics 而它不存在)必须**新建** ——
RFC 4918 §9.7 规定 PUT 到不存在的 URI 就是创建资源。
这也兼容"客户端用自己编的 UID 当资源名"的场景。 */
$old = $taskId > 0 ? self::one($familyId, $personId, $taskId) : null;
if ($old !== null) {
if (!Task::canWrite($old, $personId)) {
throw new \RuntimeException('这条任务你没有写权限。');
}
/* ⚠️ Task::update() 会把没传的字段清空(它按 `$d['x'] ?? ''` 取值),
所以必须把原值补全再传 —— 否则客户端只改标题就会把备注/截止时间清掉。
这是"部分更新"最容易出的 bug。 */
Task::update($familyId, $taskId, [
'title' => $n['summary'],
'note' => $n['description'],
'status' => $n['status'],
'due_at' => $n['due'] ?? '',
'assignee_id' => (int)($old['assignee_id'] ?? 0),
'repeat_rule' => (string)($old['repeat_rule'] ?? ''),
'parent_id' => (int)($old['parent_id'] ?? 0),
'board_id' => (int)($old['board_id'] ?? 0),
// 分类跟随**本**:客户端把资源放进了哪一本,就归哪个分类
'category_id' => $catId === -1 ? 0 : $catId,
]);
self::fixDoneAt($familyId, $taskId, $n);
return ['id' => $taskId, 'created' => false];
}
// ---- 新建 ----
$id = Task::create($familyId, $personId, [
'title' => $n['summary'],
'note' => $n['description'],
'status' => $n['status'],
'due_at' => $n['due'] ?? '',
'category_id' => $catId === -1 ? 0 : $catId,
]);
if ($id <= 0) {
throw new \RuntimeException('创建任务失败。');
}
self::fixDoneAt($familyId, $id, $n);
return ['id' => $id, 'created' => true];
}
/**
* 用客户端给的 COMPLETED 时间覆盖 done_at。
*
* `Task::update()` 走到 done 时会自动 markDone(用"服务器当前时间"),
* 但客户端给的 COMPLETED 更准(它可能补录昨天完成的事)。
*/
private static function fixDoneAt(int $familyId, int $taskId, array $n): void
{
if ($n['status'] !== Task::DONE_STATUS || empty($n['completed'])) {
return;
}
Database::get()->prepare(
'UPDATE tasks SET done_at = ? WHERE id = ? AND family_id = ?'
)->execute([$n['completed'], $taskId, $familyId]);
}
/** 客户端 DELETE 一条待办 */
public static function delete(int $familyId, int $personId, int $taskId): bool
{
$t = self::one($familyId, $personId, $taskId);
if (!$t) {
return false;
}
if (!Task::canWrite($t, $personId)) {
return false;
}
Task::destroy($familyId, $taskId);
return true;
}
}
+215 -10
View File
@@ -904,9 +904,152 @@ final class Trip
* 这里一律**存下来**,界面按出行方式决定显示哪几个(详情页只显示有值的 + * 这里一律**存下来**,界面按出行方式决定显示哪几个(详情页只显示有值的 +
* 该出行方式相关的),这样换个方式再换回来,填过的东西还在。 * 该出行方式相关的),这样换个方式再换回来,填过的东西还在。
*/ */
/**
* 行程动作常量(#48)。
*
* ⚠️ 只做三个动作,**不含"删除"**:删除走既有软删(`deleted_at`),
* 与"取消"是两件完全不同的事 —— 用户明确要求区分:
* · 取消 = 不去了,数据保留,列在「未成行」组
* · 删除 = 彻底不要了,不再显示
*/
public const NODE_ACTIONS = ['start', 'done', 'cancel'];
/**
* 执行一个行程动作(#48 的核心逻辑)。
*
* 用户原话(2026-11-05):
* 「添加计划行程时,刚开始添加的时候,它的状态就是"计划中"。
* 当我开始执行这个计划时…这时候就要记录住 8:30 这个时间,因为 8:30 会
* 成为实际行程的开始时间。也就是说,原来计划的时间和现在真正开始的时间都要记录。
* 一旦开始,这个行程的状态就变成"进行中"。等到走完了…我点"完成"。
* 这时候这个内容或者计划行程就变成一个实际行程。
* **一个实际行程没有再没有状态了,因为实际已经发生完了。**
* 但如果这个计划我没有去做…点击一个按钮,然后它就变成了"取消"或者"删除"…
* **取消和删除是不一样的**。」
*
* ## 三个动作
*
* | action | stage | status | 真实时间 |
* |---|---|---|---|
* | `start` | 不变(plan) | `ongoing` | 写 `actual_started_at` = 此刻 |
* | `done` | **plan → actual** | `done` | 写 `actual_ended_at` = 此刻 |
* | `cancel` | **plan → skipped** | `cancelled` | 不写 |
*
* ⚠️ `done` 会把 stage 改成 `actual` —— 这是"完成即转实际"的落点。
* 转成 actual 之后,界面**不再显示任何状态**(用户:实际行程没状态)。
* ⚠️ `cancel` 改成 `skipped` 而不是软删:数据要保留,
* 最后旅程结束时要能看到"哪些计划没做"。
*
* @param string $action start / done / cancel
* @return array{ok:bool,stage?:string,status?:string,error?:string}
*/
public static function nodeAction(int $familyId, int $eventId, int $nodeId, string $action): array
{
if (!in_array($action, self::NODE_ACTIONS, true)) {
return ['ok' => false, 'error' => '不认识的动作。'];
}
$db = Database::get();
$st = $db->prepare(
'SELECT * FROM trip_nodes WHERE id = ? AND event_id = ? AND deleted_at IS NULL'
);
$st->execute([$nodeId, $eventId]);
$cur = $st->fetch();
if (!$cur) {
return ['ok' => false, 'error' => '找不到这段行程。'];
}
/* ⚠️ 已经转成实际行程的,不再接受状态类动作。
用户:「如果这个行程已经转变成实际行程了…那这个行程就不再有其他状态了。」
⇒ `done` 之后点 `start` / `cancel` 都无意义,直接拒绝。 */
$curStage = (string)($cur['stage'] ?? 'plan');
if ($curStage === 'actual' && $action !== 'done') {
return ['ok' => false, 'error' => '这段已经是实际行程了,不再有状态。'];
}
$now = date('Y-m-d H:i:s');
$stage = $curStage;
$status = (string)($cur['status'] ?? 'planned');
if ($action === 'start') {
$status = 'ongoing';
/* 只在**第一次**开始时记录:重复点不该刷新真实开始时间 */
if ((string)($cur['actual_started_at'] ?? '') === '') {
$up = $db->prepare('UPDATE trip_nodes SET actual_started_at = ? WHERE id = ?');
$up->execute([$now, $nodeId]);
}
} elseif ($action === 'done') {
$stage = 'actual';
$status = 'done';
/* 若用户没点过「开始」直接点「完成」,用计划开始时间兜底记实际开始
(否则实际行程缺开始时间,展示上少一半信息) */
if ((string)($cur['actual_started_at'] ?? '') === '') {
$up0 = $db->prepare(
'UPDATE trip_nodes SET actual_started_at = COALESCE(starts_at, ?) WHERE id = ?'
);
$up0->execute([$now, $nodeId]);
}
$up2 = $db->prepare('UPDATE trip_nodes SET actual_ended_at = ? WHERE id = ?');
$up2->execute([$now, $nodeId]);
} else { // cancel
$stage = 'skipped';
$status = 'cancelled';
}
/* 同步 category(老的双轨字段,由 stage 派生,两边不能打架) */
$category = $stage === 'actual' ? 'reality' : 'plan';
$up3 = $db->prepare(
'UPDATE trip_nodes SET stage = ?, status = ?, category = ?, updated_at = NOW()
WHERE id = ?'
);
$up3->execute([$stage, $status, $category, $nodeId]);
return ['ok' => true, 'stage' => $stage, 'status' => $status];
}
/**
* 这段行程该显示哪些动作(#48)。
*
* ⚠️ 前后端**共用同一份规则**,避免"网页端能点、端上不能点"这种不一致。
*
* 规则:
* · `actual` → 空数组(实际行程没有状态类动作,只有编辑/删除)
* · `skipped` → 空数组(已经取消,不再转)
* · `plan` + planned → 开始
* · `plan` + ongoing → 完成
* · `plan` + 其他 → 开始
*
* @return string[] 动作 key 列表
*/
public static function nodeAvailableActions(string $stage, string $status): array
{
if ($stage === 'actual' || $stage === 'skipped') {
/* ⚠️ 实际行程 / 未成行**没有状态动作** ——
用户:「实际行程是没有状态的…实际已经发生完了,它再有状态就没有意义了。」 */
return [];
}
if ($status === 'ongoing') {
return ['done'];
}
return ['start'];
}
public static function saveNode(int $familyId, int $eventId, array $d, ?int $id = null): int public static function saveNode(int $familyId, int $eventId, array $d, ?int $id = null): int
{ {
$db = Database::get(); $db = Database::get();
/* 🔴 2026-11-05(#27 事故):**先取出当前行**,供下面"没传就沿用原值"用。
⚠️ 不要用 `$cur ?? []` —— `saveNode()` 这层根本没有 `$cur`
(那是 `ApiHandler::trips()` 的局部变量),
写成 `?? []` 会永远拿到空数组,"保留原值"形同虚设。
CUR_ROW_LOADED */
$curRow = [];
if ($id !== null && $id > 0) {
$sc = $db->prepare('SELECT * FROM trip_nodes WHERE id = ?');
$sc->execute([$id]);
$got = $sc->fetch();
if (is_array($got)) {
$curRow = $got;
}
}
// v24 第 13 批:新加时由用户选「计划行程 / 实际行程」(用户原话第 2 条)。 // v24 第 13 批:新加时由用户选「计划行程 / 实际行程」(用户原话第 2 条)。
// 编辑时若表单带了 stage 就一并改(修订面板里"标记为实际"走的是另一个方法)。 // 编辑时若表单带了 stage 就一并改(修订面板里"标记为实际"走的是另一个方法)。
$stage = in_array($d['stage'] ?? '', array_keys(self::NODE_STAGES), true) $stage = in_array($d['stage'] ?? '', array_keys(self::NODE_STAGES), true)
@@ -923,18 +1066,54 @@ final class Trip
// 用户第 3 条要「用高德地图的接口,记录详细地址」,第 1 条要按**城市**聚合统计。 // 用户第 3 条要「用高德地图的接口,记录详细地址」,第 1 条要按**城市**聚合统计。
// 用户第 3 条又明确「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」 // 用户第 3 条又明确「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」
// → `seat_info` / `gate` 不再从表单读(列还留着,老数据不丢)。 // → `seat_info` / `gate` 不再从表单读(列还留着,老数据不丢)。
$fromPlace = (string)($d['from_place'] ?? ''); /* 🔴🔴 2026-11-05 事故修复(#27「北京返回青岛」的出发地/到达地被清空)。
$toPlace = (string)($d['to_place'] ?? '');
$fromAddr = trim((string)($d['from_addr'] ?? '')); 原来这 8 行是 `(string)($d['xxx'] ?? '')` —— **无条件取**:
$toAddr = trim((string)($d['to_addr'] ?? '')); 调用方没传 `from_place` 就得到空串,接着 `textOrNull2('')` 写成 NULL,
$fromLng = self::coordOrNull($d['from_lng'] ?? null); **库里原有的地点被静默抹掉**。
$fromLat = self::coordOrNull($d['from_lat'] ?? null);
$toLng = self::coordOrNull($d['to_lng'] ?? null); 踩到的调用方:
$toLat = self::coordOrNull($d['to_lat'] ?? null); · 端上编辑浮层(旧版只传 `place`,不传 from_place/to_place)
· 任何脚本/第三方调用
· 我的验证脚本(就是这次事故的直接原因)
⇒ 改成 PATCH 语义:**没传就沿用原值,传了空串才是明确清空**。
这样"用户主动删掉地点"仍然生效(他传空串),
而"调用方不关心这个字段"不会造成破坏。
⚠️ `$cur` 是本方法开头查出来的当前行(编辑时有值,新建时为 null)。
取不到时退回空串 —— 新建本来就该是空。 */
$hasF = static fn(string $k): bool => array_key_exists($k, $d);
$fromPlace = $hasF('from_place')
? (string)$d['from_place'] : (string)($curRow['from_place'] ?? '');
$toPlace = $hasF('to_place')
? (string)$d['to_place'] : (string)($curRow['to_place'] ?? '');
$fromAddr = $hasF('from_addr')
? trim((string)$d['from_addr']) : (string)($curRow['from_addr'] ?? '');
$toAddr = $hasF('to_addr')
? trim((string)$d['to_addr']) : (string)($curRow['to_addr'] ?? '');
$fromLng = $hasF('from_lng')
? self::coordOrNull($d['from_lng']) : self::coordOrNull($curRow['from_lng'] ?? null);
$fromLat = $hasF('from_lat')
? self::coordOrNull($d['from_lat']) : self::coordOrNull($curRow['from_lat'] ?? null);
$toLng = $hasF('to_lng')
? self::coordOrNull($d['to_lng']) : self::coordOrNull($curRow['to_lng'] ?? null);
$toLat = $hasF('to_lat')
? self::coordOrNull($d['to_lat']) : self::coordOrNull($curRow['to_lat'] ?? null);
// 城市:**优先用表单传来的**(前端联想选中时高德已经把城市带回来了,零成本), // 城市:**优先用表单传来的**(前端联想选中时高德已经把城市带回来了,零成本),
// 没有才去解析。解析绝不抛异常(Geo 内部全兜底)。 // 没有才去解析。解析绝不抛异常(Geo 内部全兜底)。
$fromCity = mb_substr(trim((string)($d['from_city'] ?? '')), 0, 60, 'UTF-8'); /* 🔴 2026-11-05 修(用户:「城市…省也带上了,市也带上了,区甚至都带上了,
$toCity = mb_substr(trim((string)($d['to_city'] ?? '')), 0, 60, 'UTF-8'); 这没必要,直接城市就可以」)。
前端传来的可能是「山东省青岛市市南区」(高德的 district 整串),
直接存会让旅程的城市聚合变成一长串行政区划。
⇒ 过一道 `Geo::cityOnly()` 剥成纯市名「青岛市」。
⚠️ 不能指望前端改好就完事:老版本 App 还在用、
第三方调用不经过我们的前端、老数据已经脏了。
服务端归一化是**唯一的真防线**。 */
$fromCity = \App\Geo::cityOnly(mb_substr(trim((string)($d['from_city'] ?? '')), 0, 60, 'UTF-8'));
$toCity = \App\Geo::cityOnly(mb_substr(trim((string)($d['to_city'] ?? '')), 0, 60, 'UTF-8'));
if ($fromCity === '') { if ($fromCity === '') {
$fromCity = \App\Geo::cityFromPoint($fromLng, $fromLat, $fromPlace !== '' ? $fromPlace : $fromAddr); $fromCity = \App\Geo::cityFromPoint($fromLng, $fromLat, $fromPlace !== '' ? $fromPlace : $fromAddr);
} }
@@ -1464,6 +1643,32 @@ final class Trip
return self::NODE_TYPES[$key][1] ?? '📌'; return self::NODE_TYPES[$key][1] ?? '📌';
} }
/**
* 行程卡左侧的图标:**出行方式优先,节点类型兜底**。
*
* 🔴 2026-11-05(用户:「明明选的是坐火车,但网页版的图标显示的还是一辆汽车」)。
*
* 原来左侧只按 `node_type` 取 ⇒「赶路」永远显示 🚗,
* 哪怕这段实际是坐火车(`travel_mode = 'train'` 应该是 🚄)。
*
* 现在:
* ① 有出行方式 → 用它(火车 🚄 / 飞机 ✈️ / 轮船 🚢 / 自驾 🚗…)
* ② 没有 → 退回节点类型(游玩 🎡 / 用餐 🍜 / 住宿 🏨)
*
* ⚠️ 端上 `fbNodeLeadIcon()` 必须是**同一套优先级**,否则两边图标不一致。
*/
public static function leadIcon(string $nodeType, string $mode, int $familyId = 0): string
{
if ($mode !== '') {
$mi = self::modeIcon($mode);
/* modeIcon 对未知 key 回 '🧭'(不是空串),所以这里判空保底 */
if ($mi !== '') {
return $mi;
}
}
return self::typeIcon($nodeType, $familyId);
}
/** 出行方式的名字 / 图标 */ /** 出行方式的名字 / 图标 */
public static function modeWord(string $key): string public static function modeWord(string $key): string
{ {
+242
View File
@@ -0,0 +1,242 @@
<?php
/**
* 视频转码(H.265/HEVC → H.264)。
*
* 🔴 为什么必须转(2026-11-05 用户问「网页端播放视频的问题解决了吗?」):
*
* 用户的视频是手机拍的 H.265/HEVC(`hvc1`)。浏览器支持现状:
*
* | 浏览器 | HEVC |
* |---|---|
* | Safari | ✅ 系统解码器 |
* | Chrome Windows | ⚠️ 需装微软商店付费扩展 |
* | Chrome macOS/Linux | ❌ |
* | Firefox | ❌ |
* | Edge | ⚠️ 同 Chrome |
*
* **不存在能解码 HEVC 的前端 JS 库** —— hls.js / flv.js / video.js
* 都只是传输层封装,不提供编解码器。所以只能在服务端转。
*
* ## 转码参数为什么这样选
*
* -c:v libx264 -preset veryfast -crf 23
* 兼容性最好的 H.264 编码器。`veryfast` 在服务器上速度可接受,
* `crf 23` 是视觉无损区间(18 更大更清,28 更小更糊)。
* -pix_fmt yuv420p
* 必须显式指定:手机视频常是 yuv420p10le(10bit)或 4:2:2,
* 这类像素格式 **Chrome 不认**,不转成 8bit 4:2:0 会照样黑屏。
* -c:a aac -b:a 128k
* 音频统一 AAC。HEVC 视频的音频常是 PCM/AC3,浏览器也不支持。
* -movflags +faststart
* 把 moov 移到文件头 ⇒ 可边下边播。原来有些视频 moov 在尾部,
* 浏览器得下完整个文件才能开始播。
* -vf scale='min(1920,iw)':-2
* 宽度超过 1920 才缩,等比(-2 保证高度是偶数 —— yuv420 要求)。
* 手机 4K 视频转完还是有 1080p,体积能小一半以上。
*
* ## 失败时的策略
*
* **转码失败不阻断上传** —— 原样保存。理由:
* · 转码失败可能是 ffmpeg 挂了、磁盘满、编码异常,不该因此让用户传不了
* · 原文件至少能下载后用本地播放器看(HEVC 系统播放器都支持)
* · 失败原因写进 error 返回,界面能提示
*/
declare(strict_types=1);
namespace App;
final class Video
{
/** ffmpeg 可执行文件(PATH 里有就直接写 ffmpeg) */
private const FFMPEG = 'ffmpeg';
/** ffprobe */
private const FFPROBE = 'ffprobe';
/** 转码超时(秒)。15MB 的视频 veryfast 大约 10~30 秒,给足余量。 */
private const TIMEOUT = 300;
/**
* 这个 MIME 是视频吗。
*/
public static function isVideo(string $mime): bool
{
return strpos(strtolower($mime), 'video/') === 0;
}
/**
* ffmpeg 是否可用(缓存一次,避免每次上传都 shell 一次)。
*
* ⚠️ 用静态变量缓存:一个请求里可能传多个附件,不必重复探测。
*/
public static function available(): bool
{
static $ok = null;
if ($ok !== null) {
return $ok;
}
$out = [];
$code = 1;
@exec(self::FFMPEG . ' -version 2>&1', $out, $code);
$ok = $code === 0;
return $ok;
}
/**
* 探测视频的编码信息。
*
* @return array{codec:string,pix_fmt:string,width:int,height:int,duration:float}
*/
public static function probe(string $path): array
{
$empty = ['codec' => '', 'pix_fmt' => '', 'width' => 0, 'height' => 0, 'duration' => 0.0];
$cmd = self::FFPROBE . ' -v error -select_streams v:0'
. ' -show_entries stream=codec_name,pix_fmt,width,height'
. ' -show_entries format=duration'
. ' -of default=noprint_wrappers=1 '
. escapeshellarg($path) . ' 2>&1';
$out = [];
$code = 1;
@exec($cmd, $out, $code);
if ($code !== 0) {
return $empty;
}
$kv = [];
foreach ($out as $line) {
$p = strpos($line, '=');
if ($p > 0) {
$kv[substr($line, 0, $p)] = substr($line, $p + 1);
}
}
return [
'codec' => strtolower((string)($kv['codec_name'] ?? '')),
'pix_fmt' => strtolower((string)($kv['pix_fmt'] ?? '')),
'width' => (int)($kv['width'] ?? 0),
'height' => (int)($kv['height'] ?? 0),
'duration' => (float)($kv['duration'] ?? 0),
];
}
/**
* 这个视频需要转码吗。
*
* 判据(任一成立就要转):
* ① 编码不是 h264 —— HEVC/VP9/AV1 在部分浏览器播不了
* ② 像素格式不是 yuv420p/yuvj420p —— 10bit / 4:2:2 浏览器不认
*
* ⚠️ 已经符合的**不重复转**:转码要 CPU 也要时间,
* 用户传 H.264 视频时不该白等。
*/
public static function needsTranscode(string $path): bool
{
$p = self::probe($path);
if ($p['codec'] === '') {
/* 探测失败(文件损坏/ffprobe 缺失)⇒ 不转,原样存,
至少还能下载。硬转可能把好文件弄坏。 */
return false;
}
if ($p['codec'] !== 'h264') {
return true;
}
$okPix = ['yuv420p', 'yuvj420p', 'nv12'];
return !in_array($p['pix_fmt'], $okPix, true);
}
/**
* 转成 H.264。
*
* @return array{ok:bool,path?:string,error?:string,note?:string}
* ok=true 时 `path` 是**新文件**的临时路径(调用方负责清理),
* `note` 是给用户看的一句话(如「已从 H.265 转成 H.264」)。
*/
public static function toH264(string $src): array
{
if (!self::available()) {
return ['ok' => false, 'error' => '服务器没有 ffmpeg,视频无法转码'];
}
$before = self::probe($src);
$dst = $src . '.h264.mp4';
/* 缩放:宽超过 1920 才缩;-2 保证高度偶数(yuv420 要求) */
$scale = "scale='min(1920,iw)':-2";
$cmd = self::FFMPEG . ' -hide_banner -loglevel error -y'
. ' -i ' . escapeshellarg($src)
. ' -c:v libx264 -preset veryfast -crf 23'
. ' -pix_fmt yuv420p'
. ' -vf ' . escapeshellarg($scale)
. ' -c:a aac -b:a 128k'
. ' -movflags +faststart'
. ' ' . escapeshellarg($dst)
. ' 2>&1';
$out = [];
$code = 1;
@exec('timeout ' . self::TIMEOUT . ' ' . $cmd, $out, $code);
if ($code !== 0 || !is_file($dst) || (int)@filesize($dst) === 0) {
@unlink($dst);
$msg = trim(implode(' ', array_slice($out, 0, 3)));
return ['ok' => false, 'error' => '转码失败' . ($msg !== '' ? ':' . $msg : '')];
}
$after = self::probe($dst);
/* 转完自检:必须真的是 h264 + yuv420p,否则别拿去用 */
if ($after['codec'] !== 'h264' || !in_array($after['pix_fmt'], ['yuv420p', 'yuvj420p'], true)) {
@unlink($dst);
return ['ok' => false, 'error' => '转码结果异常(' . $after['codec'] . '/' . $after['pix_fmt'] . ')'];
}
$fromName = $before['codec'] !== '' ? strtoupper($before['codec']) : '原格式';
return [
'ok' => true,
'path' => $dst,
'note' => '已从 ' . $fromName . ' 转成 H.264(' . $before['width'] . 'x' . $before['height']
. ' → ' . $after['width'] . 'x' . $after['height'] . ')',
];
}
/**
* 抽一帧做封面(2026-11-06,观影照片支持视频)。
*
* 为什么要抽首帧:列表/网格里不可能为每个视频实时解码 —— 一屏 9 个视频,
* 端上要同时挂 9 个解码器,必然卡死。必须服务端抽一帧存成**普通图片**,
* 列表只显示这张图,点开才真播。
*
* ⚠️ 取第 **1 秒**而不是第 0 帧:
* 手机视频开头经常是黑场/对焦过程(`-ss 0` 抽出来是一片黑),
* 第 1 秒通常已经有内容了。
* ⚠️ **失败不阻断**(返回空 path):
* 抽帧失败不影响视频本身能用;端上对没有封面的视频显示一个通用图标即可。
* 让"没有封面"变成上传失败是最不能接受的取舍。
*
* @return array{ok:bool, path:string, error:string}
*/
public static function poster(string $src): array
{
if (!self::available()) {
return ['ok' => false, 'path' => '', 'error' => '服务器没有 ffmpeg'];
}
$dst = $src . '.poster.jpg';
/* 宽度 480 足够列表缩略(端上最大也就显示 100vp 左右)。
等比、-2 保证偶数。`-frames:v 1` 只出这一帧。 */
$cmd = self::FFMPEG . ' -hide_banner -loglevel error -y'
. ' -ss 1 -i ' . escapeshellarg($src)
. ' -frames:v 1 -vf ' . escapeshellarg("scale=480:-2")
. ' -q:v 4'
. ' ' . escapeshellarg($dst)
. ' 2>&1';
$out = [];
$code = 1;
/* 抽帧很快,给 30 秒足够;超时按失败处理,不拖住上传 */
@exec('timeout 30 ' . $cmd, $out, $code);
if ($code !== 0 || !is_file($dst) || (int)@filesize($dst) === 0) {
@unlink($dst);
$msg = trim(implode(' ', array_slice($out, 0, 2)));
return ['ok' => false, 'path' => '', 'error' => '抽帧失败' . ($msg !== '' ? ':' . $msg : '')];
}
return ['ok' => true, 'path' => $dst, 'error' => ''];
}
}
+2 -2
View File
@@ -21,7 +21,7 @@ final class View
$tplFile = $file; $tplFile = $file;
$pageKey = $page ?: $tpl; $pageKey = $page ?: $tpl;
$pageTitle = $data['title'] ?? '家书'; $pageTitle = $data['title'] ?? '岁月家';
$extraHead = $data['extraHead'] ?? ''; $extraHead = $data['extraHead'] ?? '';
extract($data, EXTR_SKIP); extract($data, EXTR_SKIP);
@@ -67,7 +67,7 @@ final class View
throw new \RuntimeException('模板不存在:' . $tpl); throw new \RuntimeException('模板不存在:' . $tpl);
} }
$tplFile = $file; $tplFile = $file;
$pageTitle = $data['title'] ?? '家书'; $pageTitle = $data['title'] ?? '岁月家';
$extraHead = $data['extraHead'] ?? ''; $extraHead = $data['extraHead'] ?? '';
extract($data, EXTR_SKIP); extract($data, EXTR_SKIP);
require dirname(__DIR__) . '/templates/layout-bare.php'; require dirname(__DIR__) . '/templates/layout-bare.php';
+1 -1
View File
@@ -47,7 +47,7 @@ if (!defined('ICON_STORE')) {
$configFile = FB_ROOT . '/config.php'; $configFile = FB_ROOT . '/config.php';
if (!is_file($configFile)) { if (!is_file($configFile)) {
http_response_code(500); http_response_code(500);
echo '<!doctype html><meta charset="utf-8"><title>家书 · 未配置</title>' echo '<!doctype html><meta charset="utf-8"><title>岁月家 · 未配置</title>'
. '<div style="max-width:520px;margin:12vh auto;padding:28px 32px;' . '<div style="max-width:520px;margin:12vh auto;padding:28px 32px;'
. 'background:#fff;border:1px solid rgba(47,36,30,.1);border-radius:16px;' . 'background:#fff;border:1px solid rgba(47,36,30,.1);border-radius:16px;'
. 'font:15px/1.8 -apple-system,\'PingFang SC\',\'Microsoft YaHei\',sans-serif;color:#2F241E">' . 'font:15px/1.8 -apple-system,\'PingFang SC\',\'Microsoft YaHei\',sans-serif;color:#2F241E">'
+99 -2
View File
@@ -88,7 +88,31 @@ function avatar_url(?string $key): string
* 附件代理地址。 * 附件代理地址。
* @param array $extra 额外参数,如 ['dl' => 1] 表示按下附件下载 * @param array $extra 额外参数,如 ['dl' => 1] 表示按下附件下载
*/ */
function media_url(?string $key, array $extra = []): string /**
* 附件访问地址(走 media.php 代理)。
*
* @param array $extra 额外参数:`w`(压缩档位 1600)、`dl`(下载)等
* @param int $ver 内容版本(通常是附件 size)。
* **变了就让 URL 变**,从而绕过一切缓存。
*
* 🔴 #57(用户:「他说是H.265,还是不让播呢?」):
* 转码后服务端确实是 h264 了(15 个视频全部实测过),但浏览器还在放旧的。
*
* 原因是**媒体缓存**:`Cache-Control: max-age=604800`(7 天),
* 而浏览器对 `<video>` 有自己的一套:
* · 不完全遵守 ETag
* · `Range` 分段缓存会把每段当独立资源
* · `src` 不变时直接复用已有媒体对象
*
* ⇒ 最可靠的做法是**让 URL 变**。URL 一变任何缓存层都不会命中。
*
* ⚠️ 版本号用 `size` 而不是 `mtime`/`md5`:
* · `mtime`:对象在 OSS 上,本地拿不到可靠时间
* · `md5`:要读整个文件,几百 MB 的视频不可接受
* · `size` 够用(转码前后体积必然不同:1.22MB → 2.19MB),
* 而且它本来就在数据库里,零开销。
*/
function media_url(?string $key, array $extra = [], int $ver = 0): string
{ {
$key = trim((string)$key); $key = trim((string)$key);
if ($key === '') { if ($key === '') {
@@ -98,7 +122,31 @@ function media_url(?string $key, array $extra = []): string
if (preg_match('#^https?://#i', $key)) { if (preg_match('#^https?://#i', $key)) {
return $key; return $key;
} }
return 'media.php?' . http_build_query(array_merge(['k' => ltrim($key, '/')], $extra)); $q = array_merge(['k' => ltrim($key, '/')], $extra);
/* 🔴🔴 #56 严重修复(用户 2026-11-05:「你好像把 .mp4 后面也带上了问号,
写成了 1600 这样的参数…是你更新了这个功能之后,出了这个问题」):
**`w`(压缩档位)只允许给图片。** 给视频传
`x-oss-process=image/resize,l_1600` 会让 OSS 图片处理服务
对非图片返回错误内容 ⇒ `<video>` 播不了。
这里用扩展名判断(只有 key、没有 MIME):
· 图片扩展名 → 保留 w
· 其它(mp4/pdf…) → **去掉 w**
⚠️ `v` 版本号**所有类型都保留**:视频转码后也要靠它破缓存。
它只是查询串,不参与 OSS 签名。 */
if (isset($q['w'])) {
$isImg = (bool)preg_match('#\.(jpe?g|png|gif|webp|bmp|avif|heic)$#i', $key);
if (!$isImg) {
unset($q['w']);
}
}
if ($ver > 0) {
$q['v'] = $ver;
}
return 'media.php?' . http_build_query($q);
} }
// ---------------- 人:怎么称呼、什么颜色 ---------------- // ---------------- 人:怎么称呼、什么颜色 ----------------
@@ -378,6 +426,40 @@ function days_until_birthday(?string $birthday): ?int
* @param array $extra 额外要显示的行 [[标签, 值], …] * @param array $extra 额外要显示的行 [[标签, 值], …]
* @return string 可直接放进 data-fb-entry 的 JSON 字符串(已转义) * @return string 可直接放进 data-fb-entry 的 JSON 字符串(已转义)
*/ */
/**
* 把 hex 主题色往白色方向混,得到一档浅色。
*
* 🔴 #85(用户 2026-11-05):「参考一下那个反馈系统里面的下拉框到底是怎么做的…
* 它也有主题颜色,我们这个也有主题颜色,就按照一样的做法去做。」
*
* FeedLite 那边用三档浅色区分下拉项的三态:
* tint-1(混 91% 白)→ hover
* tint-2(混 80% 白)→ 选中项(比 hover 深一档,一眼分开)
* tint-3(混 72% 白)→ 选中项 hover
*
* ⚠️ 在 PHP 里算好再输出,**不用 CSS 的 `color-mix()`** ——
* FeedLite 实测过:`color-mix(in srgb, var(--brand) 9%, #fff)`
* 在 Chrome 里解析成透明(规则匹配上了但不生效)。
* ⚠️ `$ratio` 是"往白里混多少",不是"保留多少":
* 0.91 = 91% 白 + 9% 原色(很浅),0.0 = 原色本身。
*/
function fb_color_tint(string $hex, float $ratio): string
{
$hex = trim($hex);
if (preg_match('/^#?([0-9a-fA-F]{6})$/', $hex, $m) !== 1) {
return ''; // 不是 6 位 hex(比如 rgba()/色名)就放弃,调用方退回默认
}
$h = $m[1];
$r = (int)hexdec(substr($h, 0, 2));
$g = (int)hexdec(substr($h, 2, 2));
$b = (int)hexdec(substr($h, 4, 2));
$ratio = max(0.0, min(1.0, $ratio));
return sprintf('#%02x%02x%02x',
(int)round($r + (255 - $r) * $ratio),
(int)round($g + (255 - $g) * $ratio),
(int)round($b + (255 - $b) * $ratio));
}
function fb_entry_json(array $ev, string $detail = '', string $edit = '', array $extra = []): string function fb_entry_json(array $ev, string $detail = '', string $edit = '', array $extra = []): string
{ {
$date = (string)($ev['date'] ?? ''); $date = (string)($ev['date'] ?? '');
@@ -398,6 +480,16 @@ function fb_entry_json(array $ev, string $detail = '', string $edit = '', array
if ($when !== '') { $rows[] = ['时间', $when]; } if ($when !== '') { $rows[] = ['时间', $when]; }
if ((string)($ev['calname'] ?? '') !== '') { $rows[] = ['日历本', (string)$ev['calname']]; } if ((string)($ev['calname'] ?? '') !== '') { $rows[] = ['日历本', (string)$ev['calname']]; }
if ((string)($ev['location'] ?? '') !== '') { $rows[] = ['地点', (string)$ev['location']]; } if ((string)($ev['location'] ?? '') !== '') { $rows[] = ['地点', (string)$ev['location']]; }
/* 🔴 #67(用户 2026-11-05):「如果行程里面有地址,也就是有出发地和目的地,
应该像行程里显示的那样,把它显示在下面,这样一点就可以导航。」
⇒ 行程派生条目(kind=trip)额外给两行:出发地 / 目的地。
@ 用两行而不是一行「A → B」:详情卡是 `dl.kv` 两列表格,
一行一个地名读起来更清楚,也便于将来给单个地名挂导航链接。
@ 普通日程没有这两个字段(),下面的 if 自然不产出。 */
$evFrom = (string)($ev['from_place'] ?? '');
$evTo = (string)($ev['to_place'] ?? '');
if ($evFrom !== '') { $rows[] = ['出发地', $evFrom]; }
if ($evTo !== '') { $rows[] = ['目的地', $evTo]; }
/* 🔴 v44(用户 2026-10-12 第 3 条):「"状态确定"这个不要了,把"状态确定"换成地点。 /* 🔴 v44(用户 2026-10-12 第 3 条):「"状态确定"这个不要了,把"状态确定"换成地点。
当然,如果没有地点,这一行就不要显示了。」 当然,如果没有地点,这一行就不要显示了。」
⇒ **删掉「状态」那一行**(地点上一行已经加了,"换成地点"就是把状态让位给地点)。 ⇒ **删掉「状态」那一行**(地点上一行已经加了,"换成地点"就是把状态让位给地点)。
@@ -541,6 +633,11 @@ function task_panel_json(int $taskId, string $msg = '', bool $ok = true): void
'id' => $taskId, 'id' => $taskId,
'status' => (string)($t['status'] ?? ''), 'status' => (string)($t['status'] ?? ''),
'word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')), 'word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
/* 🔴 #79(用户 2026-11-05):「首页卡片没有刷新。」
前端 `syncRow()` 原来只同步状态徽标,不同步标题。
标题可以从面板输入框读,但那条路依赖 DOM 还在 ——
服务端直接回一份才是稳的(同一个函数给所有面板动作复用)。 */
'title' => (string)($t['title'] ?? ''),
'html' => $html, 'html' => $html,
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
exit; exit;
+530 -4
View File
@@ -1958,6 +1958,12 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
padding: 2px 12px; border-radius: 20px; color: var(--muted); padding: 2px 12px; border-radius: 20px; color: var(--muted);
} }
.empty-hint { color: var(--muted); font-size: 14px; margin: 0; } .empty-hint { color: var(--muted); font-size: 14px; margin: 0; }
/* 🔴 工单 #20:首页「近期任务」的空态提示是**一直渲染、靠 hidden 切换**的
(服务端有任务时给它 `hidden`,前端把最后一条删掉后再显示出来)。
⚠️ 浏览器默认的 `[hidden]{display:none}` 优先级最低 —— 只要哪天有人给
`.empty-hint` 加上 `display:flex` 之类,这句提示就会在"有任务时"也冒出来。
所以在这里显式再声明一次(与上面第 601 行那条注释是同一个约定)。 */
.empty-hint[hidden] { display: none !important; }
.viewers-list { display: flex; flex-wrap: wrap; gap: 12px; } .viewers-list { display: flex; flex-wrap: wrap; gap: 12px; }
.viewers-list .viewer-tag { padding: 4px 12px 4px 6px; font-size: 14px; } .viewers-list .viewer-tag { padding: 4px 12px 4px 6px; font-size: 14px; }
.viewers-list .viewer-tag .chip-av { width: 28px; height: 28px; } .viewers-list .viewer-tag .chip-av { width: 28px; height: 28px; }
@@ -2406,7 +2412,19 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
max-width: 92vw; max-height: 100%; width: auto; height: auto; max-width: 92vw; max-height: 100%; width: auto; height: auto;
object-fit: contain; border-radius: 8px; object-fit: contain; border-radius: 8px;
} }
.pv-video { max-width: 92vw; max-height: 100%; width: auto; height: auto; background: #000; } /* 🔴 2026-11-05 修:视频打开后是黑的(用户:「点开之后是黑的,没办法播放」)。
原因:`max-height: 100%` 依赖父级的**确定高度**,而 `.pv-stage` 是
flex + `align-items:center`,百分比高度解析不出具体像素
⇒ `<video>` 按 0 高度渲染 ⇒ 只剩 background:#000 一块黑。
改成 `vh`(相对视口,永远是确定值)+ `object-fit: contain`(竖屏不变形)。 */
.pv-video {
max-width: 92vw;
max-height: 82vh;
width: auto;
height: auto;
object-fit: contain;
background: #000;
}
.pv-pdf { .pv-pdf {
width: 92vw; height: 100%; border: 0; border-radius: 8px; background: #fff; width: 92vw; height: 100%; border: 0; border-radius: 8px; background: #fff;
} }
@@ -3523,6 +3541,61 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
.tg-block:hover { text-decoration: none; filter: brightness(1.1); z-index: 2; } .tg-block:hover { text-decoration: none; filter: brightness(1.1); z-index: 2; }
.tg-block b { font-weight: 600; margin-right: 3px; } .tg-block b { font-weight: 600; margin-right: 3px; }
/* ============================================================
跨天日程的分段(v88 / 工单 #35)
============================================================
用户:「如果一个日程跨了三天,应该在日历本上怎么显示呢?在周视图和日视图,
应该怎么显示?现在显示的有问题。」
做法:一个跨天日程在**每一列各画一段**(`build_time_grid` 算好每段的
top/height),视觉上要让它们看起来是**一条**,而不是三个互不相干的块。
为什么不做跨列绝对定位层:`.tg-body` 左边有 56px 固定 gutter,
窄屏还会横向滚动(`.cal-tg-card{overflow:auto}` + 每列 min-width 92px),
`.tg-col` 又是 `overflow:hidden`。跨列层要自己算偏移、跟滚动跑、还不能被裁。
在列内画段则天然对齐,代价只是段间有 1px 列边框缝 —— 用下面的样式糊上。
三段的关系:
· start(起始段):上圆下平 —— 下方要接中间段
· mid (中间段):上下都平 —— 两头都接
· end (结束段):上平下圆 —— 上方接中间段
⚠️ 内侧不画 border:`.tg-block` 原本有 1px 白边,段与段之间会多出两条白线,
看着像断开了。这里按段位取消对应的那条边。
⚠️ 左右内缩量要比单天块**小**(单天是 left:2px / width:-4px):
跨天段要尽量贴满整列,缝隙才窄,连起来才像一条。模板已给 1%/98%。 */
.tg-block.is-seg-start {
border-top-left-radius: 4px; border-top-right-radius: 4px;
border-bottom-left-radius: 0; border-bottom-right-radius: 0;
border-bottom-color: transparent; /* 下边不画,与中间段连上 */
padding-bottom: 0;
}
.tg-block.is-seg-mid {
border-radius: 0;
border-top-color: transparent;
border-bottom-color: transparent;
padding-top: 1px; padding-bottom: 0;
}
.tg-block.is-seg-end {
border-top-left-radius: 0; border-top-right-radius: 0;
border-bottom-left-radius: 4px; border-bottom-right-radius: 4px;
border-top-color: transparent; /* 上边不画,与中间段连上 */
padding-top: 1px;
}
/* 延续标记:这段不是从今天开始的,用一个小箭头代替开始时刻 */
.tg-cont {
display: inline-block; margin-right: 2px;
opacity: .75; font-size: 10px; line-height: 1;
}
/* 跨天段占满整列宽度,内边距跟着收一点,字才不会被挤 */
.tg-block[data-seg] {
padding-left: 4px; padding-right: 4px;
/* 🔴 v88:跨天段用 left:0 / width:100%(模板按段区分),
必须配 box-sizing:border-box,否则 padding 会把宽度撑出列外。
`.tg-block` 已有 box-sizing:border-box,这里显式再写一次是为了防止
以后有人给单天块改 box-sizing 时顺手把这条也改掉。 */
box-sizing: border-box;
}
/* 天视图只有一列,把列拉宽好读;周视图 7 列时列宽由 flex 平分 */ /* 天视图只有一列,把列拉宽好读;周视图 7 列时列宽由 flex 平分 */
@media (max-width: 720px) { @media (max-width: 720px) {
/* 窄屏留不住 56px 的刻度沟槽 + 7 列,改成整体可横向滚动(.cal-tg-card 已 overflow:auto), /* 窄屏留不住 56px 的刻度沟槽 + 7 列,改成整体可横向滚动(.cal-tg-card 已 overflow:auto),
@@ -3888,8 +3961,56 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
font-size: 11.5px; padding: 1px 8px; border-radius: 5px; font-size: 11.5px; padding: 1px 8px; border-radius: 5px;
background: var(--line-2); color: var(--muted); background: var(--line-2); color: var(--muted);
} }
.node-route-line { font-size: 12.5px; color: var(--muted); } /* 🔴 2026-11-05 重做(用户:「这个位都没对齐呀…直接用个标签,然后右边一个箭头,
.node-route-line .arrow { color: var(--faint); margin: 0 3px; } 到北京南站就得了,不用写从哪到哪」)。
原来只是一个普通 div,里面的 <a> 与 <span class="arrow"> 各按自己的基线排
⇒ 字号不同就"没对齐"。
现在:容器 flex + align-items:center,两个地名各自成胶囊标签,
箭头只作窄分隔。视觉上就是「[青岛北站] → [北京南站]」,
不再需要"从/到"这种字。 */
.node-route-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 12.5px;
color: var(--muted);
line-height: 1;
margin-top: 2px;
}
/* 地名标签:浅底圆角,长名字省略 */
.node-route-line > a,
.node-route-line > span.route-stop {
display: inline-flex;
align-items: center;
max-width: 46%;
padding: 3px 8px;
border-radius: 999px;
background: var(--fill, #F1EAE1);
color: var(--ink, #2C231D);
font-size: 12px;
line-height: 1.2;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-route-line > a:hover {
background: var(--brand-soft, #F5E3DC);
}
/* 箭头:窄、淡,只做分隔 */
.node-route-line .arrow {
color: var(--faint);
margin: 0 1px;
font-size: 12px;
line-height: 1;
flex: none;
}
.node-spent { font-size: 12.5px; color: var(--brand-ink); } .node-spent { font-size: 12.5px; color: var(--brand-ink); }
.node-member { display: inline-flex; align-items: center; gap: 4px; } .node-member { display: inline-flex; align-items: center; gap: 4px; }
.node-role { .node-role {
@@ -6793,12 +6914,179 @@ h6.md-h { font-size: 17px; color: var(--muted); }
.tk-sec + .tk-sec { border-top: 1px solid var(--line-2); padding-top: 14px; } .tk-sec + .tk-sec { border-top: 1px solid var(--line-2); padding-top: 14px; }
.tk-sec-h { .tk-sec-h {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 600; color: var(--ink); /* #82:小节标题改 **模块主题色 + 加粗 + 字距** ——
与鸿蒙端 FbSectionTitle 一致(用户:「颜色也是用主题颜色」)。 */
font-size: 13px; font-weight: 700; letter-spacing: .6px;
color: var(--tk-accent, var(--brand));
} }
.tk-sec-h .group-n { margin-left: 0; } .tk-sec-h .group-n { margin-left: 0; }
/* 「状态」「看板」这两段是"标签 + 一排可点的东西",横排。 /* 「状态」「看板」这两段是"标签 + 一排可点的东西",横排。
⚠️ 用 flex-wrap:看板可能是七八个,窄屏要能折行而不是撑出面板。 */ ⚠️ 用 flex-wrap:看板可能是七八个,窄屏要能折行而不是撑出面板。 */
.tk-sec-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; } .tk-sec-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; }
/* 🔴 #74:面板里的「截止时间」AntD 控件。
`.dt-input` 是 `display:flex; flex-wrap:wrap`,放进 `.tk-sec-inline`
(也是 flex-wrap)里会被当成一个弹性项 —— 不给宽度约束时它会按内容撑,
在窄屏上可能把「截止」标签挤到上一行、或者控件自己溢出。
`flex: 1 1 auto` + `min-width: 0` 让它占满标签右侧的剩余宽度,
`max-width` 兜住极宽屏(AntD 的 picker 本身有固定宽度)。 */
.tk-sec-inline > .dt-input { flex: 1 1 auto; min-width: 0; max-width: 320px; }
/* 「还剩 3 天」那类小字:贴在控件右边,不与控件抢宽度 */
.tk-due-word { flex: 0 0 auto; font-size: 12.5px; color: var(--faint); white-space: nowrap; }
.tk-due-word.kb-late { color: var(--red); }
/* 🔴 #76:「更多信息」小节 —— 截止 / 负责人 / 分类 三行 + 保存。
用户 2026-11-05:「没有保存按钮…负责人和分类都没法修改。」
每行结构 = 固定宽标签 + 占满剩余宽度的控件,与 .mv-row 同一套观感
(但标签用 tk-attr-lb,因为面板里字号比整页表单小一号)。 */
.tk-attrs { display: flex; flex-direction: column; gap: 14px; }
/* 🔴🔴 #82(用户 2026-11-05):「网页版编辑在首页编辑任务的时候,那个输入框
怎么那么短呢?所有输入框都应该跟弹出框一样宽。」
「布局顺序也和鸿蒙版一致」「下面分成两行:第一行是任务标题,第二行是状态」
⇒ `.tk-attr-row` 从**横向**(标签左 / 控件右)改成**纵向两行**:
① 标签独占一行(主题色粗体)
② 控件独占一行,**占满整行宽度**(不再有 300px 上限)
为什么去掉 300px 上限:
那个上限是 #80 为"对齐"引入的,但它的代价是**输入框比面板窄一大截** ——
面板 680 宽,控件只有 300,右边空着 300 多像素,看起来像没做完。
现在改成上下两行之后,每行只有一个控件,天然左缘对齐,
**不再需要靠限宽来对齐** ⇒ 直接 width:100%。 */
.tk-attr-row {
display: flex; flex-direction: column; align-items: stretch; gap: 6px;
/* 🔴 #80:这里原来有 `flex-wrap: wrap` —— 窄屏下控件(300px)加标签(46px)
加间距(10px)放不进容器,控件就**掉到下一行**,左缘从 76 变成 0,
与标签右缘完全错开,整列看着像塌了。
⇒ 不换行:让控件按 `flex:1 1 auto` + `min-width:0` 收缩到可用宽度。
宽度不足时宁可控件变窄,也不要换行破坏对齐。
⚠️ 代价:极窄屏(< 200px)下控件会很窄 —— 但那时任何布局都放不下,
保持对齐比保持宽度更重要。 */
}
.tk-attr-lb {
/* #82:标签不再固定 46px(那是横排时的"对齐基准"),
现在独占一行,按内容宽即可;左对齐(横排时是右对齐贴控件)。
颜色从 faint 灰改成 **模块主题色 + 加粗** —— 与鸿蒙端的 fieldLb、
以及本页的小节标题同一口径。 */
font-size: 12.5px; font-weight: 700; letter-spacing: .6px;
color: var(--tk-accent, var(--brand)); text-align: left;
}
/* 控件区占满标签右侧(min-width:0 让它能被压缩,不会把标签挤出去) */
.tk-attr-row > *:not(.tk-attr-lb) { flex: 1 1 auto; min-width: 0; }
/* 🔴 #80(用户 2026-11-05):「标题和备注的输入框没有和其他输入框对齐。」
根因:各行的**宽度上限不一致** ——
· 截止(AntD picker) max-width: 300px
· 分类(select) max-width: 300px
· 负责人/协作人 max-width: 340px
· **标题(input)/ 备注(textarea)完全没设上限** ⇒ 撑满整行
于是标题那行比截止那行宽出一截,左右边缘都对不齐。
⇒ 统一成一个宽度变量。以后要调只改这一处,不会再有"某一行多一点"。
⚠️ `width: 100%` + `max-width` 而不是只给 `max-width`:
只给 max-width 时,块级 input 默认 width:auto 会按内容收缩,
反而比别的行窄。两个一起给才能"最多这么宽、但尽量占满"。
⚠️ 标签宽度(46px)+ gap(10px)= 56px,这与 `.tk-attr-actions` 的
`padding-left: 56px` 是同一个数 —— 改标签宽度时必须同步改那两处。 */
:root { --tk-attr-w: 300px; }
/* #82:所有控件一律占满整行 —— 不再有 `--tk-attr-w` 这个 300px 上限。
上下两行之后每行只有一个控件,宽度不一致反而不协调。 */
.tk-attr-row > .dt-input,
.tk-attr-row > select.select,
.tk-attr-row > .pp-root,
.tk-attr-row > input.input,
.tk-attr-row > textarea.input,
.tk-attr-row > .tk-attr-val { width: 100%; max-width: none; }
/* textarea 按行数撑高,别被 `.input` 的固定高度带偏 */
.tk-attr-row > textarea.input { resize: vertical; min-height: 46px; }
/* 保存按钮那一行:与上面的控件左缘对齐(= 46px 标签宽 + 10px gap) */
.tk-attr-actions {
/* #82:取消 padding-left(56px) —— 那是给"横排时对齐控件左缘"用的。
现在控件占满整行,按钮左缘就是容器左缘,再加 56 会缩进去一截。 */
display: flex; align-items: center; gap: 10px;
}
/* 🔴 #83(用户 2026-11-05):「保存按钮感觉太小了。要不也像鸿蒙版一样长长的一条?」
原来 `.btn-sm` 是内联宽度(按文字撑),在 648px 宽的面板里显得又小又偏。
⇒ 面板里的保存按钮改成**整行长条**:与上面的输入框同宽、同高(42),
视觉上就是"这一列的最后一步"。
⚠️ 用 `width:100%` 而不是 `flex:1`:`.tk-attr-actions` 是 flex 容器,
`flex:1` 在有其它兄弟时会按比例分 —— 这里只有一个按钮,
`width:100%` 更直接、不受后续增删影响。 */
.tk-attr-actions > .btn {
width: 100%; height: 42px; justify-content: center;
font-size: 14px; font-weight: 500; border-radius: var(--radius-s);
}
.tk-attr-tip { font-size: 12px; color: var(--green, #2E7D4F); }
/* #82:只读态的值也做成"框"的样子(浅底圆角),
与可编辑态同宽同高 —— 否则只读用户看到的是一行裸文字,和编辑态差太多。 */
.tk-attr-val {
display: block; min-height: 40px; box-sizing: border-box;
padding: 10px 12px; font-size: 14px; color: var(--ink);
background: var(--input-bg, #F7F4EF);
border-radius: var(--radius-s); word-break: break-word;
}
/* #82:面板里的"点一下就能改"的横排段(状态/看板)改成上下两行:
标签在上、chips 在下,与字段行同形态。 */
.tk-sec-inline { flex-direction: column; align-items: stretch; gap: 6px; }
.tk-sec-inline > .tk-sec-lb {
font-size: 12.5px; font-weight: 700; letter-spacing: .6px;
color: var(--tk-accent, var(--brand));
}
/* #78:面板里的选人组件(负责人单选 / 协作人多选)。
宽度已在上面的 `--tk-attr-w` 统一,这里只管 chips 的间距。
(原来这里单独写 max-width: 340px,比其他行宽 40px —— 正是 #80 对不齐的原因之一。) */
.tk-attr-row > .pp-root .pp-chips { margin-bottom: 6px; }
/* 标签在多行控件(备注、协作人)上方对齐,而不是垂直居中 */
.tk-attr-row-top { align-items: flex-start; }
.tk-attr-row-top > .tk-attr-lb { padding-top: 8px; }
/* 只读态的协作人 chips */
.tk-share-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.tk-share-chip {
font-size: 12.5px; padding: 3px 10px; border-radius: 999px;
background: var(--surface-2, #F2F0EC); color: var(--ink);
}
/* #78:评论里的「添加附件」。
用原生 file input(视觉隐藏),点 label 触发 —— 与站内其它上传入口同一手法。 */
/* 🔴 #81(用户 2026-11-05):「评论里面拖文件上传附件,那个地方太小了。」
原来是一个 `padding:4px 10px` 的小胶囊(高约 26px)+ 一行小字,
拖动落点太小:触屏上不好瞄,桌面上也容易拖偏。
⇒ 改用与「任务附件」同一套 `.upbox`(96px 高的虚线大框)。
这里只做评论特有的微调,不去覆盖 `.upbox` 本体 ——
那一套是站点级组件,改它会影响所有上传入口。 */
.cmt-attach-row {
display: flex; flex-direction: column; gap: 8px;
margin-top: 8px;
}
/* 评论里的上传框比「任务附件」矮一点:评论框本身已经在下方,
96px 会把整块讨论区推得太长;72px 在触屏上仍然好点。 */
.cmt-upbox { min-height: 72px; padding: 10px 14px; gap: 3px; }
.cmt-upbox .upbox-ico { font-size: 18px; }
.cmt-upbox .upbox-msg { font-size: 13px; }
.cmt-upbox-sub {
font-size: 11.5px; color: var(--faint); margin-top: 1px;
}
/* 拖拽经过时整框高亮(`.is-over` 由 task_panel.js 加在 `.cmt-attach-row` 上,
而 `.upbox.is-over` 是站点级规则 —— 这里补一条子选择器把它传导下去) */
.cmt-attach-row.is-over .cmt-upbox {
border-color: var(--brand); border-style: solid;
background: #FDF3EC; color: var(--brand);
}
/* 已选文件列表:一行一个,前面带个圆点;超长名截断 */
.cmt-file-list {
display: flex; flex-direction: column; gap: 3px;
padding: 6px 10px; border-radius: var(--radius-s);
background: var(--surface-2, #F2F0EC);
font-size: 12px; color: var(--muted);
}
.cmt-file-list span {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cmt-file-list span::before { content: '📎 '; }
.tk-sec-lb { flex: 0 0 34px; font-size: 12.5px; color: var(--faint); } .tk-sec-lb { flex: 0 0 34px; font-size: 12.5px; color: var(--faint); }
.tk-seg { display: flex; gap: 6px; flex-wrap: wrap; } .tk-seg { display: flex; gap: 6px; flex-wrap: wrap; }
/* 面板里的状态徽标比卡片上的大一档(这里是要点的按钮,不是标签) */ /* 面板里的状态徽标比卡片上的大一档(这里是要点的按钮,不是标签) */
@@ -6943,3 +7231,241 @@ label.is-req,
.dark .mv-row.is-req > .mv-lb { .dark .mv-row.is-req > .mv-lb {
color: #F08A78 !important; color: #F08A78 !important;
} }
/* ============================================================
#47(2026-11-05)行程状态动作按钮
------------------------------------------------------------
用户:「做成两个按钮,颜色也尽可能保持一致…
选中其中一个按钮之后…还加了一个那种椭圆的那种框那种背景,灰色的。」
实现:胶囊(border-radius:999px)+ 浅底;
`.is-on` 是"当前状态",用实心主题色表示"正在被选中",
其余动作是浅底 —— 一眼能看出现在是什么、能改成什么。
============================================================ */
.node-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin: 4px 0 2px;
}
.node-act {
display: inline-flex;
align-items: center;
border: 0;
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
line-height: 1.35;
background: var(--fill, #F1EAE1);
color: var(--muted, #8A7A6D);
cursor: pointer;
font-family: inherit;
transition: background .12s ease, color .12s ease;
}
.node-act:hover { background: var(--brand-soft, #F5E3DC); }
/* 当前状态:实心 = 正在被选中 */
.node-act.is-on {
background: var(--muted, #8A7A6D);
color: #fff;
cursor: default;
}
.node-act-go { color: #2F6FA8; }
.node-act-go:hover { background: #EAF2F9; }
.node-act-ok { color: #3F7D5A; }
.node-act-ok:hover { background: #EAF4EE; }
.node-act-no { color: #C0533F; }
.node-act-no:hover { background: #FBEDEA; }
.node-act-gray { color: var(--muted, #8A7A6D); }
/* #48(2026-11-05)补充:取消按钮(琥珀色,与"删除"的砖红区分开)。
用户强调「取消和删除是不一样的」⇒ 视觉上也不能同色。 */
.node-act-warn { color: #B8862B; }
.node-act-warn:hover { background: #FDF6E7; }
/* 时间行:计划与实际并列时的区分 */
.node-time .t-plan { color: var(--muted, #8A7A6D); }
.node-time .t-actual {
color: var(--brand, #A8574F);
margin-left: 8px;
font-weight: 500;
}
/* ============================================================
#53(2026-11-05)视频解不了时的提示卡片
------------------------------------------------------------
用户传的视频是 H.265/HEVC,Chrome/Firefox 解不了 ⇒ 黑屏。
不给提示的话用户会以为"文件坏了/网站坏了"。
这里把原因和出路直接写在画面里。
============================================================ */
.pv-fallback {
max-width: 520px;
padding: 28px 24px;
border-radius: 12px;
background: rgba(255, 255, 255, .08);
border: 1px solid rgba(255, 255, 255, .18);
text-align: center;
color: #fff;
}
.pv-fallback-h {
margin: 0 0 10px;
font-size: 17px;
font-weight: 600;
}
.pv-fallback-p {
margin: 0 0 18px;
font-size: 13px;
line-height: 1.7;
opacity: .82;
}
.pv-fallback-dl {
display: inline-block;
padding: 9px 22px;
border-radius: 8px;
background: #fff;
color: #1a1a1a;
font-size: 14px;
font-weight: 600;
text-decoration: none;
}
.pv-fallback-dl:hover { background: #eee; }
/* ══════════════════════════════════════════════════════════════════════
#85 自绘下拉框(替换原生 select 的展开列表)
══════════════════════════════════════════════════════════════════════
🔴 用户 2026-11-05:「网页版的下拉框也太丑了。你参考一下那个反馈系统
(FeedLite)里面的下拉框到底是怎么做的?按它的做法做,它也有主题颜色,
我们这个也有主题颜色,就按照一样的做法去做。」
**为什么必须自绘**:原生 `<select>` 收起时能改样式,
但**展开的选项列表由浏览器/系统绘制,CSS 完全改不动**(只能改 option 底色),
各平台样式还不统一。所以想"好看"只有一条路:自己画。
**但绝不重写 HTML**:原生 select 保留在 DOM 里参与表单提交,
JS 在它旁边生成一个按钮 + 浮层。这样:
· 全站所有 select 自动获得新样式,不用逐个改模板;
· `onchange="this.form.submit()"` 这类内联逻辑照样工作(我们派发原生 change);
· 无 JS 时原生 select 仍可用(只是朴素),不会白屏。
⚠️ 浮层用 `position: fixed` 挂在 body 上 —— 面板/浮层里有很多
`overflow: hidden`,放在 select 旁边会被裁掉(FeedLite 踩过)。
⚠️ 三档浅色由 layout.php 里 PHP 算好(--brand-tint-1/2/3),
不用 `color-mix()`:那个配 var() 在 Chrome 里会解析成透明。
══════════════════════════════════════════════════════════════════════ */
/* 原生 select:视觉隐藏但仍参与提交与事件(**不能 display:none**,那样不参与提交) */
.sel-native {
position: absolute !important;
width: 1px !important; height: 1px !important;
margin: 0 !important; padding: 0 !important;
border: 0 !important; opacity: 0 !important;
pointer-events: none !important;
clip: rect(0 0 0 0) !important;
}
/* 外层:inline-flex,跟输入框一样参与行内布局 */
.sel { position: relative; display: inline-flex; vertical-align: middle; max-width: 100%; min-width: 0; }
/* 触发按钮:外观与普通输入框完全一致(同高同圆角同边框) */
.sel-btn {
display: inline-flex; align-items: center; gap: 10px;
height: 40px; padding: 0 12px;
border: 1px solid var(--line); border-radius: var(--radius-s);
background: #fff; color: var(--ink);
font-family: inherit; font-size: 14px; line-height: 1;
cursor: pointer; text-align: left;
box-shadow: 0 1px 2px rgba(16,24,40,.04);
transition: border-color .12s, box-shadow .12s, background .12s;
max-width: 100%; min-width: 0;
}
.sel-btn:hover { border-color: var(--brand-2); }
.sel-btn:focus-visible { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-tint-1, rgba(168,74,40,.10)); }
.sel-txt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 未选值(「未分类」「不循环」这类占位)→ 次要色,弱化存在感 */
.sel-btn.is-placeholder .sel-txt { color: var(--muted); }
/* 自绘箭头:默认灰;**打开时转 180° 并变主题色** */
.sel-caret {
width: 11px; height: 7px; flex: none;
background: currentColor; opacity: .55;
-webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='7' viewBox='0 0 11 7'%3E%3Cpath d='M1 1.2 5.5 5.8 10 1.2' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/contain no-repeat;
mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='7' viewBox='0 0 11 7'%3E%3Cpath d='M1 1.2 5.5 5.8 10 1.2' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/contain no-repeat;
transition: transform .16s, background .12s, opacity .12s;
}
.sel-btn.is-open .sel-caret { transform: rotate(180deg); background: var(--brand); opacity: 1; }
/* 打开态整体染主题色:浅主题底 + 主题边框 */
.sel-btn.is-open {
border-color: var(--brand);
background: var(--brand-tint-1, #FAF2EE);
color: var(--brand);
box-shadow: 0 0 0 3px var(--brand-tint-1, rgba(168,74,40,.10));
}
.sel-btn.is-open .sel-txt { color: var(--brand); font-weight: 600; }
/* 浮层:fixed,不被任何 overflow 容器裁切 */
.sel-panel {
position: fixed; z-index: 9998;
max-height: min(320px, calc(100vh - 32px));
overflow-y: auto; overscroll-behavior: contain;
padding: 5px;
background: #fff;
border: 1px solid var(--line);
border-radius: 11px;
box-shadow: 0 10px 30px rgba(16,24,40,.16), 0 2px 6px rgba(16,24,40,.06);
animation: selIn .12s ease-out;
}
@keyframes selIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
.sel-panel.is-up { animation-name: selInUp; }
@keyframes selInUp { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
/* 列表项 */
.sel-item {
display: flex; align-items: center; gap: 8px; width: 100%;
padding: 0 10px; height: 34px;
border: 0; border-radius: 7px;
background: transparent; color: var(--ink);
font-family: inherit; font-size: 14px; line-height: 1;
cursor: pointer; text-align: left; white-space: nowrap;
transition: background .1s, color .1s;
-webkit-appearance: none; appearance: none;
}
.sel-item-txt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
/* hover / 键盘高亮:主题色最浅一档 */
.sel-item:hover, .sel-item.is-active {
background: var(--brand-tint-1, #FAF2EE);
color: var(--brand);
}
/* 选中项:深一档 + 主题色文字 + 右侧对勾,一眼看出当前选的是哪个 */
.sel-item.on {
background: var(--brand-tint-2, #F3E3DC);
color: var(--brand);
font-weight: 600;
}
.sel-item.on:hover, .sel-item.on.is-active { background: var(--brand-tint-3, #EDD5CB); color: var(--brand); }
.sel-item-ck {
width: 13px; height: 10px; flex: none;
background: currentColor;
-webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='13' height='10' viewBox='0 0 13 10'%3E%3Cpath d='M1.5 5.2 4.8 8.4 11.5 1.6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/contain no-repeat;
mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='13' height='10' viewBox='0 0 13 10'%3E%3Cpath d='M1.5 5.2 4.8 8.4 11.5 1.6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/contain no-repeat;
}
/* 面板里的字段行:下拉要占满整行(与输入框同宽) */
.tk-attr-row > .sel { width: 100%; }
.tk-attr-row > .sel > .sel-btn { width: 100%; }
/* 窄屏:至少占满可用宽度,别挤成一小坨 */
@media (max-width: 640px) {
.sel { flex: 1 1 auto; min-width: 0; }
.sel-btn { width: 100%; }
}
+440 -11
View File
@@ -118,6 +118,193 @@
* 实测两者不是同时都有。取到哪级用哪级,取不到就留空(**绝不瞎编**)。 * 实测两者不是同时都有。取到哪级用哪级,取不到就留空(**绝不瞎编**)。
* @returns {string} 形如 "青岛市 市南区" / "青岛" / ''(都没有) * @returns {string} 形如 "青岛市 市南区" / "青岛" / ''(都没有)
*/ */
/**
* 只取「市 + 区」,**不带省** —— 给输入框 value 用。
*
* 🔴 2026-11-03:用户要求详细地址写成「青岛市黄岛区珠江路1018号」。
* 而 `readPlace()` 返回的是带省的「山东省 青岛市 黄岛区」。
*
* 为什么 value 里**不带省**:
* · 城市名足够定位,省是冗余的(中国没有跨省同名地级市)
* · 输入框宽度有限,带上「山东省」会让后半截(门牌号)显示不全
* · 导航用不到省
*
* 直辖市的坑:`province === city === '北京市'`,只留一份。
*/
/**
* 只取**城市名**(「青岛市」),用于 `from_city` / `to_city` 这类聚合字段。
*
* 🔴 2026-11-05 新增。用户:「城市…直接城市就可以」。
*
* 与 `readPlace()` 的区别:
* · `readPlace()` = 省 + 市 + 区(「山东省青岛市市南区」)→ 给 `place` 显示用
* · `cityOnly()` = 只要市一级(「青岛市」) → 给 city 存库用
*
* 三级取值(高德不同接口给的字段不一样):
* ① `addressComponent.city`(AutoComplete / 逆地理)—— 最准
* ② 直辖市:`addressComponent.city` 是空,用 `province`(「北京市」)
* ③ `poi.district` 整串(inputtips:「山东省青岛市崂山区」)→ 正则剥出市名
*
* ⚠️ 拿不到就返回空串,**不猜**。空着比错着好(服务端还有一层 `Geo::cityOnly` 会补)。
*/
function cityOnly(poi) {
var a = poi.addressComponent || null;
var city = '';
var prov = '';
if (a) {
city = String(a.city || '').trim();
prov = String(a.province || '').trim();
/* 直辖市:`city` 为空(或与 province 相同),本身就是市一级 */
if (!city && prov) {
city = prov;
}
}
if (!city) {
/* inputtips 只给一个 district 整串:`山东省青岛市崂山区` /
`北京市丰台区` / `内蒙古自治区呼和浩特市新城区` */
var d = String(poi.district || '').trim();
city = cityFromDistrict(d);
}
return city;
}
/**
* 从「省市区」整串里剥出**市名**(「山东省青岛市崂山区」→「青岛市」)。
*
* 规则(按可靠度排序):
* ① 取**第一个**「XX市」—— 中文地址是 省→市→区 的层级顺序,
* 第一个「市」就是地级市。
* ⚠️ 不能取最后一个:「黑龙江省黑河市五大连池市」会得到县级市;
* 「山东省青岛市市南区」的"市南区"里还有"市"字,靠"第一个"才能对。
* ② 直辖市(不带"市"字):「北京市丰台区」→ 第一个「市」就是「北京市」,
* 走 ① 即可;万一写成「北京朝阳区」才需要这级。
* ③ 都没有 ⇒ 空串。
*/
function cityFromDistrict(d) {
if (!d) { return ''; }
var s = String(d).trim();
/* ① 第一个「XX市」:XX 里不能含"省市县区旗盟"这些层级字眼,
否则「山东省」会被当成「山东省市」 */
var m = s.match(/([^\s省市县区旗盟]{1,10})市/);
if (m) {
return m[1] + '市';
}
/* ② 直辖市:开头就是「北京市」这类,上面已覆盖;
剩下「北京朝阳区」这种去掉末尾区县,取前段 */
var m2 = s.match(/^(北京|上海|天津|重庆)/);
if (m2) {
return m2[1] + '市';
}
return '';
}
function cityDistrictOf(poi) {
var a = poi.addressComponent || null;
var city = '', district = '';
if (a) {
city = String(a.city || a.province || '').trim();
district = String(a.district || '').trim();
}
if (!city && !district) {
city = String(poi.cityname || poi.pname || '').trim();
district = String(poi.adname || '').trim();
}
if (!city && !district) {
// inputtips 只给一个整串:「山东省青岛市崂山区」
// ⇒ 从整串里截出「XX市XX区」部分
var whole = String(poi.district || '').trim();
if (whole !== '') {
var mi = whole.indexOf('市');
if (mi >= 0) {
city = whole.substring(0, mi + 1);
district = whole.substring(mi + 1);
} else {
city = whole;
}
}
}
var parts = [];
if (city) { parts.push(city); }
if (district && district !== city) { parts.push(district); }
return parts.join('');
}
/**
* 把「省市区」与「街道门牌」合并,**缺哪段补哪段**,已有的不重复。
*
* 🔴 2026-11-03:原来这里判断太粗("address 含「区」字就整个不用 cd"),
* 导致 `cd='青岛市黄岛区'` + `address='黄岛区珠江路1018号'`
* 拼成 `黄岛区珠江路1018号` —— **把「青岛市」丢了**。
* 而城市名正是地理编码最关键的线索(丢了就会搜到外省同名路)。
*
* 正确逻辑:逐段检查 `address` 里有没有,没有才补。
*
* cd='青岛市黄岛区', address='珠江路1018号'
* → 两段都没有 ⇒ 全补 ⇒ '青岛市黄岛区珠江路1018号'
* cd='青岛市黄岛区', address='黄岛区珠江路1018号'
* → 有「黄岛区」、缺「青岛市」⇒ 补城市 ⇒ '青岛市黄岛区珠江路1018号'
* cd='青岛市黄岛区', address='青岛市黄岛区珠江路1018号'
* → 全都有 ⇒ 原样
*
* @param {string} cd cityDistrictOf 的结果(如「青岛市黄岛区」)
* @param {string} addr 高德的 poi.address(如「珠江路1018号」)
* @return {string}
*/
function mergeAddrTail(cd, addr) {
var ad = String(addr || '').trim();
var c = String(cd || '').trim();
if (c === '') { return ad; }
if (ad === '') { return c; }
// 把 cd 拆成「城市」与「区县」两段:找第一个「市」字切开
var mi = c.indexOf('市');
var city = '', dist = '';
if (mi >= 0) {
city = c.substring(0, mi + 1); // 含「市」
dist = c.substring(mi + 1);
} else {
city = c;
}
var needCity = city !== '' && ad.indexOf(city) < 0;
// 区县名可能不带「区/县」后缀出现在 address 里,用去掉后缀的裸名判断
var distBare = dist.replace(/[区县市旗]$/, '');
var needDist = dist !== '' && ad.indexOf(dist) < 0
&& (distBare === '' || ad.indexOf(distBare) < 0);
/* ⚠️ 拼接顺序必须是「城市 → 区县 → 街道」。
测出来的 case:cd='青岛市黄岛区'、addr='青岛市珠江路1018号'
(有城缺区)时,若直接 `prefix + ad` 会得到
「黄岛区青岛市珠江路1018号」—— 区跑到城市前面了。
⇒ 分段拼:先城市,再区,最后街道(街道在 ad 里,
但要先把 ad 里已有的城市/区剥掉避免重复)。 */
var tailAd = ad;
if (!needCity) {
// ad 已含城市 ⇒ 剥掉前缀,后面统一按 城市+区+街道 拼
var idxC = ad.indexOf(city);
if (idxC === 0) {
tailAd = ad.substring(city.length);
} else if (idxC > 0) {
tailAd = ad.substring(0, idxC) + ad.substring(idxC + city.length);
}
}
if (!needDist && dist !== "") {
var idxD = tailAd.indexOf(dist);
if (idxD >= 0) {
tailAd = tailAd.substring(0, idxD) + tailAd.substring(idxD + dist.length);
} else if (distBare !== "") {
var idxB = tailAd.indexOf(distBare);
if (idxB >= 0) {
// 裸名保留(「黄岛珠江路」里的「黄岛」是地址的一部分,不剥)
}
}
}
var prefix = "";
if (city !== "") { prefix += city; }
if (dist !== "") { prefix += dist; }
return prefix + tailAd.trim();
}
function readPlace(poi) { function readPlace(poi) {
var a = poi.addressComponent || null; var a = poi.addressComponent || null;
var province = '', city = '', district = ''; var province = '', city = '', district = '';
@@ -147,6 +334,168 @@
return parts.join(' '); return parts.join(' ');
} }
/**
* v69(工单 #32):自建候选面板。
*
* **为什么要自建**:`AMap.AutoComplete` 的内置下拉只渲染 `name`,
* 同名地点(多个「星巴克」、同一个商场的不同入口)在候选里长得一模一样,
* 用户没法判断该选哪个。要在地名后面补详细地址,就只能自己接管渲染。
*
* ⚠️ 定位用 `absolute` + 挂在 `document.body` 上,**不挂在输入框父节点里**:
* 1. 输入框常在 `.mv-row` 这种"标签 62px + 控件占满"的两列网格里,
* 多一个子项会把右边那列挤掉(本项目 v24 踩过同类排版坑);
* 2. 表单弹框多有 `overflow: hidden`,挂里面会被裁掉。
* 用 `position: fixed` + 实时读 `getBoundingClientRect()` 定位,
* 滚动/缩放时跟着走(见 `placePanel`)。
* @param {HTMLElement} input 那个地址输入框
* @param {Function} onPick 点中候选后的回调(收到高德的 poi 对象)
*/
function makePanel(input, onPick) {
var el = document.createElement('div');
el.className = 'fb-addr-panel';
el.setAttribute('data-fb-addr-panel', '1');
/* ⚠️ 样式内联而不写进 app.css:这份脚本是全站共用、按需加载的,
把样式焊在这里才不会有"某个页面忘了引那段 CSS"的漏。 */
el.style.cssText = [
'position:fixed', 'z-index:2147483000', 'display:none',
'margin:0', 'padding:4px 0', 'list-style:none',
'max-height:264px', 'overflow-y:auto',
'background:#fff', 'border-radius:10px',
'box-shadow:0 6px 24px rgba(0,0,0,.16), 0 1px 3px rgba(0,0,0,.10)',
'border:1px solid rgba(0,0,0,.08)',
'-webkit-overflow-scrolling:touch',
'font-size:14px', 'line-height:1.35'
].join(';');
document.body.appendChild(el);
var panel = { el: el, input: input, onPick: onPick, tips: [] };
/* 滚动/缩放时重算位置 —— 用 capture 阶段接,任何滚动容器都能收到 */
window.addEventListener('scroll', function () { placePanel(panel); }, true);
window.addEventListener('resize', function () { placePanel(panel); });
return panel;
}
/** 把面板放到输入框正下方,宽度跟随输入框(不超出视口) */
function placePanel(panel) {
if (!panel.el || panel.el.style.display === 'none') { return; }
var r = panel.input.getBoundingClientRect();
/* ⚠️ 量不到尺寸(`0×0`)有两种可能:
① 输入框真的不在布局里(表单被隐藏/已从 DOM 摘掉)→ 该收起;
② **刚 append 完、样式还没算完**(同一 tick 内读 rect 常见于旧引擎),
或**无布局环境**(jsdom 做单元测试时 rect 恒为 0)。
原来的写法一律 `hidePanel` 收掉,于是②这种情况会出现
"候选明明查到了却一显示就消失"——**这不是理论风险,是实测踩到的**。
⇒ 改成:量不到就**先不定位**(保留默认位置/宽度),但**不收起**。
面板若真该消失,`hidePanel` 在别处(失焦、选出、输入过短)都会调。 */
if (r.width === 0 && r.height === 0) {
panel.el.style.minWidth = '240px';
return;
}
var vw = window.innerWidth || document.documentElement.clientWidth;
var w = Math.min(Math.max(r.width, 240), vw - 16);
var left = Math.min(Math.max(r.left, 8), vw - w - 8);
panel.el.style.width = w + 'px';
panel.el.style.left = left + 'px';
/* 下方空间不足就翻到输入框上方(手机上键盘弹起时尤其常见) */
var below = window.innerHeight - r.bottom;
var h = panel.el.offsetHeight || 0;
if (below < Math.min(h, 160) && r.top > below) {
panel.el.style.top = Math.max(8, r.top - h - 6) + 'px';
} else {
panel.el.style.top = (r.bottom + 6) + 'px';
}
}
function hidePanel(panel) {
if (!panel || !panel.el) { return; }
panel.el.style.display = 'none';
panel.el.innerHTML = '';
}
/**
* 渲染候选:**每行 = 地名(深色,主)+ 省市区/详细地址(灰色,副)**。
*
* 🔴 这正是工单 #32 的诉求 —— 地址必须出现在下拉里,作为选择依据。
* 同名地点只有靠这一行灰字才区分得开。
*
* ⚠️ 高德的 `tips` 里不同条目字段并不齐(实测):
* · POI 类:`name` + `address` + `district`
* · 纯行政区划类:`name`(就是地名)+ `district`,**没有 address**
* ⇒ secondary 要把能拿到的都拼上,全空就只显示主标题(不能留一片空白)。
* ⚠️ 用 `textContent` 逐个填,**不拼 HTML 字符串**:
* 地点名是高德返回的、可能含 `<` `&`,拼 HTML 会破版甚至注入。
*/
function renderPanel(panel, tips, onPick) {
var el = panel.el;
el.innerHTML = '';
var list = [];
for (var i = 0; i < tips.length; i++) {
var t = tips[i];
if (!t) { continue; }
/* 只要"有名字"的;`location` 为空的纯提示行(高德有时给空壳)跳过 */
var nm = String(t.name || '').trim();
if (nm === '') { continue; }
list.push(t);
}
if (list.length === 0) { hidePanel(panel); return; }
list.forEach(function (t) {
var row = document.createElement('div');
row.style.cssText = [
'padding:9px 14px', 'cursor:pointer', 'display:block',
'border-bottom:1px solid rgba(0,0,0,.04)'
].join(';');
var main = document.createElement('div');
main.style.cssText = 'color:#1b1b1f;font-weight:500;word-break:break-all';
main.textContent = String(t.name || '').trim();
row.appendChild(main);
/* 副标题 = 省市区 + 详细地址。两者常常有包含关系
(`district` 是"山东省青岛市崂山区",`address` 是"香港东路 195 号"),
直接拼接不会重复;但若 address 已经以 district 开头则去重。 */
var place = readPlace(t);
var addr = String(t.address || '').trim();
var parts = [];
if (place !== '') { parts.push(place); }
if (addr !== '' && addr.indexOf(place) !== 0) { parts.push(addr); }
else if (addr !== '' && place === '') { parts.push(addr); }
var sub = parts.join(' ').replace(/\s+/g, ' ').trim();
if (sub !== '') {
var desc = document.createElement('div');
desc.style.cssText = 'color:#8a8a8e;font-size:12px;margin-top:2px;word-break:break-all';
desc.textContent = sub;
row.appendChild(desc);
}
/* 悬停/按下高亮(触屏没有 hover,所以两件都绑) */
row.addEventListener('mouseenter', function () { row.style.background = '#f5f5f7'; });
row.addEventListener('mouseleave', function () { row.style.background = 'transparent'; });
row.addEventListener('touchstart', function () { row.style.background = '#f5f5f7'; });
/* ⚠️ 用 `mousedown` + `preventDefault` 而不是 `click`:
输入框失焦会触发收起逻辑,`click` 可能来不及。
`preventDefault` 还能阻止输入框先失焦再复位造成的"闪一下"。 */
row.addEventListener('mousedown', function (ev) {
ev.preventDefault();
hidePanel(panel);
onPick(t);
});
/* 触屏上 mousedown 也会触发,但为稳妥补一次 touchend(并挡掉随后的 300ms click 穿透) */
row.addEventListener('touchend', function (ev) {
ev.preventDefault();
hidePanel(panel);
onPick(t);
});
el.appendChild(row);
});
el.style.display = 'block';
placePanel(panel); // 先显示再量高度,才能判断"下方够不够"
}
/** /**
* 绑定一个地址输入框。 * 绑定一个地址输入框。
* *
@@ -232,9 +581,19 @@
var detail = input.getAttribute('data-addr-detail') === '1'; var detail = input.getAttribute('data-addr-detail') === '1';
try { try {
var ac = new window.AMap.AutoComplete({ input: input }); /* 🔴🔴 v69(用户 2026-10-30,工单 #32):「输入一个简单地址、下拉显示详细地址时,
ac.on('select', function (e) { 不能只把地点告诉我,一定要把详细地址也放在后面。因为同一个地址可能有好几个,
var poi = (e && e.poi) ? e.poi : null; 我根本不知道应该选哪一个。」
⇒ **不再用 `AMap.AutoComplete` 的内置下拉**(它只画 `name`,我们无法控制),
改成"高德只当查询器、下拉自己画":
· 构造时**不传 `input`** ⇒ 高德不会往 DOM 里塞它自己的候选面板;
· 用户输入 → 防抖 → `ac.search(kw, cb)` → 拿 `result.tips`;
· 自己渲染每行「**地名 + 省市区 + 详细地址**」,地址在后、灰色小字。
⚠️ 选中后的全部既有行为(回填、`data-addr-detail`、框下提示、
`fb-addr-picked` 事件)**原样保留**,只是入口从高德的 `select`
事件换成我们自己那行候选的 click —— 见下面的 `pick()`。 */
var ac = new window.AMap.AutoComplete({});
var pick = function (poi) {
if (!poi) { return; } if (!poi) { return; }
// 输入框里放**名字**(用户认的是名字,不是"一串门牌号") // 输入框里放**名字**(用户认的是名字,不是"一串门牌号")
@@ -250,11 +609,31 @@
`poi.name` 塞回输入框(实测,v68 第一版就因此白写)—— `poi.name` 塞回输入框(实测,v68 第一版就因此白写)——
同步写会被它覆盖,看起来像"详细地址没生效"。 */ 同步写会被它覆盖,看起来像"详细地址没生效"。 */
if (detail) { if (detail) {
var ad0 = String(poi.address || '').trim(); /* 🔴🔴 2026-11-03 修(用户:「这并不是详细地址,详细地址应该是
if (ad0 !== '' && name !== '') { 青岛市黄岛区珠江路1018号」):
var full = name + ' · ' + ad0;
input.value = full; 原来拼的是 `name · poi.address`,而高德的 `poi.address`
setTimeout(function () { input.value = full; }, 0); **只到街道级**(「珠江路1018号」),省市区在别的字段里。
⇒ 存进库里就是「招商银行(...) · 珠江路1018号」,
**缺城市**。鸿蒙端做地理编码时抓不到城市,
全国搜「珠江路1018号」→ 搜到**江苏南通**(差 500 公里)。
现在拼三段:`name · 市+区 + 街道门牌`
→ 「招商银行(...) · 青岛市黄岛区珠江路1018号」
⚠️ 用 `cityDistrictOf()` 而不是 `readPlace()`:
value 里不带省(省冗余、输入框显示不下、导航用不到)。
⚠️ 若高德连 city 都没给(极少数),退回原来的两段拼法,
至少不比改之前差。 */
var ad0 = String(poi.address || '').trim();
var cd = cityDistrictOf(poi);
if (name !== '') {
var tail = mergeAddrTail(cd, ad0);
if (tail !== '') {
var full = name + ' · ' + tail;
input.value = full;
setTimeout(function () { input.value = full; }, 0);
}
} }
} }
@@ -267,9 +646,21 @@
// 而"抹掉用户输入"是不可逆的(他点保存后就真没了)。 // 而"抹掉用户输入"是不可逆的(他点保存后就真没了)。
// ⇒ 空着才补;已经有内容就尊重用户的。 // ⇒ 空着才补;已经有内容就尊重用户的。
if (String(el.value || '').trim() !== '') { return; } if (String(el.value || '').trim() !== '') { return; }
if (t === 'place' || t === 'city') { /* 🔴 2026-11-05 修(用户:「城市…什么省也带上了,市也带上了,
区甚至都带上了,这没必要,直接城市就可以」)。
`city` 字段**不能用 `readPlace()`** —— 那个函数的用途是
「显示完整地名」(省 + 市 + 区),是给 `place` 输入框用的。
拿它填 city 会存进库里变成「山东省青岛市市南区」,
导致旅程的城市聚合统计里出现一长串行政区划。
⇒ `city` 单独走 `cityOnly()`:只取「青岛市」这一级。 */
if (t === 'place') {
var pl = readPlace(poi); var pl = readPlace(poi);
if (pl !== '') { el.value = pl; } if (pl !== '') { el.value = pl; }
} else if (t === 'city') {
var co = cityOnly(poi);
if (co !== '') { el.value = co; }
} else if (t === 'address' || t === 'addr') { } else if (t === 'address' || t === 'addr') {
var ad = String(poi.address || '').trim(); var ad = String(poi.address || '').trim();
if (ad !== '') { el.value = ad; } if (ad !== '') { el.value = ad; }
@@ -290,8 +681,46 @@
detail: { name: name, place: readPlace(poi), address: String(poi.address || '') } detail: { name: name, place: readPlace(poi), address: String(poi.address || '') }
})); }));
} catch (err) { /* 老浏览器没有 CustomEvent 构造器,忽略即可 */ } } catch (err) { /* 老浏览器没有 CustomEvent 构造器,忽略即可 */ }
}); };
/* ✅ 到这里才算**真的绑上了** —— v68 之前这行在最前面,绑不上也照样标记(见上)。 */
var panel = makePanel(input, pick);
var timer = -1;
/* 输入 → 防抖 260ms → 查候选。
⚠️ 260ms 是本项目其它联想(观影片名)同款节奏,别乱调:
太快会在中文输入法拼字过程中反复请求(白烧配额、候选跳来跳去)。 */
var onInput = function () {
if (timer >= 0) { clearTimeout(timer); }
var kw = String(input.value || '').trim();
/* 少于 2 个字不查 —— 一个字的候选既多又没用,还会把高德配额吃掉。
⚠️ 但要把面板收起来:用户已经把字删掉了,留着旧候选会误点。 */
if (kw.length < 2) {
if (timer >= 0) { clearTimeout(timer); timer = -1; }
hidePanel(panel);
return;
}
timer = setTimeout(function () {
timer = -1;
try {
ac.search(kw, function (status, result) {
if (status !== 'complete' || !result) { hidePanel(panel); return; }
var tips = result.tips || [];
renderPanel(panel, tips, pick);
});
} catch (e) { hidePanel(panel); }
}, 260);
};
input.addEventListener('input', onInput);
/* 点别处收起。用 `mousedown` 而不是 `click`:
`click` 在「点候选行」时也会冒泡上来,会把候选先收掉导致点不中。 */
var onDocDown = function (ev) {
if (panel.el && panel.el.contains(ev.target)) { return; }
if (ev.target === input) { return; }
hidePanel(panel);
};
document.addEventListener('mousedown', onDocDown);
input.setAttribute('data-addr-bound', '1'); input.setAttribute('data-addr-bound', '1');
} catch (err) { } catch (err) {
/* SDK 已加载但初始化失败(key 没绑域名等)—— 静默降级、不打扰用户。 /* SDK 已加载但初始化失败(key 没绑域名等)—— 静默降级、不打扰用户。
+503 -2
View File
@@ -90,14 +90,21 @@
document.head.appendChild(l); document.head.appendChild(l);
}); });
} }
/* ⚠️ 与 Semi 段那个 `loadJs` 用**同一个全局登记表** —— 两处都加载 React
(Semi 无条件、antd 有条件),不共享去重就会执行两次 ⇒ React #321。
详见 Semi 段 `loadJs` 上方的完整说明。 */
function loadJs(src) { function loadJs(src) {
return new Promise(function (res, rej) { var reg = window.__fbJsLoaded;
if (!reg) { reg = window.__fbJsLoaded = {}; }
if (reg[src]) { return reg[src]; }
reg[src] = new Promise(function (res, rej) {
var el = document.createElement('script'); var el = document.createElement('script');
el.src = src; el.src = src;
el.onload = function () { res(); }; el.onload = function () { res(); };
el.onerror = function () { rej(new Error('加载失败: ' + src)); }; el.onerror = function () { rej(new Error('加载失败: ' + src)); };
document.head.appendChild(el); document.head.appendChild(el);
}); });
return reg[src];
} }
function ensureCropper() { function ensureCropper() {
if (window.Cropper) { return Promise.resolve(); } if (window.Cropper) { return Promise.resolve(); }
@@ -1597,14 +1604,47 @@
document.head.appendChild(l); document.head.appendChild(l);
}); });
} }
/**
* 加载一个 JS 文件。
*
* 🔴🔴 2026-11-05 修 React #321(Invalid hook call):
*
* 症状:首页点任务 → 浮层点「编辑」→ 控制台的 `AntD 日期控件已挂上 1 处`
* 紧接着抛 `Minified React error #321`,日期控件崩掉。
*
* 根因:**React 被加载了两遍**。
* · Semi 段的 `boot()` 用 `requestIdleCallback` 延迟跑,
* 里面**无条件** `loadJs(conf.react)` + `loadJs(conf.dom)`;
* · antd 段的 `ensure()` 只在 `!window.React` 时才加载 —— 看着更"谨慎",
* 但两边谁先谁后取决于 idle 回调的时机。
* ⇒ antd 先加载、Semi 的 idle 后到 ⇒ Semi **又插了一遍**同一个
* `react.min.js` ⇒ 那段 UMD **执行第二次** ⇒ `window.React` 被覆盖。
* 而 antd 已经在内部捕获了**第一次**的 React 引用 ⇒ 两套 React 并存
* ⇒ antd 的 hook(useContext)在"第二套"的 dispatcher 上调用 ⇒ #321。
*
* 堆栈里 `VM303 react.min.js` / `VM320 react-dom.min.js` 这种 **VM 前缀**
* 就是"同一个文件被 eval 执行了不止一次"的铁证。
*
* ⇒ 修法:按 `src` **全局去重**,同一个地址永远只执行一次。
* 缓存在 `window.__fbJsLoaded`(跨 IIFE 共享 —— 本文件里有两个独立的
* `loadJs` 定义,Semi 一个、Semi 外层一个,各自闭包内的变量互相看不见)。
*
* ⚠️ 不能只给 Semi 那边加判断(`if (!window.React)`):那种写法依赖"现有全局
* 是否已经有 React",而 UMD 执行完才赋值 —— 两个请求几乎同时发出时,
* 两边都还看不到 `window.React`,照样会各插一个。**按 URL 去重才是确定的。**
*/
function loadJs(src) { function loadJs(src) {
return new Promise(function (res, rej) { var reg = window.__fbJsLoaded;
if (!reg) { reg = window.__fbJsLoaded = {}; }
if (reg[src]) { return reg[src]; }
reg[src] = new Promise(function (res, rej) {
var s = document.createElement('script'); var s = document.createElement('script');
s.src = src; s.src = src;
s.onload = function () { res(); }; s.onload = function () { res(); };
s.onerror = function () { rej(new Error('加载失败: ' + src)); }; s.onerror = function () { rej(new Error('加载失败: ' + src)); };
document.head.appendChild(s); document.head.appendChild(s);
}); });
return reg[src];
} }
function pad(n) { return (n < 10 ? '0' : '') + n; } function pad(n) { return (n < 10 ? '0' : '') + n; }
@@ -2257,6 +2297,19 @@
writeDT(box, s); // 写回隐藏四字段 writeDT(box, s); // 写回隐藏四字段
if (box.__fbSyncParts) { box.__fbSyncParts(); } if (box.__fbSyncParts) { box.__fbSyncParts(); }
box.classList.toggle('has-value', !!s); box.classList.toggle('has-value', !!s);
/* 🔴 #77(用户 2026-11-05):「截止日期的时间保存有bug,时和分没保存上。」
真根因:面板保存时读 `.dt-hour` / `.dt-minute` —— 那是**镜像**,
依赖本函数的 writeDT 写回。而实测(Playwright + 真实 antd)显示
用户在弹层里选完、**不点弹层里的「确定」** 就直接点面板的保存时,
镜像还停在上一轮的值(甚至 00:00),于是时分就丢了。
⇒ 把 onChange 拿到的字符串**直接挂到 box 上**。这是"控件刚刚确认的值",
不经任何镜像、不受弹层是否关闭影响。面板保存时优先读它。
⚠️ 用 `data-fb-dt-value` 而不是自定义 JS 属性:dataset 会同步到 DOM 属性,
新节点/重渲染后也能用 `getAttribute` 拿到(虽然本控件是复用同一节点)。
⚠️ 空串也要写(= 用户清空了截止时间),所以不能 `if (s !== '')`。 */
box.setAttribute('data-fb-dt-value', s);
} }
var v0 = cur(); var v0 = cur();
/* 🔴 v48:与 `mountAntd` 同一处修复(用户第 2 条)——改成**受控 + 可重绘**。 /* 🔴 v48:与 `mountAntd` 同一处修复(用户第 2 条)——改成**受控 + 可重绘**。
@@ -2580,3 +2633,451 @@
if (!document.hidden) { place(); } if (!document.hidden) { place(); }
}); });
})(); })();
/* ============================================================================
首页「近期任务」:把一条已完成的任务行从卡片里摘掉(工单 #20)
----------------------------------------------------------------------------
用户原话(2026-10-28,两次强调):「首页,近期任务,如果勾选了完成,
应该刷新一下这个列表,把完成的删掉。」
🔴🔴 为什么这段放在 app.js 而不是留在 home.php 里:
"把这条标成完成"在首页有**两个入口**——
① 行首那颗复选框(脚本在 home.php 末尾)
② 点整行弹浮层 → 面板里的「状态」三段按钮(脚本是 task_panel.js)
第一版只改了 ①,于是同一个用户动作(都是"标完成")两条路径两种结果:
从复选框勾 → 行消失;从面板点「已完成」→ 行留着,徽标变成「已完成」。
用户复现的就是第二条(我先前的判据完全没测浮层,所以 20/0 全绿也是假绿,
→ pitfalls「需求横跨几个入口就每个入口都要判据」)。
⚠️ 作用域必须限定 `[data-tk-home-host]`(首页任务卡片的外层):
任务**看板**页的行也带 `data-task-row`,那里勾完成是**只改状态、不删行**
(看板是按状态分栏的,"行"就是那个任务本身,删了就没处看了)。
所以这段绝不能按 `[data-task-row]` 全局找。
⚠️ 只在"变更后确实是 done"时删(判据用服务端回的 `d.status`,不是我算出来的 next):
`tasks/status` 是幂等接口,传 done 再来一次还是 done;拿返回值判断才不会
"手快连点两下,第二下把行删了"。
@param {Element} row 要摘掉的那一行 `li[data-task-row]`
@param {string} wasStatus 改**之前**的状态('doing' / 'todo')——
决定概述卡该减「件在做」还是「件待办」。
⚠️ 不能在这里读 `data-tk-status`:调用方通常已经把它改成 done 了。
@return {boolean} 真的删了才 true(行不在首页卡片里就 false,什么也不做)
============================================================================ */
window.fbHomeTaskDrop = function (row, wasStatus) {
if (!row) { return false; }
var host = row.closest ? row.closest('[data-tk-home-host]') : null;
/* 不在首页卡片里(任务看板 / 详情页子任务列表)→ 一律不删。
⚠️ 这条判断是这段代码能被两个入口共用的**前提**,别为了"通用"去掉它。 */
if (!host || !host.contains(row)) { return false; }
var ul = row.parentNode;
if (row.parentNode) { row.parentNode.removeChild(row); }
/* 概述卡的「N 件在做 / N 件待办」同步减 1。
⚠️ 用 `[data-hbs]` 定位(模板里专门加的钩子),**不靠中文文案** ——
"件在做"改一个字就失效,而这是 JS 要读的东西。 */
if (wasStatus === 'doing' || wasStatus === 'todo') {
var a = document.querySelector('.hb-stats a.hbs[data-hbs="' + wasStatus + '"]');
var b = a ? a.querySelector('b') : null;
if (b) {
var n = Math.max(0, (parseInt(b.textContent, 10) || 0) - 1);
b.textContent = String(n);
/* 减到 0 就把整条藏起来:服务端是 `if ($nTodo > 0)` 才渲染待办那一条,
前端不藏会出现「0 件待办」——服务端永远不会画出来的状态。 */
if (n === 0) { a.hidden = true; }
}
}
/* 删到一条不剩:摘掉空 ul,把服务端渲染的空态提示露出来
—— 否则卡片变成一片空白,用户以为加载坏了。 */
if (ul && ul.querySelectorAll && ul.querySelectorAll('[data-task-row]').length === 0) {
if (ul.parentNode) { ul.parentNode.removeChild(ul); }
var empty = host.querySelector('[data-tk-empty]');
if (empty) { empty.hidden = false; }
}
return true;
};
/* ============================================================
#47(2026-11-05)行程状态动作按钮:点一下 → PATCH → 就地重绘
------------------------------------------------------------
用户要求状态由**动作**驱动,不再由编辑表单手工选。
服务端接口:PATCH /api/v1/trips/<tripId>/nodes/status {node_id, status}
⚠️ 但网页端走的是表单式站点,不是 API 客户端 —— 用一个轻量的
POST 到同源端点(下面用 fetch 打站点自己的 node_status 动作)。
============================================================ */
(function () {
function bindNodeStatus() {
var btns = document.querySelectorAll('[data-node-status-set]');
for (var i = 0; i < btns.length; i++) {
var b = btns[i];
if (b.getAttribute('data-bound') === '1') { continue; }
b.setAttribute('data-bound', '1');
b.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
var self = e.currentTarget;
if (self.getAttribute('data-busy') === '1') { return; }
self.setAttribute('data-busy', '1');
var nodeId = self.getAttribute('data-node');
var status = self.getAttribute('data-node-status-set');
var box = self.closest('[data-node-status]');
var tripId = document.body.getAttribute('data-trip-id') || '';
var fd = new FormData();
fd.append('node_id', nodeId);
fd.append('status', status);
if (tripId) { fd.append('trip_id', tripId); }
fd.append('csrf', (window.FB_CSRF || ''));
fetch('index.php?action=node_status', {
method: 'POST',
body: fd,
headers: { 'X-Requested-With': 'fetch' }
}).then(function (r) { return r.json(); }).then(function (res) {
self.removeAttribute('data-busy');
if (!res || !res.ok) {
alert((res && res.error) ? res.error : '状态没改成,请重试。');
return;
}
/* 就地刷新这一段的动作条(不整页重载,避免丢失滚动位置) */
if (box && res.html) {
box.outerHTML = res.html;
bindNodeStatus();
} else {
location.reload();
}
}).catch(function () {
self.removeAttribute('data-busy');
alert('网络出错,状态没改成。');
});
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindNodeStatus);
} else {
bindNodeStatus();
}
window.fbBindNodeStatus = bindNodeStatus;
})();
/* ============================================================
#48(2026-11-05)行程动作:开始 / 完成 / 取消 / 删除
------------------------------------------------------------
用户:「计划行程右上角就不是两个按钮了,而是至少四个按钮:
一个是开始,一个是完成,一个是编辑,一个是删除。
在删除前面还应该有一个按钮,这个按钮是取消。」
⚠️ 完成 = 转成实际行程(stage: plan → actual),之后不再有状态。
⚠️ 取消 ≠ 删除:取消保留数据(stage=skipped),删除才软删。
============================================================ */
(function () {
function bindNodeActs() {
/* ---- 状态动作(开始 / 完成 / 取消)---- */
var acts = document.querySelectorAll('[data-node-act]');
for (var i = 0; i < acts.length; i++) {
var b = acts[i];
if (b.getAttribute('data-bound') === '1') { continue; }
b.setAttribute('data-bound', '1');
b.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
var self = e.currentTarget;
if (self.getAttribute('data-busy') === '1') { return; }
var act = self.getAttribute('data-node-act');
var nodeId = self.getAttribute('data-node');
/* 取消是不可逆的语义(虽然数据还在),先确认一下 */
if (act === 'cancel' && !confirm('标记为不去了?数据会保留在「未成行」里。')) {
return;
}
self.setAttribute('data-busy', '1');
var fd = new FormData();
fd.append('node_id', nodeId);
fd.append('act', act);
fd.append('csrf', (window.FB_CSRF || ''));
fetch('index.php?action=node_act', {
method: 'POST', body: fd,
headers: { 'X-Requested-With': 'fetch' }
}).then(function (r) { return r.json(); }).then(function (res) {
self.removeAttribute('data-busy');
if (!res || !res.ok) {
alert((res && res.error) ? res.error : '没成功,请重试。');
return;
}
/* 动作会改变分组(计划→实际/未成行),必须整页重载 */
location.reload();
}).catch(function () {
self.removeAttribute('data-busy');
alert('网络出错,没成功。');
});
});
}
/* ---- 删除(软删)---- */
var dels = document.querySelectorAll('[data-node-del]');
for (var k = 0; k < dels.length; k++) {
var d = dels[k];
if (d.getAttribute('data-bound') === '1') { continue; }
d.setAttribute('data-bound', '1');
d.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
var self = e.currentTarget;
if (!confirm('彻底删掉这段行程?')) { return; }
var fd = new FormData();
fd.append('node_id', self.getAttribute('data-node-del'));
fd.append('csrf', (window.FB_CSRF || ''));
fetch('index.php?action=node_del', {
method: 'POST', body: fd,
headers: { 'X-Requested-With': 'fetch' }
}).then(function (r) { return r.json(); }).then(function (res) {
if (!res || !res.ok) {
alert((res && res.error) ? res.error : '删不掉,请重试。');
return;
}
location.reload();
}).catch(function () { alert('网络出错。'); });
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindNodeActs);
} else {
bindNodeActs();
}
window.fbBindNodeActs = bindNodeActs;
/* ══════════════════════════════════════════════════════════════════════
#85 自绘下拉框(把原生 select 的展开列表换成自绘浮层)
══════════════════════════════════════════════════════════════════════
🔴 用户 2026-11-05:「网页版的下拉框也太丑了。你参考一下那个反馈系统
(FeedLite)里面的下拉框到底是怎么做的?按它的做法做。」
为什么必须自绘:原生 `<select>` 收起时能改样式,但**展开列表由浏览器绘制,
CSS 完全改不动**(只能改 option 底色),各平台样式还不统一。
但**不重写 HTML**:原生 select 留在 DOM 里参与表单提交,我们只在旁边
生成一个按钮 + 浮层。好处:
· 全站所有 select 自动获得新样式,不用逐个改模板;
· `onchange="this.form.submit()"` 这类内联逻辑照样工作(我们派发原生 change);
· 无 JS 时原生 select 仍可用(只是朴素),不会白屏。
⚠️ 浮层 `position: fixed` 挂 body —— 面板/浮层里大量 `overflow:hidden`,
放 select 旁边会被裁掉(FeedLite 踩过这个坑)。
⚠️ AJAX 换进来的内容(任务面板)要**再次调用** `fbBindSelects()`,
否则新 select 没有增强。已在 task_panel.paint / entry_popup.loadPanel 里接上。
══════════════════════════════════════════════════════════════════════ */
function fbBindSelects(root) {
var scope = root || document;
var sels = scope.querySelectorAll('select');
for (var i = 0; i < sels.length; i++) {
(function (sel) {
/* 幂等:已经增强过的跳过(AJAX 重画时会重复调用) */
if (sel.getAttribute('data-sel-enhanced') === '1') { return; }
/* 明确排除的(比如需要在原位置显示多行列表的) */
if (sel.hasAttribute('data-no-enhance')) { return; }
/* 多选 / 列表框不动 —— 那些本来就有自己的多行展示方式 */
if (sel.multiple || (parseInt(sel.getAttribute('size'), 10) || 0) > 1) { return; }
/* 隐藏域也不要(有些地方用它传值) */
if (sel.style.display === 'none' || sel.hidden) { return; }
sel.setAttribute('data-sel-enhanced', '1');
sel.classList.add('sel-native');
var wrap = document.createElement('span');
wrap.className = 'sel';
sel.parentNode.insertBefore(wrap, sel);
wrap.appendChild(sel);
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'sel-btn';
btn.setAttribute('aria-haspopup', 'listbox');
btn.setAttribute('aria-expanded', 'false');
wrap.appendChild(btn);
var panel = document.createElement('div');
panel.className = 'sel-panel';
panel.hidden = true;
panel.setAttribute('role', 'listbox');
document.body.appendChild(panel);
var active = -1;
function curText() {
var o = sel.options[sel.selectedIndex];
return o ? o.textContent : '';
}
function syncBtn() {
btn.innerHTML = '<span class="sel-txt"></span><span class="sel-caret" aria-hidden="true"></span>';
btn.querySelector('.sel-txt').textContent = curText();
btn.disabled = sel.disabled;
/* 首项值为空 = 占位项(未分类 / 不循环),文字用次要色 */
btn.classList.toggle('is-placeholder',
sel.selectedIndex <= 0 && sel.options.length > 0 && sel.options[0].value === '');
}
function buildList() {
panel.innerHTML = '';
for (var k = 0; k < sel.options.length; k++) {
(function (idx) {
var o = sel.options[idx];
if (o.hidden) { return; }
var item = document.createElement('button');
item.type = 'button';
item.className = 'sel-item';
item.setAttribute('role', 'option');
var on = idx === sel.selectedIndex;
if (on) { item.classList.add('on'); item.setAttribute('aria-selected', 'true'); }
var label = document.createElement('span');
label.className = 'sel-item-txt';
label.textContent = o.textContent;
item.appendChild(label);
if (on) {
var ck = document.createElement('span');
ck.className = 'sel-item-ck';
ck.setAttribute('aria-hidden', 'true');
item.appendChild(ck);
}
item.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
pick(idx);
});
panel.appendChild(item);
})(k);
}
}
function pick(idx) {
if (idx < 0 || idx >= sel.options.length) { return; }
var changed = sel.selectedIndex !== idx;
sel.selectedIndex = idx;
syncBtn();
close();
/* 派发原生 change —— 兼容 onchange="this.form.submit()" 那种写法 */
if (changed) {
var ev = document.createEvent('HTMLEvents');
ev.initEvent('change', true, false);
sel.dispatchEvent(ev);
}
}
function open() {
if (btn.disabled) { return; }
buildList();
panel.hidden = false;
btn.setAttribute('aria-expanded', 'true');
btn.classList.add('is-open');
/* 先量宽度再定位:panel 刚显示时 offsetWidth 才有值 */
position();
active = sel.selectedIndex;
highlight(active);
document.addEventListener('mousedown', outside, true);
document.addEventListener('keydown', onKey, true);
window.addEventListener('scroll', position, true);
window.addEventListener('resize', position);
}
function close() {
if (panel.hidden) { return; }
panel.hidden = true;
btn.setAttribute('aria-expanded', 'false');
btn.classList.remove('is-open');
document.removeEventListener('mousedown', outside, true);
document.removeEventListener('keydown', onKey, true);
window.removeEventListener('scroll', position, true);
window.removeEventListener('resize', position);
}
function outside(e) {
if (!panel.contains(e.target) && !btn.contains(e.target)) { close(); }
}
/* 定位:fixed 挂 body,不服从任何祖先的 overflow。
优先向下展开;下方不够且上方更宽裕时向上翻。 */
function position() {
if (panel.hidden) { return; }
var r = btn.getBoundingClientRect();
var vw = window.innerWidth, vh = window.innerHeight;
var pw = Math.max(panel.offsetWidth, r.width);
var ph = panel.offsetHeight;
var margin = 8;
var left = r.left;
if (left + pw + margin > vw) { left = Math.max(margin, vw - pw - margin); }
var below = vh - r.bottom - margin;
var above = r.top - margin;
var openUp = (ph > below) && (above > below);
var top = openUp ? (r.top - ph - 4) : (r.bottom + 4);
if (top < margin) { top = margin; }
if (top + ph > vh - margin) { top = Math.max(margin, vh - ph - margin); }
panel.style.left = Math.round(left) + 'px';
panel.style.top = Math.round(top) + 'px';
/* 与触发按钮同宽(用户要求"和输入框一样宽") */
panel.style.minWidth = Math.round(r.width) + 'px';
panel.classList.toggle('is-up', openUp);
}
function highlight(idx) {
var items = panel.querySelectorAll('.sel-item');
for (var k = 0; k < items.length; k++) {
items[k].classList.toggle('is-active', k === idx);
}
if (idx >= 0 && items[idx]) {
var it = items[idx];
var top = it.offsetTop, bot = top + it.offsetHeight;
if (top < panel.scrollTop) { panel.scrollTop = top; }
else if (bot > panel.scrollTop + panel.clientHeight) {
panel.scrollTop = bot - panel.clientHeight;
}
}
}
function onKey(e) {
var items = panel.querySelectorAll('.sel-item');
if (e.key === 'Escape') { e.preventDefault(); close(); btn.focus(); return; }
if (e.key === 'ArrowDown') {
e.preventDefault();
active = Math.min(items.length - 1, active + 1); highlight(active);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
active = Math.max(0, active - 1); highlight(active);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (active >= 0) { pick(active); }
} else if (e.key === 'Home') {
e.preventDefault(); active = 0; highlight(active);
} else if (e.key === 'End') {
e.preventDefault(); active = items.length - 1; highlight(active);
}
}
btn.addEventListener('click', function (e) {
e.preventDefault(); e.stopPropagation();
if (panel.hidden) { open(); } else { close(); }
});
/* 原生 select 被程序改值时,按钮文字要跟着变 */
sel.addEventListener('change', syncBtn);
syncBtn();
})(sels[i]);
}
}
/* 对外暴露:AJAX 换进来的内容要再调一次 */
window.fbBindSelects = fbBindSelects;
/* #85:首屏自动增强所有 select */
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { fbBindSelects(document); });
} else {
fbBindSelects(document);
}
})();
+338
View File
@@ -0,0 +1,338 @@
/* ============================================================================
日程「就地更新」—— 新建 / 修改**不刷新页面**(工单 #37)
----------------------------------------------------------------------------
用户原话(2026-11-01):
「日程又有了新bug,修改一个日程时,页面会刷新,这样如果我新建或者修改的日程
不是今天的,那么当刷新后,就看不到我编辑的日程的变化,不知道修改成功了没有。
修改和新建,都不应该刷新页面,而是使用js动态调整。」
---------------------------------------------------------------- 为什么这么写
历史上「新建不刷新」是靠**手写复刻服务端渲染**做的(`templates/calendar/index.php`
底部那段 `putMonth` / `putTimed` / `relayoutColumn`)。那段代码自己的注释里就写着:
· "插入用的定位方式必须与服务端渲染的完全一致,否则刷新一次小条就会跳位置"
· 一次真实事故:新加一条 07:00 的日程被 append 到末尾 ⇒ 落进「+N 条」看不见,
可刷新一次又跑到第一位
修改要处理的情况比新建多得多(跨天变了 / 全天状态变了 / 换了日历本改了颜色 /
改了重复规则 / 从这天挪到了别月),复刻必然出错。
⇒ 本文件改用**服务端渲染片段 → 前端整块替换**:
改完 → 取一份服务端画好的视图 → 换掉容器里的内容。
只有一处渲染,"插进去的样子"与"刷新一次的样子"**不可能**不一致。
---------------------------------------------------------------- 三个落点
① `[data-cal-view]` 日历页的月/周/日视图(`templates/calendar/_view.php`)
② `#homeCard-agenda` 首页「今天的日程」卡片(`templates/home/_card_agenda.php`)
③ 「+N 条」清单的 `#calDayData` 数据岛(月视图那一份 JSON,换掉后清单才跟着变对)
---------------------------------------------------------------- 硬约束(都是踩过的)
· **只换内容、不换容器**:容器上挂着 `data-cal-view-kind` 之类的上下文,
换掉会让 CSS 的 `.cal-main > *` 重算(视觉会跳一下),而且监听会丢。
· **替换前记滚动位置**:`.hc-agenda [data-ag-scroll]` 进场会滚到"现在",
不恢复的话用户正看着的那条就被弹走了。
· **替换后要补跑三件事**:红线定时器(`data-now-line` 是 app.js 首屏扫的)、
「+N 条」数据岛、以及本文件自己的重绑。否则换进来的新节点是"死的"。
· **幂等**:脚本全站加载,被重复引入(AJAX 换正文、模板重复)不能绑两遍 ——
`window.__fbCalSyncBound` 闸门。
· **拿不到片段就退回整页刷新**:宁可刷一次,不要"没刷新却也没变"。
用户最怕的就是"不知道修改成功了没有",那种状态比刷新更糟。
============================================================================ */
(function () {
'use strict';
if (window.__fbCalSyncBound) { return; }
window.__fbCalSyncBound = true;
/* ---------------------------------------------------------------- 工具 */
function byId(id) { return document.getElementById(id); }
/** 当前页面是哪个视图:从容器上读(服务端渲染时就写好了)。 */
function viewKind() {
var el = document.querySelector('[data-cal-view]');
return el ? (el.getAttribute('data-cal-view-kind') || '') : '';
}
/**
* 拼局部重绘要请求的地址。
*
* ⚠️ 参数**照抄当前页面的 URL**:用户在月视图看 11 月,就还取 11 月;
* 在周视图看某周,就还取那一周。绝不"按改动的那条日程重新算" ——
* 改一条别的月份的日程时,那会把用户正在看的那一屏换掉。
* 这条是用户报的现象的核心(改完刷新,跳到了别的月份/别的视图)。
*/
function viewUrl() {
var kind = viewKind();
if (!kind) { return ''; }
var q = new URLSearchParams();
q.set('r', 'calendar/view');
q.set('view', kind);
/* 上下文全部从**容器自己身上**读(服务端渲染时写的,准)。
不去猜隐藏域、也不从页头翻页链接里抠字符串 —— 那些在"翻过页"之后会对不上
(踩过:`#calJumpD` 带的是 anchor,翻到 12 月它还是 11 月那天的日期)。 */
var el = document.querySelector('[data-cal-view]');
var ym = el ? (el.getAttribute('data-cal-ym') || '') : '';
var anc = el ? (el.getAttribute('data-cal-anchor') || '') : '';
if (kind === 'week' || kind === 'day') {
if (anc) { q.set('d', anc); }
} else if (ym) {
q.set('ym', ym);
}
return 'index.php?' + q.toString();
}
function fetchHtml(url) {
return fetch(url, {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) {
if (!r.ok) { throw new Error('http ' + r.status); }
return r.text();
});
}
/* ---------------------------------------------------------------- 重绘 */
/** 记录 / 恢复首页日程卡片的滚动位置(它进场会滚到"现在")。 */
function grabScroll() {
var box = document.querySelector('#homeCard-agenda [data-ag-scroll]');
return box ? box.scrollTop : null;
}
function putScroll(top) {
if (top === null || top === undefined) { return; }
var box = document.querySelector('#homeCard-agenda [data-ag-scroll]');
if (box) { box.scrollTop = top; }
}
/**
* 把新片段塞进容器:**只换子节点,不动容器本身**。
*
* 片段顶层是一个 `<div class="cal-view">`(或卡片的 `.hc-body`),
* 拿它的 innerHTML 填进现有容器即可 —— 这样容器上的属性/监听都还在。
*/
function swap(host, html) {
var tmp = document.createElement('div');
tmp.innerHTML = html;
/* 片段顶层可能是 .cal-view(日历)或 .hc-body(卡片),两种都认 */
var src = tmp.querySelector('[data-cal-view], .hc-body') || tmp.firstElementChild;
if (!src) { return false; }
host.innerHTML = src.innerHTML;
return true;
}
/**
* 重画「+N 条」用的数据岛(月视图)。
*
* ⚠️ 为什么必须换它:清单内容来自 `#calDayData` 那份 JSON。
* 改了一条之后如果不更新,点「+N 条」看到的还是改之前的样子 ——
* 这正是本项目 v70 记过的那类"清单与格子不一致"。
* 数据岛由服务端随视图片段一起产出(`_view.php` 里就在网格前面),
* 所以这里只要把新的那份整段搬过来。
*/
function syncDayData(html) {
var host = byId('calDayData');
if (!host) { return; }
var tmp = document.createElement('div');
tmp.innerHTML = html;
var fresh = tmp.querySelector('#calDayData');
if (!fresh) { return; }
try { host.textContent = fresh.textContent || '{}'; } catch (e) { /* 失败就别动 */ }
}
/** 重绘后补跑那些"首屏扫过一次"的初始化。 */
function reinit() {
/* 红线:app.js 里那个 IIFE 只在首屏扫 `[data-now-line]` 并起定时器。
换进来的新节点没人管它 —— 不补的话线会停在服务端给的位置不动
(一小时后再看就明显错了)。这里就地算一次当前位置,并起一个新的定时器
(旧的会因为没有节点而空转,量极小;用标记避免重复起)。 */
var lines = document.querySelectorAll('[data-now-line]');
if (lines.length) {
var paint = function () {
var d = new Date();
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));
});
};
paint();
if (!window.__fbCalNowTimer) {
window.__fbCalNowTimer = setInterval(paint, 60000);
}
}
}
/**
* 局部重绘入口。
*
* @param {Object} [opts]
* @param {string} [opts.msg] 成功提示(落在页面上的轻提示里)
* @param {boolean} [opts.hard] true = 强制走整页刷新(例如整条系列都变了)
* @return {Promise<boolean>} 重绘是否成功
*/
function refresh(opts) {
opts = opts || {};
if (opts.hard || !window.fetch) {
window.location.reload();
return Promise.resolve(false);
}
var hasCal = !!document.querySelector('[data-cal-view]');
var hasCard = !!(byId('homeCard-agenda') || document.querySelector('#homeCard-agenda'));
/* 两个落点都不在(比如在任务页、资源页):这条日程的改动没有可见的地方,
安静地成功即可 —— 不要为了"看不见的东西"刷一次页。 */
if (!hasCal && !hasCard) { return Promise.resolve(true); }
var jobs = [];
if (hasCal) {
var u = viewUrl();
if (!u) { window.location.reload(); return Promise.resolve(false); }
jobs.push(fetchHtml(u).then(function (html) {
var host = document.querySelector('[data-cal-view]');
if (!host || !swap(host, html)) { throw new Error('swap'); }
syncDayData(html);
}));
}
if (hasCard) {
jobs.push(fetchHtml('index.php?r=home/agenda').then(function (html) {
var host = document.querySelector('#homeCard-agenda');
/* 卡片的外层是 `<section id="homeCard-agenda">`,要换的是里面的 `.hc-body`。
当天的日程块本身就是 `.hc-body.hc-agenda` —— 拿它当替换目标。 */
var body = host ? host.querySelector('.hc-body') : null;
if (!body) { throw new Error('no body'); }
var top = grabScroll();
if (!swap(body, html)) { throw new Error('swap'); }
putScroll(top);
}));
}
return Promise.all(jobs).then(function () {
reinit();
if (opts.msg) { toast(opts.msg); }
return true;
}).catch(function () {
/* 任何一步失败 → 退回整页刷新。宁可刷一次,也不要"没刷新却也没变"。 */
window.location.reload();
return false;
});
}
/* ---------------------------------------------------------------- 轻提示 */
/**
* 页面顶部一句轻提示。
*
* ⚠️ 为什么不用 `flash`(服务端那条):flash 要等下一次整页渲染才出现,
* 而本工单的整个目的就是"不刷新"。没有提示的话,用户在原地看不到任何
* 变化时仍然会怀疑"到底存没存上"(他原话:"不知道修改成功了没有")。
*/
var toastTimer = null;
function toast(msg, isErr) {
/* ⚠️ 复用站内**已有的**那套浮动小条(`.tk-flash`,v55 为"任务状态改完给一句反馈"
加的那一个),不另发明一套 —— 两套视觉迟早不一致,深色模式还要各补一遍。
⚠️ 开关只认 `hidden` 属性(与 `templates/tasks/show.php` 同一个用法):
`[hidden]` 的 `display:none` 是浏览器默认样式、优先级最低,
CSS 里必须有一条 `.tk-flash[hidden]{display:none}` 兜着,
否则作者样式里的 `display` 会把它盖掉 ⇒ 提示永远收不起来。 */
var box = byId('fbCalToast');
if (!box) {
box = document.createElement('div');
box.id = 'fbCalToast';
box.className = 'tk-flash';
box.setAttribute('role', 'status');
box.setAttribute('aria-live', 'polite');
box.hidden = true;
document.body.appendChild(box);
}
box.textContent = msg;
box.className = 'tk-flash' + (isErr ? ' is-err' : '');
box.hidden = false;
if (toastTimer) { clearTimeout(toastTimer); }
toastTimer = setTimeout(function () {
box.hidden = true;
}, 3200);
}
/* ---------------------------------------------------------------- 首页新建 */
/**
* 首页那张「新建日程」表单**也要不刷新**。
*
* ⚠️ 只接管"首页那条路":判据是页面上**没有** `.cal-wrap`(那是日历模块页)。
* 日历页自己有完整的无刷新逻辑(模板底部那段),两边都绑会导致
* 提交两次(一次 fetch 一次原生)—— 本项目 `add_modal.js` 的注释里
* 记着同一类"绑两遍"事故。
*/
function bindHomeAddForm() {
if (document.querySelector('.cal-wrap')) { return; } /* 日历模块页:不接管 */
var form = byId('calAddForm');
if (!form || form.getAttribute('data-fb-cal-bound') === '1') { return; }
if (!window.fetch) { return; }
form.setAttribute('data-fb-cal-bound', '1');
var tip = byId('calAddTip');
function sayErr(msg) {
if (tip) { tip.textContent = msg; tip.className = 'cal-modal-tip err'; }
else { toast(msg, true); }
}
form.addEventListener('submit', function (e) {
if (form.getAttribute('data-native-submit') === '1') { return; }
e.preventDefault();
var sub = byId('calAddSubmit');
if (sub) { sub.disabled = true; }
if (tip) { tip.textContent = ''; tip.className = 'cal-modal-tip'; }
fetch(form.getAttribute('action'), {
method: 'POST', body: new FormData(form), credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
}).then(function (r) { return r.json().catch(function () { return null; }); })
.then(function (d) {
if (sub) { sub.disabled = false; }
if (d && d.ok) {
/* 关浮层 → 清掉"只填一次"的字段(保留日历本选择,用户常连加几条) */
if (window.fbAddModal && form.closest('.cal-modal')) {
window.fbAddModal.close('#' + form.closest('.cal-modal').id);
} else {
var m = form.closest('.cal-modal');
if (m) { m.hidden = true; }
}
['ev-title', 'ev-location', 'ev-note'].forEach(function (id) {
var el = byId(id);
if (el) { el.value = ''; }
});
/* 观影/旅程本是派生的:服务端给了 goto,就别急着说"日程已加上" */
refresh({ msg: (d.msg || '日程已加上。') });
return;
}
if (d && d.msg) { sayErr(d.msg); return; }
/* 拿不到 JSON:会话过期 / 500 → 退回原生提交,让服务端说话 */
form.setAttribute('data-native-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
})
.catch(function () {
if (sub) { sub.disabled = false; }
form.setAttribute('data-native-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
});
});
}
/* ---------------------------------------------------------------- 导出 */
window.fbCalSync = {
refresh: refresh,
viewUrl: viewUrl,
viewKind: viewKind,
toast: toast
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindHomeAddForm);
} else {
bindHomeAddForm();
}
})();
+145 -10
View File
@@ -46,8 +46,12 @@
(页面里还有"点某天新建日程"那套逻辑,它同样排除 `.cal-ev`)。 (页面里还有"点某天新建日程"那套逻辑,它同样排除 `.cal-ev`)。
③ **数据全在 `data-fb-entry` 上**:浮层不发请求就能画出来,秒开。 ③ **数据全在 `data-fb-entry` 上**:浮层不发请求就能画出来,秒开。
④ **详情/编辑都就地换正文**(用户:「就在当前的页面」)——不叠第二层浮层。 ④ **详情/编辑都就地换正文**(用户:「就在当前的页面」)——不叠第二层浮层。
⑤ **保存走原生提交**:四个来源的 update 路由本来就收普通 POST 并跳转 ⑤ **保存:日程走 AJAX,其余来源走原生提交**(工单 #37 起)。
(自己写 AJAX 保存要为四个来源各写一套"成功后回哪",得不偿失)。 · 日程(`#calEditForm`)→ fetch → 成功后原地重绘,**不刷新**。
服务端 `calendar/event_update` 认 X-Requested-With 回 JSON(新加的分支)。
· 名册 / 观影 / 行程 / 任务 → 仍是原生提交 + 302,行为与以前完全一致。
它们各自的 update 路由没有 JSON 出口,硬改要把四个模块一起动;
用户报的是日程,范围就收在日程上,不顺手扩大回归面。
⑥ **删除走 AJAX**(v49 新增,服务端 `calendar/event_del` 认 X-Requested-With 回 JSON): ⑥ **删除走 AJAX**(v49 新增,服务端 `calendar/event_del` 认 X-Requested-With 回 JSON):
成功后就地摘掉那一条,**不刷页** —— 与编辑不同,删除不需要"回到哪一页"的语义。 成功后就地摘掉那一条,**不刷页** —— 与编辑不同,删除不需要"回到哪一页"的语义。
⚠️ 两种例外退回整页重载:`removed_forward`(这一次及以后砍掉了整个系列尾段, ⚠️ 两种例外退回整页重载:`removed_forward`(这一次及以后砍掉了整个系列尾段,
@@ -135,7 +139,15 @@
*/ */
function paintDetail(data) { function paintDetail(data) {
var isEvent = (data.source === 'event'); var isEvent = (data.source === 'event');
if (titleEl()) { titleEl().textContent = data.title || '详情'; } /* 🔴 #83(用户 2026-11-05):「最左上角也是一样,应该显示"编辑任务",
而不是显示任务的标题。」
任务面板(带 `data.panel`)本身就是**编辑面板** —— 标题栏写任务标题
会让用户以为"这是详情页,只是恰好能改"。
其它来源(观影/旅程/日程/纪念日)仍显示各自的标题:
它们点开的确实是"这条记录的详情",显示名字才是对的。 */
var headText = data.panel ? '编辑任务' : (data.title || '详情');
if (titleEl()) { titleEl().textContent = headText; }
if (bodyEl()) { bodyEl().innerHTML = '<dl class="kv">' + rowsHtml(data.rows) + '</dl>'; } if (bodyEl()) { bodyEl().innerHTML = '<dl class="kv">' + rowsHtml(data.rows) + '</dl>'; }
/* 🔴 v78:任务带 `panel`(可就地操作的那块)—— 面板比只读信息宽一号, /* 🔴 v78:任务带 `panel`(可就地操作的那块)—— 面板比只读信息宽一号,
所以这里按**有没有 panel** 决定宽度,而不是按 source。 */ 所以这里按**有没有 panel** 决定宽度,而不是按 source。 */
@@ -172,7 +184,16 @@
/* 🔴 用户:「来自其他模块时,下面就没有编辑、也没有删除,就只有一个详情」—— /* 🔴 用户:「来自其他模块时,下面就没有编辑、也没有删除,就只有一个详情」——
所以这两颗**只对 source='event'** 显(且各要有地址)。 */ 所以这两颗**只对 source='event'** 显(且各要有地址)。 */
var ed = byId('fbEntryEdit'); var ed = byId('fbEntryEdit');
if (ed) { ed.hidden = !(isEvent && data.edit); } /* 🔴 #78(用户 2026-11-05):「现在在首页上点一个任务,直接就是编辑页面啊。
所以不需要再加一个编辑按钮了。」
⇒ 任务(带 `panel`)的面板里已经能改**全部**字段
(标题/截止/负责人/协作人/分类/备注 + 状态/看板/子任务/评论/附件),
再给一颗「编辑」只会让用户以为"是不是还有别的东西要进另一个页面改"。
⚠️ **不能无条件藏**:观影 / 旅程 / 日程 / 纪念日 那些来源没有 panel,
它们仍然只有「详情 + 编辑」,那颗按钮必须留着。
⚠️ `data.del`(删除)不动 —— 用户没要求改删除,保持原样。 */
if (ed) { ed.hidden = !(isEvent && data.edit) || !!data.panel; }
var de = byId('fbEntryDelete'); var de = byId('fbEntryDelete');
if (de) { de.hidden = !(isEvent && data.del); } if (de) { de.hidden = !(isEvent && data.del); }
@@ -204,6 +225,41 @@
if (!bodyEl() || html.indexOf('data-tk-panel') < 0) { throw new Error('panel'); } if (!bodyEl() || html.indexOf('data-tk-panel') < 0) { throw new Error('panel'); }
bodyEl().innerHTML = html; bodyEl().innerHTML = html;
runInlineScripts(bodyEl(), html); runInlineScripts(bodyEl(), html);
/* 🔴🔴 #74(用户 2026-11-05):「空间有了,但是不是我想要的空间,这个空间需要换。」
—— 实为「控件」:面板里的截止时间显示成**五个原生下拉**(年/月/日/时/分),
而不是 AntD 的弹层日历。
真根因:**首次打开面板走的是这条 loadPanel()**,它只 innerHTML + 跑内联脚本,
**没有补挂 AntD**。
我在 task_panel.js 的 paint() 里加过 fbBootAntd(),但那只管
"面板里做了动作之后的重画"——**首次打开不经过 paint()**。
⇒ 用户点开任务看到的是从未被升级过的原生下拉。
⚠️ 与上面 loadEdit() 里那次调用同一个理由:AJAX 换进来的 HTML
不会自己跑初始化;bootAntd() 只在页面加载时扫过一次
[data-dt-antd-single],那时面板还不存在。
⚠️ 放在 runInlineScripts 之后:内联脚本里可能也有初始化逻辑,
让它先跑完;mountAntd* 有 data-antd-done 幂等标记,重复调用无害。 */
if (typeof window.fbBootAntd === 'function') {
try { window.fbBootAntd(); } catch (e) { /* 挂不上就留在原生下拉,不阻断面板 */ }
/* #85:AJAX 换进来的 select 也要增强成自绘下拉 */
if (typeof window.fbBindSelects === 'function') {
try { window.fbBindSelects(document); } catch (e2) { /* 忽略 */ }
}
}
/* 🔴 #78(用户 2026-11-05):「选人的时候不能按列表,因为人太多了。
你参考一下观影模式里面选人的方式。」
⇒ 面板里的负责人/协作人改用 `_person_picker.php`(弹窗 + 拼音联想),
那个组件靠 `fbInitPickers(容器的)` 接管点击。
`loadEdit()` 里一直有这一句(第 315 行),**`loadPanel()` 漏了** ——
所以面板里的选人按钮点了没反应。
⚠️ 组件自带 `data-pp-init` 幂等标记,重复调用不会重复绑。 */
if (typeof window.fbLoadPicker === 'function') {
try { window.fbLoadPicker(); } catch (e) { /* 忽略 */ }
}
if (typeof window.fbInitPickers === 'function') {
try { window.fbInitPickers(bodyEl()); } catch (e) { /* 忽略 */ }
}
}) })
.catch(function () { .catch(function () {
if (!bodyEl()) { return; } if (!bodyEl()) { return; }
@@ -308,14 +364,93 @@
.catch(function () { window.location.href = url; }); .catch(function () { window.location.href = url; });
} }
/** 编辑态「保存」= 原生提交那张表单(服务端 update 路由会跳转,刷新到新页)。 */ /** 找到编辑态那张表单(四个来源的 id 不同,兜底取第一个 form)。 */
function editForm() {
var b = bodyEl();
if (!b) { return null; }
return b.querySelector('form#anForm, form#mvForm, form#nodeForm, form#tripForm, form#calEditForm')
|| b.querySelector('form');
}
/**
* 编辑态「保存」。
*
* 🔵 工单 #37(用户 2026-11-01)——**用户原话**:
* 「修改一个日程时,页面会刷新,这样如果我新建或者修改的日程不是今天的,
* 那么当刷新后,就看不到我编辑的日程的变化,不知道修改成功了没有。
* 修改和新建,都不应该刷新页面,而是使用js动态调整。」
*
* ⇒ **日程**(`#calEditForm`)改走 AJAX + 原地重绘;
* 另外三个来源(名册 / 观影 / 行程 / 任务…)保持原生提交 ——
* 它们各自的 update 路由没有 JSON 出口,硬改就得把四个模块一起动,
* 而用户这次报的是日程。范围收在日程上,其它来源行为一字不变(避免回归)。
*/
function submitEdit() { function submitEdit() {
var form = bodyEl() && (bodyEl().querySelector('form#anForm, form#mvForm, form#nodeForm, form#tripForm, form#calEditForm') var form = editForm();
|| bodyEl().querySelector('form'));
if (!form) { setTip('找不到可保存的表单。', true); return; } if (!form) { setTip('找不到可保存的表单。', true); return; }
form.setAttribute('data-fb-entry-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); } /* 只有日程那张表有 AJAX 出口(`calendar/event_update` 认 X-Requested-With)。
else { form.submit(); } 其余来源照旧原生提交,与改动前完全一致。 */
/* ⚠️⚠️ 必须用 `getAttribute('id')`,**不能**写 `form.id`:
日程编辑表里有 `<input type="hidden" name="id">`,而 HTMLFormElement 的具名访问
优先级高于原型上的 `id` 属性 ⇒ `form.id` 拿到的是那个 input 元素。
原来那句 `form.id === 'calEditForm'` 因此**恒为 false**,日程也走了原生提交、
整页刷新(本轮实测抓到:请求上没有 X-Requested-With,响应 302)。 */
var isCal = form.getAttribute('id') === 'calEditForm'
&& typeof window.fetch === 'function';
if (!isCal) {
form.setAttribute('data-fb-entry-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
return;
}
submitEditAsync(form);
}
/** 日程:fetch 提交 → 成功则关浮层 + 局部重绘;失败留在浮层里让人改。 */
function submitEditAsync(form) {
var saveBtn = byId('fbEntrySave');
if (saveBtn) { saveBtn.disabled = true; }
setTip('正在保存…');
footNone(); /* 保存中把按钮收起来,防连点(重复提交在重复日程上后果很重) */
fetch(form.getAttribute('action'), {
method: 'POST', body: new FormData(form), credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
})
.then(function (r) { return r.json().catch(function () { return null; }); })
.then(function (d) {
if (saveBtn) { saveBtn.disabled = false; }
if (d && d.ok) {
setOpen(false);
/* `moved_series`(改「这一次」/「从现在到以后」)= 整条系列都变了,
影响面超出当前这一屏 ⇒ 交给 refresh 自己判断要不要硬刷。 */
var sync = window.fbCalSync;
if (sync && typeof sync.refresh === 'function') {
sync.refresh({ msg: (d.msg || '已保存。'), hard: !!d.moved_series });
} else {
/* 没加载到脚本(老缓存):退回刷新,别当成功 */
window.location.reload();
}
return;
}
/* 业务拒绝(没权限 / 时间不对 / 标题为空):留在浮层里,让用户改完再存 */
if (d && d.msg) {
setTip(d.msg, true);
footShow('edit');
return;
}
/* 拿不到 JSON:会话过期 / 500 → 退回原生提交,让服务端说话(与改动前一致) */
form.setAttribute('data-fb-entry-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
})
.catch(function () {
if (saveBtn) { saveBtn.disabled = false; }
form.setAttribute('data-fb-entry-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
});
} }
/* ---- ③ 详情:就地展开更多信息 ---------------------------------------------- /* ---- ③ 详情:就地展开更多信息 ----------------------------------------------
+64 -5
View File
@@ -70,6 +70,8 @@
var kind = trigger.getAttribute('data-prev') || 'image'; var kind = trigger.getAttribute('data-prev') || 'image';
var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || ''; var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || '';
var dl = trigger.getAttribute('data-dl') || src; var dl = trigger.getAttribute('data-dl') || src;
/* #53:文件名要在 error 回调里用到,提前取出来(原来 nm 在函数末尾才定义) */
var nm2 = trigger.getAttribute('data-name') || '';
if (!src) { return false; } if (!src) { return false; }
opener = trigger; opener = trigger;
clear(); clear();
@@ -82,6 +84,37 @@
el.controls = true; el.controls = true;
el.autoplay = true; el.autoplay = true;
el.playsInline = true; el.playsInline = true;
/* 🔴🔴 #53(2026-11-05):**HEVC 解码失败时给明确提示,不要装死**。
实测用户的视频是 H.265/HEVC(`hvc1`),Chrome/Firefox 的根本解不了 ——
表现为一块黑屏,什么提示都没有,用户只会以为"文件坏了"。
这里监听 error:能取到 `el.error.code` 就说明是解码/加载失败。
把解释写进画面本身(而不是 alert),并给出下载入口 ——
下载下来用系统播放器是能看的(VLC/iPhone/Windows 都支持 HEVC)。
⚠️ 不做"静默回退到下载页":用户点的是"看",直接跳走会困惑。
要的是**在原地说明白**,同时给一条出路。 */
el.addEventListener('error', function () {
var wrap = document.createElement('div');
wrap.className = 'pv-fallback';
var h = document.createElement('p');
h.className = 'pv-fallback-h';
h.textContent = '这个视频浏览器解不了';
var p = document.createElement('p');
p.className = 'pv-fallback-p';
p.textContent = '它可能是 H.265(HEVC)编码。Chrome、Firefox 不支持这种格式,'
+ '但文件本身没坏 —— 下载后用系统播放器(iPhone / VLC / Windows 播放器)可以正常观看。';
var a = document.createElement('a');
a.className = 'pv-fallback-dl';
a.href = dl;
a.textContent = '下载这个视频';
if (nm2) { a.setAttribute('download', nm2); }
wrap.appendChild(h);
wrap.appendChild(p);
wrap.appendChild(a);
while (stage.firstChild) { stage.removeChild(stage.firstChild); }
stage.appendChild(wrap);
});
} else if (kind === 'pdf') { } else if (kind === 'pdf') {
el = document.createElement('iframe'); el = document.createElement('iframe');
el.className = 'pv-pdf'; el.className = 'pv-pdf';
@@ -153,9 +186,28 @@
cur = -1; cur = -1;
} }
/** 正在看的是不是图片(视频/PDF 里方向键另有含义,不能抢) */ /**
function isImage() { * 按键是否发生在**媒体控件内部**(视频 / PDF iframe)。
return items[cur] && (items[cur].getAttribute('data-prev') || '') === 'image'; *
* 🔴 #58(用户 2026-11-05):「行程里面图片轮播的时候,切换到视频,
* 上下左右键就失效了。」
*
* 原来的写法是 `if (e.key === 'ArrowLeft' && isImage())` ——
* 只有**当前是图片**才翻页,切到视频后左右键直接失效。
*
* 当年这么写的顾虑(注释原文)是「视频/PDF 里方向键另有含义,不能抢」,
* 这个顾虑对:视频的左右键是快退/快进,PDF 里是翻页。
*
* 但**一刀切禁掉**是错的解法。正确做法是看**事件目标**:
* · 按键发生在 video/iframe 内部 ⇒ 让它自己处理(快进、翻页)
* · 按键发生在别处(背景、标题栏)⇒ 照常翻页
*
* ⚠️ 视频**没聚焦**时 `e.target` 往往是 `<body>` ——
* 这时用户没在操作视频,就是想换下一张,应该翻页。
*/
function inMediaControl(t) {
if (!t || !t.closest) { return false; }
return !!t.closest('video, iframe');
} }
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
@@ -177,7 +229,14 @@
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (box.hidden) { return; } if (box.hidden) { return; }
if (e.key === 'Escape') { close(); return; } if (e.key === 'Escape') { close(); return; }
if (e.key === 'ArrowLeft' && isImage()) { e.preventDefault(); step(-1); return; }
if (e.key === 'ArrowRight' && isImage()) { e.preventDefault(); step(1); } /* #58:焦点在视频/PDF 控件里 ⇒ 放行给控件(快进、PDF 翻页)。
这样"视频里左右键是快进"和"轮播里左右键是翻页"**不冲突**。 */
if (inMediaControl(e.target)) { return; }
/* 其余情况:左右键翻页 —— **不再限制只有图片才能翻**。
⚠️ 上下键保持不处理:页面滚动/视频音量另有含义,抢了会别扭。 */
if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); return; }
if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
}); });
})(); })();
+432 -8
View File
@@ -78,6 +78,29 @@
var ti2 = b.querySelector('.tk-inline-form input[name="title"]'); var ti2 = b.querySelector('.tk-inline-form input[name="title"]');
if (ti2) { ti2.value = draftTitle; } if (ti2) { ti2.value = draftTitle; }
} }
/* 🔴 #74(用户 2026-11-05):「点开之后,就可以修改任务的所有信息,
包括…修改截止时间。」
⇒ 面板里现在有 AntD 的单点日期控件(`[data-dt-antd-single]`)。
这段 HTML 是 AJAX 换进来的,**不会自己跑初始化** ——
`bootAntd()` 只在页面加载时扫过一次 `[data-dt-antd-single]`,
那时面板还不存在 ⇒ 控件会是一片原生下拉(或空白)。
⇒ 每次重画面板都要补挂一次。
⚠️ `mountAntd*` 有 `data-antd-done` 幂等标记,重复调用不会挂两遍。
⚠️ 必须放在 `innerHTML` 之后 —— 要挂的节点得先真的在 DOM 里。 */
if (typeof window.fbBootAntd === 'function') {
try { window.fbBootAntd(); } catch (err) { /* 挂不上就留在原生下拉,不阻断面板 */ }
/* #85:AJAX 换进来的 select 也要增强成自绘下拉 */
if (typeof window.fbBindSelects === 'function') {
try { window.fbBindSelects(document); } catch (e2) { /* 忽略 */ }
}
}
/* #78:重画后选人组件是新节点 ⇒ 要重新接管(自带 data-pp-init 幂等标记)。
不补这一步的话,做过任何动作之后「更换负责人 / + 添加人」就点不动了。 */
if (typeof window.fbInitPickers === 'function') {
try { window.fbInitPickers(b); } catch (err) { /* 忽略 */ }
}
return true; return true;
} }
@@ -88,21 +111,79 @@
* 用户改完关掉浮层,如果卡片上还写着「待办」,他会怀疑"到底改上了没有"。 * 用户改完关掉浮层,如果卡片上还写着「待办」,他会怀疑"到底改上了没有"。
* 只改状态徽标与复选框 —— 看板徽标/逾期色这些要重算的东西留给下次刷新, * 只改状态徽标与复选框 —— 看板徽标/逾期色这些要重算的东西留给下次刷新,
* 不在这里猜(猜错比不猜更糟)。 * 不在这里猜(猜错比不猜更糟)。
*
* 🔴🔴 工单 #20(用户 2026-10-28,第二次提同一条需求):
* 「首页,近期任务,如果勾选了完成,应该刷新一下这个列表,把完成的删掉。」
* 第一版只改了行首那颗复选框(home.php 里的脚本),**漏了这里**——
* 从面板点「已完成」时,行留在原地只把徽标改成「已完成」。
* 同一个用户动作(都是"标成完成")两条路径两种结果,用户复现的就是这条。
*
* ⇒ 变更后是 done 时,也把这一行从首页卡片里摘掉。
* 真正干活的在 app.js 的 `window.fbHomeTaskDrop()`:它自己判断
* "行在不在 `[data-tk-home-host]` 里",**不在就完全不碰**
* (任务看板页的行也在 `[data-task-row]` 上,那里必须保留该行)。
*
* ⚠️ `d.status` 是**父任务**的状态(`task_panel_json()` 用 `panel_of` 找的父任务,
* 见 app/helpers.php:506)。面板里勾的是**子任务**时,请求带 `panel_of`,
* 回来的 status 仍是父任务的 ⇒ 这里不会因为"子任务做完了"就删掉父任务那一行。
*
* ⚠️ 删行前先把 `wasStatus` 取出来:下面几行会改写 `data-tk-status`,
* 改完再读就只能读到 done,概述卡不知道该减「在做」还是「待办」。
*/ */
function syncRow(d) { function syncRow(d) {
if (!d || !d.status) { return; } if (!d || !d.status) { return; }
var row = document.querySelector('[data-task-row="' + taskId() + '"]'); var row = document.querySelector('[data-task-row="' + taskId() + '"]');
if (!row) { return; } if (!row) { return; }
/* 🔴 #79(用户 2026-11-05):「首页卡片没有刷新。」
根因:这个函数原来**只管状态徽标和复选框**,不碰标题。
而 #78 之后面板里能改标题了 ⇒ 改完标题点保存,卡片上还是旧标题。
⇒ 补上标题同步:`.dash-what` 的文本 + 它的 `title` 属性
(`title` 是鼠标悬停提示,不同步会出现"悬停是旧标题"的怪事)。
⚠️ 只在服务端真的回了 `title` 时才改 —— `task_panel_json()` 目前
不带这个字段(见 app/helpers.php),所以下面还有一道兜底:
从当前面板的输入框里读。两条路任一命中即可。 */
var newTitle = (typeof d.title === 'string' && d.title !== '') ? d.title : '';
if (newTitle === '') {
var ti = bodyEl() ? bodyEl().querySelector('#tkp-title') : null;
if (ti) { newTitle = String(ti.value || '').trim(); }
}
if (newTitle !== '') {
var what = row.querySelector('.dash-what');
if (what) {
what.textContent = newTitle;
what.setAttribute('title', newTitle);
}
/* 浮层下次打开时读的是 `data-fb-entry` 里的 title —— 一并改掉,
否则"卡片上是新标题、再点开浮层显示旧标题"。 */
if (what && what.getAttribute('data-fb-entry')) {
try {
var ent = JSON.parse(what.getAttribute('data-fb-entry'));
ent.title = newTitle;
what.setAttribute('data-fb-entry', JSON.stringify(ent));
} catch (e) { /* JSON 坏了就跳过,不阻断 */ }
}
}
/* 🔴 必须在改写 `data-tk-status` **之前**读 —— 见上面最后一条 ⚠️。 */
var chk0 = row.querySelector('.tk-check[data-tk-done]');
var was = chk0 ? (chk0.getAttribute('data-tk-status') || 'todo') : '';
var badge = row.querySelector('.kb-st'); var badge = row.querySelector('.kb-st');
if (badge) { if (badge) {
badge.className = 'kb-st is-' + d.status + ' is-static'; badge.className = 'kb-st is-' + d.status + ' is-static';
if (d.word) { badge.textContent = d.word; } if (d.word) { badge.textContent = d.word; }
} }
var chk = row.querySelector('.tk-check[data-tk-done]'); if (chk0) {
if (chk) { chk0.setAttribute('data-tk-status', d.status);
chk.setAttribute('data-tk-status', d.status); chk0.classList.toggle('is-done', d.status === 'done');
chk.classList.toggle('is-done', d.status === 'done'); chk0.setAttribute('aria-pressed', d.status === 'done' ? 'true' : 'false');
chk.setAttribute('aria-pressed', d.status === 'done' ? 'true' : 'false'); }
/* 标成完成 ⇒ 从首页「近期任务」里摘掉(工单 #20)。
⚠️ 用服务端回的 `d.status`,不是"我点之前猜的 next"。 */
if (d.status === 'done' && typeof window.fbHomeTaskDrop === 'function') {
window.fbHomeTaskDrop(row, was);
} }
} }
@@ -112,7 +193,13 @@
* @param {FormData} fd 已经带好 `_csrf` / `ajax_panel` 的请求体 * @param {FormData} fd 已经带好 `_csrf` / `ajax_panel` 的请求体
* @param {boolean} keepDraft 换面板时要不要保住用户正在打的字(传附件时为 true) * @param {boolean} keepDraft 换面板时要不要保住用户正在打的字(传附件时为 true)
*/ */
function send(url, fd, keepDraft) { /**
* 发请求并刷新面板。
*
* @param keepDraft #78:重画后保住用户没发出去的评论草稿(传附件时用)
* @param closeOnOk #78:成功后**关掉浮层**(「保存」用;其它动作不关)
*/
function send(url, fd, keepDraft, closeOnOk) {
if (!url) { setTip('这一项暂时不能在首页改。', true); return; } if (!url) { setTip('这一项暂时不能在首页改。', true); return; }
if (!window.fetch || !window.FormData) { window.location.reload(); return; } if (!window.fetch || !window.FormData) { window.location.reload(); return; }
fetch(url, { fetch(url, {
@@ -124,6 +211,20 @@
return r.json().catch(function () { return null; }); return r.json().catch(function () { return null; });
}).then(function (d) { }).then(function (d) {
if (!d) { window.location.reload(); return; } // 会话过期 / 500:整页重载最诚实 if (!d) { window.location.reload(); return; } // 会话过期 / 500:整页重载最诚实
/* 🔴 #78(用户 2026-11-05):「点击保存之后,按道理说应该直接把这个窗口关闭才对呀。」
⇒ 保存成功就关窗,不再重画面板(用户已经不在看了)。
⚠️ 关窗前**先更新背后那张首页卡片**:否则用户关掉浮层看到的还是旧标题/旧状态,
"改了没生效"的错觉比不关窗更糟。
⚠️ 失败(d.ok === false)时**不关** —— 让用户看到错误提示、就地改。 */
if (closeOnOk && d.ok !== false) {
syncRow(d);
if (typeof window.fbEntryClose === 'function') {
window.fbEntryClose();
} else if (typeof window.fbCloseEntry === 'function') {
window.fbCloseEntry();
}
return;
}
paint(d, keepDraft); paint(d, keepDraft);
syncRow(d); syncRow(d);
setTip(d.msg || '', !d.ok); setTip(d.msg || '', !d.ok);
@@ -131,13 +232,23 @@
} }
/** 把「id + 几个附加字段」包成一个动作请求 */ /** 把「id + 几个附加字段」包成一个动作请求 */
function act(url, extra) { /**
* 发一个面板动作。
*
* @param url 目标地址
* @param extra 额外字段
* @param closeOnOk #78:成功后**关掉浮层**(只有「保存」用)。
* 用户 2026-11-05:「点击保存之后,按道理说应该直接把这个窗口关闭才对呀。」
* ⚠️ 只给「保存」用:改状态 / 挪看板 / 加子任务 / 发评论 / 传附件
* 都还要继续在这个面板里操作,关窗会把用户赶走。
*/
function act(url, extra, closeOnOk) {
var fd = new FormData(); var fd = new FormData();
fd.append('_csrf', csrf()); fd.append('_csrf', csrf());
fd.append('id', taskId()); fd.append('id', taskId());
fd.append('ajax_panel', '1'); fd.append('ajax_panel', '1');
Object.keys(extra || {}).forEach(function (k) { fd.append(k, extra[k]); }); Object.keys(extra || {}).forEach(function (k) { fd.append(k, extra[k]); });
send(url, fd); send(url, fd, false, closeOnOk);
} }
/* ---- ① 表单提交(加子任务 / 发评论 / 删附件)------------------------------ /* ---- ① 表单提交(加子任务 / 发评论 / 删附件)------------------------------
@@ -193,6 +304,180 @@
act(urlOf('move'), { board: btn.getAttribute('data-board') || '0' }); act(urlOf('move'), { board: btn.getAttribute('data-board') || '0' });
return; return;
} }
/* 🔴 #76(用户 2026-11-05):「时间控件是对的,但是现在没有保存按钮…
另外,负责人和分类都没法修改,按道理讲都应该可以修改才对。」
⇒ 「截止 / 负责人 / 分类」三项一次性提交。
⚠️ 上一版是"选完截止时间立即保存"(监听 change),用户不认可 ——
他要的是"改完点保存"这种确定感。
⚠️ 只提交这三项,服务端 `Task::setAttrs()` 也只写这三列,
不碰 title/note/status/repeat(见那个方法的注释)。 */
/**
* 读出「截止时间」框当前值 → `YYYY-MM-DD HH:mm:ss`(#77)。
*
* 🔴 用户 2026-11-05:「截止日期的时间保存有bug,时和分没保存上。」
*
* 为什么不能只读 `.dt-hour` / `.dt-minute`:
* AntD 一旦挂上(`.dt-input.is-antd`),`.dt-native` 被 CSS 收成
* `display:none`。那几档原生 select 里的值是**控件写回来的镜像**,
* 而镜像的写入依赖 `mountAntdSingleDT.writeBack()`:
* onChange(val, str) → writeDT(box, str)
* `str` 来自 antd 的 `format: 'YYYY-MM-DD HH:mm'`。
* 实测 antd 5 在**受控**模式下(我们传了 `value`),
* 某些路径给的是 `val`(dayjs 对象)而 `str` 可能是空串 ——
* 此时 writeDT 走进 else 分支,把 .dt-date/.dt-hour/.dt-minute **全清成空/00**。
* ⇒ 日期还有值(另一条路径补齐了)、时分却恒为 00,正是用户报的现象。
*
* ⇒ 读值时**以用户看得见的那个框为准**:
* ① 先看 AntD 的可见 input(`.ant-picker-input > input`)里的文本,
* 它的格式就是 `YYYY-MM-DD HH:mm`;
* ② 没有 AntD(降级成原生下拉)时才读隐藏字段;
* ③ 都没有就当空。
*
* ⚠️ AntD 的输入框是**只读文本框**(用户通过弹层选,不直接打字),
* 所以解析它的 text 是安全的 —— 不会出现用户在框里敲了半截日期的中间态。
*/
/**
* 替用户点掉 AntD 弹层的「确定」(#77)。
*
* 🔴 为什么必须点:antd 的 `DatePicker` + `showTime` **只有点它内部的
* 「确定」才会提交值**。点别处(包括点我们面板的「保存」)会被当成取消,
* 值**弹回上一次确认的值**。
* 实测:选完 2026-11-16 20:34(未点确定)→ 点 body → 50ms 后
* 可见框弹回 "2026-11-07 00:00",onChange 一次都没触发。
* 这正是用户报的「时和分没保存上」(库里变成 00:00:00)。
*
* ⚠️ 只在弹层真的开着时才点(`:not(.ant-picker-dropdown-hidden)`),
* 否则会点到别的东西。
*/
function commitAntd() {
var drop = document.querySelector('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)');
if (!drop) { return false; }
var okBtn = drop.querySelector('.ant-picker-ok button');
if (okBtn && !okBtn.disabled) {
okBtn.click();
return true;
}
return false;
}
function readDue(box) {
if (!box) { return ''; }
/* 🔴🔴 第 ⓪ 步(**最可靠**,优先于其它一切):读 antd 的 onChange 直挂值。
`app.js` 的 `mountAntdSingleDT.writeBack()` 现在会把每次 onChange 拿到的
字符串写到 `box[data-fb-dt-value]`。那是"控件刚刚确认的值",
**不经任何镜像、不受弹层是否关闭影响**。
实测过的那两种读法都有坑:
· 读 .dt-hour/.dt-minute(镜像)→ 用户没点弹层「确定」时停在旧值/00;
· 读可见框 → 弹层未提交时是半截值(20:34 而不是 20:30)。
⇒ 有 data-fb-dt-value 就用它,一步到位。 */
var dv = box.getAttribute('data-fb-dt-value');
if (dv !== null) {
var dvt = String(dv).trim();
if (dvt === '') { return ''; } /* 用户清空了 */
var dmv = dvt.match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/);
if (dmv) {
var pd = function (n) { return (n < 10 ? '0' : '') + n; };
return dmv[1] + '-' + pd(+dmv[2]) + '-' + pd(+dmv[3]) + ' '
+ pd(+dmv[4]) + ':' + pd(+dmv[5]) + ':' + pd(+(dmv[6] || 0));
}
var dmd = dvt.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (dmd) {
var pd2 = function (n) { return (n < 10 ? '0' : '') + n; };
return dmd[1] + '-' + pd2(+dmd[2]) + '-' + pd2(+dmd[3]) + ' 00:00:00';
}
}
/* ⚠️ 「点确定」不在这里做 —— 它是 `commitAntd()` + 等一帧之后才读。
原因见 save-attrs 分支上的长注释:antd 的 setState→重渲染在微任务里,
同步读拿到的是提交前那一帧。 */
/* ① AntD 可见框 */
var vis = box.querySelector('.ant-picker-input > input');
if (vis) {
var vt = String(vis.value || '').trim();
if (vt !== '') {
/* 已选:'2026-12-31 20:00' 或 '2026-12-31 20:00:00' */
var mv = vt.match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/);
if (mv) {
var p2 = function (n) { return (n < 10 ? '0' : '') + n; };
return mv[1] + '-' + p2(+mv[2]) + '-' + p2(+mv[3]) + ' '
+ p2(+mv[4]) + ':' + p2(+mv[5]) + ':' + p2(+(mv[6] || 0));
}
/* 只有日期没时间(用户只选了日子就点保存):补 00:00 */
var md = vt.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (md) {
var p3 = function (n) { return (n < 10 ? '0' : '') + n; };
return md[1] + '-' + p3(+md[2]) + '-' + p3(+md[3]) + ' 00:00:00';
}
}
/* AntD 框存在但空着 = 用户清空了截止时间 */
return '';
}
/* ② 降级形态:读隐藏字段(与 app.js 的 readDT 同一口径) */
var dt = box.querySelector('.dt-date');
var dstr = dt ? String(dt.value || '').trim() : '';
if (dstr === '') { return ''; }
var hh = box.querySelector('.dt-hour');
var mm = box.querySelector('.dt-minute');
var h2 = hh && String(hh.value || '').trim() !== '' ? String(hh.value) : '00';
var m2 = mm && String(mm.value || '').trim() !== '' ? String(mm.value) : '00';
return dstr + ' ' + h2 + ':' + m2 + ':00';
}
if (what === 'save-attrs') {
e.preventDefault();
if (btn.disabled) { return; }
var bd = bodyEl();
if (!bd) { return; }
var dbox = bd.querySelector('.dt-input[data-dt]');
var asg = bd.querySelector('select[name="assignee_id"]');
var cat = bd.querySelector('select[name="category_id"]');
btn.disabled = true;
/* 🔴🔴 保存前的两步(顺序不能变):
① `commitAntd()` 替用户点掉 antd 弹层的「确定」—— 不点就**弹回原值**;
② **等一帧再读** —— antd 的 onChange → setState → 重渲染落在微任务/下一帧,
同步读拿到的是提交前那一帧(实测差几分钟,或者干脆是旧值)。
实测对照(Playwright + 真实 antd):
不点确定、点保存 → readDue="2026-11-07 00:00:00"(原值,修改丢失)
点确定后等 60ms → readDue="2026-11-16 20:30:00" ✅
60ms 用户完全无感,足够 React 把一帧画完 + writeBack 写好镜像。 */
commitAntd();
setTimeout(function () {
var extra = {};
extra['due_at'] = readDue(dbox);
/* ⚠️ 只有控件真的在 DOM 里才提交该项 —— 控件不渲染时不该把原值清掉。 */
if (asg) { extra['assignee_id'] = asg.value; }
if (cat) { extra['category_id'] = cat.value; }
/* #78:标题 / 备注 —— 删掉「编辑」按钮后,它们只能在这里改 */
var ti = bd.querySelector('#tkp-title');
if (ti) { extra['title'] = ti.value; }
var nt = bd.querySelector('#tkp-note');
if (nt) { extra['note'] = nt.value; }
/* #78:协作人(`_person_picker.php` 的 multi 选人把结果存在
隐藏 checkbox 里,name="shares[]")。逐个收集。
⚠️ 必须带 `shares_submitted=1` 这个哨兵:
服务端靠它区分"表单里根本没有协作人控件"(不动)与
"用户把它们全删了"(清空)—— 只靠 `shares[]` 是否存在,
空数组时 PHP 收不到任何键,无法区分。 */
var shareBox = bd.querySelector('input[name="shares[]"]');
if (shareBox) {
extra['shares_submitted'] = '1';
var picked = bd.querySelectorAll('input[name="shares[]"]:checked');
for (var i = 0; i < picked.length; i++) {
extra['shares[' + i + ']'] = picked[i].value;
}
}
act(urlOf('save'), extra, true);
}, 60);
/* 面板会被整块换掉(act → send → paint),按钮随之重建,不用手动恢复 */
setTimeout(function () { btn.disabled = false; }, 4000);
return;
}
if (what === 'cmt-del') { if (what === 'cmt-del') {
e.preventDefault(); e.preventDefault();
if (!window.confirm('删掉这条留言?')) { return; } if (!window.confirm('删掉这条留言?')) { return; }
@@ -239,6 +524,144 @@
if (inp) { inp.click(); } if (inp) { inp.click(); }
}); });
/* ============================================================
#79(用户 2026-11-05):「评论框下面的附件也需要可以拖动添加。」
与上面「任务附件」的拖拽是**两条独立的路**,别混:
· `[data-tk-drop-box]` = 点了/拖了**立刻上传**(任务已经存在,传上去就生效);
· `[data-cmt-drop]` = 把文件挂到评论表单上,**点「发表」才一起提交**
(评论是先写文字再发,附件跟着这条评论走)。
🔴 核心技术点:`<input type=file>.files` 是**只读**的,
不能 `input.files = event.dataTransfer.files`(直接赋值在某些浏览器
会静默失败)。必须用 `DataTransfer` 重建一个 FileList 赋回去:
var dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files; ← 这个赋值是允许的
而且**必须真的赋给 input**,否则只是"看起来加上了"、
表单提交时浏览器一个文件都不带。
⚠️ 支持**多次拖入累加**:用户可能先拖一张再拖一张。
每次取 `input.files` 现有内容 + 新拖进来的,合并后写回。
⚠️ 同一文件重复拖入要跳过(按 name+size+lastModified 判重),
否则会出现"我明明只拖了一次,传上去两张一样的"。
============================================================ */
function cmtDropZone(e) {
var t = e.target;
if (!t || !t.closest) { return null; }
return t.closest('[data-cmt-drop]');
}
function cmtFileInput(zone) {
return zone ? zone.querySelector('[data-cmt-file-input]') : null;
}
/** 把 files 合并进 input(累加 + 去重),并更新旁边那行文件名提示 */
function cmtAddFiles(zone, files) {
var input = cmtFileInput(zone);
if (!input || !files || !files.length) { return; }
var dt;
try { dt = new DataTransfer(); } catch (e) { return; } // 老浏览器:放弃,用户仍可点选
/* 先把已有的放进去(累加语义) */
var seen = {};
var add = function (f) {
if (!f) { return; }
var key = f.name + '|' + f.size + '|' + (f.lastModified || 0);
if (seen[key]) { return; }
seen[key] = 1;
dt.items.add(f);
};
if (input.files) {
for (var i = 0; i < input.files.length; i++) { add(input.files[i]); }
}
for (var j = 0; j < files.length; j++) { add(files[j]); }
try { input.files = dt.files; } catch (e) { /* 个别内核只读:下面提示照旧 */ }
/* 更新已选文件列表(用户要能确认"确实加上了哪几个")。
#81:从"一行逗号拼接"改成"一行一个" —— 拖 5 个文件时
逗号拼起来读不清,也看不出到底几个。 */
cmtRenderNames(zone, input);
}
/**
* 把 `input.files` 渲染成一行一个的列表(#81)。
*
* ⚠️ 用 `textContent` 逐个 append,**不要** `innerHTML +=` 拼字符串 ——
* 文件名是用户可控的(`<img onerror=...>.jpg` 这种),拼进 innerHTML
* 就是 XSS。用 `document.createElement('span')` + `textContent` 才安全。
* ⚠️ 列表为空时 `hidden`,非空才显示 —— 否则评论区永远挂着一块空灰条。
*/
function cmtRenderNames(zone, input) {
var box = zone.querySelector('[data-cmt-file-name]');
if (!box) { return; }
while (box.firstChild) { box.removeChild(box.firstChild); }
var n = input && input.files ? input.files.length : 0;
if (!n) {
box.hidden = true;
return;
}
for (var i = 0; i < n; i++) {
var sp = document.createElement('span');
sp.textContent = input.files[i].name;
box.appendChild(sp);
}
box.hidden = false;
}
/* dragover:必须 preventDefault,否则浏览器把文件当"打开"处理(页面跳走) */
document.addEventListener('dragover', function (e) {
var zone = cmtDropZone(e);
if (!zone) { return; }
e.preventDefault();
zone.classList.add('is-over');
});
document.addEventListener('dragenter', function (e) {
var zone = cmtDropZone(e);
if (!zone) { return; }
e.preventDefault();
});
document.addEventListener('dragleave', function (e) {
var zone = cmtDropZone(e);
if (!zone) { return; }
/* 只有真的离开这片区域才去掉高亮(进出子元素也会触发 dragleave) */
if (!zone.contains(e.relatedTarget)) {
zone.classList.remove('is-over');
}
});
document.addEventListener('drop', function (e) {
var zone = cmtDropZone(e);
if (!zone) { return; }
e.preventDefault(); // 不拦的话浏览器会直接打开这个文件
zone.classList.remove('is-over');
var files = e.dataTransfer && e.dataTransfer.files;
cmtAddFiles(zone, files);
});
/* #81:新的上传框带 `role="button" tabindex="0"`(`.upbox` 的既有约定),
键盘 Enter/Space 也要能打开文件选择 —— 只做鼠标的话键盘用户点不动。 */
document.addEventListener('keydown', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var zone = t.closest('[data-cmt-drop]');
if (!zone) { return; }
if (e.key !== 'Enter' && e.key !== ' ') { return; }
e.preventDefault();
var inp = zone.querySelector('[data-cmt-file-input]');
if (inp) { inp.click(); }
});
/* 点选也要更新列表(原生 onchange)—— 与拖拽共用同一个渲染函数 */
document.addEventListener('change', function (e) {
var t = e.target;
if (!t || !t.hasAttribute || !t.hasAttribute('data-cmt-file-input')) { return; }
var zone = t.closest ? t.closest('[data-cmt-drop]') : null;
if (!zone) { return; }
cmtRenderNames(zone, t);
});
/* 拖到框上要给 dragover preventDefault,否则浏览器不认这是落点(还会当"打开文件") */ /* 拖到框上要给 dragover preventDefault,否则浏览器不认这是落点(还会当"打开文件") */
document.addEventListener('dragover', function (e) { document.addEventListener('dragover', function (e) {
var t = e.target; var t = e.target;
@@ -316,4 +739,5 @@
} }
step(); step();
} }
})(); })();
+263
View File
@@ -58,6 +58,7 @@ use App\CalDav;
use App\Calendar; use App\Calendar;
use App\Database; use App\Database;
use App\Ics; use App\Ics;
use App\TaskDav;
/** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */ /** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */
function dav_fail(int $code, string $msg): void function dav_fail(int $code, string $msg): void
@@ -317,6 +318,96 @@ $emit = static function (string $xmlns, array $parts): void {
// ---- 5. PROPFIND:发现链 + 清单 / 属性 / 资源列表 ---- // ---- 5. PROPFIND:发现链 + 清单 / 属性 / 资源列表 ----
if ($method === 'PROPFIND') { if ($method === 'PROPFIND') {
/* ============================================================
* 待办本(VTODO)—— 必须在原有日历逻辑之前拦下
* ============================================================
* /dav/tasks.ics/ 是本模块的虚拟本,不对应 calendar_events 的任何一行。
* 在这里前置拦截,原有日历分支一个字都不用改(零回归风险)。
*/
if (TaskDav::isTaskPath($path)) {
/* ============================================================
* 待办本(VTODO)—— 按分类多本,与参照 DAViCal 同构
* ============================================================
* 用户 2026-11-02 给了参照服务器并指出
* 「应该可以直接显示那个家庭的待办分类」。
* 参照实测:每个分类是独立的 calendar 集合,各自 comp 只含 VTODO。
*
* ⚠️ supported-calendar-component-set 必须是 VTODO:
* 给 VEVENT 的话客户端会按日程解析,VTODO 会被过滤掉不显示。
*/
$tHit = TaskDav::parse($path);
$tSeg = $tHit === null ? '' : (string)$tHit['seg'];
$tBook = TaskDav::resolveBook($familyId, $me, $tSeg);
if ($tBook === null) {
dav_fail(404, '没有这本待办。');
}
$tCatId = (int)$tBook['id'];
$tDepth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
$taskEmit = static function (string $xmlns, array $items): void {
header('Content-Type: application/xml; charset=utf-8');
http_response_code(207);
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
. '<d:multistatus ' . $xmlns . '>' . "\n"
. implode("\n", $items) . "\n"
. '</d:multistatus>';
exit;
};
if ($tDepth !== '1') {
// Depth:0 —— 这一本自身的属性
$ctag = TaskDav::ctag($familyId, $me, $tCatId);
$taskEmit(
'xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:cs="http://calendarserver.org/ns/" '
. 'xmlns:x="http://apple.com/ns/ical/"',
[
'<d:response><d:href>' . CalDav::x($tBook['href']) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x($tBook['name']) . '</d:displayname>'
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
. '<c:calendar-description>' . CalDav::x('岁月家 · 待办 · ' . $tBook['name']) . '</c:calendar-description>'
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x($tBook['color']) . '</x:calendar-color>'
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
. '<cs:getctag>' . CalDav::x($ctag) . '</cs:getctag>'
. '<d:getctag>' . CalDav::x($ctag) . '</d:getctag>'
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:current-user-principal>'
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
. '<d:current-user-privilege-set><d:privilege><d:all/></d:privilege></d:current-user-privilege-set>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]
);
}
/* Depth:1 —— 本 + 里面的资源清单。
⚠️ href 必须与 GET/REPORT 用同一个 segmentOf 算,
三处不一致的话客户端会反复重拉(日历那边踩过)。 */
$items = [
'<d:response><d:href>' . CalDav::x($tBook['href']) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x($tBook['name']) . '</d:displayname>'
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
];
foreach (TaskDav::tasksIn($familyId, $me, $tCatId) as $t) {
$seg = TaskDav::segmentOf($t);
if ($seg === '') {
continue;
}
$etag = TaskDav::resourceOf($t, $tBook['name'])['etag'];
$items[] = '<d:response><d:href>'
. CalDav::x($tBook['href'] . $seg . '.ics') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CalDav::x($etag) . '</d:getetag>'
. '<d:resourcetype/>'
. '<d:getcontenttype>text/calendar; charset=utf-8</d:getcontenttype>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
$taskEmit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $items);
}
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0'); $depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
// 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal。 // 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal。
@@ -464,6 +555,27 @@ if ($method === 'PROPFIND') {
. '</d:href></c:calendar-home-set>' . '</d:href></c:calendar-home-set>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>'); . '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>');
} }
/* 待办本:**按分类拆成多本**(与参照 DAViCal 同构)。
用户 2026-11-02:「应该可以直接显示那个家庭的待办分类」。
参照实测:每个分类是独立 calendar 集合,客户端里并列显示为多个待办列表。
没有这一整批,客户端 PROPFIND /dav/ 时根本不知道有待办,也就不会去拉。 */
foreach (TaskDav::books($familyId, $me) as $tb) {
$tCtag = TaskDav::ctag($familyId, $me, (int)$tb['id']);
$parts[] = '<d:response><d:href>' . CalDav::x($tb['href']) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x($tb['name']) . '</d:displayname>'
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
. '<c:calendar-description>' . CalDav::x('岁月家 · 待办 · ' . $tb['name']) . '</c:calendar-description>'
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x($tb['color']) . '</x:calendar-color>'
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
. '<cs:getctag>' . CalDav::x($tCtag) . '</cs:getctag>'
. '<d:getctag>' . CalDav::x($tCtag) . '</d:getctag>'
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:current-user-principal>'
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" ' $emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:x="http://apple.com/ns/ical/" ' . 'xmlns:x="http://apple.com/ns/ical/" '
. 'xmlns:cs="http://calendarserver.org/ns/"', $parts); . 'xmlns:cs="http://calendarserver.org/ns/"', $parts);
@@ -522,6 +634,48 @@ if ($method === 'PROPFIND') {
// ---- 6. GET / HEAD:整本导出成 ics,或单条资源 ---- // ---- 6. GET / HEAD:整本导出成 ics,或单条资源 ----
if ($method === 'GET' || $method === 'HEAD') { if ($method === 'GET' || $method === 'HEAD') {
/* 待办本:整本导出,或单条 VTODO。
⚠️ 单条必须校验资源属于**这一本**且本用户可见,
否则改 URL 就能读到别人的任务(越权)。 */
if (TaskDav::isTaskPath($path)) {
$tHit = TaskDav::parse($path);
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
if ($tBook === null) {
dav_fail(404, '没有这本待办。');
}
$tCatId = (int)$tBook['id'];
$tSub = (string)($tHit['sub'] ?? '');
if ($tSub !== '') {
$tParsed = TaskDav::idOfSegment($tSub);
$tOne = $tParsed > 0 ? TaskDav::one($familyId, $me, $tParsed) : null;
// ⚠️ 还要确认这条**确实属于这一本**,否则可用 A 本的 href 读到 B 本的任务
if ($tOne === null) {
dav_fail(404, '没有这条待办。');
}
$tOwnCat = (int)($tOne['category_id'] ?? 0);
$tExpectCat = $tCatId === -1 ? 0 : $tCatId;
if ($tOwnCat !== $tExpectCat) {
dav_fail(404, '这条待办不在这本里。');
}
$tRes = TaskDav::resourceOf($tOne, $tBook['name']);
header('Content-Type: text/calendar; charset=utf-8');
header('ETag: ' . $tRes['etag']);
if ($method !== 'HEAD') {
echo $tRes['ics'];
}
exit;
}
// 整本
header('Content-Type: text/calendar; charset=utf-8');
header('ETag: ' . TaskDav::ctag($familyId, $me, $tCatId));
if ($method !== 'HEAD') {
echo TaskDav::ics($familyId, $me, $tCatId, $tBook['name']);
}
exit;
}
/* 🔴🔴 v45b(2026-10-12 用户「TB 还是找不到日历」)——**真 bug 修在这里**。 /* 🔴🔴 v45b(2026-10-12 用户「TB 还是找不到日历」)——**真 bug 修在这里**。
原来这一段是: 原来这一段是:
if (!$cal) { dav_fail(404, '没有这本日历。'); } if (!$cal) { dav_fail(404, '没有这本日历。'); }
@@ -596,6 +750,53 @@ if ($method === 'GET' || $method === 'HEAD') {
// ---- 7. REPORT:calendar-multiget(按 href 取) / calendar-query(整本或按时间范围)---- // ---- 7. REPORT:calendar-multiget(按 href 取) / calendar-query(整本或按时间范围)----
if ($method === 'REPORT') { if ($method === 'REPORT') {
/* 待办本:calendar-query / calendar-multiget 都返回 VTODO。
⚠️ 支持 multiget 按 href 过滤 —— 整本回在条目多时是纯浪费。 */
if (TaskDav::isTaskPath($path)) {
$tHit = TaskDav::parse($path);
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
if ($tBook === null) {
dav_fail(404, '没有这本待办。');
}
$tCatId = (int)$tBook['id'];
$tBody = (string)file_get_contents('php://input');
$tWantsMulti = stripos($tBody, 'calendar-multiget') !== false;
$tWant = [];
if ($tWantsMulti && preg_match_all('#<[^>]*href[^>]*>([^<]+)</[^>]*href>#i', $tBody, $mm)) {
foreach ($mm[1] as $h) {
$h = trim(html_entity_decode($h, ENT_QUOTES | ENT_XML1, 'UTF-8'));
$seg = preg_replace('/\.ics$/', '', (string)basename(rtrim($h, '/')));
if ($seg !== '') {
$tWant[$seg] = true;
}
}
}
$tItems = [];
foreach (TaskDav::tasksIn($familyId, $me, $tCatId) as $t) {
$seg = TaskDav::segmentOf($t);
if ($seg === '' || ($tWantsMulti && !isset($tWant[$seg]))) {
continue;
}
$res = TaskDav::resourceOf($t, $tBook['name']);
$tItems[] = '<d:response><d:href>'
. CalDav::x($tBook['href'] . $seg . '.ics') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CalDav::x($res['etag']) . '</d:getetag>'
. '<c:calendar-data>' . CalDav::x($res['ics']) . '</c:calendar-data>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
header('Content-Type: application/xml; charset=utf-8');
http_response_code(207);
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">' . "\n"
. implode("\n", $tItems) . "\n"
. '</d:multistatus>';
exit;
}
/* 🔴 v45b:REPORT 打到 **home 容器**(`/dav/`,bookId=0)时 —— /* 🔴 v45b:REPORT 打到 **home 容器**(`/dav/`,bookId=0)时 ——
照参照(DAViCal)回 **403**,不回 404。 照参照(DAViCal)回 **403**,不回 404。
参照实测:`REPORT /caldav/<user>/` → `403 Forbidden`(不是 404)。 参照实测:`REPORT /caldav/<user>/` → `403 Forbidden`(不是 404)。
@@ -706,6 +907,51 @@ if ($method === 'REPORT') {
// 函数,所以"手机上建的"与"网页里导入的"结果完全一致(UID 去重、重复规则、 // 函数,所以"手机上建的"与"网页里导入的"结果完全一致(UID 去重、重复规则、
// 提醒、全天与否都由它统一处理)。**绝不在 DAV 里另写一套 INSERT**。 // 提醒、全天与否都由它统一处理)。**绝不在 DAV 里另写一套 INSERT**。
if ($method === 'PUT') { if ($method === 'PUT') {
/* 待办本:新建 / 更新一条 VTODO。
⚠️ 路径里有段 = 更新那一条;没有段 = 新建(客户端会给新资源自己编名字)。
⚠️ PUT 到不存在的资源名是**创建**(RFC 4918 §9.7),
由 TaskDav::put 内部判断,这里不预先拒绝。 */
if (TaskDav::isTaskPath($path)) {
$tHit = TaskDav::parse($path);
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
if ($tBook === null) {
dav_fail(404, '没有这本待办。');
}
$tCatId = (int)$tBook['id'];
$tBody = (string)file_get_contents('php://input');
if (trim($tBody) === '') {
dav_fail(400, '正文是空的。');
}
$tCtype = (string)($_SERVER['CONTENT_TYPE'] ?? '');
if ($tCtype !== '' && stripos($tCtype, 'calendar') === false
&& stripos($tCtype, 'text/plain') === false) {
dav_fail(415, '只接受 text/calendar 正文。');
}
$tTaskId = TaskDav::idOfSegment((string)($tHit['sub'] ?? ''));
try {
$tRes2 = TaskDav::put($familyId, $me, $tCatId, $tTaskId, $tBody);
} catch (RuntimeException $e) {
dav_fail(403, $e->getMessage());
}
$tSaved = TaskDav::one($familyId, $me, (int)$tRes2['id']);
if (!$tSaved) {
dav_fail(500, '写入后读取失败。');
}
$tRes3 = TaskDav::resourceOf($tSaved, $tBook['name']);
$tNewHref = $tBook['href'] . TaskDav::segmentOf($tSaved) . '.ics';
http_response_code($tRes2['created'] ? 201 : 204);
header('ETag: ' . $tRes3['etag']);
if ($tRes2['created']) {
header('Location: ' . $tNewHref);
}
exit;
}
if (!$cal) { if (!$cal) {
dav_fail(404, '没有这本日历。'); dav_fail(404, '没有这本日历。');
} }
@@ -898,6 +1144,23 @@ if ($method === 'PUT') {
// ⚠️ 覆盖行与 EXDATE 都是 `calendar_events` 的子表(FK ON DELETE CASCADE), // ⚠️ 覆盖行与 EXDATE 都是 `calendar_events` 的子表(FK ON DELETE CASCADE),
// 所以这一句 DELETE 会把它们一并带走,不需要额外清理。 // 所以这一句 DELETE 会把它们一并带走,不需要额外清理。
if ($method === 'DELETE') { if ($method === 'DELETE') {
if (TaskDav::isTaskPath($path)) {
$tHit = TaskDav::parse($path);
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
if ($tBook === null) {
dav_fail(404, '没有这本待办。');
}
$tTaskId = TaskDav::idOfSegment((string)($tHit['sub'] ?? ''));
if ($tTaskId <= 0) {
dav_fail(400, '要指定是哪一条待办(如 /dav/tasks-3.ics/task-5.ics)。');
}
if (!TaskDav::delete($familyId, $me, $tTaskId)) {
dav_fail(404, '没有这条待办,或你没有删除权限。');
}
http_response_code(204);
exit;
}
if (!$cal) { if (!$cal) {
dav_fail(404, '没有这本日历。'); dav_fail(404, '没有这本日历。');
} }
+859 -126
View File
File diff suppressed because it is too large Load Diff
+96 -5
View File
@@ -41,6 +41,59 @@ function media_fail(int $code, string $message): void
} }
$key = (string)($_GET['k'] ?? ''); $key = (string)($_GET['k'] ?? '');
/* 🔴 #56(用户 2026-11-05):「OSS在获取图片的时候,好像可以传一个参数,
它会给你返回一张压缩过的图片文件…如果可行的话,就用OSS的压缩就行了。」
`?w=400` ⇒ 让 OSS 实时返回压缩版(`x-oss-process=image/resize,l_400`)。
⚠️ **白名单**而不是透传:OSS 图片处理**按次计费**(约 ¥0.0005/次),
若放任 `?w=` 任意值,`?w=1`、`?w=9999` 这种请求会让处理次数暴涨。
只允许下面这几档 —— 覆盖实际用途,且数量可控。
⚠️ 不带 `w` ⇒ 原图,**行为完全不变**(300+ 处既有 `<img>` 不受影响)。
⚠️ 本地存储的图片:OSS 档位参数**自动失效**(Storage::read 里判驱动),
用户明确要求「本地的不不管它」。 */
$imgW = (int)($_GET['w'] ?? 0);
/**
* 允许的尺寸档位 ⇒ OSS 处理参数。
*
* 🔴 #56 修正(用户 2026-11-05):「400压缩的太多了。800也不够,
* 只保留一个1600。」
*
* ⇒ 只留 1600。原来 400/800 两档删掉 —— 实测 400 只有 26KB,
* 在手机/平板上看已经很糊;800 也不够清。
* 用户宁可图大一点,也要看得清。
*
* ⚠️ 删掉这两档后,仍传 `?w=400` 的旧链接会落到"非法值 ⇒ 原图"分支,
* **不会报错**,只是不压缩。所以旧链接是安全的,只是不再省流量。
* ⚠️ 1600 的质量提到 **q_88**(从 85):既然只保留一档,
* 就该在"省流量"和"看得清"之间偏向前者之外的一侧。
*/
$OssSizes = [
1600 => 'image/resize,l_1600/quality,q_88',
];
if (!array_key_exists($imgW, $OssSizes)) {
$imgW = 0; // 非法值一律按原图处理
}
/**
* 这个 key 是图片吗(#56)。
*
* ⚠️ **必须在 Storage::read 之前算好** —— 压缩参数只允许给图片,
* 给视频传 `x-oss-process=image/...` 会让 OSS 返回错误内容,
* 表现就是"视频播不了"。(这就是 2026-11-05 那次事故的根因。)
*
* ⚠️ 除了路径前缀,再看扩展名兜底:`files/xxx.mp4` 不在前缀里,
* 但万一将来有图片挪到别的目录,扩展名还能挡住视频。
*/
$isImageKey = (bool)preg_match('#^avatars/|^bookcovers/|^posters/|^photos/|^tickets/#', $key);
if (!$isImageKey && preg_match('#\.(jpe?g|png|gif|webp|bmp|avif)$#i', $key)) {
$isImageKey = true;
}
/* 视频/文档扩展名强制非图片(防止上面的扩展名兜底误判) */
if (preg_match('#\.(mp4|mov|m4v|webm|avi|mkv|mp3|m4a|pdf|docx?|xlsx?|pptx?|rar|zip)$#i', $key)) {
$isImageKey = false;
}
$stageTok = (string)($_GET['stage'] ?? ''); $stageTok = (string)($_GET['stage'] ?? '');
/* /*
@@ -154,7 +207,6 @@ foreach (explode('/', $key) as $seg) {
不让缓存无限长大把磁盘吃满。 不让缓存无限长大把磁盘吃满。
⚠️ 顺序不能反:先放锁、再取内容(取内容可能要几百毫秒)。 */ ⚠️ 顺序不能反:先放锁、再取内容(取内容可能要几百毫秒)。 */
$isImageKey = (bool)preg_match('#^avatars/|^bookcovers/|^posters/|^photos/|^tickets/#', $key);
if ($isImageKey) { if ($isImageKey) {
// 图片是"只读、可重复取、可静态化"的,两次都用不到会话写权限 // 图片是"只读、可重复取、可静态化"的,两次都用不到会话写权限
if (session_status() === PHP_SESSION_ACTIVE) { if (session_status() === PHP_SESSION_ACTIVE) {
@@ -162,14 +214,33 @@ if ($isImageKey) {
} }
} }
$res = Storage::read($familyId, $key); /* 🔴🔴 #56 严重修复(用户 2026-11-05:「你好像把 .mp4 后面也带上了问号…
是你更新了这个功能之后,出了这个问题」):
**图片压缩参数绝不能应用到视频上。**
原来的写法只看 `$imgW`,不看这是不是图片:
$res = Storage::read($familyId, $key, $imgW > 0 ? $OssSizes[$imgW] : '');
⇒ 视频请求带上 `?w=1600` 后,会给 **.mp4 对象**传
`x-oss-process=image/resize,l_1600` ⇒ OSS 图片处理服务
对非图片返回错误/空内容 ⇒ `<video>` 拿不到有效数据 ⇒ **播不了**。
⇒ 现与 `$isImageKey` 绑定:**只有图片才允许压缩**。
视频/PDF/文档一律走原样。 */
$res = Storage::read($familyId, $key,
($imgW > 0 && $isImageKey) ? $OssSizes[$imgW] : '');
$status = (int)($res['status'] ?? 0); $status = (int)($res['status'] ?? 0);
$body = (string)($res['body'] ?? ''); $body = (string)($res['body'] ?? '');
// 远程取到的图片回写本地(下一跳命中文件系统,省掉整个 OSS round-trip) /* 远程取到的图片回写本地(下一跳命中文件系统,省掉整个 OSS round-trip)
🔴 #56:**按尺寸分档存** —— 否则 400/800/1600 三档会互相覆盖,
用户看到的尺寸随机。原图($imgW=0)仍存原路径,行为不变。 */
if ($isImageKey && !empty($res['ok']) && $status === 200 if ($isImageKey && !empty($res['ok']) && $status === 200
&& ($res['from'] ?? '') !== 'local' && $body !== '') { && ($res['from'] ?? '') !== 'local' && $body !== '') {
Storage::cacheLocally($key, $body); $cacheKey = $imgW > 0 ? ('_proc/' . $imgW . '/' . $key) : $key;
Storage::cacheLocally($cacheKey, $body);
} }
if (empty($res['ok']) || $status < 200 || $status >= 300) { if (empty($res['ok']) || $status < 200 || $status >= 300) {
@@ -217,8 +288,25 @@ $etag = '"' . md5($key . '|' . $size . '|' . $mime) . '"';
v47 的缓存回写(见第 3 步)让第二次开始命中的是**本地文件**, v47 的缓存回写(见第 3 步)让第二次开始命中的是**本地文件**,
所以这里"取回来再判 304"的代价已经降到本地读盘级别,够用了。 所以这里"取回来再判 304"的代价已经降到本地读盘级别,够用了。
(真要连本地读都省掉,得把 ETag 单独持久化 —— 那是下一步的事。) */ (真要连本地读都省掉,得把 ETag 单独持久化 —— 那是下一步的事。) */
/* 🔴🔴 2026-11-05 修(用户:「网页端的视频…点开之后是黑的,没办法播放」):
**带 Range 的请求绝不能回 304**。
原来的顺序是"先判 304,再处理 Range"。视频第一次播完后浏览器缓存了 ETag,
拖动进度条 / 第二次打开时会同时发:
Range: bytes=1048576-
If-None-Match: "abc123"
⇒ 命中 304(304 **没有响应体**)⇒ `<video>` 拿不到数据 ⇒ **黑屏**。
这就是"有时候能播、有时候黑"的根因:取决于浏览器有没有带 ETag。
RFC 7233 也明确:304 不能替代对 Range 的响应。
⇒ 有 Range 就跳过 304,直接走第 6 步。 */
$hasRange = trim((string)($_SERVER['HTTP_RANGE'] ?? '')) !== '';
$inm = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')); $inm = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''));
if ($inm !== '' && $inm === $etag) { if (!$hasRange && $inm !== '' && $inm === $etag) {
http_response_code(304); http_response_code(304);
header('ETag: ' . $etag); header('ETag: ' . $etag);
header('Cache-Control: private, max-age=604800'); header('Cache-Control: private, max-age=604800');
@@ -261,6 +349,9 @@ if ($isRange) {
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size); header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
} }
header('Accept-Ranges: bytes'); header('Accept-Ranges: bytes');
/* 2026-11-05:补 Last-Modified —— 部分浏览器/Safari 需要它才允许 seek,
缺了会对某些 mp4 直接拒绝播放(表现为黑屏 + 无报错)。 */
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
$filename = basename($key); $filename = basename($key);
$disp = !empty($_GET['dl']) ? 'attachment' : 'inline'; $disp = !empty($_GET['dl']) ? 'attachment' : 'inline';
header('Content-Disposition: ' . $disp . '; filename="' . rawurlencode($filename) . '"'); header('Content-Disposition: ' . $disp . '; filename="' . rawurlencode($filename) . '"');
+15 -1
View File
@@ -49,7 +49,21 @@ $attDelUrl = $attDelUrl ?? '';
<?php <?php
$attKind = \App\Attachment::kind($attRow); $attKind = \App\Attachment::kind($attRow);
$canPrev = \App\Attachment::previewable($attRow); $canPrev = \App\Attachment::previewable($attRow);
$attView = media_url((string)$attRow['key']); /* 🔴🔴 #56(用户 2026-11-05:「你好像把 .mp4 后面也带上了问号,
写成了 1600 这样的参数…是你更新了这个功能之后,出了这个问题」)
**压缩档位只能给图片。** 原来这里对**所有附件**都传 `w=1600`:
视频 URL 变成 `media.php?k=xxx.mp4&w=1600` ⇒ OSS 收到
`x-oss-process=image/resize,l_1600` 去处理 .mp4 ⇒ 返回错误内容
⇒ `<video>` 拿不到有效数据 ⇒ **播不了**。
⇒ `$attKind === 'image'` 才传 `w`。
⚠️ `v`(版本号)**所有类型都传** —— 视频转码后靠它破缓存。
⚠️ 下载链接 `$attDl` 一律不带 `w`(要原文件,不能偷偷降质)。 */
$attOpts = $attKind === 'image' ? ['w' => 1600] : [];
$attView = media_url((string)$attRow['key'], $attOpts,
(int)($attRow['size'] ?? 0));
$attDl = media_url((string)$attRow['key'], ['dl' => 1]); $attDl = media_url((string)$attRow['key'], ['dl' => 1]);
$attNm = (string)($attRow['name'] ?? ''); $attNm = (string)($attRow['name'] ?? '');
$attSz = \App\Attachment::sizeWord($attRow['size'] ?? 0); $attSz = \App\Attachment::sizeWord($attRow['size'] ?? 0);
+15
View File
@@ -187,6 +187,21 @@ $evLab = static function (string $text, string $for = '', bool $req = false) use
⇒ 那句 `.field-hint` 整块删除(不再输出)。 */ ?> ⇒ 那句 `.field-hint` 整块删除(不再输出)。 */ ?>
<?php echo $evRowOpen(); ?> <?php echo $evRowOpen(); ?>
<?php /* 🔴 2026-11-05(用户:「全天那个应该是输入框,它应该往右和那个输入框对齐」):
观影式排版(`.mv-row`)里每一行的左侧都有一个 62px 的 `.mv-lb` 标签列,
控件从 62px 之后开始 —— 上面所有输入框的左边缘都在那条线上。
而「全天」这一行**没有标签**(它不需要文字标签,checkbox 自带「全天」二字),
`<label class="chk-inline">` 就直接贴在行的最左边(x=0),
比上面的输入框往左偏了 62px,看着像"错位了"。
⇒ 补一个**空的** `.mv-lb` 占位(`no-colon` 免得画出「:」)。
不用 `padding-left:62px` 的原因:那条 62px 是 `.mv-row > .mv-lb`
的 `flex:0 0 62px` 算出来的,而 `.mv-lb` 的宽度在窄屏媒体查询里
会变(见 app.css 的 6196 行附近),写死 padding 会跟它脱节。
用空标签则**天然跟随**同一套规则,改一处两边都对。
⚠️ 仅在观影式排版(`$evMv`)下补:整页表单是 `.form-grid` 两列,
没有 62px 标签列,补了反而会多出缩进。 */ ?>
<?php if ($evMv): ?><span class="mv-lb no-colon" aria-hidden="true"></span><?php endif; ?>
<label class="chk-inline"> <label class="chk-inline">
<input type="checkbox" id="ev-allday" name="all_day" value="1" <input type="checkbox" id="ev-allday" name="all_day" value="1"
<?= $evIsAllDay ? 'checked' : '' ?>> <?= $evIsAllDay ? 'checked' : '' ?>>
+301
View File
@@ -0,0 +1,301 @@
<?php
/**
* 日历视图区(月 / 周 / 日)—— 从 templates/calendar/index.php 抽出来的独立片段。
*
* 🔴 工单 #37:新建 / 修改日程后要"不刷新页面、就地更新"。做法是**服务端渲染同一份
* 片段 → 前端整块替换**,而不是让 JS 手写复刻渲染逻辑。
* 这就必须把视图区从页面模板里抽出来,让整页路径与局部重绘走同一份代码 ——
* 只有一处渲染,"插进去的样子"与"刷新一次的样子"才不可能不一致。
*
* 抽出的方式:原文**一字未改**地搬过来,只把外层缩进调平。
* 所以这里用的变量与原来完全一样,由调用方给全:
*
* @var string $view month / week / day
* @var array $cols 周/日视图的列(day_columns 产出)
* @var array $tgrid build_time_grid 产出
* @var array $weeks build_month_grid 产出
* @var bool $canNew 能不能新建
* @var float $nowTop 当前时间红线位置(%,服务端算好)
*
* ⚠️ 调用方必须**在 require 之前**把上面这些变量准备好;本片段自己不做任何兜底
* —— 兜底会让"少给了一个变量"变成静默画错,而不是当场报错。
*/
?>
<?php /* 🔴 工单 #37:这个外层容器是**局部重绘的落点**。
JS 拿到服务端渲染的新片段后,只替换 `[data-cal-view]` 里面的内容
(换的是 .card 那一层,见 assets/js/cal_sync.js 的 swap())。
⚠️ 容器本身**不能**被替换掉:它上面挂着"当前视图"这个上下文,
替换容器会让 CSS 上依赖 .cal-main > * 的选择器重新算一遍(视觉会跳一下)。 */ ?>
<div class="cal-view" data-cal-view data-cal-view-kind="<?= e((string)$view) ?>" data-cal-ym="<?= e((string)($ym ?? '')) ?>" data-cal-anchor="<?= e((string)($anchor ?? '')) ?>">
<?php if ($view === 'week' || $view === 'day'): ?>
<?php /* ===== 周 / 天:按小时铺的时间网格(全天日程单独挂顶上) ===== */ ?>
<div class="card cal-tg-card">
<div class="tg-head">
<div class="tg-gutter"></div>
<?php foreach (($cols ?? []) as $c): ?>
<div class="tg-dayname<?= $c['is_today'] ? ' is-today' : '' ?>">
<?php /* $c['w'] 是 date('w')(0=周日),所以这张索引表保持"日"开头——
它只是把数字换成汉字,与列的排列顺序无关。 */ ?>
<span class="tg-wd"><?= e(['日','一','二','三','四','五','六'][$c['w']]) ?></span>
<span class="tg-dd"><?= (int)$c['day'] ?></span>
</div>
<?php endforeach; ?>
</div>
<?php $tgAll = $tgrid['all_day'] ?? []; ?>
<?php if (array_filter($tgAll)): ?>
<div class="tg-allday">
<div class="tg-gutter"><span class="tg-tag">全天</span></div>
<?php foreach (($cols ?? []) as $i => $c): ?>
<div class="tg-adcell">
<?php foreach (($tgAll[$i] ?? []) as $ev): ?>
<?php
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<a class="cal-ev is-allday" data-cal="<?= (int)$ev['cal'] ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
style="background: <?= e((string)$ev['color']) ?>;"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>"><?= e((string)$ev['title']) ?></a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="tg-body">
<div class="tg-gutter tg-hours">
<?php for ($h = 0; $h < 24; $h++): ?>
<span class="tg-hr"><?= sprintf('%02d', $h) ?></span>
<?php endfor; ?>
</div>
<?php foreach (($cols ?? []) as $i => $c): ?>
<div class="tg-col<?= $c['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
data-new-at="<?= e((string)$c['date']) ?>">
<?php for ($h = 0; $h < 24; $h++): ?>
<span class="tg-line"></span>
<?php endfor; ?>
<?php /* ==================== 当前时间红线(v24 第 20 批 用户第 6 条)====================
用户原话:「日程模块,周视图和日视图,应该在视图中间显示一条红线,
用来表示当前进行的时间,就是说,用一个不断从上到下的红线,
来表示当前时间在视图上的刻度和位置。」
⚠️ 只在**今天那一列**画(别的日子没有"现在"可言)。
⚠️ 位置是服务端算好的百分比(与上面 .tg-block 用的是同一套:
列高 = 24h,所以 1 分钟 = 100/1440 %)。**不在 CSS 里写死**
某个时间 —— 那就成了"固定的一条线",不叫"当前时间"。
⚠️ 线的**移动**由 app.js 里的一分钟定时器负责(`initNowLines()`):
服务端给的是"渲染那一刻"的位置,用户开着页面不走,
过一小时线还停在那儿就荒谬了。
⚠️ 全天日程的"全天"那一行**不画** —— 那条线表达的是"一天之内的时间刻度",
画在全天行上会让人以为全天日程跟时间有关。 */ ?>
<?php if ($c['is_today'] && !empty($nowTop)): ?>
<span class="tg-now" data-now-line style="top: <?= (float)$nowTop ?>%;"
title="现在 <?= e(date('H:i')) ?>"></span>
<?php endif; ?>
<?php /* 🔴 v30 第 4 条:时间冲突的日程**错开画**(不再堆叠)。
用户原话:「如果两个事件时间上有冲突了,现在是堆叠在一起,应该错开,
显示两个色块,如果三个冲突了,就是三个色块,错开显示。」
left/width 由 App\Calendar::conflictLanes 算好(算法借鉴 SyncCalendar
的 TimelineUtil:贪心分列,列数 = 最大同时重叠数)。
⚠️ 用**内联 left/width 百分比**而不是 CSS 类:
列数 1/2/3/4 是运行时才知道的,类名会爆炸;
而且模板已经在内联 top/height 了,同一套坐标系最不容易错。 */ ?>
<?php foreach (($tgrid['timed'][$i] ?? []) as $ev): ?>
<?php /* 🔴 v43(用户第 6 条):点条目弹详情浮层(不再直接跳页)。
数据由 fb_entry_json() 统一编(三个入口共用一个函数)。
「详细信息」跳该来源的详情页;「编辑」取该来源的编辑表单片段。
⚠️ 编辑地址一律带可写判断:只读的(农历/节假日)不给「编辑」。 */ ?>
<?php
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evDet = (string)($ev['href'] ?? '');
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
// trip 的条目指向"某个旅程里的某段行程",编辑器在旅程详情页里 —— 这里不给「编辑」
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<?php
/* 🔴 v51(用户 2026-10-15 第 3 条):分栏(冲突错开)**在浏览器里重算**要用到
每条色块**声明的起止分钟**(服务端算分栏时用的就是这一对数)。
⚠️ 必须取 `time` / `end_time`,**不能**从 top/height 反推 ——
服务端的 `top/height` 是按**"实际显示结束"**算的(没填结束时可能被
同列下一条的起点截断),那不是它的真实结束分钟;拿它去分栏会把冲突判错。
⚠️ `data-e` 缺失 = "没填结束" ⇒ 前端按 +30′ 处理(与服务端 `$probe` 同口径)。 */
preg_match('/^(\d{1,2}):(\d{2})/', (string)($ev['time'] ?? ''), $mS);
$evS = $mS ? ((int)$mS[1] * 60 + (int)$mS[2]) : 0;
preg_match('/^(\d{1,2}):(\d{2})/', (string)($ev['end_time'] ?? ''), $mE);
$evE = $mE ? ((int)$mE[1] * 60 + (int)$mE[2]) : null;
if ($evE !== null && $evE <= $evS) { $evE = null; }
?>
<?php
/* 🔴 v88(工单 #35):跨天日程的分段。
`seg` 由 `build_time_grid` 给出:
start = 起始段(从开始时刻到当日 24:00)
mid = 中间整天
end = 结束段(00:00 到结束时刻)
only / 空 = 单天日程(原样)
视觉上要让三段"连成一条":把段与段之间的圆角和内侧边框去掉,
只保留最外两端是圆的。具体在 CSS 的 `.tg-block.is-seg-*` 里。 */
$evSeg = (string)($ev['seg'] ?? '');
$evCont = !empty($ev['cont']);
$segCls = '';
if ($evSeg === 'start') { $segCls = ' is-seg-start'; }
elseif ($evSeg === 'mid') { $segCls = ' is-seg-mid'; }
elseif ($evSeg === 'end') { $segCls = ' is-seg-end'; }
/* 分隔线:起始段的下边不画(要跟下一段接上),
中间段上下都不画,结束段上边不画 —— 由 CSS 处理。 */
?>
<a class="tg-block<?= $segCls ?>" data-cal="<?= (int)$ev['cal'] ?>"
data-s="<?= (int)$evS ?>"<?= $evE !== null ? ' data-e="' . (int)$evE . '"' : '' ?>
<?php if ($evSeg !== '') : ?>data-seg="<?= e($evSeg) ?>"<?php endif; ?>
data-fb-entry='<?= fb_entry_json($ev, $evDet, $evEdit) ?>'
<?php
/* 🔴 v88:跨天段用**贴满整列**的定位(left:0 / width:100%),
和单天块的 `calc(2px + N%)` / `calc(N% - 4px)` 不是一套。
单天块要留 2px 好跟列边框区分;跨天段要跟上一列接上,
留缝就"断成三段"了(实测 10.9px 空隙)。
⚠️ 用 CSS 类而不是内联值区分:内联 calc 里做条件太绕,
而且两边混着写以后必然有人改错一边。 */
$evSegStyle = ($evSeg !== '')
? 'left: 0; width: 100%;'
: 'left: calc(2px + ' . (float)($ev['left'] ?? 0) . '%); '
. 'width: calc(' . (float)($ev['width'] ?? 100) . '% - 4px);';
?>
style="background: <?= e((string)$ev['color']) ?>;
top: <?= (float)$ev['top'] ?>%; height: <?= (float)$ev['height'] ?>%;
<?= $evSegStyle ?>"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?><?= $evCont ? '(接前一日)' : '' ?>">
<?php if (!$evCont && (string)($ev['time'] ?? '') !== '') : ?>
<b><?= e((string)$ev['time']) ?></b>
<?php endif; ?>
<?php if ($evCont) : ?><span class="tg-cont">↳</span><?php endif; ?><?= e((string)$ev['title']) ?>
</a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<?php
/* 🔴 v70:把「每格全部条目」一次性送到前端(点 "+N 条" 时浮层直接用,
不再发请求)。**只送当月格子里的日期**,量很小(一个月最多几百条)。
⚠️ 用 `<script type="application/json">` 而不是把 JSON 塞进 data-*:
data-* 属性里几千条会被 HTML 转义得又长又容易截断;
JSON 里若出现 `</script>` 也会截断 —— 所以下面统一走 json_encode 的
JSON_HEX_TAG(把 < > 转成 \u003C \u003E,永远不会提前闭合)。
⚠️ 这是**渲染层**的便利数据,字段与 `data-fb-entry` 同一套(fb_entry_json),
所以浮层能沿用全部既有逻辑(详情/编辑/删除/来源判断)。 */
$dayData = [];
foreach (($weeks ?? []) as $wk) {
foreach ($wk as $cell) {
if (!$cell['events']) {
continue;
}
$items = [];
foreach ($cell['events'] as $ev) {
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
$items[] = ['_rank' => \App\Calendar::displayRank($ev),
'_time' => (string)($ev['time'] ?? '')]
+ json_decode(
fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit),
true
);
}
$dayData[(string)$cell['date']] = $items;
}
}
?>
<script type="application/json" id="calDayData"><?= json_encode(
$dayData,
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP
) ?></script>
<div class="card cal-grid-card">
<div class="cal-head">
<?php /* ⚠️ 用户第 11 批 #6:「月视图,从周一开始。而不是周日。」
表头顺序必须与 month_grid_range() 的起算规则一致(那里已改成周一)。 */ ?>
<?php foreach (['一', '二', '三', '四', '五', '六', '日'] as $wd): ?>
<div class="cal-wd"><?= e($wd) ?></div>
<?php endforeach; ?>
</div>
<div class="cal-grid">
<?php foreach ($weeks as $wk): ?>
<?php foreach ($wk as $cell): ?>
<?php /* 用户第 5 条:点格子里的空白 = 在这天建日程。
用 data-new-at 把日期带过去,由底部 JS 交给新建表单
(点了小条本身是"打开那条日程",由 <a> 自己处理,JS 会排除掉)。 */ ?>
<div class="cal-cell<?= $cell['in_month'] ? '' : ' is-out' ?><?= $cell['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
data-new-at="<?= e((string)$cell['date']) ?>">
<div class="cal-dnum"><?= (int)$cell['day'] ?></div>
<div class="cal-evs">
<?php
/* 🔴 v70(用户 2026-10-20 第 2 条):「日历导入进去了,但是不显示」。
真根因不在这里的 3 条,而在**排序 + "+N 条"点不开**的组合:
每格只画 3 条,而旧排序把"全天"(时间空串)排最前 ⇒ 农历/节气/
节假日天天占满前三,用户自己导入的日程全被挤进 "+N 条",
而那个 "+N 条" 只是个 <span>,点不开 = 完全看不到。
两处都已修:① `Calendar::displayRank()` 让派生条目排最后;
② 下面把 "+N 条" 换成**真按钮**,点开当天的全部条目浮层。
⚠️ 保留 3 条不改成"全画":格子是固定高度(min-height:104px),
15 条硬塞会把月网格撑得不像月历;超出部分交给浮层看。 */
$cellEvents = $cell['events'];
$shown = 3;
$hiddenCnt = count($cellEvents) - $shown;
?>
<?php $n = 0; foreach ($cellEvents as $ev): $n++; ?>
<?php if ($n <= $shown): ?>
<?php
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<a class="cal-ev<?= empty($ev['all_day']) ? '' : ' is-allday' ?>"
data-cal="<?= (int)$ev['cal'] ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
style="background: <?= e((string)$ev['color']) ?>;"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>">
<?php if (($ev['time'] ?? '') !== ''): ?><b><?= e((string)$ev['time']) ?></b> <?php endif; ?>
<?= e((string)$ev['title']) ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
<?php if ($hiddenCnt > 0): ?>
<?php /* 点开 = 当天全部条目(含被折叠的),一律走全站的条目详情浮层;
每条都带 data-fb-entry,所以在浮层里还能继续点进详情/编辑/删除。
⚠️ 用一个 `<a href>` 兜住无 JS 的情况:直接跳那天的日视图
(日视图按时间网格铺开,不会再截断)。 */ ?>
<button type="button" class="cal-more"
data-cal-day="<?= e((string)$cell['date']) ?>"
aria-label="看这一天全部 <?= count($cellEvents) ?> 条">+<?= $hiddenCnt ?> 条</button>
<?php /* 浮层用的全部条目数据(服务端渲染好,点开时不需要再请求)。
放在格子外面(display:none 的 script 里)—— 塞进格子会撑高月网格。 */ ?>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
+108 -551
View File
@@ -187,243 +187,13 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
unset($calCanNew, $calWritable, $calCtx); unset($calCanNew, $calWritable, $calCtx);
?> ?>
<?php if ($view === 'week' || $view === 'day'): ?> <?php /* 🔵 工单 #37:视图区抽成独立片段 `_view.php`。
<?php /* ===== 周 / 天:按小时铺的时间网格(全天日程单独挂顶上) ===== */ ?> 整页路径与「新建/修改后的局部重绘」**必须**走同一份模板 ——
<div class="card cal-tg-card"> 这样"插进去的样子"与"刷新一次的样子"不可能不一致。
<div class="tg-head"> ⚠️ 原来这里是一大段内联 HTML(月/周/日三套)。抽走时**一字未改**,
<div class="tg-gutter"></div> 只把外层缩进调平;变量的给出方式也照旧($view/$cols/$tgrid/$weeks/
<?php foreach (($cols ?? []) as $c): ?> $canNew/$nowTop 都由上面备好)。 */ ?>
<div class="tg-dayname<?= $c['is_today'] ? ' is-today' : '' ?>"> <?php require __DIR__ . '/_view.php'; ?>
<?php /* $c['w'] 是 date('w')(0=周日),所以这张索引表保持"日"开头——
它只是把数字换成汉字,与列的排列顺序无关。 */ ?>
<span class="tg-wd"><?= e(['日','一','二','三','四','五','六'][$c['w']]) ?></span>
<span class="tg-dd"><?= (int)$c['day'] ?></span>
</div>
<?php endforeach; ?>
</div>
<?php $tgAll = $tgrid['all_day'] ?? []; ?>
<?php if (array_filter($tgAll)): ?>
<div class="tg-allday">
<div class="tg-gutter"><span class="tg-tag">全天</span></div>
<?php foreach (($cols ?? []) as $i => $c): ?>
<div class="tg-adcell">
<?php foreach (($tgAll[$i] ?? []) as $ev): ?>
<?php
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<a class="cal-ev is-allday" data-cal="<?= (int)$ev['cal'] ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
style="background: <?= e((string)$ev['color']) ?>;"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>"><?= e((string)$ev['title']) ?></a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="tg-body">
<div class="tg-gutter tg-hours">
<?php for ($h = 0; $h < 24; $h++): ?>
<span class="tg-hr"><?= sprintf('%02d', $h) ?></span>
<?php endfor; ?>
</div>
<?php foreach (($cols ?? []) as $i => $c): ?>
<div class="tg-col<?= $c['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
data-new-at="<?= e((string)$c['date']) ?>">
<?php for ($h = 0; $h < 24; $h++): ?>
<span class="tg-line"></span>
<?php endfor; ?>
<?php /* ==================== 当前时间红线(v24 第 20 批 用户第 6 条)====================
用户原话:「日程模块,周视图和日视图,应该在视图中间显示一条红线,
用来表示当前进行的时间,就是说,用一个不断从上到下的红线,
来表示当前时间在视图上的刻度和位置。」
⚠️ 只在**今天那一列**画(别的日子没有"现在"可言)。
⚠️ 位置是服务端算好的百分比(与上面 .tg-block 用的是同一套:
列高 = 24h,所以 1 分钟 = 100/1440 %)。**不在 CSS 里写死**
某个时间 —— 那就成了"固定的一条线",不叫"当前时间"。
⚠️ 线的**移动**由 app.js 里的一分钟定时器负责(`initNowLines()`):
服务端给的是"渲染那一刻"的位置,用户开着页面不走,
过一小时线还停在那儿就荒谬了。
⚠️ 全天日程的"全天"那一行**不画** —— 那条线表达的是"一天之内的时间刻度",
画在全天行上会让人以为全天日程跟时间有关。 */ ?>
<?php if ($c['is_today'] && !empty($nowTop)): ?>
<span class="tg-now" data-now-line style="top: <?= (float)$nowTop ?>%;"
title="现在 <?= e(date('H:i')) ?>"></span>
<?php endif; ?>
<?php /* 🔴 v30 第 4 条:时间冲突的日程**错开画**(不再堆叠)。
用户原话:「如果两个事件时间上有冲突了,现在是堆叠在一起,应该错开,
显示两个色块,如果三个冲突了,就是三个色块,错开显示。」
left/width 由 App\Calendar::conflictLanes 算好(算法借鉴 SyncCalendar
的 TimelineUtil:贪心分列,列数 = 最大同时重叠数)。
⚠️ 用**内联 left/width 百分比**而不是 CSS 类:
列数 1/2/3/4 是运行时才知道的,类名会爆炸;
而且模板已经在内联 top/height 了,同一套坐标系最不容易错。 */ ?>
<?php foreach (($tgrid['timed'][$i] ?? []) as $ev): ?>
<?php /* 🔴 v43(用户第 6 条):点条目弹详情浮层(不再直接跳页)。
数据由 fb_entry_json() 统一编(三个入口共用一个函数)。
「详细信息」跳该来源的详情页;「编辑」取该来源的编辑表单片段。
⚠️ 编辑地址一律带可写判断:只读的(农历/节假日)不给「编辑」。 */ ?>
<?php
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evDet = (string)($ev['href'] ?? '');
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
// trip 的条目指向"某个旅程里的某段行程",编辑器在旅程详情页里 —— 这里不给「编辑」
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<?php
/* 🔴 v51(用户 2026-10-15 第 3 条):分栏(冲突错开)**在浏览器里重算**要用到
每条色块**声明的起止分钟**(服务端算分栏时用的就是这一对数)。
⚠️ 必须取 `time` / `end_time`,**不能**从 top/height 反推 ——
服务端的 `top/height` 是按**"实际显示结束"**算的(没填结束时可能被
同列下一条的起点截断),那不是它的真实结束分钟;拿它去分栏会把冲突判错。
⚠️ `data-e` 缺失 = "没填结束" ⇒ 前端按 +30′ 处理(与服务端 `$probe` 同口径)。 */
preg_match('/^(\d{1,2}):(\d{2})/', (string)($ev['time'] ?? ''), $mS);
$evS = $mS ? ((int)$mS[1] * 60 + (int)$mS[2]) : 0;
preg_match('/^(\d{1,2}):(\d{2})/', (string)($ev['end_time'] ?? ''), $mE);
$evE = $mE ? ((int)$mE[1] * 60 + (int)$mE[2]) : null;
if ($evE !== null && $evE <= $evS) { $evE = null; }
?>
<a class="tg-block" data-cal="<?= (int)$ev['cal'] ?>"
data-s="<?= (int)$evS ?>"<?= $evE !== null ? ' data-e="' . (int)$evE . '"' : '' ?>
data-fb-entry='<?= fb_entry_json($ev, $evDet, $evEdit) ?>'
style="background: <?= e((string)$ev['color']) ?>;
top: <?= (float)$ev['top'] ?>%; height: <?= (float)$ev['height'] ?>%;
left: calc(2px + <?= (float)($ev['left'] ?? 0) ?>%);
width: calc(<?= (float)($ev['width'] ?? 100) ?>% - 4px);"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>">
<b><?= e((string)$ev['time']) ?></b> <?= e((string)$ev['title']) ?>
</a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<?php
/* 🔴 v70:把「每格全部条目」一次性送到前端(点 "+N 条" 时浮层直接用,
不再发请求)。**只送当月格子里的日期**,量很小(一个月最多几百条)。
⚠️ 用 `<script type="application/json">` 而不是把 JSON 塞进 data-*:
data-* 属性里几千条会被 HTML 转义得又长又容易截断;
JSON 里若出现 `</script>` 也会截断 —— 所以下面统一走 json_encode 的
JSON_HEX_TAG(把 < > 转成 \u003C \u003E,永远不会提前闭合)。
⚠️ 这是**渲染层**的便利数据,字段与 `data-fb-entry` 同一套(fb_entry_json),
所以浮层能沿用全部既有逻辑(详情/编辑/删除/来源判断)。 */
$dayData = [];
foreach (($weeks ?? []) as $wk) {
foreach ($wk as $cell) {
if (!$cell['events']) {
continue;
}
$items = [];
foreach ($cell['events'] as $ev) {
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
$items[] = ['_rank' => \App\Calendar::displayRank($ev),
'_time' => (string)($ev['time'] ?? '')]
+ json_decode(
fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit),
true
);
}
$dayData[(string)$cell['date']] = $items;
}
}
?>
<script type="application/json" id="calDayData"><?= json_encode(
$dayData,
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP
) ?></script>
<div class="card cal-grid-card">
<div class="cal-head">
<?php /* ⚠️ 用户第 11 批 #6:「月视图,从周一开始。而不是周日。」
表头顺序必须与 month_grid_range() 的起算规则一致(那里已改成周一)。 */ ?>
<?php foreach (['一', '二', '三', '四', '五', '六', '日'] as $wd): ?>
<div class="cal-wd"><?= e($wd) ?></div>
<?php endforeach; ?>
</div>
<div class="cal-grid">
<?php foreach ($weeks as $wk): ?>
<?php foreach ($wk as $cell): ?>
<?php /* 用户第 5 条:点格子里的空白 = 在这天建日程。
用 data-new-at 把日期带过去,由底部 JS 交给新建表单
(点了小条本身是"打开那条日程",由 <a> 自己处理,JS 会排除掉)。 */ ?>
<div class="cal-cell<?= $cell['in_month'] ? '' : ' is-out' ?><?= $cell['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
data-new-at="<?= e((string)$cell['date']) ?>">
<div class="cal-dnum"><?= (int)$cell['day'] ?></div>
<div class="cal-evs">
<?php
/* 🔴 v70(用户 2026-10-20 第 2 条):「日历导入进去了,但是不显示」。
真根因不在这里的 3 条,而在**排序 + "+N 条"点不开**的组合:
每格只画 3 条,而旧排序把"全天"(时间空串)排最前 ⇒ 农历/节气/
节假日天天占满前三,用户自己导入的日程全被挤进 "+N 条",
而那个 "+N 条" 只是个 <span>,点不开 = 完全看不到。
两处都已修:① `Calendar::displayRank()` 让派生条目排最后;
② 下面把 "+N 条" 换成**真按钮**,点开当天的全部条目浮层。
⚠️ 保留 3 条不改成"全画":格子是固定高度(min-height:104px),
15 条硬塞会把月网格撑得不像月历;超出部分交给浮层看。 */
$cellEvents = $cell['events'];
$shown = 3;
$hiddenCnt = count($cellEvents) - $shown;
?>
<?php $n = 0; foreach ($cellEvents as $ev): $n++; ?>
<?php if ($n <= $shown): ?>
<?php
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<a class="cal-ev<?= empty($ev['all_day']) ? '' : ' is-allday' ?>"
data-cal="<?= (int)$ev['cal'] ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
style="background: <?= e((string)$ev['color']) ?>;"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>">
<?php if (($ev['time'] ?? '') !== ''): ?><b><?= e((string)$ev['time']) ?></b> <?php endif; ?>
<?= e((string)$ev['title']) ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
<?php if ($hiddenCnt > 0): ?>
<?php /* 点开 = 当天全部条目(含被折叠的),一律走全站的条目详情浮层;
每条都带 data-fb-entry,所以在浮层里还能继续点进详情/编辑/删除。
⚠️ 用一个 `<a href>` 兜住无 JS 的情况:直接跳那天的日视图
(日视图按时间网格铺开,不会再截断)。 */ ?>
<button type="button" class="cal-more"
data-cal-day="<?= e((string)$cell['date']) ?>"
aria-label="看这一天全部 <?= count($cellEvents) ?> 条">+<?= $hiddenCnt ?> 条</button>
<?php /* 浮层用的全部条目数据(服务端渲染好,点开时不需要再请求)。
放在格子外面(display:none 的 script 里)—— 塞进格子会撑高月网格。 */ ?>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div> </div>
</div> </div>
@@ -538,34 +308,55 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
(它在该 IIFE 外面,看不到闭包里的 openNew)⇒ 暴露一个窗口入口。 */ (它在该 IIFE 外面,看不到闭包里的 openNew)⇒ 暴露一个窗口入口。 */
window.fbCalOpenNew = openNew; window.fbCalOpenNew = openNew;
/* 月视图格子 */ /* ============================================================
Array.prototype.forEach.call(document.querySelectorAll('.cal-cell[data-new-at]'), function (cell) { 「点格子 / 点列空白 = 在那天(那个时刻)新建日程」
cell.addEventListener('click', function (ev) { ------------------------------------------------------------
// 点在小条上 = 想打开那条日程(<a> 的默认行为),不要抢 🔵 工单 #37:**改成 document 上的事件委托**,不再逐元素绑定。
if (ev.target.closest && ev.target.closest('.cal-ev')) { return; }
/* 🔴 v70:点「+N 条」= 要看**当天的全部条目**,不是要新建日程。
⚠️ 必须显式排除:它是个 <button>,不排除的话会**同时**触发
"新建日程浮层" 与 "当天全部条目浮层" 两个浮层叠在一起。 */
if (ev.target.closest && ev.target.closest('.cal-more')) { return; }
if (!<?= $canWrite && $writable ? 'true' : 'false' ?>) { return; }
openNew(cell.getAttribute('data-new-at'), '09', '00');
});
});
/* 周/日视图的列:纵向位置 → 时刻。 为什么必须改:原来是 `querySelectorAll(...).forEach(el => addEventListener(...))`,
列高 1056px = 24 小时(见 .tg-col 的 CSS),所以 1 分钟 = 1056/1440 px。 */ 只对"渲染那一刻存在"的节点生效。而本工单让视图区可以整块重绘(新建/修改后
Array.prototype.forEach.call(document.querySelectorAll('.tg-col[data-new-at]'), function (col) { 取服务端片段替换),重绘后换进来的新格子**没人绑** ⇒ 点一下没反应。
col.addEventListener('click', function (ev) { 委托之后一根监听管全部(含以后换进来的),重绘也不需要重新绑定。
if (ev.target.closest && ev.target.closest('.tg-block')) { return; }
if (!<?= $canWrite && $writable ? 'true' : 'false' ?>) { return; } ⚠️ 顺序有意义:先判 `.cal-ev` / `.tg-block`(点条目 = 打开那条,不抢),
var box = col.getBoundingClientRect(); 再判 `.cal-more`(点 +N 条 = 看清单,不新建),最后才落到"新建"。
var ratio = (ev.clientY - box.top) / Math.max(1, box.height); ============================================================ */
var mins = Math.max(0, Math.min(24 * 60 - 30, Math.round(ratio * 1440 / 30) * 30)); (function () {
var hh = String(Math.floor(mins / 60)).padStart(2, '0'); /* 服务端就把"能不能写"烘进这段脚本 —— 只读账号点了不该有反应 */
var mm = String(mins % 60).padStart(2, '0'); var CAN_WRITE = <?= $canWrite && $writable ? 'true' : 'false' ?>;
openNew(col.getAttribute('data-new-at'), hh, mm);
document.addEventListener('click', function (ev) {
var t = ev.target;
if (!t || !t.closest) { return; }
if (!CAN_WRITE) { return; }
/* ① 月视图格子:一格 = 一天 → 09:00 起("当日默认"比 00:00 更像个日程) */
var cell = t.closest('.cal-cell[data-new-at]');
if (cell) {
// 点在小条上 = 想打开那条日程(<a> 的默认行为),不要抢
if (t.closest('.cal-ev')) { return; }
/* 🔴 v70:点「+N 条」= 要看**当天的全部条目**,不是要新建日程。
⚠️ 必须显式排除:它是个 <button>,不排除的话会**同时**触发
"新建日程浮层" 与 "当天全部条目浮层" 两个浮层。 */
if (t.closest('.cal-more')) { return; }
openNew(cell.getAttribute('data-new-at'), '09', '00');
return;
}
/* ② 周/日视图的列:按鼠标在**那一列里的纵向位置**反算时刻,最接近半小时。
列高 1056px = 24 小时(见 .tg-col 的 CSS),所以 1 分钟 = 1056/1440 px。 */
var col = t.closest('.tg-col[data-new-at]');
if (col) {
if (t.closest('.tg-block')) { return; }
var box = col.getBoundingClientRect();
var ratio = (ev.clientY - box.top) / Math.max(1, box.height);
var mins = Math.max(0, Math.min(24 * 60 - 30, Math.round(ratio * 1440 / 30) * 30));
var hh = String(Math.floor(mins / 60)).padStart(2, '0');
var mm = String(mins % 60).padStart(2, '0');
openNew(col.getAttribute('data-new-at'), hh, mm);
}
}); });
}); })();
/* 从 ?new_at= 进来(比如别处链过来)→ 直接开弹窗并预填 */ /* 从 ?new_at= 进来(比如别处链过来)→ 直接开弹窗并预填 */
var preset = <?= json_encode($newAt, JSON_UNESCAPED_UNICODE) ?>; var preset = <?= json_encode($newAt, JSON_UNESCAPED_UNICODE) ?>;
@@ -626,268 +417,22 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
if (tip) { tip.textContent = msg; tip.className = 'cal-modal-tip err'; } if (tip) { tip.textContent = msg; tip.className = 'cal-modal-tip err'; }
} }
/** 把一条 `fb_entry_json` 形态的条目补进 `#calDayData`(按天分组,幂等)。 /* 🔵 工单 #37(用户 2026-11-01):「修改和新建,都不应该刷新页面,
* ⚠️ 只补**还没在里面**的那条(同 ref + 同 occ 视为同一条)—— 而是使用js动态调整。」
* 否则连点两次「加上」或前端重放会把同一条塞两遍,清单里出现两条一样的。 */
function pushDayData(day, entry) {
var host = document.getElementById('calDayData');
if (!host || !day || !entry) { return; }
var all;
try { all = JSON.parse(host.textContent || '{}') || {}; } catch (e) { return; }
var list = all[day] || [];
var same = false;
for (var i = 0; i < list.length; i++) {
if (String(list[i].ref || '') === String(entry.ref || '')
&& String(list[i].occ || '') === String(entry.occ || '')
&& String(list[i].title || '') === String(entry.title || '')) { same = true; break; }
}
if (!same) {
/* 位置按服务端那套 `displayRank + 时间` 插(派生条目垫底),
免得清单里的顺序与刷新后的顺序不一致。 */
var rk = function (o) {
var s = String(o.source || '');
return s === 'event' ? 0 : 4; /* 派生条目一律 4,与服务端 displayRank 的兜底一致 */
};
var tm = function (o) {
var rows = o.rows || [];
for (var k = 0; k < rows.length; k++) {
if (rows[k][0] === '时间') {
var v = String(rows[k][1] || '');
/* "YYYY-MM-DD HH:MM–HH:MM" / "YYYY-MM-DD 全天" → 取时刻部分比较 */
var seg = v.slice(day.length).trim();
return seg === '全天' ? '' : seg.slice(0, 5);
}
}
return '';
};
var pos = list.length;
for (var j = 0; j < list.length; j++) {
if (rk(list[j]) > rk(entry)) { pos = j; break; }
if (rk(list[j]) === rk(entry) && tm(list[j]) > tm(entry)) { pos = j; break; }
}
list.splice(pos, 0, entry);
all[day] = list;
try { host.textContent = JSON.stringify(all); } catch (e2) { /* 序列化失败就别动 */ }
}
}
/** 月视图:往 data-new-at="YYYY-MM-DD" 那个格子里塞一条小条 */ 这里**原来有一整套手写复刻服务端渲染的代码**(`pushDayData` / `putMonth` /
function putMonth(ev) { `relayoutColumn` / `putTimed` / `insert`,约 270 行):月视图往格子里塞小条
var day = String(ev.starts_at || '').slice(0, 10); 并按时间插到正确位置、周/日视图按"24h=100%"算 top/height、插完重算整列分栏…
var cell = document.querySelector('.cal-cell[data-new-at="' + day + '"]');
if (!cell) { return false; }
var box = cell.querySelector('.cal-evs');
if (!box) { return false; }
var a = document.createElement('a');
a.className = 'cal-ev' + (ev.all_day ? ' is-allday' : '');
a.setAttribute('data-cal', String(ev.cal || ''));
a.style.background = ev.color || '';
a.setAttribute('href', ev.href || '#');
a.setAttribute('title', ev.title || '');
/* ⚠️ 时间前缀的形状必须与服务端一致(<b>HH:MM</b> 后面一个空格)——
不一致的话刷新一次就会看出差别。 */
if (ev.time) {
var b = document.createElement('b');
b.textContent = ev.time;
a.appendChild(b);
a.appendChild(document.createTextNode(' '));
}
a.appendChild(document.createTextNode(ev.title || ''));
/* 🔴 插到**正确的时间位置**,不能 append 到末尾。
为什么必须按时间插:服务端只渲染前 3 条(第 4 条起收进"+N 条"),
而"哪 3 条"是按**时间顺序**取的。若这边一律 append:
· 新加一条**早上 7 点**的日程,它会被排到最后 → 落到"+N 条"里看不见,
可刷新一次又会跑到第一位 —— 这正是"插进去和刷新后长得不一样"。
⚠️ 判据与服务端渲染同一套:先比 <b>HH:MM</b>,没有时间前缀的(全天)排前面
(与 Calendar::month 给 all_day 的处理一致),再比标题兜底稳定性。 */
var newTime = ev.time || '';
var placed = false;
var existing = Array.prototype.slice.call(box.querySelectorAll('.cal-ev'));
for (var k = 0; k < existing.length; k++) {
var tEl = existing[k].querySelector('b');
var tTxt = tEl ? tEl.textContent.trim() : '';
if (tTxt === '' && newTime !== '') { continue; } /* 空时间(全天)在前 */
if (tTxt !== '' && newTime === '') { box.insertBefore(a, existing[k]); placed = true; break; }
if (tTxt > newTime) { box.insertBefore(a, existing[k]); placed = true; break; }
}
if (!placed) { box.appendChild(a); }
/* 再按"只显示前 3 条"收口,与服务端渲染对齐(否则会多出一条)。 */ 它自己的注释里就记着一次真实事故:新加一条 07:00 的日程被 append 到末尾 ⇒
var links = box.querySelectorAll('.cal-ev'); 落进「+N 条」看不见,可刷新一次又跑到第一位。
var more = box.querySelector('.cal-more'); 这类 bug 的根子是"两处渲染必须同步",只要两处存在就一定会有偏差。
/* 🔴 v70:新条目同步进 `#calDayData` —— 否则「+N 条」点开是**旧**清单,
刚建的这条不在里面(刷新一次又有了)。放在收口之前做,跟服务端渲染时
"先把当天全部条目收进数据岛、再只画前 3 条"的顺序一致。 */
if (ev.entry) { pushDayData(day, ev.entry); }
for (var j = 0; j < links.length; j++) {
links[j].style.display = (j < 3) ? '' : 'none';
}
if (links.length > 3) {
var extra = links.length - 3;
if (!more) {
/* 🔴 v70(用户 2026-10-20 第 2 条):「+N 条」原来是颗 `<span>`,
**点不动** ⇒ 被它折叠掉的条目永远看不到("导进去了但不显示")。
这里必须造**按钮**并带 `data-cal-day`(与模板渲染同形),
否则新建一条之后,这个格子就退回"点不动"。 */
more = document.createElement('button');
more.type = 'button';
more.className = 'cal-more';
box.appendChild(more);
}
more.setAttribute('data-cal-day', day);
more.setAttribute('aria-label', '看这一天全部 ' + links.length + ' 条');
more.textContent = '+' + extra + ' 条';
} else if (more) {
more.parentNode.removeChild(more);
}
return true;
}
/* 🔴🔴 v51(用户 2026-10-15 第 3 条):「添加一个日程之后,只是把这个日程放在了 ⇒ **整段删掉**,改用 `assets/js/cal_sync.js` 的 `refresh()`:
周视图或者是日视图上面。如果它这个时间段已经有日程了,它会**直接盖在上面**, 新建成功后取一份服务端渲染好的视图片段整块替换。
却并没有按照**分栏**的方式左右显示。这个需要再调整一下。」 这样"加进去的样子"与"刷新一次的样子"**必然**相同,因为只有一处渲染。
日历页新建 / 首页新建 / 浮层改完,三条路走同一个函数。
真根因:`putTimed()` 只把自己那条色块按 top/height **整宽铺上去**, */
完全不知道同一列里已有的日程 —— 于是两条重叠时后加的直接压在先有的上面
(服务端渲染时是有分栏的:`App\Calendar::conflictLanes`,但那是**整页重绘**时才算的)。
⇒ 修法:**在浏览器里重算整列的分栏**(与服务端同一套算法),插入后重排该列。
为什么"重算整列"而不是"给新块腾个位置":
新日程可能同时与**多条**已有日程冲突(比如插进一个 3 段重叠的时段),
只挪新块会让已有多条的宽度/位置也不对(它们现在是 2 列、重算后该是 3 列)
—— 必须让整列一起重排才是自洽的。
⚠️ 算法**照抄** app/Calendar.php 的 conflictLanes(贪心分列 + 分组复列):
两边算错一个边界,刷新一次位置就会跳。
⚠️ 高度仍按各自"声明的起止"算(没结束的用 +30′)—— 与服务端那套一致。
(服务端在"没填结束"时还会拿同列下一条的起点当视觉结束;这里简化成 +30′,
差别只在"未填结束"这一种情况,且插入后用户一刷新就归位。 */
function minsAttr(el, attr) {
var v = el.getAttribute(attr);
if (v === null || v === '') { return null; }
var n = parseInt(v, 10);
return isNaN(n) ? null : n;
}
/** 重算某一列里**所有** .tg-block 的分栏(left/width)与 top/height */
function relayoutColumn(col) {
var blocks = Array.prototype.slice.call(col.querySelectorAll('.tg-block'));
if (!blocks.length) { return; }
// ① 归一成 [{el, s, e}](缺 data-e → +30′;缺 data-s → 从 top 反推兜底)
var rows = [];
blocks.forEach(function (el) {
var s = minsAttr(el, 'data-s');
if (s === null) {
var tp = parseFloat(el.style.top) || 0;
s = Math.round(tp / 100 * 1440);
}
var e = minsAttr(el, 'data-e');
if (e === null || e <= s) { e = s + 30; }
rows.push({ el: el, s: s, e: Math.min(e, 1440) });
});
// ② 按开始时间升序(同开始则长的在前)—— 与服务端 usort 同序
rows.sort(function (a, b) { return a.s - b.s || b.e - a.e; });
// ③ 划分冲突组(与组内最晚结束仍重叠就并组)
var groups = [], cur = [], endMax = -1;
rows.forEach(function (r) {
if (cur.length && r.s >= endMax) { groups.push(cur); cur = []; endMax = -1; }
cur.push(r);
endMax = Math.max(endMax, r.e);
});
if (cur.length) { groups.push(cur); }
// ④ 组内贪心分列(每列记最后结束分钟,放进编号最小的可用列)
groups.forEach(function (g) {
var laneEnd = [], laneOf = [];
g.forEach(function (r, k) {
var lane = -1;
for (var li = 0; li < laneEnd.length; li++) {
if (laneEnd[li] <= r.s) { lane = li; break; }
}
if (lane < 0) { lane = laneEnd.length; laneEnd.push(r.e); }
else { laneEnd[lane] = r.e; }
laneOf[k] = lane;
});
var cols = Math.max(1, laneEnd.length);
g.forEach(function (r, k) {
var left = laneOf[k] / cols * 100, width = 100 / cols;
r.el.style.left = 'calc(2px + ' + left.toFixed(4) + '%)';
r.el.style.width = 'calc(' + width.toFixed(4) + '% - 4px)';
r.el.style.top = (r.s / 1440 * 100).toFixed(4) + '%';
r.el.style.height = ((Math.max(15, r.e - r.s)) / 1440 * 100).toFixed(4) + '%';
});
});
}
/** 周/日视图:按"列高 = 24 小时"算 top/height 插时间块 */
function putTimed(ev) {
if (ev.all_day) {
var ad = document.querySelector('.tg-adcell');
if (!ad) { return false; }
var box2 = document.querySelector('.tg-allday');
var col = null;
if (box2) {
/* 找到"那一天"那一列:列的顺序与 .tg-dayname 一致,
用 data-new-at 反查(服务端给每列写了当天日期)。 */
var cols = Array.prototype.slice.call(document.querySelectorAll('.tg-col[data-new-at]'));
var day2 = String(ev.starts_at || '').slice(0, 10);
var idx = -1;
cols.forEach(function (c, i) { if (c.getAttribute('data-new-at') === day2) { idx = i; } });
if (idx >= 0) {
var cells = document.querySelectorAll('.tg-adcell');
col = cells[idx] || null;
}
}
var host = col || ad;
if (!host) { return false; }
var a2 = document.createElement('a');
a2.className = 'cal-ev is-allday';
a2.setAttribute('data-cal', String(ev.cal || ''));
a2.style.background = ev.color || '';
a2.setAttribute('href', ev.href || '#');
a2.setAttribute('title', ev.title || '');
a2.textContent = ev.title || '';
host.appendChild(a2);
return true;
}
var cols3 = Array.prototype.slice.call(document.querySelectorAll('.tg-col[data-new-at]'));
var day3 = String(ev.starts_at || '').slice(0, 10);
var ci = -1;
cols3.forEach(function (c, i) { if (c.getAttribute('data-new-at') === day3) { ci = i; } });
if (ci < 0) { return false; }
var target = cols3[ci];
var minsOf = function (t) {
var m = String(t || '').match(/[ T](\d{1,2}):(\d{1,2})/);
return m ? (+m[1]) * 60 + (+m[2]) : 0;
};
var st = minsOf(ev.starts_at), en = minsOf(ev.ends_at);
if (en <= st) { en = st + 30; } /* 没填结束就给半小时 —— 与服务端一致 */
var top = (st / 1440) * 100, hgt = ((en - st) / 1440) * 100;
var blk = document.createElement('a');
blk.className = 'tg-block';
blk.setAttribute('data-cal', String(ev.cal || ''));
/* 🔴 v51:带上"声明的起止分钟",重算分栏要用(服务端渲染的同名属性)。
⚠️ 与服务端口径一致:结束缺失/倒挂 → 不带 data-e(按 +30′ 处理)。 */
blk.setAttribute('data-s', String(st));
var declE = minsOf(ev.ends_at);
if (declE > st) { blk.setAttribute('data-e', String(declE)); }
blk.style.background = ev.color || '';
blk.style.top = top.toFixed(4) + '%';
blk.style.height = hgt.toFixed(4) + '%';
blk.setAttribute('href', ev.href || '#');
blk.setAttribute('title', ev.title || '');
blk.textContent = ev.title || '';
target.appendChild(blk);
/* 🔴 v51(第 3 条):插完**重排整列** —— 否则这条会整宽盖在已有日程上(用户报的现象)。 */
relayoutColumn(target);
return true;
}
function insert(ev) {
/* 先看当前视图是哪一种:页面上有 .cal-grid 就是月视图;
有 .tg-col 就是周/日视图。两者都不在(理论上不会)→ 退回刷新。 */
if (document.querySelector('.cal-grid')) { return putMonth(ev); }
if (document.querySelector('.tg-col[data-new-at]')) { return putTimed(ev); }
return false;
}
form.addEventListener('submit', function (e) { form.addEventListener('submit', function (e) {
if (form.getAttribute('data-native-submit') === '1') { return; } if (form.getAttribute('data-native-submit') === '1') { return; }
@@ -903,38 +448,50 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
.then(function (d) { .then(function (d) {
if (sub) { sub.disabled = false; } if (sub) { sub.disabled = false; }
if (d && d.ok && d.event) { if (d && d.ok && d.event) {
var ok = false; /* 🔵 工单 #37:不再手写插 DOM,改为让 cal_sync 取片段重绘。
try { ok = insert(d.event); } catch (err) { ok = false; } `d.msg` 里带着服务端那句人话(含"已加进观影/旅程"这类说明)。
if (ok) { ⚠️ 观影/旅程本是**派生**的:服务端给了 `goto` 时反而**不能**
/* 观影 / 旅行本是**派生**的(内容来自观影/旅程表):AJAX 时服务端 立刻重绘收提示 —— 用户可能想点那个"去看详情"。所以这两条路分开:
不再自动跳详情页(跳走就离开了日历),改在提示行给一个链接。 */ 有 goto(派生本)→ 保留提示行 + 链接,重绘照做;
if (d.goto && tip) { 没有 goto(普通日程)→ 关浮层 + 轻提示 + 重绘。 */
tip.className = 'cal-modal-tip'; var sync = window.fbCalSync;
tip.textContent = (d.msg || '已加上。') + ' '; if (!sync || typeof sync.refresh !== 'function') {
var ga = document.createElement('a'); /* 老缓存没加载到脚本 → 退回原生提交,别假装成功 */
ga.href = d.goto; form.setAttribute('data-native-submit', '1');
ga.textContent = '去看详情 →'; if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
tip.appendChild(ga); else { form.submit(); }
setTimeout(function () {
if (tip) { tip.textContent = ''; }
}, 6000);
}
closeNew();
/* 重置"只填一次的字段",但**保留**日历本的选择(用户往往连加几条)。
⚠️ 标题必须清掉,否则下一条会带着上一条的标题(很容易漏掉)。 */
var t = document.getElementById('ev-title');
var lo = document.getElementById('ev-location');
var no = document.getElementById('ev-note');
if (t) { t.value = ''; }
if (lo) { lo.value = ''; }
if (no) { no.value = ''; }
return; return;
} }
/* 插入不成功 = "没刷新却也没看到" → 宁可刷新一次,别假装成功 */ /* 观影 / 旅行本是**派生**的(内容来自观影/旅程表):AJAX 时服务端
form.setAttribute('data-native-submit', '1'); 不再自动跳详情页(跳走就离开了日历),改在提示行给一个链接。 */
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); } if (d.goto && tip) {
else { form.submit(); } tip.className = 'cal-modal-tip';
tip.textContent = (d.msg || '已加上。') + ' ';
var ga = document.createElement('a');
ga.href = d.goto;
ga.textContent = '去看详情 →';
tip.appendChild(ga);
setTimeout(function () {
if (tip) { tip.textContent = ''; }
}, 6000);
}
closeNew();
/* 重置"只填一次的字段",但**保留**日历本的选择(用户往往连加几条)。
⚠️ 标题必须清掉,否则下一条会带着上一条的标题(很容易漏掉)。 */
var t = document.getElementById('ev-title');
var lo = document.getElementById('ev-location');
var no = document.getElementById('ev-note');
if (t) { t.value = ''; }
if (lo) { lo.value = ''; }
if (no) { no.value = ''; }
/* 重绘当前视图(不刷新页面)。有 goto 的是派生本,提示留 6 秒让人点链接。 */
var opts = { msg: (d.msg || '已加上。') };
if (d.goto) {
opts.msg = (d.msg || '已加上。') + ' 去看详情 →';
}
sync.refresh(opts);
return; return;
} }
if (d && d.msg) { sayErr(d.msg); return; } if (d && d.msg) { sayErr(d.msg); return; }
/* 拿不到 JSON:会话过期 / 500 → 退回原生提交,让服务端说话 */ /* 拿不到 JSON:会话过期 / 500 → 退回原生提交,让服务端说话 */
+48 -1
View File
@@ -196,6 +196,10 @@
ev.stopPropagation(); ev.stopPropagation();
var now = b.getAttribute('data-tk-status') || 'todo'; var now = b.getAttribute('data-tk-status') || 'todo';
var next = (now === 'done') ? 'todo' : 'done'; var next = (now === 'done') ? 'todo' : 'done';
/* 🔴 工单 #20:先把"改之前的状态"存下来 —— 后面删行时要用它决定
「概述」卡里该减「件在做」还是「件待办」。
⚠️ 不能在删行时再读 `data-tk-status`:那时它已经被下面改成 `d.status` 了。 */
var was = now;
document.getElementById('doneId').value = b.getAttribute('data-tk-done'); document.getElementById('doneId').value = b.getAttribute('data-tk-done');
document.getElementById('doneStatus').value = next; document.getElementById('doneStatus').value = next;
document.getElementById('doneBack').value = b.getAttribute('data-tk-back') || 'home'; document.getElementById('doneBack').value = b.getAttribute('data-tk-back') || 'home';
@@ -217,10 +221,53 @@
badge.className = 'kb-st is-' + d.status + ' is-static'; badge.className = 'kb-st is-' + d.status + ' is-static';
badge.textContent = staticWord[d.status] || d.word; badge.textContent = staticWord[d.status] || d.word;
} }
say(d.msg || '已更新。', true); /* 🔴🔴 工单 #20(用户 2026-10-28):「首页,近期任务,如果勾选了完成,
应该刷新一下这个列表,把完成的删掉。」
⇒ 在这里把这一行**从列表里摘掉**,并同步概述卡的计数。
⚠️ 判据是**服务端返回的 d.status**,不是我点之前算出的 next:
`tasks/status` 是幂等接口(传 done 再来一次还是 done),
拿 d.status 判断才不会"用户手快连点两下,第二下又把行删了"。
⚠️ 首页这份列表只收 doing + todo(index.php:839),done 本来就不在其中
⇒ 手动刷新时它确实会消失,所以"删行"与刷新后的结果**一致**,
不是前端自己造了个假象。
⚠️ 当年这里是刻意**不删**的(原注释:怕用户以为"点到删除了")——
用户现在明确要它消失,所以改掉。
🔴 补偿方式是**一句说明去哪了**(下面 say() 那段),**不是"撤回按钮"**:
先前这里的注释写着"补偿了一个可撤回的提示",但代码里从来没有撤回按钮,
只有一句 3.2 秒自动消失的 `say()`。
注释描述不存在的功能,比没有注释更糟 —— 下一个人会去找它、找不到、然后再写一个。
(撤回的正路:卡片右上角「全部」→ 任务看板,那里 done 的还在,能改回来。) */
if (d.status === 'done' && row) {
dropTaskRow(row, was);
/* 🔴 工单 #20:行被摘掉了,必须**明说**它去哪了 ——
当年"不删行"的理由就是"怕用户以为点到删除了"(见上面原注释)。
删了行就要把这条路补上:告诉他已从近期任务移除,
而卡片右上角本来就有「全部」能找回($hcMore → tasks)。
⚠️ 不能只沿用服务端那句 `已标成「已完成」` —— 那会让人以为行还在,
下一眼发现没了反而更慌。 */
say((d.msg || '已标成「已完成」。') + '(已从近期任务移出,去「全部」里能看到)', true);
} else {
say(d.msg || '已更新。', true);
}
}).catch(function () { b.disabled = false; form.submit(); }); }).catch(function () { b.disabled = false; form.submit(); });
}); });
}); });
/* ============================================================
把一条刚完成的任务行从首页列表里摘掉(工单 #20)
============================================================
🔴🔴 实现在 `assets/js/app.js` 的 `window.fbHomeTaskDrop()` —— 这里只是转发。
**不要**把那段逻辑抄回来:同一个动作在首页有两个入口
(① 这颗复选框 ② 点整行弹浮层、在面板里点「已完成」,
脚本是 task_panel.js),抄两份必然只改一份。
第一版就是抄在本地,于是"从复选框勾会删行、从面板点不删",
用户复现了后者,而我的判据只测前者 ⇒ 20/0 全绿也照样是假绿。
⚠️ 保留这个薄封装是因为下面两处调用都读 `was`,就地传更清楚;
同时也让 home.php 里"删行"这件事还有一处可 grep 的落点。 */
function dropTaskRow(row, wasStatus) {
if (typeof window.fbHomeTaskDrop !== 'function') { return; }
window.fbHomeTaskDrop(row, wasStatus);
}
})(); })();
</script> </script>
<?php endif; ?> <?php endif; ?>
+7 -3
View File
@@ -51,12 +51,16 @@ foreach (($tasks ?? []) as $__t) {
<b><?= count($todayEvents ?? []) ?></b> 条日程(今天) <b><?= count($todayEvents ?? []) ?></b> 条日程(今天)
</a> </a>
<?php /* 两个数字与右侧「近期任务」卡片**同源**(同一个 $tasks 数组), <?php /* 两个数字与右侧「近期任务」卡片**同源**(同一个 $tasks 数组),
「在做」与「待办」按状态拆开数 —— 见 v53 第 2 条那条注释。 */ ?> 「在做」与「待办」按状态拆开数 —— 见 v53 第 2 条那条注释。
<a class="hbs" href="<?= e(url('tasks')) ?>" title="近期任务(进行中)"> 🔴 工单 #20:`data-hbs` 是给首页脚本定位用的 —— 勾完成一条任务后,
这两个数字要跟着减 1(否则与列表自相矛盾)。
⚠️ 不能让脚本去匹配中文「件在做」:文案改一个字就失效,
而且这里是 JS 会读的钩子,写成文案匹配等于把展示文案变成接口。 */ ?>
<a class="hbs" data-hbs="doing" href="<?= e(url('tasks')) ?>" title="近期任务(进行中)">
<b><?= $nDoing ?></b> 件在做 <b><?= $nDoing ?></b> 件在做
</a> </a>
<?php if ($nTodo > 0): ?> <?php if ($nTodo > 0): ?>
<a class="hbs" href="<?= e(url('tasks')) ?>" title="记下来了、还没动手的那些"> <a class="hbs" data-hbs="todo" href="<?= e(url('tasks')) ?>" title="记下来了、还没动手的那些">
<b><?= $nTodo ?></b> 件待办 <b><?= $nTodo ?></b> 件待办
</a> </a>
<?php endif; ?> <?php endif; ?>
+35 -4
View File
@@ -20,10 +20,23 @@
* 面板取不到(404 / 网络)时,浮层会退回 `rows` 那份只读信息 —— 不会白屏。 * 面板取不到(404 / 网络)时,浮层会退回 `rows` 那份只读信息 —— 不会白屏。
*/ */
?> ?>
<div class="hc-body"> <div class="hc-body" data-tk-home-host>
<?php if (!$tasks): ?> <?php /* 🔴 工单 #20(用户 2026-10-28):「首页,近期任务,如果勾选了完成,
<p class="empty-hint">没有进行中的任务,也没有待办。</p> 应该刷新一下这个列表,把完成的删掉。」
<?php else: ?> ⇒ 勾完要把这一行**从列表里摘掉**(原来只改徽章文字,见 home.php 那段脚本)。
⚠️ 但"删到一条不剩"时卡里不能是一片空白 —— 下面这句 `empty-hint` 由服务端渲染,
前端删完不会自己冒出来,所以脚本要能在空的时候把它补上。
`data-tk-empty` 就是给脚本找它的钩子(不靠文案匹配,文案改一个字就失效)。
🔴🔴 把任务标成完成在首页有**两个入口**,两个都要删行:
① 每行前面那颗复选框 → 脚本在 `home.php` 末尾
② **点整行弹浮层** → 面板里的「状态」三段按钮 → `assets/js/task_panel.js`
真正干活的是 `assets/js/app.js` 的 `window.fbHomeTaskDrop()`,两个入口共用。
⚠️ 第一版只改了 ①,用户点的是 ②,于是"改完还是不消失"——
而我的判据只测了 ①,所以 20/0 全绿也是假绿。
**需求有几个入口,就要有几份判据。** */ ?>
<p class="empty-hint" data-tk-empty<?= $tasks ? ' hidden' : '' ?>>没有进行中的任务,也没有待办。</p>
<?php if ($tasks): ?>
<ul class="dash-list tk-list"> <ul class="dash-list tk-list">
<?php foreach ($tasks as $t): ?> <?php foreach ($tasks as $t): ?>
<?php $late = \App\Task::isOverdue($t['due_at'] ?? null); ?> <?php $late = \App\Task::isOverdue($t['due_at'] ?? null); ?>
@@ -43,6 +56,24 @@
'title' => (string)$t['title'], 'title' => (string)$t['title'],
'status_word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')), 'status_word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
'note' => (string)($t['note'] ?? ''), 'note' => (string)($t['note'] ?? ''),
/* 🔴🔴 2026-11-05(用户:「网页端,在首页上修改一个日程,
没有填写截止日期的地方」):
根因:`fb_entry_json()` 用 `kind === 'custom' && ref_id > 0`
算 `$evOwn`,而这里**只传了 edit 地址、没传 ref_id**
⇒ `$evOwn = false` ⇒ `source = 'derived'`
⇒ 浮层底栏的「编辑」「删除」两颗按钮**都不显示**
⇒ 用户点开任务只能看到只读面板,压根没有进编辑表单的入口
⇒ 永远看不到「截止时间」(那控件只在 _edit_form.php 里)。
⚠️ 只补 `ref_id`,**不补 kind**:`fb_entry_json` 的判据是
`kind === 'custom'`,而这个值只用来区分
「日历里真正属于某本日历的条目」vs「从别的模块派生的」。
对任务来说它只关心"能不能在这里改",答案是**能**
(有 `tasks/edit`),所以让它走默认的 'custom' 就对了。
⚠️ 补了之后 `del` 也会变非空 —— 这是**正确的**,
任务本来就能删,只是之前被同一个 bug 一起藏掉了。 */
'ref_id' => (int)$t['id'],
/* v78:浮层把正文换成这块面板(可就地改状态/看板/子任务/附件/评论) */ /* v78:浮层把正文换成这块面板(可就地改状态/看板/子任务/附件/评论) */
'panel' => url('tasks/panel', ['id' => (int)$t['id']]), 'panel' => url('tasks/panel', ['id' => (int)$t['id']]),
], url('tasks/show', ['id' => (int)$t['id']]), ], url('tasks/show', ['id' => (int)$t['id']]),
+2 -2
View File
@@ -1,7 +1,7 @@
<div class="auth-wrap"> <div class="auth-wrap">
<div class="auth-card"> <div class="auth-card">
<img class="logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt=""> <img class="logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
<h1>安装「家书」</h1> <h1>安装「岁月家」</h1>
<p class="lead">先建好你的家庭空间,再给家里人开通知。填完这一步就能用了。</p> <p class="lead">先建好你的家庭空间,再给家里人开通知。填完这一步就能用了。</p>
<?php if (!empty($errors['_'])): ?> <?php if (!empty($errors['_'])): ?>
@@ -15,7 +15,7 @@
<label for="family_name">家庭名称</label> <label for="family_name">家庭名称</label>
<input class="input" id="family_name" name="family_name" <input class="input" id="family_name" name="family_name"
value="<?= e(old('family_name', '我们家')) ?>" placeholder="比如:我们家" required> value="<?= e(old('family_name', '我们家')) ?>" placeholder="比如:我们家" required>
<span class="tip">只存档用 —— 页面上显示的是「某某某的家书」,不会再展示这个名称。</span> <span class="tip">只存档用 —— 页面上显示的是「某某某的家庭岁月」,不会再展示这个名称。</span>
</div> </div>
<div class="field full"> <div class="field full">
<label for="motto">系统 slogan</label> <label for="motto">系统 slogan</label>
+1 -1
View File
@@ -14,7 +14,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($pageTitle) ?> · 家书</title> <title><?= e($pageTitle) ?> · 岁月家</title>
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest"> <link rel="manifest" href="/site.webmanifest">
+23 -3
View File
@@ -43,7 +43,7 @@ $isLoggedIn = \App\Auth::check();
document.documentElement.className.replace('no-js', 'js');</script> document.documentElement.className.replace('no-js', 'js');</script>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($pageTitle) ?> · 家书</title> <title><?= e($pageTitle) ?> · 岁月家</title>
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest"> <link rel="manifest" href="/site.webmanifest">
@@ -134,16 +134,25 @@ if ($_needAddr): ?>
下面所有用到品牌色的地方(按钮、链接、页头高亮…)自动跟着变。 下面所有用到品牌色的地方(按钮、链接、页头高亮…)自动跟着变。
⚠️ 只覆盖这三个变量,**不碰** --paper / --ink 这些纸墨底色 —— ⚠️ 只覆盖这三个变量,**不碰** --paper / --ink 这些纸墨底色 ——
模块色是用来"分区"的,把整页背景也换了就不是家书了。 */ ?> 模块色是用来"分区"的,把整页背景也换了就不是家书了。 */ ?>
<?php
/* #85:下拉框浮层要用的三档浅色(与 FeedLite 同一套比例)。
在这里一次算好,CSS 直接用 var(--brand-tint-1) 之类。
⚠️ 算不出来(主题色不是 6 位 hex)就留空,CSS 里有 fallback。 */
$fbTint1 = fb_color_tint((string)$themeVars['brand'], 0.91);
$fbTint2 = fb_color_tint((string)$themeVars['brand'], 0.80);
$fbTint3 = fb_color_tint((string)$themeVars['brand'], 0.72);
?>
<body class="mod-<?= e($moduleKey) ?>" <body class="mod-<?= e($moduleKey) ?>"
data-media-base="media.php?k=" data-media-base="media.php?k="
style="--brand: <?= e($themeVars['brand']) ?>; --brand-2: <?= e($themeVars['brand2']) ?>; --brand-ink: <?= e($themeVars['ink']) ?>;"> style="--brand: <?= e($themeVars['brand']) ?>; --brand-2: <?= e($themeVars['brand2']) ?>; --brand-ink: <?= e($themeVars['ink']) ?>;
--brand-tint-1: <?= e($fbTint1) ?>; --brand-tint-2: <?= e($fbTint2) ?>; --brand-tint-3: <?= e($fbTint3) ?>;">
<?php if ($isLoggedIn): ?> <?php if ($isLoggedIn): ?>
<header class="topbar"> <header class="topbar">
<div class="topbar-inner"> <div class="topbar-inner">
<a class="brand" href="<?= e(url('home')) ?>"> <a class="brand" href="<?= e(url('home')) ?>">
<img src="<?= e(asset('/assets/icons/icon-96.png')) ?>" alt=""> <img src="<?= e(asset('/assets/icons/icon-96.png')) ?>" alt="">
<span>家书</span> <span>岁月家</span>
</a> </a>
<nav class="nav"> <nav class="nav">
<?php foreach ($navItems as $key => list($label, $route)): ?> <?php foreach ($navItems as $key => list($label, $route)): ?>
@@ -251,6 +260,17 @@ require __DIR__ . '/_entry_popup.php'; ?>
三处入口分散在首页/日程页,按需加载的判据会在这些页面之间来回漏; 三处入口分散在首页/日程页,按需加载的判据会在这些页面之间来回漏;
它只有几 KB,不值得为省这点流量赌"某个页面漏加载"。 */ ?> 它只有几 KB,不值得为省这点流量赌"某个页面漏加载"。 */ ?>
<script src="<?= e(asset('/assets/js/entry_popup.js')) ?>" defer></script> <script src="<?= e(asset('/assets/js/entry_popup.js')) ?>" defer></script>
<?php /* 🔵 工单 #37(用户 2026-11-01):「修改和新建,都不应该刷新页面,
而是使用js动态调整。」
⇒ 新建/修改成功后**取服务端渲染好的片段整块替换**,不手写复刻渲染。
历史教训:手写复刻必然出错(`templates/calendar/index.php` 里 `putMonth` 的注释记着
一次真实事故 —— 新加一条 07:00 的日程被 append 到末尾,落进「+N 条」看不见,
刷新后却又跑到第一位)。
⚠️ 全站加载:它在**首页**和**日程页**各有落点,按需加载的判据会来回漏;
自带"两个落点都不在就什么都不做"的判据,开销可忽略。
⚠️ 必须排在 `entry_popup.js` 之后(浮层保存成功后要调它的 refresh)。
`defer` 保证按文档顺序执行,所以这两行的先后就是执行先后。 */ ?>
<script src="<?= e(asset('/assets/js/cal_sync.js')) ?>" defer></script>
<?php /* 🔴 v78:首页任务面板的动作脚本(状态/看板/子任务/附件/评论)。 <?php /* 🔴 v78:首页任务面板的动作脚本(状态/看板/子任务/附件/评论)。
同样全站加载:面板是浮层的一部分,而浮层本身就挂在本布局里; 同样全站加载:面板是浮层的一部分,而浮层本身就挂在本布局里;
脚本自带"面板不在就什么都不做"的判据。 */ ?> 脚本自带"面板不在就什么都不做"的判据。 */ ?>
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="auth-head"> <div class="auth-head">
<img class="logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt=""> <img class="logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
<h1><?= e($familyName) ?></h1> <h1><?= e($familyName) ?></h1>
<p class="lead">用手机号登录你的家书</p> <p class="lead">用手机号登录你的岁月家</p>
</div> </div>
<?php if (!empty($error)): ?> <?php if (!empty($error)): ?>
+11 -23
View File
@@ -289,7 +289,8 @@ $fbAdd = !empty($fbAddModalAttr);
* 按钮留在原位、用 HTML5 的 `form="id"` 关联到外面那个表单。 * 按钮留在原位、用 HTML5 的 `form="id"` 关联到外面那个表单。
*/ */
$mHasPoster = $isEdit && !empty($m['poster_key']); $mHasPoster = $isEdit && !empty($m['poster_key']);
$mHasTicket = $isEdit && !empty($m['ticket_key']); /* 🔴 2026-11-06:`$mHasTicket` 取消。票根概念已并入「观影照片」,
旧字段迁完置 NULL,页面不再有第三个分类。 */
$mPhotos = $isEdit ? (array)($m['photos'] ?? []) : []; $mPhotos = $isEdit ? (array)($m['photos'] ?? []) : [];
$mDelForms = []; // 收集起来,在表单**外面**统一渲染 $mDelForms = []; // 收集起来,在表单**外面**统一渲染
?> ?>
@@ -355,21 +356,13 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
<div class="mv-row mv-row-media"> <div class="mv-row mv-row-media">
<span class="mv-lb no-colon">观影照片</span> <span class="mv-lb no-colon">观影照片</span>
<div class="mv-media-body"> <div class="mv-media-body">
<?php if ($mHasTicket || $mPhotos): ?> <?php if ($mPhotos): ?>
<div class="photo-grid"> <div class="photo-grid">
<?php if ($mHasTicket): /* 历史数据:旧票根(不在 event_photos 里,只展示) */ ?> <?php /* 🔴 2026-11-06:**票根那个特殊格子删掉了**。用户要求只分两类
<?php $mDelForms['ticket'] = 'movies/media_del'; ?> (海报 / 观影照片),旧票根已经一次性迁进 `event_photos`
<figure class="photo-cell"> (220 条,position 排最前),现在它就是下面 foreach 里的
<a href="<?= e(media_url((string)$m['ticket_key'])) ?>" data-prev="image" 一条普通照片 —— 有 id、能删、能拖进灯箱。
data-src="<?= e(media_url((string)$m['ticket_key'])) ?>" ⚠️ 别再把它加回来。留着就等于承认还有第三类。 */ ?>
data-dl="<?= e(media_url((string)$m['ticket_key'], ['dl' => 1])) ?>"
data-name="票根">
<img src="<?= e(media_url((string)$m['ticket_key'])) ?>" alt="票根">
</a>
<button class="btn btn-ghost btn-sm" type="submit" form="mv-del-ticket"
onclick="return confirm('删掉这张旧票根?')">删除</button>
</figure>
<?php endif; ?>
<?php foreach ($mPhotos as $mp): $mpId = (int)$mp['id']; ?> <?php foreach ($mPhotos as $mp): $mpId = (int)$mp['id']; ?>
<figure class="photo-cell"> <figure class="photo-cell">
<a href="<?= e(media_url((string)$mp['key'])) ?>" data-prev="image" <a href="<?= e(media_url((string)$mp['key'])) ?>" data-prev="image"
@@ -450,13 +443,8 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
<input type="hidden" name="slot" value="poster"> <input type="hidden" name="slot" value="poster">
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php if ($mHasTicket): ?> <?php /* 🔴 2026-11-06:`mv-del-ticket` 这个表单删掉了 —— 票根已并入
<form id="mv-del-ticket" method="post" action="<?= e(url('movies/media_del')) ?>" class="form-outside"> `event_photos`,走下面那条按 photo_id 删的通用表单。 */ ?>
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="slot" value="ticket">
</form>
<?php endif; ?>
<?php foreach ($mPhotos as $mp): ?> <?php foreach ($mPhotos as $mp): ?>
<form id="mv-del-photo-<?= (int)$mp['id'] ?>" method="post" <form id="mv-del-photo-<?= (int)$mp['id'] ?>" method="post"
action="<?= e(url('movies/photo_del')) ?>" class="form-outside"> action="<?= e(url('movies/photo_del')) ?>" class="form-outside">
@@ -467,7 +455,7 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
<input type="hidden" name="back" value="edit"> <input type="hidden" name="back" value="edit">
</form> </form>
<?php endforeach; ?> <?php endforeach; ?>
<?php unset($mHasPoster, $mHasTicket, $mPhotos, $mDelForms, $mp, $mpId); ?> <?php unset($mHasPoster, $mPhotos, $mDelForms, $mp, $mpId); ?>
<?php /* ---- 新建影院:弹框(不跳转,见上)---- */ ?> <?php /* ---- 新建影院:弹框(不跳转,见上)---- */ ?>
<div class="cn-modal" id="cinemaNewModal" hidden> <div class="cn-modal" id="cinemaNewModal" hidden>
+16 -28
View File
@@ -3,13 +3,14 @@
* 观影详情页(v9,展示方式照搬 MovieLog 的 detail.php)。 * 观影详情页(v9,展示方式照搬 MovieLog 的 detail.php)。
* *
* 五大块:英雄区(左海报右信息)→ 一起看的人 → 电影院信息 → 观影照片(灯箱)→ 评价与评论。 * 五大块:英雄区(左海报右信息)→ 一起看的人 → 电影院信息 → 观影照片(灯箱)→ 评价与评论。
* 海报/票根/照片一律走 media_url() 代理;感受标签只存 key,emoji 与文案查 CATALOG。 * 海报/观影照片一律走 media_url() 代理;感受标签只存 key,emoji 与文案查 CATALOG。
* 访客(只读)看不到编辑/删除/传照片/评价表单 —— 与名册同一口径。 * 访客(只读)看不到编辑/删除/传照片/评价表单 —— 与名册同一口径。
* 评价人是「登录账号背后的人」,与同行观影人无关(MovieLog 同款规则)。 * 评价人是「登录账号背后的人」,与同行观影人无关(MovieLog 同款规则)。
*/ */
$canWrite = \App\Auth::canWrite(); $canWrite = \App\Auth::canWrite();
$hasPoster = !empty($m['poster_key']); $hasPoster = !empty($m['poster_key']);
$hasTicket = !empty($m['ticket_key']); /* 🔴 2026-11-06:`$hasTicket` 取消 —— 票根已并入 `event_photos`,
只分「海报 / 观影照片」两类。 */
// 豆瓣元数据(v10):整体 JSON 存 douban_meta,随用随解;解不开就当没有 // 豆瓣元数据(v10):整体 JSON 存 douban_meta,随用随解;解不开就当没有
$doubanMeta = []; $doubanMeta = [];
if (!empty($m['douban_meta'])) { if (!empty($m['douban_meta'])) {
@@ -160,7 +161,7 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
<button class="btn" type="button" id="mvEditBtn" aria-haspopup="dialog" aria-expanded="false" <button class="btn" type="button" id="mvEditBtn" aria-haspopup="dialog" aria-expanded="false"
data-href="<?= e(url('movies/edit', ['id' => (int)$m['id']])) ?>">编辑</button> data-href="<?= e(url('movies/edit', ['id' => (int)$m['id']])) ?>">编辑</button>
<form method="post" action="<?= e(url('movies/destroy')) ?>" <form method="post" action="<?= e(url('movies/destroy')) ?>"
onsubmit="return confirm('删除这条观影记录?海报、票根、照片也会一并删除,不可恢复。')"> onsubmit="return confirm('删除这条观影记录?海报、观影照片也会一并删除,不可恢复。')">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<button type="submit" class="btn btn-del">删除</button> <button type="submit" class="btn btn-del">删除</button>
@@ -258,30 +259,19 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- 观影照片(v35:票根 + 现场照片 + 其它相关图片,**不再分类**,合成一块) --> <!-- 观影照片(v38:**只此一类**,票根已并入 event_photos) -->
<?php if ($hasTicket || $m['photos']): ?> <?php if ($m['photos']): ?>
<div class="detail-section"> <div class="detail-section">
<h3>观影照片 <span class="badge"><?= count($m['photos']) + ($hasTicket ? 1 : 0) ?> 张</span></h3> <h3>观影照片 <span class="badge"><?= count($m['photos']) ?> 张</span></h3>
<div class="gallery-grid"> <div class="gallery-grid">
<?php /* ⚠️ 旧票根(movie_details.ticket_key)是历史数据,仍要能看到; <?php /* 🔴 2026-11-06:**票根那个特殊格子删掉了**。用户要求只分两类
归并后它跟 event_photos 里的照片**同一块展示、同一套灯箱**, (海报 / 观影照片);旧 `movie_details.ticket_key` 里的票根
但删除要走它自己的槽位表单(media_del)。 */ ?> 已经一次性迁进 `event_photos`(220 条,position 排最前),
<?php if ($hasTicket): $lbIdx = 1; ?> 现在它就是下面 foreach 里的普通一张 —— 同一个灯箱、同一个删除路由。
<div class="gallery-item lb-openable" data-lb-index="<?= $lbIdx ?>" role="button" tabindex="0" aria-label="放大查看" title="点击查看大图"> ⚠️ 别再把它加回来,留着就等于承认还有第三类。
<img src="<?= e(media_url($m['ticket_key'])) ?>" alt="" loading="lazy"> ⚠️ `$lbIdx` 也因此从 1 起直接算,不再需要 `+ ($hasTicket ? 2 : 1)`。 */ ?>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('movies/media_del')) ?>"
onsubmit="return confirm('删除这张照片?')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="slot" value="ticket">
<button type="submit" class="photo-del" title="删除这张照片">×</button>
</form>
<?php endif; ?>
</div>
<?php endif; ?>
<?php foreach ($m['photos'] as $i => $ph): ?> <?php foreach ($m['photos'] as $i => $ph): ?>
<div class="gallery-item lb-openable" data-lb-index="<?= $i + ($hasTicket ? 2 : 1) ?>" role="button" tabindex="0" aria-label="放大查看照片" title="点击查看大图"> <div class="gallery-item lb-openable" data-lb-index="<?= $i + 1 ?>" role="button" tabindex="0" aria-label="放大查看照片" title="点击查看大图">
<img src="<?= e(media_url((string)$ph['key'])) ?>" alt="" loading="lazy"> <img src="<?= e(media_url((string)$ph['key'])) ?>" alt="" loading="lazy">
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<form method="post" action="<?= e(url('movies/photo_del')) ?>" <form method="post" action="<?= e(url('movies/photo_del')) ?>"
@@ -434,7 +424,7 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if ($hasPoster || $hasTicket || $m['photos']): ?> <?php if ($hasPoster || $m['photos']): ?>
<!-- 图片灯箱:点任意一张图在当前页弹出,可左右翻看 / Esc 关闭(MovieLog 同款) --> <!-- 图片灯箱:点任意一张图在当前页弹出,可左右翻看 / Esc 关闭(MovieLog 同款) -->
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-hidden="true" aria-label="图片查看器"> <div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-hidden="true" aria-label="图片查看器">
<?php /* v24 第 3 条:预览层右上角恒有下载按钮(与全局附件预览一个规矩) */ ?> <?php /* v24 第 3 条:预览层右上角恒有下载按钮(与全局附件预览一个规矩) */ ?>
@@ -454,9 +444,7 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
<?php if ($hasPoster): ?> <?php if ($hasPoster): ?>
items.push({url: <?= json_encode(media_url((string)$m['poster_key']), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, cap: <?= json_encode('海报 · ' . $m['title'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>}); items.push({url: <?= json_encode(media_url((string)$m['poster_key']), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, cap: <?= json_encode('海报 · ' . $m['title'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>});
<?php endif; ?> <?php endif; ?>
<?php if ($hasTicket): ?> <?php /* 🔴 2026-11-06:票根那条 items.push 删掉了 —— 它已在 `$m['photos']` 里 */ ?>
items.push({url: <?= json_encode(media_url((string)$m['ticket_key']), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, cap: <?= json_encode('观影照片 · ' . $m['title'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>});
<?php endif; ?>
<?php foreach ($m['photos'] as $ph): ?> <?php foreach ($m['photos'] as $ph): ?>
items.push({url: <?= json_encode(media_url((string)$ph['key']), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, cap: <?= json_encode('观影照片 · ' . $m['title'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>}); items.push({url: <?= json_encode(media_url((string)$ph['key']), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, cap: <?= json_encode('观影照片 · ' . $m['title'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>});
<?php endforeach; ?> <?php endforeach; ?>
+105 -3
View File
@@ -408,10 +408,40 @@ $imgParts = static function (string $c): array {
<div id="noteAttForm"> <div id="noteAttForm">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<div class="note-att-drop" id="noteAttDrop"> <?php /* 🔴 工单 #21(用户 2026-10-28):「网页端,笔记应该可以同时上传多个附件。」
实测查清的三件事(别凭印象改):
① **多选本来就是好的**:`multiple` + `name="file[]"` + 后端
`attach_save_files()` 那段数组分支,一次选 3 个(txt/md/png 混合)
真的存了 3 行。这条不用动。
② **拖放是假承诺**:上面那句「把文件拖到这里」从来没人接。
`app.js` 的 `scan()` 会给每个 file input 挂 `bindZone(input, input)`,
但落点绑在 **input 自己**身上 —— 而用户拖的是**外层这个框**,
事件发生在框上、不会反向冒泡到 input ⇒ input.files 永远是 0。
更糟的是没有 `dragover` 的 preventDefault,浏览器按默认行为
**直接打开那个文件**,正在写的笔记连未保存的正文一起丢。
③ 传完跳回**阅读态**(见 index.php 的 attach_add),用户看不见刚传的附件,
体感就是"没传上"。
修法:给框加 `data-sd-for="noteAttInput"` —— 这是项目里既有的约定
(`bindInput()` 见到它就 `bindZone(box, input)` + `bindBox`),
一行属性就能复用成熟实现,不另写一套拖放。
🔴🔴 `data-sd-for` 里写的是**裸 id,不带 `#`**!
取值处是 `querySelectorAll('[data-sd-for="' + input.id + '"]')`,
而 `input.id` 是 `noteAttInput`。我第一版写成 `#noteAttInput`,
选择器就变成 `[data-sd-for="#noteAttInput"]` —— 一个都匹配不上,
拖放照样没人接(判据 ④-a/④-b 直接红)。**照抄 _stage_drop.php:135
的写法**(那里是 `data-sd-for="<?= e($sdId) ?>"`,$sdId 是裸 id)。
⚠️ 不能改成 `data-drop-stage`(暂存式):那套要配合 `upload_stage.php`
与 `stage_token[]` 认领,笔记这边是"直接 POST 到 attach_add",两套不能混。
⚠️ `bindFileEcho` 只认 `.upbox-msg` / `.upbox-ico`,本框用的是 `.note-att-tip`,
所以它插不上手 —— 选中后的即时反馈由下面那段小脚本自己负责。 */ ?>
<div class="note-att-drop" id="noteAttDrop" data-sd-for="noteAttInput">
<input type="file" name="file[]" id="noteAttInput" multiple <input type="file" name="file[]" id="noteAttInput" multiple
accept="image/*,.pdf,.txt,.md,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx"> accept="image/*,.pdf,.txt,.md,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx">
<span class="note-att-tip">把文件拖到这里,或<label for="noteAttInput">点这里选文件</label></span> <span class="note-att-tip" data-note-att-tip>把文件拖到这里,或<label for="noteAttInput">点这里选文件</label>(可一次选多个)</span>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<?php /* ⚠️ formaction/formenctype 必须写在**按钮**上:表单主体是"保存", <?php /* ⚠️ formaction/formenctype 必须写在**按钮**上:表单主体是"保存",
@@ -448,8 +478,80 @@ $imgParts = static function (string $c): array {
<?php /* 初始块数据:JSON 塞进 <script type="application/json"> 而不是 data-* —— <?php /* 初始块数据:JSON 塞进 <script type="application/json"> 而不是 data-* ——
属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?> 属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?>
<script type="application/json" id="noteSeed"><?= json_encode( <script type="application/json" id="noteSeed"><?php echo json_encode(
$blocks, $blocks,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
) ?></script> ) ?></script>
<?php endif; ?>
<?php if ($isEditing): ?>
<script>
/* ============================================================================
笔记附件:选中 / 拖入多个文件后**立刻**给出反馈,并自动提交上传(工单 #21)
----------------------------------------------------------------------------
用户原话:「网页端,笔记应该可以同时上传多个附件。」
拖放本身不在这里做 —— `app.js` 的 `bindInput()` 靠框上的
`data-sd-for="noteAttInput"` 已经挂好了 `bindZone`,会把 drop 的文件
写进 `input.files` 并派发一次 `change`。**本段只做两件事**:
① `change` 一来就把提示文案换成「已选择 N 个文件」——
否则用户点完/拖完看不到任何变化,会再点一遍(重复选同一批还会重叠)。
② 顺手自动提交到 `notes/attach_add`:以前要再点一次「上传所选文件」,
多选时这个多余的步骤特别容易漏点,漏点就等于"没传上"。
⚠️ 为什么用 `requestSubmit(btn)` 而不是 `form.submit()`:
`form.submit()` 会**绕过** submit 事件(`note_editor.js` 在 submit 里
serialize 正文块),而且不会带上按钮的 `formaction`/`formenctype`——
那样这次提交会跑去 `notes/save` 且不是 multipart,文件直接丢。
`requestSubmit(btn)` 会带着那颗按钮的 `formaction` 走,语义正确。
⚠️ 老浏览器没有 `requestSubmit` 就退回 `form.submit()` 之前先手动把
action/enctype 改对——但更简单的做法是让用户点那颗按钮(下面兜底提示)。
⚠️ 提交前把 `data-sd-echo`/拖放高亮之类收尾交给 app.js,这里不碰。
============================================================================ */
(function () {
var input = document.getElementById('noteAttInput');
var form = document.getElementById('noteForm');
var tip = document.querySelector('[data-note-att-tip]');
if (!input || !form || !tip) { return; }
/* 原始文案要记下来:用户取消选择时要还原(不能变成空字符串) */
var original = tip.innerHTML;
var btn = document.querySelector('#noteAttForm button[formaction]');
function paint() {
var n = input.files ? input.files.length : 0;
if (n === 0) { tip.innerHTML = original; return; }
var names = Array.prototype.slice.call(input.files, 0, 3)
.map(function (f) { return f.name; }).join('、');
tip.textContent = '已选择 ' + n + ' 个文件:' + names + (n > 3 ? ' 等' : '')
+ ',正在上传…';
}
input.addEventListener('change', function () {
paint();
/* 没选到东西(用户点取消)就别提交了 */
if (!input.files || input.files.length === 0) { return; }
if (!btn) { return; }
/* requestSubmit 会**带上这颗按钮的 formaction** 触发完整提交流程;
form.submit() 不会 —— 见上面那条 ⚠️。 */
if (typeof form.requestSubmit === 'function') {
form.requestSubmit(btn);
} else {
/* 兜底:把 action/enctype 临时改成附件端点再提交。
⚠️ 改完**不还原**——页面马上就要跳走了,还原反而可能在
"跳转前的最后一刻"被别的代码读到错误状态。 */
form.setAttribute('action', btn.getAttribute('formaction'));
form.setAttribute('enctype', 'multipart/form-data');
form.submit();
}
});
/* 上传回来(页面重载)时把提示复位 —— 服务端渲染的是原始文案,
不需要额外做;这里只在"从往返缓存恢复"的极端情况下兜一下。 */
window.addEventListener('pageshow', function (ev) {
if (ev.persisted) { paint(); }
});
})();
</script>
<?php endif; ?> <?php endif; ?>
+1 -1
View File
@@ -366,7 +366,7 @@ $renderGrid = function (array $list) use ($meId, $canMove): void {
<b>你的姓名或手机号</b> + <b>DAV 口令</b>,就能把<b>这份名册</b>同步过去。 <b>你的姓名或手机号</b> + <b>DAV 口令</b>,就能把<b>这份名册</b>同步过去。
<br>同步的就是你在这一页看到的这些人(<b><?= (int)$total ?></b> 个)—— <br>同步的就是你在这一页看到的这些人(<b><?= (int)$total ?></b> 个)——
谁加进你的名册、谁就会出现在手机通讯录里。 谁加进你的名册、谁就会出现在手机通讯录里。
<br>目前是<b>只读</b>同步:在手机上改的联系人<b>不会</b>写回家书,要改请回到本站来改。 <br>目前是<b>只读</b>同步:在手机上改的联系人<b>不会</b>写回岁月家,要改请回到本站来改。
</p> </p>
<div class="field"> <div class="field">
<label>通讯录地址</label> <label>通讯录地址</label>
+1 -1
View File
@@ -564,7 +564,7 @@ $shortKind = \App\Birthday::KINDS;
<?php if (empty($p['account_id'])): ?> <?php if (empty($p['account_id'])): ?>
<p class="small muted" style="margin:0 0 14px"> <p class="small muted" style="margin:0 0 14px">
开通后,<?= e($p['name']) ?> 就能用手机号和密码登录家书了。 开通后,<?= e($p['name']) ?> 就能用手机号和密码登录岁月家了。
</p> </p>
<form method="post" action="<?= e(url('accounts/create')) ?>" class="form-grid"> <form method="post" action="<?= e(url('accounts/create')) ?>" class="form-grid">
<?= csrf_field() ?> <?= csrf_field() ?>
+47
View File
@@ -532,6 +532,47 @@ $myPerson = (int)\App\Auth::personId();
会丢掉滚动位置;既然列表页已经有 `#resListHost` 这样的容器就顺手接上。 */ 会丢掉滚动位置;既然列表页已经有 `#resListHost` 这样的容器就顺手接上。 */
var addBtn = document.getElementById('resAddBtn'); var addBtn = document.getElementById('resAddBtn');
var addCard = document.getElementById('resAddCard'); var addCard = document.getElementById('resAddCard');
/* 🔴 工单 #19(用户 2026-10-28):「资源模块,新建资源,会把之前的附件带进来,
就是说,附件那里不是空的。」
【根因】`#resAddCard` 是新建/编辑**共用**同一个弹窗(v42 第 7 条),
但附件区**从来没被复位过**:
· 缩略图卡片与隐藏令牌 `stage_token[]` 是 queueDrop() 上传成功后
**动态插进 `.stage-list`** 的(assets/js/app.js:668),
只有点卡片上那个「×」才会移除;
· 关弹窗只做 `addCard.hidden = true`,DOM 原样留着 ⇒ 下次打开就看见上一次的附件。
⇒ 复位函数 `resetToCreate()` 清了标题/正文/归属人/共享人,**唯独漏了附件**。
⚠️ 这不只是"看着脏":上传后**没保存就关掉**的那批,暂存目录还在(没被认领),
令牌残留在表单里 ⇒ 下次点「确定」时会被 `StagedUpload::claim()` 认领到
**新资源**上 —— 实测「新建的资源莫名多了 1 张照片」
(见 `_tooling/_b36_res_attach_reset.py` 的 D1)。
⚠️ 编辑态**也要清**:`openEdit()` 并不灌附件(已有附件走列表里独立的「附件」弹窗),
所以编辑时看到的任何附件都是"上一次没做完的残留" ⇒ 保存时会被挂到这条资源上。
⚠️ 为什么"只清 DOM"就够了:卡片在**拖入时**就已经 append 进 `.stage-list`
(app.js:600,同步执行),上传完成的回调是在那个**已脱离文档**的节点上
改样式、塞令牌,不会再回到表单里 ⇒ 不需要额外加"代/epoch"计数器。
⚠️ 除了卡片,还要兜底清 `input[name="stage_token[]"]`:
用户看不见令牌,但它才是真正会被提交的东西(#456 同族:DOM 删了、数据还在)。 */
function clearStaged() {
var form = document.getElementById('resForm');
if (!form) { return; }
var list = form.querySelector('[data-stage-list]');
if (list) {
Array.prototype.forEach.call(list.querySelectorAll('.stage-cell'), function (c) {
if (c.parentNode) { c.parentNode.removeChild(c); }
});
// 一张不剩就藏回去(模板初始就 hidden,别留一条空边框)
list.hidden = true;
}
Array.prototype.forEach.call(form.querySelectorAll('input[name="stage_token[]"]'),
function (i) {
if (i.parentNode) { i.parentNode.removeChild(i); }
});
var tip = form.querySelector('[data-stage-tip]');
if (tip) { tip.textContent = ''; tip.hidden = true; }
}
function setResOpen(on) { function setResOpen(on) {
if (!addCard) { return; } if (!addCard) { return; }
addCard.hidden = !on; addCard.hidden = !on;
@@ -611,6 +652,10 @@ $myPerson = (int)\App\Auth::personId();
ids = arr.map(function (o) { return o.id; }); ids = arr.map(function (o) { return o.id; });
} catch (e) { ids = []; } } catch (e) { ids = []; }
setPickerValues('share_ids[]', ids); setPickerValues('share_ids[]', ids);
/* 🔴 工单 #19:编辑态**也要清**暂存区 —— openEdit 并不灌附件(已有附件走
列表里独立的「附件」弹窗,见下面的 openPane),所以此刻表单里若还有卡片/令牌,
那一定是"上一次拖进来没保存"的残留 ⇒ 不清的话会被挂到**这条资源**上。 */
clearStaged();
setResOpen(true); setResOpen(true);
/* 🔴 v42:编辑态才显示底栏的「共享」「附件」两颗按钮(新建时资源还不存在, /* 🔴 v42:编辑态才显示底栏的「共享」「附件」两颗按钮(新建时资源还不存在,
没有"共享给谁/挂哪些附件"可说)。 */ 没有"共享给谁/挂哪些附件"可说)。 */
@@ -666,6 +711,8 @@ $myPerson = (int)\App\Auth::personId();
if (el) { el.hidden = true; el.removeAttribute('data-res-id'); } if (el) { el.hidden = true; el.removeAttribute('data-res-id'); }
}); });
setPickerValues('share_ids[]', []); setPickerValues('share_ids[]', []);
/* 🔴 工单 #19:附件也要清 —— 否则新建时带着上一次的附件,见 clearStaged 的注释 */
clearStaged();
} }
/* 事件委托:列表里的「编辑」按钮(AJAX 换过列表后仍然有效) */ /* 事件委托:列表里的「编辑」按钮(AJAX 换过列表后仍然有效) */
+1 -1
View File
@@ -66,7 +66,7 @@ $rosterCount = $rosterCount ?? 0;
<b>我的日历本</b>和<b>我的名册</b>同步过去。 <b>我的日历本</b>和<b>我的名册</b>同步过去。
<br><b>口令和登录密码是分开的</b> —— 第三方客户端会把凭据长期存在设备上并反复自动发送, <br><b>口令和登录密码是分开的</b> —— 第三方客户端会把凭据长期存在设备上并反复自动发送,
用登录密码等于把主密码复制到每一台设备;所以这里单独发一个,随时可换、不影响登录。 用登录密码等于把主密码复制到每一台设备;所以这里单独发一个,随时可换、不影响登录。
<br>目前是<b>只读</b>同步:在手机上改日程 / 加联系人<b>不会</b>写回家书, <br>目前是<b>只读</b>同步:在手机上改日程 / 加联系人<b>不会</b>写回岁月家,
要改请回到本站来改。 要改请回到本站来改。
</p> </p>
+209 -23
View File
@@ -68,6 +68,12 @@ foreach ($tpCats as $tpCat) {
} }
} }
$tpDue = (string)($tpT['due_at'] ?? ''); $tpDue = (string)($tpT['due_at'] ?? '');
/* #78:协作人(共享给)。Task::find 已经带回 `shares`,直接用。 */
$tpShares = (array)($tpT['shares'] ?? []);
$tpShareIds = [];
foreach ($tpShares as $tpS) {
$tpShareIds[] = (int)($tpS['person_id'] ?? 0);
}
$tpDueWord = \App\Task::dueWord($tpDue !== '' ? $tpDue : null); $tpDueWord = \App\Task::dueWord($tpDue !== '' ? $tpDue : null);
$tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpDue); $tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpDue);
?> ?>
@@ -75,11 +81,31 @@ $tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpD
data-tk-url-status="<?= e(url('tasks/status')) ?>" data-tk-url-status="<?= e(url('tasks/status')) ?>"
data-tk-url-move="<?= e(url('tasks/move')) ?>" data-tk-url-move="<?= e(url('tasks/move')) ?>"
data-tk-url-cmt-del="<?= e(url('tasks/comment_del')) ?>" data-tk-url-cmt-del="<?= e(url('tasks/comment_del')) ?>"
data-tk-url-attach="<?= e(url('tasks/attach_add')) ?>"> data-tk-url-attach="<?= e(url('tasks/attach_add')) ?>"
<?php /* #74:面板里直接改截止时间(不必点「编辑」进整页表单) */ ?>
data-tk-url-due="<?= e(url('tasks/due')) ?>"
<?php /* #76:截止/负责人/分类 三项一起保存(用户要显式「保存」按钮) */ ?>
data-tk-url-save="<?= e(url('tasks/panel_save')) ?>">
<?php /* CSRF 放在面板里(见头部第 ① 条):表单各自带一份,JS 里的按钮动作 <?php /* CSRF 放在面板里(见头部第 ① 条):表单各自带一份,JS 里的按钮动作
从这里取一份 —— 两处同一个值,`csrf_token()` 天然幂等。 */ ?> 从这里取一份 —— 两处同一个值,`csrf_token()` 天然幂等。 */ ?>
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>" data-tk-csrf> <input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>" data-tk-csrf>
<?php /* ---- 任务标题 ---- */ ?>
<div class="tk-sec">
<div class="tk-attrs">
<div class="tk-attr-row">
<span class="tk-attr-lb">任务标题</span>
<?php if ($tpCan): ?>
<input class="input" id="tkp-title" name="title" maxlength="200"
value="<?= e((string)($tpT['title'] ?? '')) ?>">
<?php else: ?>
<span class="tk-attr-val"><?= e((string)($tpT['title'] ?? '')) ?></span>
<?php endif; ?>
</div>
</div>
</div>
<?php /* ---- 状态:三段平铺,点一下就改(不是下拉 —— 手机上少一次展开) ---- */ ?> <?php /* ---- 状态:三段平铺,点一下就改(不是下拉 —— 手机上少一次展开) ---- */ ?>
<div class="tk-sec tk-sec-inline"> <div class="tk-sec tk-sec-inline">
<span class="tk-sec-lb">状态</span> <span class="tk-sec-lb">状态</span>
@@ -123,28 +149,148 @@ $tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpD
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php /* ---- 只读的事实(有才显示) ---- */ ?> <?php /* ---- 更多信息:截止 / 负责人 / 备注 / 协作人 ----
<dl class="kv tk-kv"> 🔴🔴 #82(用户 2026-11-05):「后续内容也和鸿蒙版保持一致,不需要额外信息。
<?php if ($tpDue !== ''): ?> 布局顺序也和鸿蒙版一致:1.任务标题 2.状态 3.看板
<div class="kv-row"><dt>截止</dt> 4.截止日期、负责人、备注、协作人 5.子任务」
<dd class="<?= $tpLate ? 'kb-late' : '' ?>"><?= e($tpDue) ?><?= $tpDueWord !== '' ? '(' . e($tpDueWord) . ')' : '' ?></dd>
⇒ 去掉「分类」和「循环」两条 —— 它们属于用户说的"额外信息"
(分类是标签、循环是只读事实,鸿蒙端面板里也没有)。
⇒ 顺序改成 截止 → 负责人 → 备注 → 协作人(与鸿蒙端逐项对齐)。
⚠️ 服务端 `tasks/panel_save` 收不到 category_id 时会**保留原值**
(`array_key_exists` 判"传没传"),所以删掉这个字段不会清空分类。 */ ?>
<div class="tk-sec">
<div class="tk-attrs">
<div class="tk-attr-row">
<span class="tk-attr-lb">截止</span>
<?php if ($tpCan): ?>
<?php
/* ⚠️ 变量名必须与 `_datetime.php` 的约定一致;
它内部一律用 `dt` 前缀的临时变量,不会踩到这里的 $tp* 或宿主页的 $t。 */
$dtName = 'due_at';
$dtValue = $tpDue;
$dtPlugin = 'antd-single';
$dtRequired = false;
$dtId = 'tkp-due';
require __DIR__ . '/../_datetime.php';
?>
<?php if ($tpDueWord !== ''): ?>
<span class="tk-due-word<?= $tpLate ? ' kb-late' : '' ?>"><?= e($tpDueWord) ?></span>
<?php endif; ?>
<?php else: ?>
<span class="<?= $tpLate ? 'kb-late' : '' ?>"><?= $tpDue !== '' ? e($tpDue) : '未设' ?></span>
<?php endif; ?>
</div> </div>
<?php endif; ?>
<?php if (!empty($tpT['assignee_name'])): ?> <div class="tk-attr-row">
<div class="kv-row"><dt>负责人</dt> <span class="tk-attr-lb">负责人</span>
<dd><?= e(person_name(['name' => $tpT['assignee_name'], 'my_nickname' => $tpT['assignee_nick'] ?? ''])) ?></dd> <?php if ($tpCan): ?>
<?php
$ppName = 'assignee_id';
$ppPeople = $tpPeople;
$ppSelected = [(int)($tpT['assignee_id'] ?? 0)];
$ppMode = 'single';
$ppOpenBtn = '更换';
$ppTitle = '谁负责这件事';
$ppEmptyText = '不指定';
$ppAllowEmpty = true;
require __DIR__ . '/../_person_picker.php';
?>
<?php else: ?>
<span><?= !empty($tpT['assignee_name'])
? e(person_name(['name' => $tpT['assignee_name'],
'my_nickname' => $tpT['assignee_nick'] ?? '']))
: '不指定' ?></span>
<?php endif; ?>
</div> </div>
<?php endif; ?>
<?php if ($tpCatName !== ''): ?> <div class="tk-attr-row tk-attr-row-top">
<div class="kv-row"><dt>分类</dt><dd><?= e($tpCatName) ?></dd></div> <span class="tk-attr-lb">备注</span>
<?php endif; ?> <?php if ($tpCan): ?>
<?php if (!empty($tpT['repeat_rule'])): ?> <textarea class="input" id="tkp-note" name="note" rows="2"
<div class="kv-row"><dt>循环</dt><dd><?= e(\App\Task::repeatWord((string)$tpT['repeat_rule'])) ?></dd></div> placeholder="补充说明…"><?= e((string)($tpT['note'] ?? '')) ?></textarea>
<?php endif; ?> <?php else: ?>
<?php if (trim((string)($tpT['note'] ?? '')) !== ''): ?> <span><?= trim((string)($tpT['note'] ?? '')) !== ''
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e((string)$tpT['note'])) ?></dd></div> ? nl2br(e((string)$tpT['note'])) : '无' ?></span>
<?php endif; ?> <?php endif; ?>
</dl> </div>
<div class="tk-attr-row tk-attr-row-top">
<span class="tk-attr-lb">协作人</span>
<?php if ($tpCan): ?>
<?php
$ppName = 'shares[]';
$ppPeople = array_values(array_filter($tpPeople,
fn($pp) => (int)($pp['id'] ?? 0) !== $tpMe));
$ppSelected = array_values(array_filter($tpShareIds,
fn($sid) => $sid !== $tpMe));
$ppMode = 'multi';
$ppOpenBtn = '+ 添加人';
$ppTitle = '共享给谁';
require __DIR__ . '/../_person_picker.php';
?>
<?php else: ?>
<?php if (!$tpShares): ?>
<span>无</span>
<?php else: ?>
<span class="tk-share-chips">
<?php foreach ($tpShares as $tpS): ?>
<span class="tk-share-chip"><?= e(person_name($tpS)) ?></span>
<?php endforeach; ?>
</span>
<?php endif; ?>
<?php endif; ?>
</div>
<?php /* 🔴 #83(用户 2026-11-05):「你倒是提醒我了,分类和重复,也就是循环,
都需要加上,鸿蒙端也得加上。」
#82 里我按「不需要额外信息」把这两项删了 —— 那是我理解偏了,
用户说的是"不要编辑表单里那些额外的字段",不是"分类/循环不要"。
⇒ 补回。位置放在协作人之后:用户明确指定的前四项
(截止/负责人/备注/协作人)顺序不动,这两项作为补充排在后面。 */ ?>
<div class="tk-attr-row">
<span class="tk-attr-lb">分类</span>
<?php if ($tpCan): ?>
<select class="select" name="category_id" id="tkp-category">
<option value="0">未分类</option>
<?php foreach ($tpCats as $tpC): ?>
<?php $tpCid = (int)($tpC['id'] ?? 0); ?>
<option value="<?= $tpCid ?>"
<?= $tpCid === (int)($tpT['category_id'] ?? 0) ? ' selected' : '' ?>>
<?= e((string)($tpC['name'] ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
<?php else: ?>
<span class="tk-attr-val"><?= $tpCatName !== '' ? e($tpCatName) : '未分类' ?></span>
<?php endif; ?>
</div>
<div class="tk-attr-row">
<span class="tk-attr-lb">循环</span>
<?php if ($tpCan): ?>
<select class="select" name="repeat_rule" id="tkp-repeat">
<?php foreach (\App\Task::REPEAT as $tpRk => $tpRv): ?>
<option value="<?= e($tpRk) ?>"
<?= (string)($tpT['repeat_rule'] ?? '') === (string)$tpRk ? ' selected' : '' ?>>
<?= e($tpRv) ?>
</option>
<?php endforeach; ?>
</select>
<?php else: ?>
<span class="tk-attr-val"><?= e(\App\Task::repeatWord((string)($tpT['repeat_rule'] ?? ''))) ?></span>
<?php endif; ?>
</div>
<?php if ($tpCan): ?>
<div class="tk-attr-actions">
<button type="button" class="btn btn-sm" data-tk-act="save-attrs">保存</button>
</div>
<?php endif; ?>
</div>
</div>
<?php /* ---- 子任务(子任务自己不再挂子任务,与详情页同一口径) ---- */ ?> <?php /* ---- 子任务(子任务自己不再挂子任务,与详情页同一口径) ---- */ ?>
<?php if (!$tpIsChild): ?> <?php if (!$tpIsChild): ?>
@@ -259,12 +405,52 @@ $tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpD
<p class="empty-hint">还没有留言。写 @名字 可以点名。</p> <p class="empty-hint">还没有留言。写 @名字 可以点名。</p>
<?php endif; ?> <?php endif; ?>
<?php if ($tpCan): ?> <?php if ($tpCan): ?>
<form method="post" action="<?= e(url('tasks/comment_add')) ?>" class="cmt-form"> <form method="post" action="<?= e(url('tasks/comment_add')) ?>" class="cmt-form"
enctype="multipart/form-data">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $tpId ?>"> <input type="hidden" name="id" value="<?= $tpId ?>">
<input type="hidden" name="ajax_panel" value="1"> <input type="hidden" name="ajax_panel" value="1">
<textarea class="input" name="content" rows="2" required <textarea class="input" name="content" rows="2"
placeholder="说点什么… 写 @名字 可以点名"></textarea> placeholder="说点什么… 写 @名字 可以点名"></textarea>
<?php /* 🔴 #78(用户 2026-11-05):「评论那个地方也需要可以添加附件。」
服务端**早就支持**了 —— `tasks/comment_add` 里一直有
`attach_save_files($familyId, 'task_comment', $cid, 'attach', $me);`
(index.php 那一行),只是这个表单里**没有放 file input**。
⇒ 补上。字段名 `attach[]` 与详情页、任务附件同一套。
⚠️ `<form>` 必须带 `enctype="multipart/form-data"`,
否则浏览器只提交文件名、文件本体丢失(那种"上传成功但打开是空的"最迷惑)。
⚠️ 纯附件不写字也允许(把 `required` 从 textarea 上去掉了)——
"只发一张图"是常见用法;服务端在 content 为空时会用一句
占位文案,见 comment_add 的处理。
⚠️ 用原生 `<input type=file>` 而不是拖拽框:评论是一次性提交,
拖拽框那套(`data-tk-drop-box`)是"点了立刻上传",两者语义不同。 */ ?>
<?php /* 🔴 #79(用户 2026-11-05):「评论框下面的附件也需要可以拖动添加。」
🔴 #81(同日):「评论里面拖文件上传附件,那个地方太小了。」
⇒ 原来是一个 `padding:4px 10px` 的小胶囊 + 一行小字提示,
拖动落点只有 ~26px 高,触屏上根本不好瞄,桌面上也经常拖偏。
⇒ 改成与**「任务附件」那块完全一样**的 `.upbox`:
高 96px、虚线大框、加号图标 + 说明文字。
同一个交互在一个页面里不该长两个样子,也不该一个大一个小。
⚠️ 这块的语义是"挂到评论表单、点发表才一起提交",
而上面「任务附件」是"拖上去立刻上传" —— 外观一致但行为不同,
所以说明文字要写清"随这条评论一起发出",避免用户以为已经传上去了。
⚠️ `data-cmt-drop` 仍是拖拽落点标记(task_panel.js 认它),
不要改成 `data-tk-drop-box`(那是立刻上传那条路)。 */ ?>
<div class="cmt-attach-row" data-cmt-drop>
<label class="upbox cmt-upbox" role="button" tabindex="0"
aria-label="点击选择文件,或把文件拖进来">
<input type="file" name="attach[]" multiple class="sr-only"
id="tkp-cmt-files" data-cmt-file-input>
<span class="upbox-ico" aria-hidden="true">+</span>
<span class="upbox-msg">点击选择,或把文件拖到这里</span>
<span class="cmt-upbox-sub">点「发表」时随这条评论一起发出</span>
</label>
<div class="cmt-file-list" data-cmt-file-name hidden></div>
</div>
<div class="btn-row"><button class="btn btn-sm" type="submit">发表</button></div> <div class="btn-row"><button class="btn btn-sm" type="submit">发表</button></div>
</form> </form>
<?php endif; ?> <?php endif; ?>
+13 -17
View File
@@ -171,13 +171,12 @@ $nfEmbed = !empty($nfEmbed);
</option> </option>
<?php endif; ?> <?php endif; ?>
</select> </select>
<span class="tip">在右上角「设置 → 节点类型」里可以增删自定义。</span>
</div> </div>
<div class="field"> <?php /* 🔴 #47(2026-11-05):**「当前状态」不再显示**。
<label>当前状态</label> 用户:「实际行程、当前状态实际行程,这个地方也不要出现,
<div class="node-stage-ro"><?= e(\App\Trip::stageWord($stage)) ?></div> 因为这是没有意义的。我现在本来就是添加的一个实际行程。」
</div> ⇒ 阶段由入口决定(计划/实际两颗按钮),编辑时不该再展示。 */ ?>
<?php /* ---- 标题 ---- */ ?> <?php /* ---- 标题 ---- */ ?>
<div class="field full"> <div class="field full">
@@ -202,7 +201,6 @@ $nfEmbed = !empty($nfEmbed);
<?php $dtName = 'ends_at'; $dtValue = $n['ends_at'] ?? ''; <?php $dtName = 'ends_at'; $dtValue = $n['ends_at'] ?? '';
$dtId = 'nf-e'; $dtRange = 'end'; $dtPlugin = 'antd'; $dtId = 'nf-e'; $dtRange = 'end'; $dtPlugin = 'antd';
require __DIR__ . '/../_datetime.php'; ?> require __DIR__ . '/../_datetime.php'; ?>
<span class="tip">结束时间可不填;填到几点几分,详情页就显示到几点几分。</span>
</div> </div>
<?php /* ---- 出行方式(TripLog 的 travel_modes)---- */ ?> <?php /* ---- 出行方式(TripLog 的 travel_modes)---- */ ?>
@@ -251,7 +249,6 @@ $nfEmbed = !empty($nfEmbed);
<label for="nf-tripno">车次 / 航班号</label> <label for="nf-tripno">车次 / 航班号</label>
<input class="input" id="nf-tripno" name="trip_no" <input class="input" id="nf-tripno" name="trip_no"
value="<?= e(old('trip_no', $n['trip_no'] ?? '')) ?>" placeholder="如:G1234、CA1234"> value="<?= e(old('trip_no', $n['trip_no'] ?? '')) ?>" placeholder="如:G1234、CA1234">
<span class="tip">座位这类同团不同座的信息就不记了。</span>
</div> </div>
<?php /* 线路名与承运方:v24 第 20 批 第 5 条 用户明确「都不要了」—— <?php /* 线路名与承运方:v24 第 20 批 第 5 条 用户明确「都不要了」——
原话:「添加行程,线路名、承运方,都不要了。」 原话:「添加行程,线路名、承运方,都不要了。」
@@ -283,16 +280,15 @@ $nfEmbed = !empty($nfEmbed);
</div> </div>
</div> </div>
<div class="field"> <?php /* 🔴 #47(2026-11-05):**「执行状态」不再由表单选**。
<label for="nf-status">执行状态</label> 用户:「这个里面不要让它手动去动。只要是添加的计划行程,
<select class="select" id="nf-status" name="status"> 它的状态就是计划中。在计划行程里面点击开始执行,
<?php foreach (\App\Trip::NODE_STATUS as $sk => $sv): ?> 它的状态自然变成进行中…所以编辑这个地方不要出现
<option value="<?= e($sk) ?>"<?= ($n['status'] ?? 'planned') === $sk ? ' selected' : '' ?>> 计划中、进行中、已完成、已取消。」
<?= e($sv) ?>
</option> ⇒ 状态由 show.php 节点卡片上的动作按钮改(#47 新增的 API)。
<?php endforeach; ?> 表单**不提交 status**,服务端 saveNode 的 PATCH 语义会沿用原值
</select> (见 Trip::saveNode 里 `array_key_exists` 的判定)。 */ ?>
</div>
<div class="field full"> <div class="field full">
<label for="nf-detail">记录</label> <label for="nf-detail">记录</label>
+86 -13
View File
@@ -36,10 +36,14 @@ foreach ((array)($nodes ?? []) as $n) {
if (!isset($bysStage[$sg])) { $sg = 'plan'; } if (!isset($bysStage[$sg])) { $sg = 'plan'; }
$bysStage[$sg][] = $n; $bysStage[$sg][] = $n;
} }
/* 🔴 #48(2026-11-05):**提示语删掉**(用户:「已走过 —— 也出现在日历与首页
『往年今日』,这句话删除」)。不只删这一句 —— 三个分组的提示语一律去掉:
组头只留名称和条数,界面更干净。
⚠️ 保留数组结构(只用 [0] 名称),改动面最小。 */
$groupMeta = [ $groupMeta = [
'plan' => ['计划的行程', '还没走 —— 可以修正 / 完成 / 标记未成行'], 'plan' => ['计划的行程'],
'actual' => ['实际行程', '已走过 —— 也出现在日历与首页「往年今日」'], 'actual' => ['实际行程'],
'skipped' => ['未成行', '这次没去成;不上日程,但在这里留着可查'], 'skipped' => ['未成行'],
]; ];
?> ?>
<div class="page-head"> <div class="page-head">
@@ -97,7 +101,8 @@ $groupMeta = [
<div class="card card-pad trip-hero"> <div class="card card-pad trip-hero">
<div class="trip-hero-cover"> <div class="trip-hero-cover">
<?php if (!empty($t['cover_key'])): ?> <?php if (!empty($t['cover_key'])): ?>
<img src="<?= e(media_url((string)$t['cover_key'])) ?>" alt=""> <?php /* #56:题图是大图位置,给 1600 档(实测 6.6MB → 468KB) */ ?>
<img src="<?= e(media_url((string)$t['cover_key'], ['w' => 1600])) ?>" alt="">
<?php else: ?> <?php else: ?>
<span class="trip-cover-empty">🧳</span> <span class="trip-cover-empty">🧳</span>
<?php endif; ?> <?php endif; ?>
@@ -228,14 +233,15 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php else: ?> <?php else: ?>
<?php foreach (['plan', 'actual', 'skipped'] as $sg): ?> <?php /* 🔴 #48:**实际行程放最前** —— 用户:「到最后整个旅程结束之后,
我们最终看到的是实际行程。」⇒ actual → plan → skipped(未成行沉底)。 */ ?>
<?php foreach (['actual', 'plan', 'skipped'] as $sg): ?>
<?php $list = $bysStage[$sg]; if (!$list) { continue; } /* 空分组不显示 */ ?> <?php $list = $bysStage[$sg]; if (!$list) { continue; } /* 空分组不显示 */ ?>
<div class="card card-pad trip-stage-card stage-<?= e($sg) ?>"> <div class="card card-pad trip-stage-card stage-<?= e($sg) ?>">
<h2 class="cinema-h2"> <h2 class="cinema-h2">
<?= e($groupMeta[$sg][0]) ?> <?= e($groupMeta[$sg][0]) ?>
<span class="group-n"><?= count($list) ?> 段</span> <span class="group-n"><?= count($list) ?> 段</span>
</h2> </h2>
<p class="stage-hint"><?= e($groupMeta[$sg][1]) ?></p>
<ol class="node-list"> <ol class="node-list">
<?php foreach ($list as $n): ?> <?php foreach ($list as $n): ?>
@@ -250,8 +256,12 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
?> ?>
<li class="node-row status-<?= e((string)$n['status']) ?> stage-<?= e($sg) ?>" <li class="node-row status-<?= e((string)$n['status']) ?> stage-<?= e($sg) ?>"
id="node-<?= $nid ?>"> id="node-<?= $nid ?>">
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'], $fid)) ?>"> <?php /* 🔴 2026-11-05(用户:「明明选的是坐火车,网页版的图标显示的还是一辆汽车」):
<?= e(\App\Trip::typeIcon((string)$n['node_type'], $fid)) ?> 左侧图标改成**出行方式优先** —— 坐火车就显 🚄,
不再一律显节点类型的 🚗。与端上 `fbNodeLeadIcon()` 同一逻辑。 */ ?>
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'], $fid)
. ($mode !== '' ? ' · ' . \App\Trip::modeWord($mode) : '')) ?>">
<?= e(\App\Trip::leadIcon((string)$n['node_type'], $mode, $fid)) ?>
</span> </span>
<div class="node-body"> <div class="node-body">
<div class="node-head"> <div class="node-head">
@@ -285,8 +295,22 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
<?php if ($span !== '日期待补'): ?> <?php if ($span !== '日期待补'): ?>
<div class="node-time"> <div class="node-time">
<?= e($span) ?> <?php /* 🔴 #48:**计划时间与实际时间同时显示**。
<?php if ($sg === 'actual'): ?><span class="actual-tag">实际</span><?php endif; ?> 用户:「原来计划的时间和现在真正开始的时间都要记录。」
⇒ 计划用常规色,实际用主题色并标注「实际」。 */ ?>
<?php if ($sg === 'actual'): ?>
<span class="t-plan">计划 <?= e($span) ?></span>
<?php
$aStart = (string)($n['actual_started_at'] ?? '');
$aEnd = (string)($n['actual_ended_at'] ?? '');
if ($aStart !== ''):
$aSpan = \App\Trip::nodeSpan($aStart, $aEnd !== '' ? $aEnd : null);
?>
<span class="t-actual">实际 <?= e($aSpan) ?></span>
<?php endif; ?>
<?php else: ?>
<?= e($span) ?>
<?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
@@ -316,23 +340,72 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
$navFrom = \App\Geo::navUrl($n['from_lng'] ?? null, $n['from_lat'] ?? null, (string)($n['from_place'] ?? '')); $navFrom = \App\Geo::navUrl($n['from_lng'] ?? null, $n['from_lat'] ?? null, (string)($n['from_place'] ?? ''));
$navTo = \App\Geo::navUrl($n['to_lng'] ?? null, $n['to_lat'] ?? null, (string)($n['to_place'] ?? '')); $navTo = \App\Geo::navUrl($n['to_lng'] ?? null, $n['to_lat'] ?? null, (string)($n['to_place'] ?? ''));
?> ?>
<?php /* 🔴 2026-11-05(用户:「直接用个标签,然后右边一个箭头,
到北京南站就得了,不用写从哪到哪」):
两个地名都做成**胶囊标签**,箭头只作分隔。
有坐标的套 <a>(可点开导航),没坐标的套 span ——
**两种情况都用同一个类**,样式才一致(原来无链接时
是裸文本,和另一侧的胶囊对不齐,这正是"位都没对齐"的观感来源)。 */ ?>
<?php if ($navFrom !== ''): ?> <?php if ($navFrom !== ''): ?>
<a href="<?= e($navFrom) ?>" target="_blank" rel="noopener" <a href="<?= e($navFrom) ?>" target="_blank" rel="noopener"
title="<?= e((string)($n['from_addr'] ?? '')) ?>"><?= e((string)$n['from_place']) ?></a> title="<?= e((string)($n['from_addr'] ?? '')) ?>"><?= e((string)$n['from_place']) ?></a>
<?php else: ?> <?php else: ?>
<?= e((string)($n['from_place'] ?? '—')) ?> <span class="route-stop"><?= e((string)($n['from_place'] ?? '—')) ?></span>
<?php endif; ?> <?php endif; ?>
<span class="arrow">→</span> <span class="arrow">→</span>
<?php if ($navTo !== ''): ?> <?php if ($navTo !== ''): ?>
<a href="<?= e($navTo) ?>" target="_blank" rel="noopener" <a href="<?= e($navTo) ?>" target="_blank" rel="noopener"
title="<?= e((string)($n['to_addr'] ?? '')) ?>"><?= e((string)$n['to_place']) ?></a> title="<?= e((string)($n['to_addr'] ?? '')) ?>"><?= e((string)$n['to_place']) ?></a>
<?php else: ?> <?php else: ?>
<?= e((string)($n['to_place'] ?? '—')) ?> <span class="route-stop"><?= e((string)($n['to_place'] ?? '—')) ?></span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($n['detail'])): ?> <!-- 🔴🔴 #48(2026-11-05)行程动作按钮组。
用户原话:
「计划行程右上角就不是两个按钮了,而是至少四个按钮:
一个是"开始",一个是"完成",一个是"编辑",一个是"删除"。
在删除前面还应该有一个按钮,这个按钮是取消,
指的就是不去了,但这个事情我并不把它删掉。」
⇒ 计划行程:开始 / 完成 / 编辑 / 取消 / 删除
⇒ 实际行程:编辑 / 删除(无状态、无开始无完成)
⇒ 未成行: 编辑 / 删除(已取消,不再转)
⚠️ 当前状态用实心 chip 表示,与端上 pillBtn(selected) 同一视觉语言。
⚠️ 按钮规则来自 Trip::nodeAvailableActions() —— 前后端共用一份。
⚠️ 这里用 HTML 注释而不是 PHP 注释:PHP 的 /* */ 注释里遇到 `?>`
会提前结束 PHP 段(词法层面 `?>` 优先于注释),导致解析错误。 -->
<?php
$stageNow = (string)($n['stage'] ?? 'plan');
$statusNow = (string)($n['status'] ?? 'planned');
$acts = \App\Trip::nodeAvailableActions($stageNow, $statusNow);
$isActual = $stageNow === 'actual';
?>
<div class="node-actions">
<?php if (!$isActual && $stageNow !== 'skipped'): ?>
<span class="node-act is-on"><?= e(\App\Trip::NODE_STATUS[$statusNow] ?? '计划中') ?></span>
<?php endif; ?>
<?php foreach ($acts as $a): ?>
<?php
$label = $a === 'start' ? '开始' : '完成';
$cls = $a === 'start' ? 'go' : 'ok';
?>
<button type="button" class="node-act node-act-<?= e($cls) ?>"
data-node-act="<?= e($a) ?>"
data-node="<?= e($nid) ?>"><?= e($label) ?></button>
<?php endforeach; ?>
<?php if (!$isActual && $stageNow !== 'skipped'): ?>
<button type="button" class="node-act node-act-warn"
data-node-act="cancel"
data-node="<?= e($nid) ?>"
title="不去了,但数据保留">取消</button>
<?php endif; ?>
</div>
<?php if (!empty($n['detail'])): ?>
<p class="node-detail"><?= nl2br(e((string)$n['detail'])) ?></p> <p class="node-detail"><?= nl2br(e((string)$n['detail'])) ?></p>
<?php endif; ?> <?php endif; ?>