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 |
|