v24 第 21 批:无刷新删除与新建日程 / 实际行程不回退 / 行程按时间排 / 统一上传框 / 时间范围控件修复(用户 8 条)

用户 8 条原话要点:
  ①「名册页面,删除一个联系人后,能否不刷新页面,删除成功后,直接把这个人,
     从当前页面的 dom 中删除,页面保持不动。」
  ②「观影模块,海报和票根以及照片都带进来了,但是没有缩放,把页面撑破了,
     应该缩放成缩略图。」
  ③「个人设置页面,天气设置那里,把:只影响你自己首页上的那一格 / 填个城市名就行
     (如「青岛」)… 这些提示,都删除。把城市单独放一行,精度维度放在一行。」
  ④「旅程模块,实际行程编辑后,点击保存,竟然又变成计划行程了,这个不合理,
     实际行程永远是实际行程,不会再变回计划行程了。」
  ⑤「旅程模块,旅程列表,行程列表,都是按照行程的开始时间排序的,最新的在最上面,
     而不是按照编辑修改时间。」
  ⑥「旅程的详情页面,右上角的按钮,设置在最右侧,和其他模块保持统一。」
  ⑦「所有上传图片等附件的地方,都不再放按钮,而是放一个框,点击这个框实现点击上传
     按钮的功能,拖动图片进这个框里,实现拖动多个上传功能。框里面写一句话:
     点击或拖动进行上传,其他的功能和现在保持一致。」
  ⑧「日历中新建日程,无论是点击按钮还是点击日历中间,都是采用弹框的形式进行添加,
     这样不影响当前日历的显示,添加完成后,动态修改当前视图,不要刷新。添加日历日程,
     开始时间和结束时间那里,用我们统一的时间范围选择器重做,现在功能无法使用。
     选择日历本,带上颜色。」

