v35 第 35 批:日期控件改用原生(Semi 时间面板不可用)+ 观影附件上传真修好 + 票根/合影归并成「观影照片」

用户原话(三段):
  ①「选择日期那个地方给,非常不好用。如果我不选日期,只选时间,框里面仍然是空白的,
     也没有确定按钮,这个需要重新调整一下。」
  ②「现在上传附件,也就是在观影模块里面上传附件,怎么还是有问题?上传的附件现在
     都没有保存,有时候我上传两个附件,它只保存一张。」
  ③「除了海报之外,票根、现场照片等等都不分类别,都只分一个类,放在一块。…
     只有海报是单独的,因为它是从豆瓣里面下载回来的。其他的都放在一个模块就行,
     然后所有照片都上传到一起,直接写"观影照片"就可以了。」

① 日期控件:**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)。
This commit is contained in:
2026-10-09 20:23:09 +08:00
parent 9c70dbe0ff
commit 5c81f1991f
6 changed files with 287 additions and 175 deletions
+10 -1
View File
@@ -178,7 +178,16 @@ final class Movie
]); ]);
self::saveDetails($id, $d); self::saveDetails($id, $d);
self::saveAttendees($id, $d['attendees'] ?? []); 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(编辑时可能还没建过这行) */ /** 卫星表 upsert(编辑时可能还没建过这行) */
+30
View File
@@ -599,6 +599,14 @@ textarea.input { min-height: 76px; resize: vertical; }
⚠️ 用 visibility:hidden 而不是 display:none —— 保留元素占位高度, ⚠️ 用 visibility:hidden 而不是 display:none —— 保留元素占位高度,
否则升级完成的那一瞬表单会"跳"一下(高度从 0 变 40px)。 */ 否则升级完成的那一瞬表单会"跳"一下(高度从 0 变 40px)。 */
html.js .dt-input:not(.is-semi) .dt-native { visibility: hidden; } 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` 包着,所以选择器要跟着下沉一层。 /* ⚠️ v24 第 21 批 第 7 条:两端现在各自被 `.field` 包着,所以选择器要跟着下沉一层。
写成 `> .dt-input` 的话这条**永远不会命中** → 未升级时原生控件照旧可见, 写成 `> .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; } .dt-input .dt-colon { color: var(--muted); font-weight: 600; }
/* 🔴 v35:v35 起时间不再是「时:分:秒」三个下拉,而是**一个**原生
`<input type="time" step="1">`(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-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; } .bd-pane { border: 1px solid transparent; border-radius: var(--radius); padding: 10px 12px 2px; }
+129 -122
View File
@@ -1384,153 +1384,157 @@
else if (a === null) { a = bx; } else { b = bx; } else if (a === null) { a = bx; } else { b = bx; }
}); });
if (!a || !b) { return; } 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'); /* 🔴🔴 v35:**范围控件不再用 Semi 的 dateTimeRange**,改用原生
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)→ 恰好把这一行挡住了。
一旦把两端都放出来,它就立刻炸:整个升级流程中断 → 范围控件永远挂不上
→ 用户看到一片空白。所以修"时间范围无法使用"必须**同时**修这里。
插到"第一个端点最外层包装"之前(包装拿不到就退回插在 a 前面), 实测结论(独立最小复现 + 真机,2026-10-09):
这样控件出现在标签下方、两个端点位置,视觉顺序是 标签 → 控件。 */ · Semi 的 dateTimeRange 面板**只给日期网格**,时间藏在
var wrapA = (a.closest ? a.closest('.field') : null) || a; `.semi-datepicker-switch-time` 那个小时钟按钮后面;
var insertTarget = (wrapA !== a && group.contains(wrapA)) ? wrapA : a; · 点了那个按钮切到时间面板,**面板仍然是空的**
if (insertTarget.parentNode !== group) { insertTarget = a; } (`timeLists = 0`、无选项、无 footer、无"确定")→ 根本没法只选时间;
group.insertBefore(host, insertTarget); · 单点 `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]; 值照旧写回那三个下拉(POST 契约),只是视觉上收成一个控件。 */
writeDT(a, arr[0] || ''); function mergeTimeInputs(box) {
writeDT(b, arr[1] || ''); 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 改了也不生效 mergeTimeInputs(a);
var rroot = null; mergeTimeInputs(b);
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;
/* ⚠️ 隐藏的是**端点最外层的包装**(那个 `.field`),不是里层的 `.dt-input`: // 两端各自**露出来**(不再藏)—— v35 的形态就是"两个 日期+时间 对"
只藏里层的话,两端的 `<label>` 还留在页面上,会出现 a.style.display = '';
"标签写着一套、下面只有一个控件"的错位。 b.style.display = '';
var na = a.querySelector('.dt-native'), nb = b.querySelector('.dt-native');
if (na) { na.style.display = 'contents'; }
if (nb) { nb.style.display = 'contents'; }
a.classList.add('has-value');
if (readDT(b).trim() !== ' ') { b.classList.add('has-value'); }
🔴🔴 v34:**两个端点的包装可能是同一个元素** —— 那就是范围组自己。 // 仅供 fbSetDT / fbUpgradeRange 的既有调用点使用(不再挂 Semi)
观影 / 旅程表单的结构是: group.__fbRemount = function () {
<div class="field full dt-range"> ← 两端**共用**这一个 .field if (a.__fbSyncTime) { a.__fbSyncTime(); }
<label>…</label> if (b.__fbSyncTime) { b.__fbSyncTime(); }
<div class="dt-input" …>开始 };
<div class="dt-input" …>结束
</div> /* ⚠️ 原来这里会隐藏"端点最外层的包装"(因为 Semi 范围控件顶替了两端)。
此时 `a.closest('.field')` 与 `b.closest('.field')` **都取到 .dt-range 本身** v35 起两端**就是要显示**的,所以不再隐藏任何东西 ——
⇒ `display:none` 把**整组(含刚挂好的范围控件)**藏掉 v34 那个"共用包装导致整组被藏"的 bug 也随之不再可能发生。
⇒ 用户看到「开始 / 结束时间整个没了」(连标签都没了)。 (v34 的修法保留在 _fb_b34_dtrange.py 的断言里:组必须可见。) */
⚠️ 日程页为什么没事:它两端各在自己的 `.field.ev-dt` 里,是两个不同的包装。
⇒ 凡是"包装 === 范围组本身",**不能隐藏** —— 该藏的是里层那两个 `.dt-input`,
而且标签**留在组上**(本来就只有它一个,正是范围控件要的形态)。 */
var hideA = (a.closest ? a.closest('.field') : null) || a;
var hideB = (b.closest ? b.closest('.field') : null) || b;
var wrapIsGroup = (hideA === group || hideB === group || hideA === hideB);
if (wrapIsGroup) {
// 共用包装:藏里层 .dt-input(标签留在组上,视觉正确)
group.style.display = ''; // 保险:确保范围组本身可见
if (a !== group) { a.style.display = 'none'; }
if (b !== group) { b.style.display = 'none'; }
} else {
hideA.style.display = 'none';
hideB.style.display = 'none';
}
// ⚠️ 范围里的两端同样要摘 required(隐藏的 required 会让整个表单提交不了)
guardRequired(a); guardRequired(a);
guardRequired(b); guardRequired(b);
group.setAttribute('data-semi-range-done', '1'); group.setAttribute('data-semi-range-done', '1');
group.classList.add('is-semi-range'); group.classList.add('is-semi-range');
group.classList.add('is-native-range');
} }
function upgrade(box) { function upgrade(box) {
if (box.getAttribute('data-semi-done')) { return; } if (box.getAttribute('data-semi-done')) { return; }
// 范围对的成员由 upgradeRange 统一处理(一个控件管两端,用户第 7 条) // 范围对的成员由 upgradeRange 统一处理
if (box.classList.contains('dt-part')) { return; } if (box.classList.contains('dt-part')) { return; }
if (typeof window.SemiUI === 'undefined' || !window.SemiUI.DatePicker) { return; }
if (!window.ReactDOM || !window.ReactDOM.createRoot) { return; }
var cfg = {}; var cfg = {};
try { cfg = JSON.parse(box.getAttribute('data-dt') || '{}'); } catch (e) { /* 空配置按默认走 */ } try { cfg = JSON.parse(box.getAttribute('data-dt') || '{}'); } catch (e) {}
var host = box.querySelector('.dt-semi');
var native = box.querySelector('.dt-native'); var native = box.querySelector('.dt-native');
if (!host || !native) { return; } if (!native) { return; }
var fmt = 'yyyy-MM-dd HH:mm' + (cfg.seconds === false ? '' : ':ss'); /* 🔴 v35:单点时间控件同样**不再用 Semi**(图省事不再区分两条路径)——
var init = parseDT(readDT(box)); Semi 的 `dateTime` 面板同样没有可选的时间列(见 upgradeRange 里的实测)。
这里只把原来的 时/分/秒 三个下拉合成**一个原生时间框**,
// Semi 的 onChange → 写回原生四字段(POST 契约),并派 change 事件给可能的监听者 日期框照旧是原生 `type="date"`,两者都**直接显示**。
function writeBack(date, str) { writeDT(box, str); } 契约不变(*_date/_hour/_minute/_second 仍在 DOM 里提交)。 */
if (!box.__fbMerged) {
// ⚠️ 重挂必须**先 unmount 再 createRoot**,不能对同一个 root 再 render: box.__fbMerged = true;
// DatePicker 是非受控组件(我们用 defaultValue),React 18 复用旧 root 时 var dateEl = native.querySelector('.dt-date');
// 新传的 defaultValue **不会**改变已显示的值 —— 于是 fbSetDT 改了原生字段、 var hourEl = native.querySelector('.dt-hour');
// Semi 还显示旧值("点了 15:00 控件里还写 09:00"的真实成因)。 var minEl = native.querySelector('.dt-minute');
var root = null; var secEl = native.querySelector('.dt-second');
function mount() { if (dateEl && hourEl && minEl) {
if (root) { root.unmount(); root = null; } var timeEl = document.createElement('input');
host.innerHTML = ''; timeEl.type = 'time';
root = ReactDOM.createRoot(host); timeEl.className = 'dt-time-input';
root.render(React.createElement(SemiUI.DatePicker, { timeEl.step = secEl ? '1' : '60';
type: 'dateTime', timeEl.setAttribute('aria-label', '时间(24 小时制)');
format: fmt, function syncFromSelects() {
showClear: true, timeEl.value = (hourEl.value || '00') + ':' + (minEl.value || '00')
defaultValue: parseDT(readDT(box)), + (secEl ? ':' + (secEl.value || '00') : '');
placeholder: '选择日期时间',
position: window.matchMedia('(max-width: 640px)').matches ? 'bottom' : 'bottomLeft',
onChange: writeBack
}));
} }
try { syncFromSelects();
mount(); timeEl.addEventListener('change', function () {
} catch (e) { var m = (timeEl.value || '').match(/^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/);
if (window.console) { console.warn('Semi DatePicker 挂载失败,留在原生控件:', e && e.message); } if (!m) { return; }
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');
Array.prototype.forEach.call(native.querySelectorAll('.dt-colon, .dt-time'), function (n) {
n.classList.add('dt-merged');
});
} }
// 页内 JS 可能会**程序化**改这个字段(如日程页「点日历空白处」预填开始时间)—— }
// 原生值改了 Semi 却还显示旧值,两边就不一致了。所以把重挂函数挂到元素上, // 直接露出原生(日期 + 时间)
// 供 window.fbSetDT() 调用(见文件末尾)。 native.style.display = 'contents';
box.__fbRemount = mount; box.classList.add('is-native');
box.classList.add('has-value');
box.classList.add('is-semi'); // 程序化改值时让时间框跟上
native.style.display = 'none'; box.__fbRemount = function () { if (box.__fbSyncTime) { box.__fbSyncTime(); } };
if (init) { box.classList.add('has-value'); }
guardRequired(box); guardRequired(box);
box.setAttribute('data-semi-done', '1'); box.setAttribute('data-semi-done', '1');
} }
/** /**
* 原生控件被藏起来之后,它上面的 `required` 必须**摘掉**并改由我们自己校验。 * 原生控件被藏起来之后,它上面的 `required` 必须**摘掉**并改由我们自己校验。
* *
@@ -1562,7 +1566,10 @@
if (bad.length) { if (bad.length) {
ev.preventDefault(); ev.preventDefault();
bad.forEach(function (b) { b.classList.add('dt-missing'); }); bad.forEach(function (b) { b.classList.add('dt-missing'); });
var inp = bad[0].querySelector('.dt-semi input'); // v35:不再有 .dt-semi —— 焦点落到原生日期框(那才是"该补的字段")
var inp = bad[0].querySelector('.dt-semi input')
|| bad[0].querySelector('.dt-date')
|| bad[0].querySelector('input, select');
if (inp) { inp.focus(); } if (inp) { inp.focus(); }
} }
}); });
+63 -6
View File
@@ -2749,22 +2749,58 @@ switch ($r) {
]; ];
} }
} }
if ($photoFiles) { /* 🔴🔴 v35:**拖动/点击上传的照片走的是暂存区**(`stage_token[]`),
而这里原来**只读 `$_FILES['photo']`** —— 暂存令牌**从来没人认领**,
于是"上传的照片全没了"。用户报的「上传的附件都没有保存」的真根因。
⚠️ 为什么照片与海报/票根不一样:海报/票根各有独立字段(`stage_poster` /
`stage_ticket`,见下面那段),照片是**多个**,所以走通用那组
`stage_token[]`(`_stage_drop.php` 里 `$sdField` 的规则)。
海报/票根那两段只认自己的字段,通用那组**没有落点**。
⇒ 这里把 `stage_token[]` 逐个读出来、按 'photo' 槽位重跑 Upload::image。 */
$stagedPhotoTokens = (array)($_POST['stage_token'] ?? []);
$photoKeys = $isEdit $photoKeys = $isEdit
? array_map('strval', array_column(Movie::photos($id), 'key')) // 保住已有的 ? array_map('strval', array_column(Movie::photos($id), 'key')) // 保住已有的
: []; : [];
foreach ($photoFiles as $f) { $photoTouched = false;
$up = Upload::image($familyId, $f, 'photo'); foreach ($stagedPhotoTokens as $stoken) {
$stoken = trim((string)$stoken);
if ($stoken === '') {
continue;
}
$sf = \App\StagedUpload::read($familyId, (int)Auth::personId(), $stoken);
if ($sf === null) {
$warns[] = '有一张照片:暂存文件已失效,请重传一次。';
continue;
}
$fake = [
'name' => (string)$sf['name'],
'type' => (string)$sf['mime'],
'tmp_name' => (string)$sf['path'],
'error' => UPLOAD_ERR_OK,
'size' => (int)@filesize((string)$sf['path']),
];
$up = Upload::image($familyId, $fake, 'photo');
if ($up['ok']) { if ($up['ok']) {
$photoKeys[] = (string)$up['file']; $photoKeys[] = (string)$up['file'];
$photoTouched = true;
\App\StagedUpload::forget($stoken);
} elseif ((string)($up['error'] ?? '') !== '') { } elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = '有一张照片没有保存:' . $up['error']; $warns[] = '有一张照片没有保存:' . $up['error'];
} }
} }
if ($photoFiles) {
foreach ($photoFiles as $f) {
$up = Upload::image($familyId, $f, 'photo');
if ($up['ok']) {
$photoKeys[] = (string)$up['file'];
$photoTouched = true;
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = '有一张照片没有保存:' . $up['error'];
}
}
}
if ($photoTouched || !$isEdit) {
$d['photos'] = $photoKeys; $d['photos'] = $photoKeys;
} elseif (!$isEdit) {
// 新增且一张都没选 → 明确给空(否则 create 会拿到一个不存在的键)
$d['photos'] = [];
} }
/* ---- v24 第 20 批 第 4 条:拖动**即时上传**的海报 / 票根 ---- /* ---- v24 第 20 批 第 4 条:拖动**即时上传**的海报 / 票根 ----
@@ -2855,7 +2891,28 @@ switch ($r) {
flash('err', '找不到这条观影记录。'); flash('err', '找不到这条观影记录。');
redirect('movies'); redirect('movies');
} }
$staged = (array)($_POST['stage_token'] ?? []);
$up = Upload::image($familyId, $_FILES['photo'] ?? [], 'photo'); $up = Upload::image($familyId, $_FILES['photo'] ?? [], 'photo');
if (!$up['ok'] && $staged) {
// 🔴 v35:详情页"补一张照片"的框也是**暂存式**(_stage_drop 默认走暂存),
// 所以这里原来只读 $_FILES['photo'] 时**永远拿不到文件**(用户报的
// "上传的附件都没有保存")。暂存令牌 → 重跑 Upload::image。
$stoken = trim((string)($staged[0] ?? ''));
$sf = $stoken !== ''
? \App\StagedUpload::read($familyId, (int)Auth::personId(), $stoken) : null;
if ($sf !== null) {
$up = Upload::image($familyId, [
'name' => (string)$sf['name'],
'type' => (string)$sf['mime'],
'tmp_name' => (string)$sf['path'],
'error' => UPLOAD_ERR_OK,
'size' => (int)@filesize((string)$sf['path']),
], 'photo');
if ($up['ok']) {
\App\StagedUpload::forget($stoken);
}
}
}
if ($up['ok']) { if ($up['ok']) {
// savePhotos 是整组替换 —— 现有键取出来再追加一张,位置接着排 // savePhotos 是整组替换 —— 现有键取出来再追加一张,位置接着排
$keys = array_map('strval', array_column(Movie::photos($id), 'key')); $keys = array_map('strval', array_column(Movie::photos($id), 'key'));
+27 -28
View File
@@ -166,7 +166,7 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
<?php <?php
/** /**
* v24 第 20 批 第 1 条:编辑页把**已经存过的**海报 / 票根 / 现场合影**显示出来**。 * v24 第 20 批 第 1 条:编辑页把**已经存过的**海报 / 观影照片**显示出来**。
* *
* 用户原话:「观影模块,编辑观影,已经保存的海报和票根、合影,都没带进来。」 * 用户原话:「观影模块,编辑观影,已经保存的海报和票根、合影,都没带进来。」
* ⚠️ 原来这里只有两句灰字提示("已有海报,不选新图就保留原图。")—— * ⚠️ 原来这里只有两句灰字提示("已有海报,不选新图就保留原图。")——
@@ -186,9 +186,18 @@ $mPhotos = $isEdit ? (array)($m['photos'] ?? []) : [];
$mDelForms = []; // 收集起来,在表单**外面**统一渲染 $mDelForms = []; // 收集起来,在表单**外面**统一渲染
?> ?>
<div class="form-grid" style="margin-top:4px"> <div class="form-grid" style="margin-top:4px">
<?php /* ---- 海报 ---- */ ?> <?php /* ---- 观影照片(v35:不再分类,合并成一块)----
用户原话:「除了海报之外,票根、现场照片等等都不分类别,都只分一个类,
放在一块。…只有海报是单独的…其他的都放在一个模块就行,
然后所有照片都上传到一起,直接写"观影照片"就可以了。」
⚠️ 数据层本来就是平的(`event_photos` 只有 key/position,没有类别列),
票根以前是存 `movie_details.ticket_key` 的**单张**槽位。
⇒ 归并 = 把票根也当成 `event_photos` 里的一张,页面上只留"观影照片"一块。
⚠️ **旧的 ticket_key 仍然要显示**(历史数据不能丢):编辑页把已有票根
列进下面这块的网格里(它没有 `event_photos` 行 id,所以只能"看"、
不能用照片的删除按钮删;要换就直接上传新的)。 */ ?>
<div class="field"> <div class="field">
<label for="poster">海报</label> <label for="sd-poster">海报</label>
<?php if ($mHasPoster): ?> <?php if ($mHasPoster): ?>
<?php $mDelForms['poster'] = 'movies/media_del'; ?> <?php $mDelForms['poster'] = 'movies/media_del'; ?>
<div class="mv-slot"> <div class="mv-slot">
@@ -210,12 +219,16 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
require __DIR__ . '/../_stage_drop.php'; ?> require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip"><?= $mHasPoster ? '选/拖一张新的会自动替换旧的那张。' : '还没有海报。' ?></span> <span class="tip"><?= $mHasPoster ? '选/拖一张新的会自动替换旧的那张。' : '还没有海报。' ?></span>
</div> </div>
</div>
<?php /* ---- 票根 ---- */ ?> <?php /* ---- 观影照片:票根 + 合影 + 其它相关图片,**全部放这一块** ---- */ ?>
<div class="field"> <div class="field full">
<label for="ticket">票根</label> <label for="sd-photo">观影照片</label>
<?php if ($mHasTicket): ?> <?php if ($mHasTicket || $mPhotos): ?>
<div class="mv-slot"> <div class="photo-grid">
<?php if ($mHasTicket): /* 历史数据:旧票根(不在 event_photos 里,只展示) */ ?>
<?php $mDelForms['ticket'] = 'movies/media_del'; ?>
<figure class="photo-cell">
<a href="<?= e(media_url((string)$m['ticket_key'])) ?>" data-prev="image" <a href="<?= e(media_url((string)$m['ticket_key'])) ?>" data-prev="image"
data-src="<?= e(media_url((string)$m['ticket_key'])) ?>" data-src="<?= e(media_url((string)$m['ticket_key'])) ?>"
data-dl="<?= e(media_url((string)$m['ticket_key'], ['dl' => 1])) ?>" data-dl="<?= e(media_url((string)$m['ticket_key'], ['dl' => 1])) ?>"
@@ -223,29 +236,15 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
<img src="<?= e(media_url((string)$m['ticket_key'])) ?>" alt="票根"> <img src="<?= e(media_url((string)$m['ticket_key'])) ?>" alt="票根">
</a> </a>
<button class="btn btn-ghost btn-sm" type="submit" form="mv-del-ticket" <button class="btn btn-ghost btn-sm" type="submit" form="mv-del-ticket"
onclick="return confirm('删掉现在这张票根?')">删除这张</button> onclick="return confirm('删掉这张旧票根?')">删除</button>
</div> </figure>
<?php endif; ?> <?php endif; ?>
<?php $sdName = 'ticket'; $sdSlot = 'ticket'; $sdAccept = 'image/*';
$sdMultiple = false;
$sdMsg = $mHasTicket ? '点击或拖动进行上传(换一张票根)' : '点击或拖动进行上传';
require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip"><?= $mHasTicket ? '选/拖一张新的会自动替换旧的那张。' : '还没有票根。' ?></span>
</div>
</div>
<?php /* ---- 现场合影(用户第 1 条点名的三样之一)----
新增时可以一次多选;**编辑时在这里逐张看、逐张删**(与新选的图合并保存)。*/ ?>
<div class="field full">
<label for="photo">现场合影</label>
<?php if ($mPhotos): ?>
<div class="photo-grid">
<?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"
data-src="<?= e(media_url((string)$mp['key'])) ?>" data-src="<?= e(media_url((string)$mp['key'])) ?>"
data-dl="<?= e(media_url((string)$mp['key'], ['dl' => 1])) ?>" data-dl="<?= e(media_url((string)$mp['key'], ['dl' => 1])) ?>"
data-name="现场照片"> data-name="观影照片">
<img src="<?= e(media_url((string)$mp['key'])) ?>" alt=""> <img src="<?= e(media_url((string)$mp['key'])) ?>" alt="">
</a> </a>
<button class="btn btn-ghost btn-sm" type="submit" form="mv-del-photo-<?= $mpId ?>" <button class="btn btn-ghost btn-sm" type="submit" form="mv-del-photo-<?= $mpId ?>"
@@ -254,12 +253,12 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php /* v24 第 21 批 第 7 条:统一成「点击或拖动进行上传」的框(可多次)。 */ ?> <?php /* 观影照片(票根/合影/其它)都从这里进(可多选、可多次拖) */ ?>
<?php $sdName = 'photo[]'; $sdSlot = 'photo'; $sdAccept = 'image/*'; <?php $sdName = 'photo[]'; $sdSlot = 'photo'; $sdAccept = 'image/*';
$sdMsg = '点击或拖动进行上传'; $sdMsg = '点击或拖动进行上传';
require __DIR__ . '/../_stage_drop.php'; ?> require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip"><?= $isEdit ? '可以继续加(可多选);删掉的在保存后生效。' <span class="tip"><?= $isEdit ? '观影照片都可以放这里(可多选);删掉的在保存后生效。'
: '可以多选;之后也能在详情页里逐张补传。' ?></span> : '观影照片都可以放这里(可多选);之后也能在详情页里逐张补传。' ?></span>
</div> </div>
<div class="btn-row"> <div class="btn-row">
+22 -12
View File
@@ -2,7 +2,7 @@
/** /**
* 观影详情页(v9,展示方式照搬 MovieLog 的 detail.php)。 * 观影详情页(v9,展示方式照搬 MovieLog 的 detail.php)。
* *
* 五大块:英雄区(左海报右信息)→ 一起看的人 → 电影院信息 → 影像记录(灯箱)→ 评价与评论。 * 五大块:英雄区(左海报右信息)→ 一起看的人 → 电影院信息 → 观影照片(灯箱)→ 评价与评论。
* 海报/票根/照片一律走 media_url() 代理;感受标签只存 key,emoji 与文案查 CATALOG。 * 海报/票根/照片一律走 media_url() 代理;感受标签只存 key,emoji 与文案查 CATALOG。
* 访客(只读)看不到编辑/删除/传照片/评价表单 —— 与名册同一口径。 * 访客(只读)看不到编辑/删除/传照片/评价表单 —— 与名册同一口径。
* 评价人是「登录账号背后的人」,与同行观影人无关(MovieLog 同款规则)。 * 评价人是「登录账号背后的人」,与同行观影人无关(MovieLog 同款规则)。
@@ -243,21 +243,31 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- 影像记录(票根 + 现场照片,点开灯箱) --> <!-- 观影照片(v35:票根 + 现场照片 + 其它相关图片,**不再分类**,合成一块) -->
<?php if ($hasTicket || $m['photos']): ?> <?php if ($hasTicket || $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']) + ($hasTicket ? 1 : 0) ?> 张</span></h3>
<div class="gallery-grid"> <div class="gallery-grid">
<?php if ($hasTicket): ?> <?php /* ⚠️ 旧票根(movie_details.ticket_key)是历史数据,仍要能看到;
<div class="gallery-item lb-openable" data-lb-index="1" role="button" tabindex="0" aria-label="放大查看票根" title="点击查看大图"> 归并后它跟 event_photos 里的照片**同一块展示、同一套灯箱**,
<img src="<?= e(media_url($m['ticket_key'])) ?>" alt="票根" loading="lazy"> 但删除要走它自己的槽位表单(media_del)。 */ ?>
<span class="label-badge">票根</span> <?php if ($hasTicket): $lbIdx = 1; ?>
<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">
<?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> </div>
<?php endif; ?> <?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 + 2 ?>" role="button" tabindex="0" aria-label="放大查看照片" title="点击查看大图"> <div class="gallery-item lb-openable" data-lb-index="<?= $i + ($hasTicket ? 2 : 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">
<span class="label-badge">照片</span>
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<form method="post" action="<?= e(url('movies/photo_del')) ?>" <form method="post" action="<?= e(url('movies/photo_del')) ?>"
onsubmit="return confirm('删除这张照片?')"> onsubmit="return confirm('删除这张照片?')">
@@ -277,7 +287,7 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
<form method="post" enctype="multipart/form-data" action="<?= e(url('movies/photo_add')) ?>" class="photo-add"> <form method="post" enctype="multipart/form-data" action="<?= e(url('movies/photo_add')) ?>" class="photo-add">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<label>补一张现场照片</label> <label>加照片</label>
<?php /* v24 第 21 批 第 7 条:统一成「点击或拖动进行上传」的框。 <?php /* v24 第 21 批 第 7 条:统一成「点击或拖动进行上传」的框。
⚠️ 文件框 id 保持 `photo`(原来就叫这个),改名会让既有断言假红。 */ ⚠️ 文件框 id 保持 `photo`(原来就叫这个),改名会让既有断言假红。 */
$sdName = 'photo'; $sdId = 'photo'; $sdAccept = 'image/*'; $sdName = 'photo'; $sdId = 'photo'; $sdAccept = 'image/*';
@@ -444,10 +454,10 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
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 if ($hasTicket): ?>
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) ?>}); 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 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; ?>
var box = document.getElementById('lightbox'); var box = document.getElementById('lightbox');
var stageEl = document.getElementById('lbStage'); var stageEl = document.getElementById('lbStage');