From 5c81f1991f8d97fc815e04eb937e6fa712e0be70 Mon Sep 17 00:00:00 2001 From: laoyang Date: Fri, 9 Oct 2026 20:23:09 +0800 Subject: [PATCH] =?UTF-8?q?v35=20=E7=AC=AC=2035=20=E6=89=B9=EF=BC=9A?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E6=8E=A7=E4=BB=B6=E6=94=B9=E7=94=A8=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=EF=BC=88Semi=20=E6=97=B6=E9=97=B4=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E7=94=A8=EF=BC=89+=20=E8=A7=82=E5=BD=B1?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E4=B8=8A=E4=BC=A0=E7=9C=9F=E4=BF=AE=E5=A5=BD?= =?UTF-8?q?=20+=20=E7=A5=A8=E6=A0=B9/=E5=90=88=E5=BD=B1=E5=BD=92=E5=B9=B6?= =?UTF-8?q?=E6=88=90=E3=80=8C=E8=A7=82=E5=BD=B1=E7=85=A7=E7=89=87=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户原话(三段): ①「选择日期那个地方给,非常不好用。如果我不选日期,只选时间,框里面仍然是空白的, 也没有确定按钮,这个需要重新调整一下。」 ②「现在上传附件,也就是在观影模块里面上传附件,怎么还是有问题?上传的附件现在 都没有保存,有时候我上传两个附件,它只保存一张。」 ③「除了海报之外,票根、现场照片等等都不分类别,都只分一个类,放在一块。… 只有海报是单独的,因为它是从豆瓣里面下载回来的。其他的都放在一个模块就行, 然后所有照片都上传到一起,直接写"观影照片"就可以了。」 ① 日期控件:**Semi 的时间面板根本不可用**(这条最费时间) 实测(独立最小复现页 + 真机):Semi 的 dateTimeRange 面板**只给日期网格**, 时间藏在 `.semi-datepicker-switch-time` 那个小时钟按钮后面;**点了它切到时间面板, 面板仍然是空的**(timeLists=0、无选项、无 footer、无"确定")→ 根本无法"只选时间"。 单点 dateTime 同样。⇒ 与用户描述完全一致。 修:弃用 Semi 的时间选择,改用**原生** date + time(step=1) (24 小时制、精确到秒、点了就选、不选日期也有值、**不需要**确认按钮)。 原来的 时/分/秒 三个下拉**仍留在 DOM**(POST 契约 *_date/_hour/_minute/_second 一字不改),只是加 .dt-merged 视觉收起。 app.js 的 upgradeRange()/upgrade() 都不再挂 Semi;CSS 加 .is-native-range 系列 把 Semi 时代的"藏原生 / 两端让位"规则覆盖回来。 ② 观影附件上传丢文件:**两条路径都断了** · 暂存路径(拖放):上传框走"拖进来即刻上传到暂存区",令牌进 stage_token[]; 而 movies/create|update 与 movies/photo_add **只读 $_FILES['photo']** ⇒ stage_token[] **从来没人认领** ⇒ 拖进去的一个都没存。 · Movie::update() **完全不写 photos**(那句"photos 由单独的删除路由维护"的注释下 根本没写),而路由明明算好了 $d['photos'] 传进来 ⇒ 编辑保存丢照片。 修:路由新增 stage_token[] 认领循环(与 $_FILES 路径并存);photo_add 加兜底; Movie::update 补上 savePhotos。 ③ 归并:表单去掉独立"票根"上传框,"现场合影"并入**一个**「观影照片」块; 详情页「影像记录」→「观影照片」、去掉票根/照片分类标签。 数据层本来就是平的(event_photos 只有 key/position); ⚠️ 旧数据 movie_details.ticket_key **仍要显示**(历史票根不能丢)+ 服务端仍接收 $_FILES['ticket'](向后兼容,直接 POST 仍可存)。 验证(全部真机跑,不是"看代码觉得对"): · _fb_b34_dtrange.py 33/0(四个表单页:组可见 + 两端日期框可见 + 两端时间框 可见且 step=1 + 三个原生下拉仍在 DOM + 只选时间也能写回 *_hour/_minute/_second); · _fb_b35_movie_media.py 14/0(点击上传 2 张→库里 2 行;真 HTTP 打 upload_stage.php 拿 stoken→随表单提交→落库;编辑补传→3 行;详情页补传→4 行;归并断言); · 反向验证:_fb_b34_dtrange_falsify 4/0、_fb_b35_media_falsify 8/0; · e2e 1503/0(两条断言随 v35 语义更新)、几何交互 92/0、各批真机/反向全绿、 部署脚本静态断言空跑通过;线上 app.js / form.php 的 SHA256 与本地一致。 顺带修掉的工具链脆弱(本轮暴露): · push.sh 开头加"确保本地测试库在跑"的守卫(长时间跑流水线时 mysqld 会被环境 回收 → e2e/真机探针全崩,看着像代码坏了); · 探针别用固定字符窗口找函数体(注释一长就假红)、断言别写死字段名前缀 (改期表单是 rev_ends_at_*)、set_input_files 的 id 要照 _stage_drop 的净化规则 (photo[] → sd-photo)、提交按钮要精确定位(编辑页有多个 submit)。 --- app/Movie.php | 11 +- assets/css/app.css | 30 +++++ assets/js/app.js | 253 ++++++++++++++++++++------------------ index.php | 69 ++++++++++- templates/movies/form.php | 65 +++++----- templates/movies/show.php | 34 +++-- 6 files changed, 287 insertions(+), 175 deletions(-) diff --git a/app/Movie.php b/app/Movie.php index 8c82fe7..e0548dd 100644 --- a/app/Movie.php +++ b/app/Movie.php @@ -178,7 +178,16 @@ final class Movie ]); self::saveDetails($id, $d); self::saveAttendees($id, $d['attendees'] ?? []); - // photos 由单独的「删除某张」路由维护,编辑保存不整组替换 + /* 🔴 v35:原来这里只留一句"photos 由单独的删除路由维护,编辑保存不整组替换", + 于是**根本没写 photos** —— 而 movies/update 路由明明算好了 `$d['photos']` + (库里已有的 + 这次新选上来的)传进来,**被整段忽略**。 + 症状正是用户报的「上传的附件现在都没有保存」「上传两张只保存一张」。 + ⇒ updated 时一并写回(路由已经保证 `$d['photos']` = 已有 + 新增, + 不是空数组;真的没传时路由会给 `$d['photos']` 缺席, + `?? []` 也不会误清空 —— 见 movies/update 分支的条件)。 */ + if (array_key_exists('photos', $d)) { + self::savePhotos($id, $d['photos']); + } } /** 卫星表 upsert(编辑时可能还没建过这行) */ diff --git a/assets/css/app.css b/assets/css/app.css index e727b29..776dc63 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -599,6 +599,14 @@ textarea.input { min-height: 76px; resize: vertical; } ⚠️ 用 visibility:hidden 而不是 display:none —— 保留元素占位高度, 否则升级完成的那一瞬表单会"跳"一下(高度从 0 变 40px)。 */ html.js .dt-input:not(.is-semi) .dt-native { visibility: hidden; } +/* 🔴 v35:native 升级态 —— 不再挂 Semi,原生控件**就是要显示**。 + 这条必须写在上面那条**之后**(同选择器强度下后者胜), + 否则"未 .is-semi → 藏原生"会把我们露出来的控件又藏回去。 */ +html.js .dt-input.is-native .dt-native { visibility: visible; } +html.js .dt-input.is-native::after { display: none; } /* 去掉占位骨架 */ +html.js .dt-range.is-native-range .dt-input, +html.js .dt-range.is-native-range > .dt-range-label { visibility: visible; } +html.js .dt-range.is-native-range::before { display: none; } /* 去掉范围占位骨架 */ /* ⚠️ v24 第 21 批 第 7 条:两端现在各自被 `.field` 包着,所以选择器要跟着下沉一层。 写成 `> .dt-input` 的话这条**永远不会命中** → 未升级时原生控件照旧可见, 与下面那块"占位骨架"叠在一起,会看到两套控件重影。 */ @@ -730,6 +738,28 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, } .dt-input .dt-colon { color: var(--muted); font-weight: 600; } +/* 🔴 v35:v35 起时间不再是「时:分:秒」三个下拉,而是**一个**原生 + ``(24 小时制、精确到秒、点了就选、无需确认按钮)。 + 三个下拉仍在 DOM 里(提交契约不变),但视觉上收起来。 */ +.dt-input .dt-merged { display: none !important; } +.dt-input .dt-time-input { + flex: 0 0 auto; + padding: 9px 10px; border: 1px solid var(--line); border-radius: var(--radius-s); + background: var(--surface); color: var(--ink); font: inherit; + font-variant-numeric: tabular-nums; cursor: pointer; + accent-color: var(--brand); +} +.dt-input .dt-time-input::-webkit-calendar-picker-indicator { cursor: pointer; opacity: .65; } +/* 范围里两端各占一行时,"日期 + 时间"两个框排在一起 */ +.dt-input.is-native-range { gap: 8px; } +.dt-range.is-native-range { display: flex; flex-direction: column; gap: 10px; } +/* 🔴 v35:`.dt-range.is-semi-range > .dt-input { display:none }` 是 **Semi 时代** + "两端让位给那个一体化范围控件"的规则。v35 起两端**就是要显示**的, + 所以 native-range 必须把它覆盖回来 —— 否则端点被 display:none,用户又看不到。 */ +.dt-range.is-native-range > .dt-input, +.dt-range.is-native-range > .field { display: flex; } +.dt-range.is-native-range > .field { flex-direction: column; gap: 8px; } + /* 「以哪个生日为准」的两块:被选中的那块描一圈,一眼看出填的是哪边 */ .bd-source { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; } .bd-pane { border: 1px solid transparent; border-radius: var(--radius); padding: 10px 12px 2px; } diff --git a/assets/js/app.js b/assets/js/app.js index fbeb197..be49500 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1384,153 +1384,157 @@ else if (a === null) { a = bx; } else { b = bx; } }); if (!a || !b) { return; } - if (typeof window.SemiUI === 'undefined' || !window.SemiUI.DatePicker) { return; } - if (!window.ReactDOM || !window.ReactDOM.createRoot) { return; } - var host = document.createElement('span'); - host.className = 'dt-semi dt-semi-range'; - /* 🔴 v24 第 21 批 第 7 条:这里原来写的是 `group.insertBefore(host, a)` —— - **两端各自被 `.field` 包着**时 `a` 并不是 `group` 的直接子节点, - insertBefore 会直接抛 - "Failed to execute 'insertBefore' on 'Node': The node before which the new - node is to be inserted is not a child of this node." - 这个异常以前**从没被触发过**,因为上面那道"两端都得可见"的判断总是先 return - (结束那一端默认 hidden)→ 恰好把这一行挡住了。 - 一旦把两端都放出来,它就立刻炸:整个升级流程中断 → 范围控件永远挂不上 - → 用户看到一片空白。所以修"时间范围无法使用"必须**同时**修这里。 + /* 🔴🔴 v35:**范围控件不再用 Semi 的 dateTimeRange**,改用原生 + 「日期框 + 时间框」两个小控件。 + 用户原话:「选择日期那个地方给,非常不好用。如果我不选日期,只选时间, + 框里面仍然是空白的,也没有确定按钮,这个需要重新调整一下。」 - 插到"第一个端点最外层包装"之前(包装拿不到就退回插在 a 前面), - 这样控件出现在标签下方、两个端点位置,视觉顺序是 标签 → 控件。 */ - var wrapA = (a.closest ? a.closest('.field') : null) || a; - var insertTarget = (wrapA !== a && group.contains(wrapA)) ? wrapA : a; - if (insertTarget.parentNode !== group) { insertTarget = a; } - group.insertBefore(host, insertTarget); + 实测结论(独立最小复现 + 真机,2026-10-09): + · Semi 的 dateTimeRange 面板**只给日期网格**,时间藏在 + `.semi-datepicker-switch-time` 那个小时钟按钮后面; + · 点了那个按钮切到时间面板,**面板仍然是空的** + (`timeLists = 0`、无选项、无 footer、无"确定")→ 根本没法只选时间; + · 单点 `dateTime` 同样(面板里没有可选的时间列)。 + ⇒ 该组件在这套资源下**给不了"选完即确认"的时间选择**,也不能"只选时间", + 与用户描述的症状完全一致。 + ⇒ 改用原生 `type="time" step="1"`:24 小时制、精确到秒、点了就选、 + 不选日期也照样有值(实测 `21:45:30` 会保留),且**不需要**确认按钮。 - var fmt = 'yyyy-MM-dd HH:mm' + (secs ? ':ss' : ''); + ⚠️ 提交契约**一点没变**:仍是 `*_date` / `*_hour` / `*_minute` / `*_second` + 四个字段(服务端 read_dt_input 照收、既有 e2e 照过)。 + 这里只是把两端的日期框**露出来**、并把原来的 时/分/秒 三个下拉 + 合成**一个**原生时间框(三个下拉仍留在 DOM 里,只是不单独显示)。 */ - function onRange(dates, strs) { - var arr = Array.isArray(strs) ? strs : [strs]; - writeDT(a, arr[0] || ''); - writeDT(b, arr[1] || ''); + /* 把某个端点原来的「日期 + 三个下拉」变成「日期框 + 一个时间框」。 + 值照旧写回那三个下拉(POST 契约),只是视觉上收成一个控件。 */ + function mergeTimeInputs(box) { + var native = box.querySelector('.dt-native'); + if (!native) { return; } + var dateEl = native.querySelector('.dt-date'); + var hourEl = native.querySelector('.dt-hour'); + var minEl = native.querySelector('.dt-minute'); + var secEl = native.querySelector('.dt-second'); + if (!dateEl || !hourEl || !minEl) { return; } + var timeEl = document.createElement('input'); + timeEl.type = 'time'; + timeEl.className = 'dt-time-input'; + timeEl.step = secEl ? '1' : '60'; // 要秒的槽位精确到秒 + timeEl.setAttribute('aria-label', '时间(24 小时制)'); + function syncFromSelects() { + timeEl.value = (hourEl.value || '00') + ':' + (minEl.value || '00') + + (secEl ? ':' + (secEl.value || '00') : ''); + } + syncFromSelects(); + timeEl.addEventListener('change', function () { + var m = (timeEl.value || '').match(/^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/); + if (!m) { return; } + hourEl.value = pad(+m[1]); + minEl.value = pad(+m[2]); + if (secEl) { secEl.value = pad(+(m[3] || 0)); } + }); + // 供"程序化改值时让时间框跟上"(如日程页点日历空白处预填) + box.__fbSyncTime = syncFromSelects; + // 插在日期框后面;三个下拉只留下标(提交用),视觉隐藏 + dateEl.parentNode.insertBefore(timeEl, dateEl.nextSibling); + if (secEl) { secEl.classList.add('dt-merged'); } + minEl.classList.add('dt-merged'); + hourEl.classList.add('dt-merged'); + // 原来把 时:分:秒 串起来的分隔符也一起收掉 + var nat = box.querySelector('.dt-native'); + Array.prototype.forEach.call(nat.querySelectorAll('.dt-colon, .dt-time'), function (n) { + n.classList.add('dt-merged'); + }); } - // 同上:先 unmount 再 createRoot,否则 defaultValue 改了也不生效 - var rroot = null; - function mountRange() { - if (rroot) { rroot.unmount(); rroot = null; } - host.innerHTML = ''; - var q0 = parseDT(readDT(a)), q1 = parseDT(readDT(b)); - rroot = ReactDOM.createRoot(host); - rroot.render(React.createElement(SemiUI.DatePicker, { - type: 'dateTimeRange', - format: fmt, - showClear: true, - defaultValue: (q0 || q1) ? [q0 || q1, q1 || q0] : undefined, - placeholder: ['开始时间', '结束时间'], - position: window.matchMedia('(max-width: 640px)').matches ? 'bottom' : 'bottomLeft', - onChange: onRange - })); - } - try { - mountRange(); - } catch (e) { - if (window.console) { console.warn('Semi 范围选择器挂载失败,留在原生控件:', e && e.message); } - host.parentNode.removeChild(host); - return; - } - group.__fbRemount = mountRange; + mergeTimeInputs(a); + mergeTimeInputs(b); - /* ⚠️ 隐藏的是**端点最外层的包装**(那个 `.field`),不是里层的 `.dt-input`: - 只藏里层的话,两端的 `