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:
+130
-123
@@ -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`:
|
||||
只藏里层的话,两端的 `<label>` 还留在页面上,会出现
|
||||
"标签写着一套、下面只有一个控件"的错位。
|
||||
// 两端各自**露出来**(不再藏)—— v35 的形态就是"两个 日期+时间 对"
|
||||
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:**两个端点的包装可能是同一个元素** —— 那就是范围组自己。
|
||||
观影 / 旅程表单的结构是:
|
||||
<div class="field full dt-range"> ← 两端**共用**这一个 .field
|
||||
<label>…</label>
|
||||
<div class="dt-input" …>开始
|
||||
<div class="dt-input" …>结束
|
||||
</div>
|
||||
此时 `a.closest('.field')` 与 `b.closest('.field')` **都取到 .dt-range 本身**
|
||||
⇒ `display:none` 把**整组(含刚挂好的范围控件)**藏掉
|
||||
⇒ 用户看到「开始 / 结束时间整个没了」(连标签都没了)。
|
||||
⚠️ 日程页为什么没事:它两端各在自己的 `.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 会让整个表单提交不了)
|
||||
// 仅供 fbSetDT / fbUpgradeRange 的既有调用点使用(不再挂 Semi)
|
||||
group.__fbRemount = function () {
|
||||
if (a.__fbSyncTime) { a.__fbSyncTime(); }
|
||||
if (b.__fbSyncTime) { b.__fbSyncTime(); }
|
||||
};
|
||||
|
||||
/* ⚠️ 原来这里会隐藏"端点最外层的包装"(因为 Semi 范围控件顶替了两端)。
|
||||
v35 起两端**就是要显示**的,所以不再隐藏任何东西 ——
|
||||
v34 那个"共用包装导致整组被藏"的 bug 也随之不再可能发生。
|
||||
(v34 的修法保留在 _fb_b34_dtrange.py 的断言里:组必须可见。) */
|
||||
guardRequired(a);
|
||||
guardRequired(b);
|
||||
group.setAttribute('data-semi-range-done', '1');
|
||||
group.classList.add('is-semi-range');
|
||||
group.classList.add('is-native-range');
|
||||
}
|
||||
|
||||
function upgrade(box) {
|
||||
if (box.getAttribute('data-semi-done')) { return; }
|
||||
// 范围对的成员由 upgradeRange 统一处理(一个控件管两端,用户第 7 条)
|
||||
// 范围对的成员由 upgradeRange 统一处理
|
||||
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 = {};
|
||||
try { cfg = JSON.parse(box.getAttribute('data-dt') || '{}'); } catch (e) { /* 空配置按默认走 */ }
|
||||
var host = box.querySelector('.dt-semi');
|
||||
try { cfg = JSON.parse(box.getAttribute('data-dt') || '{}'); } catch (e) {}
|
||||
var native = box.querySelector('.dt-native');
|
||||
if (!host || !native) { return; }
|
||||
if (!native) { return; }
|
||||
|
||||
var fmt = 'yyyy-MM-dd HH:mm' + (cfg.seconds === false ? '' : ':ss');
|
||||
var init = parseDT(readDT(box));
|
||||
|
||||
// Semi 的 onChange → 写回原生四字段(POST 契约),并派 change 事件给可能的监听者
|
||||
function writeBack(date, str) { writeDT(box, str); }
|
||||
|
||||
// ⚠️ 重挂必须**先 unmount 再 createRoot**,不能对同一个 root 再 render:
|
||||
// DatePicker 是非受控组件(我们用 defaultValue),React 18 复用旧 root 时
|
||||
// 新传的 defaultValue **不会**改变已显示的值 —— 于是 fbSetDT 改了原生字段、
|
||||
// Semi 还显示旧值("点了 15:00 控件里还写 09:00"的真实成因)。
|
||||
var root = null;
|
||||
function mount() {
|
||||
if (root) { root.unmount(); root = null; }
|
||||
host.innerHTML = '';
|
||||
root = ReactDOM.createRoot(host);
|
||||
root.render(React.createElement(SemiUI.DatePicker, {
|
||||
type: 'dateTime',
|
||||
format: fmt,
|
||||
showClear: true,
|
||||
defaultValue: parseDT(readDT(box)),
|
||||
placeholder: '选择日期时间',
|
||||
position: window.matchMedia('(max-width: 640px)').matches ? 'bottom' : 'bottomLeft',
|
||||
onChange: writeBack
|
||||
}));
|
||||
/* 🔴 v35:单点时间控件同样**不再用 Semi**(图省事不再区分两条路径)——
|
||||
Semi 的 `dateTime` 面板同样没有可选的时间列(见 upgradeRange 里的实测)。
|
||||
这里只把原来的 时/分/秒 三个下拉合成**一个原生时间框**,
|
||||
日期框照旧是原生 `type="date"`,两者都**直接显示**。
|
||||
契约不变(*_date/_hour/_minute/_second 仍在 DOM 里提交)。 */
|
||||
if (!box.__fbMerged) {
|
||||
box.__fbMerged = true;
|
||||
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) {
|
||||
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');
|
||||
Array.prototype.forEach.call(native.querySelectorAll('.dt-colon, .dt-time'), function (n) {
|
||||
n.classList.add('dt-merged');
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
mount();
|
||||
} catch (e) {
|
||||
if (window.console) { console.warn('Semi DatePicker 挂载失败,留在原生控件:', e && e.message); }
|
||||
return;
|
||||
}
|
||||
// 页内 JS 可能会**程序化**改这个字段(如日程页「点日历空白处」预填开始时间)——
|
||||
// 原生值改了 Semi 却还显示旧值,两边就不一致了。所以把重挂函数挂到元素上,
|
||||
// 供 window.fbSetDT() 调用(见文件末尾)。
|
||||
box.__fbRemount = mount;
|
||||
|
||||
box.classList.add('is-semi');
|
||||
native.style.display = 'none';
|
||||
if (init) { box.classList.add('has-value'); }
|
||||
// 直接露出原生(日期 + 时间)
|
||||
native.style.display = 'contents';
|
||||
box.classList.add('is-native');
|
||||
box.classList.add('has-value');
|
||||
// 程序化改值时让时间框跟上
|
||||
box.__fbRemount = function () { if (box.__fbSyncTime) { box.__fbSyncTime(); } };
|
||||
guardRequired(box);
|
||||
box.setAttribute('data-semi-done', '1');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 原生控件被藏起来之后,它上面的 `required` 必须**摘掉**并改由我们自己校验。
|
||||
*
|
||||
@@ -1562,7 +1566,10 @@
|
||||
if (bad.length) {
|
||||
ev.preventDefault();
|
||||
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(); }
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user