🔴 五个真根因(全都不像 bug):

 ① 🔴 **④「实际行程变回计划」= 表单字段同名合并**(本批最要紧的一条)
    暂存上传的令牌字段原叫 `stage[]`,与行程表单里那个隐藏的 `stage`(plan/actual)
    **同名**。PHP 解析表单时同名会**合并进同一个键**:
        `stage=actual` + `stage[]=tok` → `$_POST['stage'] = ['actual','tok']`
    (`[]` 只是"往同一个键追加"的记号,**不构成另一个键名**。)
    于是路由里 `(string)($_POST['stage'] ?? 'plan')` 得到 `"Array"` →
    `in_array('Array', ['plan','actual'])` 为假 → **静默回落成 'plan'**。
    ⚠️ 只有"拖过图片的行程"会犯(没拖就没有 `stage[]`)→ 症状是"时好时坏"。
    修法:令牌字段改名成 `stage_token[]`(改的是**字段名本身**,不是"是数组就取第一个"
    绕过去 —— 同名合并是 PHP 的表单语义,绕过去只会把坑留给下一次加字段)。
    **另加一道语义保险**:编辑路径**一律沿用库里那条自己的 stage**,不读表单 ——
    编辑表单里根本没有改阶段的控件(改阶段走详情页那几个显式动作)。
    这样即便将来又冒出某个字段名冲突,也不可能把「实际」悄悄变回「计划」。

 ② 🔴 **⑧「时间范围选择器无法使用」= 三道门叠在一起**
    ① `_event_fields.php` 的**结束端默认带 `hidden`**(要点「+ 添加结束时间」才出现);
    ② `app.js` 的 `upgradeRange()` 有「**两端都得可见**才升级」的判断 → 永远不成立
       (实测 `data-semi-range-done` 恒为 `null`);
    ③ CSS `html.js .dt-range:not(.is-semi-range) .dt-input { visibility: hidden }` →
       未升级时原生控件**也是不可见的** ⇒ 屏幕上一片空白、点哪儿都没反应。
    修法:两端**始终可见**("没有结束"由清空后半段表达,不再靠开关);
    并把旧的「+ 添加结束时间 / 去掉结束时间」开关整套删掉。
    ⚠️ 顺带挖出:`upgradeRange()` 里 `group.insertBefore(host, a)` 在"两端各自被
    `.field` 包着"时 `a` 不是 `group` 的子节点 → 直接抛
    `Failed to execute 'insertBefore' on 'Node'`。这个异常**以前从没触发过**
    (被 ② 的提前 return 挡住了)—— 一旦放出来就炸。修成插在"端点最外层包装"之前。
    ⚠️ 观察器加了 `attributes: true, attributeFilter: ['hidden']`(并把 childList
    分支的 `return` 去掉):弹框/折叠面板/tab 里"稍后才露出来"的日期控件从此都能自动升级。

 ③ 🔴 **⑧「新建日程默认建出观影记录」= 产品缺口,不只是显示问题**
    `Calendar::DEFAULT_KINDS` 原来只有 6 本**派生**本(观影/旅行/农历/节假日/传统节日/
    节气),**一本可写的普通本都没有**。于是"放在哪本"里全是派生本、默认选中排最前的
    **观影** → 用户直接打字点「加上」,建出来的是一条**观影记录**
    (日历里看着是多了条日程,观影列表里莫名多一条)。这是"建错东西",事后极难发现。
    → ① 默认本补成「我的日程」(kind='custom'),模板里默认选中也改成**优先 custom 本**;
      ② 选择器里把**纯派生只读本**禁用并标「(自动生成)」
         (直接不列会让人以为本子丢了;列为可选又会白选一次再吃错误)。
    ⚠️ 顺带发现 `Calendar::READONLY_KINDS` 把 `movie`/`trip` 也算进去了,而
    `event_add` 里**紧跟着**就有两段专门处理它们的分支(「已加进观影」/「已加进旅程」)
    —— 那两段**永远走不到**(校验先一步把请求打回去了)。判据要按**能不能写**分,
    不是按"是不是派生"。

 ④ 🔴 **②「观影编辑页图片撑破」= `.mv-slot` 这个类在样式表里根本不存在**
    第 21 批加模板时只写了结构没写样式 → `<img>` 按**原始像素**渲染:600×900 的海报
    在 236px 宽的格子里撑出 942px 高,还把整页横向顶破(实测 docW 1297 > vw 1280)。
    → 补 `.mv-slot` 规格:固定 108×162 缩略图 + `object-fit: cover`。
    ⚠️ **高度必须写死** —— 不写死就又会由原图比例决定,等于没修。

 ⑤ **⑤行程列表排序 = `category, position` 主导**
    `position` 默认同一个数(旧迁移给的多是 0/100),于是顺序被 position 挤乱。
    实测线上 #222 渲染成「返回青岛(10-5) → 出发去菏泽(9-30) → 去姥姥家(10-2)」。
    → 改成 `ORDER BY (starts_at IS NULL), starts_at, id`。
    ⚠️ 方向是**正序**(早的在前,已跟用户确认);旅程列表仍是 DESC(最新在最上)。
       两者方向不同是**刻意的**:一个是"挑哪趟旅行",一个是"读这趟旅行的行程"。
    ⚠️ 详情页**按阶段分三组**渲染(计划/实际/未成行),排序只需在**组内**有序。

本批新增能力:
- **统一上传框**(⑦):`templates/_stage_drop.php` 现在**同时**负责
  「sr-only 的真文件框 + 看得见的上传框 + 暂存清单」,调用方只 require 一行。
  框是 `role="button" tabindex="0"`,JS 转发 `input.click()`(键盘 Enter/Space 也通),
  拖文件进框走既有的暂存上传(拖一个立刻传、可续拖、失败标红、未传完拦住保存)。
  ⚠️ 框用 `<div>` 不是 `<button>`:里面要放暂存清单(含「撤掉这张」按钮),
     按钮里嵌按钮是非法 HTML(第 21 批刚在 `<form>` 嵌套上栽过同一个跟头)。
  ⚠️ 文件框用 `.sr-only` 不是 `display:none`:后者不可聚焦,带 `required` 时
     Chrome 会报 "An invalid form control is not focusable" —— 点了什么都不发生。
  落了 11 处:行程照片 / 观影海报·票根·合影 / 资源 / 任务 / 日程附件 / 书籍 /
  联系人导入 / ics 导入 / 观影补传 / 资源补传。
  ⚠️ 清单与提示行移到框**外面**(放框内会变成"一个大框套五张图"),
     所以 app.js 改从 `.upfield` 容器里找它们。
