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`: - 只藏里层的话,两端的 `