- **无刷新删除(①)与无刷新新建日程(⑧)**:路由按 `X-Requested-With`
  (或 `Accept: application/json`)区分调用者 —— 带它回 JSON、不带照旧 302
  (无 JS 的兜底**不变**,两边的反向都写了断言)。
  · 名册删除:成功 → 摘卡片 + 就地改「共 N 人」与分组人数 + 派发 `input` 让
    filter.js 自己重算(**不抄它的判据**);失败 → 把原因说出来(不刷页);
    拿不到 JSON → **退回原生提交**(绝不静默失败)。
  · 日历新建:JSON 带齐前端插入当前视图要用的字段(时间/颜色/链接/是否全天/cal)。
    月视图**按时间插入**(不能 append —— 服务端只渲染前 3 条、按时间序取,
    append 会让"新加的早间日程"掉进「+N 条」里看不见,刷新后又跑到第一位);
    周/日视图按"24h = 100%"算 top/height 插块。
  · 观影 / 旅行本是**可写派生本**:AJAX 时不再跳详情页(跳走就离开了日历),
    改在提示行给一个「去看详情 →」链接。
- **⑧日历本带颜色**:原生 `<option>` 画不出色块(浏览器不允许在 option 里放元素),
  所以做成「色块 + 下拉」并排,色块跟着当前选中的本子变色。
- **③天气卡片**:三句提示全删;城市单独一行、纬度/经度同一行(`.loc-row`)。
  删的只是说明文字,三个输入框 + 保存 + 状态回显都保留。
- **⑥设置按钮贴最右**:原来顺序是「… → 设置 ☰ → 删除 🗑」,设置被删除键挤到**倒数第二**;
  改成删除在前、设置恒为最后一个(与观影/日程/任务/资源同一个手势)。

验证(本地全套):
- e2e **1059/0**(本批新增约 40 条断言)· 结构升级 149/0 · 桌面几何 428/0
- **真机探针 75/0**(`_fb_b21_ui.py`):AJAX 删除真的没导航 / 卡片真被摘掉 /
  滚动位置保持;缩略图几何(108×162,不再溢出);上传框(框在、原生框不可见、
  点框真打开文件选择框、连拖两张都上去且清单可见);日历弹框(打开时网格一动不动、
  范围控件真的挂上且可见、能弹面板、色块跟着换、无刷新提交后当前视图里立刻出现、
  周视图也能插时间块、全程无 JS 报错)。
- **反向验证 12 条**(`_fb_b21_falsify.py`):逐条把修复改回旧行为看断言是否变红 ——
  令牌字段改回 stage[] / 编辑读表单 stage / 排序改回 position / 设置按钮挪走 /
  框不转发点击 / 文件框不再 sr-only / 删缩略图规格 / 删除不回 JSON /
  结束端改回 hidden / 日历本默认改回第一个 / 天气提示加回 / 弹框改回内联。
- 部署脚本静态断言段本地空跑 **307/0**。
This commit is contained in:
2026-10-06 12:08:40 +08:00
parent ea99968035
commit f0437e6c93
19 changed files with 1209 additions and 270 deletions
+160 -1
View File
@@ -588,7 +588,11 @@ textarea.input { min-height: 76px; resize: vertical; }
⚠️ 用 visibility:hidden 而不是 display:none —— 保留元素占位高度,
否则升级完成的那一瞬表单会"跳"一下(高度从 0 变 40px)。 */
html.js .dt-input:not(.is-semi) .dt-native { visibility: hidden; }
html.js .dt-range:not(.is-semi-range) > .dt-input { visibility: hidden; }
/* ⚠️ v24 第 21 批 第 7 条:两端现在各自被 `.field` 包着,所以选择器要跟着下沉一层。
写成 `> .dt-input` 的话这条**永远不会命中** → 未升级时原生控件照旧可见,
与下面那块"占位骨架"叠在一起,会看到两套控件重影。 */
html.js .dt-range:not(.is-semi-range) .dt-input { visibility: hidden; }
html.js .dt-range:not(.is-semi-range) > .dt-range-label { visibility: hidden; }
/* Semi 未就绪前,先给一个同高的"占位骨架",让表单高度在升级前后保持一致 */
html.js .dt-input:not(.is-semi)::after {
content: ''; display: block; flex: 1 1 150px; height: 40px;
@@ -613,8 +617,12 @@ html.js .dt-range.is-semi-range::before { display: none; }
由**一个** .dt-semi-range 顶上来。未升级(JS 没跑/加载失败)时,
里面两个原生控件照旧各占一行 —— 也就是"功能没变,只是不好看"。 */
.dt-range { display: flex; flex-direction: column; gap: 8px; }
/* 范围控件自己的标签(只有一个,见 _event_fields.php 的说明) */
.dt-range-label { font-size: 13px; color: var(--muted); font-weight: 500; }
.dt-range.is-semi-range { display: block; }
.dt-range.is-semi-range > .dt-input { display: none; } /* 两端都让位给范围控件 */
/* 两端被 .field 包着时的同一条规则(见上面第 21 批的说明) */
.dt-range.is-semi-range > .field { display: none; }
.dt-semi-range { display: none; width: 100%; max-width: 460px; }
.dt-range.is-semi-range .dt-semi-range { display: block; }
/* Semi 范围输入框:与站内 .input 同高同圆角;宽度撑开一点(要放两个时间) */
@@ -3741,3 +3749,154 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
/* v24 第 20 批 第 8 条:提醒多选的排版(勾选框多了要能换行,别挤成一长条) */
.rem-picks { display: flex; flex-wrap: wrap; gap: 6px 14px; }
.rem-pick { font-size: 13px; }
/* ============================================================
v24 第 21 批 第 2 条:观影编辑页的**已存图片**要缩成缩略图
------------------------------------------------------------
用户原话:「观影模块,海报和票根以及照片都带进来了,但是没有缩放,
把页面撑破了,应该缩放成缩略图。」
🔴 根因:`.mv-slot` 这个类**在样式表里根本不存在**(第 20 批加模板时只写了
结构没写样式)。于是里面的 <img> 按**原始像素**渲染 —— 海报是 2:3 竖图
(上传时按 600×900 裁的),就在一个 236px 宽的格子里撑出
600×900 + 942px 高,把表单顶破、还让整页横向溢出(实测 docW 1297 > vw 1280)。
「带进来了」这个功能是对的,缺的是"把它当成缩略图看"这一层样式。
现在的规格:左侧固定尺寸缩略图(海报 2:3 → 108×162;票根按同一高度裁切),
右侧「删除这张」按钮,整体是"看得见、不占地方"的一张缩略图。
⚠️ `object-fit: cover` 是必须的:票根那种横长条塞进竖框里,
不用它就会被压扁变形(看着像图坏了)。
⚠️ 缩略图高度**写死**、不能再由图片自身比例决定 —— 那正是撑破的原因。
============================================================ */
.mv-slot {
display: flex; align-items: flex-start; gap: 10px;
margin-bottom: 10px;
}
.mv-slot > a {
display: block; flex: 0 0 auto;
width: 108px; height: 162px; overflow: hidden;
border: 1px solid var(--line); border-radius: var(--radius-s);
background: #F1E7D6;
}
.mv-slot > a img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.mv-slot > .btn { flex: 0 0 auto; align-self: center; }
/* 窄屏:缩略图再小一点,别把"删除这张"挤出去 */
@media (max-width: 480px) {
.mv-slot > a { width: 84px; height: 126px; }
}
/* ============================================================
v24 第 21 批 第 7 条:「点击或拖动进行上传」的那个框
------------------------------------------------------------
用户原话:「所有上传图片等附件的地方,都不再放按钮,而是放一个框,
点击这个框实现点击上传按钮的功能,拖动图片进这个框里,
实现拖动多个上传功能。框里面写一句话:点击或拖动进行上传,
其他的功能和现在保持一致。」
⚠️ 这是**全新的统一上传入口**,取代了原来的 `<input class="input" type=file>`
(那个原生控件长得像输入框、写着"选择文件 未选择任何文件",
与旁边那个拖放虚线框是**两个**入口 —— 用户看到的就是"到底点哪个")。
现在原生框整个 `.sr-only` 藏起来,页面上只有这**一个**框。
⚠️ 落点高亮 `.is-over` 由 app.js 的 bindZone 加(拖动经过时)——
与既有的 `.dropzone.is-over` 是同一套动作,只是这个框自己有底色,
高亮用"描边+底色变亮"表达(不用 outline,避免与自带虚线边重叠成双线)。
============================================================ */
.stage-drop-tip { display: none; } /* 旧的提示位:文案已并进框里,这里留个空占位规则 */
.upbox {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 6px; width: 100%; min-height: 96px; box-sizing: border-box;
padding: 16px 14px; cursor: pointer; text-align: center;
border: 1.5px dashed var(--line); border-radius: var(--radius-s);
background: #FDFBF7; color: var(--muted);
transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}
.upbox:hover { border-color: var(--brand-2); background: #FBF6F0; color: var(--brand); }
.upbox:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; }
/* 拖动经过时的高亮(app.js 的 bindZone 加 .is-over) */
.upbox.is-over {
border-color: var(--brand); border-style: solid;
background: #FDF3EC; color: var(--brand);
}
.upbox-ico { font-size: 22px; line-height: 1; font-weight: 300; }
.upbox-msg { font-size: 13.5px; }
/* 窄屏:框再矮一点,别把表单撑太长 */
@media (max-width: 480px) {
.upbox { min-height: 78px; padding: 12px; }
.upbox-ico { font-size: 18px; }
}
/* 空的提示行(暂存清单下面那句"已上传/失败"):没内容时不占地方 */
.stage-drop-tip:not([hidden]) { display: block; margin-top: 6px; font-size: 12px; color: var(--muted); }
/* ============================================================
v24 第 21 批 第 7 条:新建日程的**弹框**
------------------------------------------------------------
用户原话:「日历中新建日程,无论是点击按钮还是点击日历中间,都是采用弹框的形式
进行添加,这样不影响当前日历的显示,添加完成后,动态修改当前视图,
不要刷新。」
⚠️ 原来那张表单是**内联**在日历网格上方的卡片,一展开就把月视图推下去 ——
正是"影响当前日历的显示"。改成浮层后,底下的日历**一动不动**。
⚠️ 视觉沿用站内既有的浮层语言(遮罩 rgba(44,35,29,.55) + 圆角面板 + 阴影),
与选人弹窗 / 名册动作框 / 裁剪器是同一套,用户不用重新认。
============================================================ */
.cal-modal {
position: fixed; inset: 0; z-index: 200;
display: flex; align-items: flex-start; justify-content: center;
padding: 24px 16px; overflow-y: auto;
background: rgba(44, 35, 29, .55); overscroll-behavior: contain;
}
.cal-modal[hidden] { display: none; }
.cal-modal-panel {
width: min(680px, 100%); margin: auto;
background: var(--surface); border-radius: 14px;
box-shadow: var(--shadow-l); display: flex; flex-direction: column;
max-height: calc(100vh - 48px);
}
.cal-modal-head {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px 12px; border-bottom: 1px solid var(--line-2);
flex: 0 0 auto;
}
.cal-modal-head b { font-size: 15.5px; font-weight: 600; }
.cal-modal-x {
margin-left: auto; border: 0; background: transparent; cursor: pointer;
font-size: 15px; line-height: 1; color: var(--muted);
padding: 4px 7px; border-radius: 6px;
}
.cal-modal-x:hover { background: var(--hov-veil); color: var(--ink); }
.cal-modal-body { padding: 16px; overflow-y: auto; flex: 1 1 auto; }
.cal-modal-foot {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px 14px; border-top: 1px solid var(--line-2);
flex: 0 0 auto;
}
/* 提交键贴右(次要动作在左、主要动作在右,与全站一致) */
.cal-modal-foot .btn[type="submit"] { margin-left: auto; }
.cal-modal-tip { font-size: 12.5px; color: var(--muted); }
.cal-modal-tip.err { color: var(--red); }
/* 日历本:色块 + 下拉(原生 option 画不出色块,见模板里的说明) */
.cal-book-row { display: flex; align-items: center; gap: 8px; }
.cal-book-dot {
flex: 0 0 auto; width: 20px; height: 20px; border-radius: 6px;
border: 1px solid rgba(44, 35, 29, .12);
}
.cal-book-row .select { flex: 1 1 auto; }
/* ============================================================
v24 第 21 批 第 7 条:日程新建弹框的显隐(**必须放收尾**)
------------------------------------------------------------
⚠️ 为什么单独补这一条:本项目有一条全局兜底
`.field[hidden], .field.full[hidden], [data-mode-vehicle][hidden], … { display:none !important }`
(第 20 批为了修"车辆藏不住"加的)。而弹框的**面板里就有 `.field`** ——
面板一旦被隐藏,CSS 的 `display:flex` 与那条 `!important` 会打架,
加上 `hidden` 属性同时落在多个层级上,会出现"明明摘了 hidden 却还是不显示"。
这里用 `:not([hidden])` 把"打开态"显式钉死(后写者赢 + 特异性更高),
两种状态各归各的规则、互不牵连。
============================================================ */
.cal-modal:not([hidden]) { display: flex; }
.cal-modal[hidden] { display: none !important; }