开始
-
结束
-
- 此时 `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(); }
}
});
diff --git a/index.php b/index.php
index 2e916c2..7864239 100644
--- a/index.php
+++ b/index.php
@@ -2749,22 +2749,58 @@ switch ($r) {
];
}
}
+ /* 🔴🔴 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
+ ? array_map('strval', array_column(Movie::photos($id), 'key')) // 保住已有的
+ : [];
+ $photoTouched = false;
+ 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']) {
+ $photoKeys[] = (string)$up['file'];
+ $photoTouched = true;
+ \App\StagedUpload::forget($stoken);
+ } elseif ((string)($up['error'] ?? '') !== '') {
+ $warns[] = '有一张照片没有保存:' . $up['error'];
+ }
+ }
if ($photoFiles) {
- $photoKeys = $isEdit
- ? array_map('strval', array_column(Movie::photos($id), 'key')) // 保住已有的
- : [];
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;
- } elseif (!$isEdit) {
- // 新增且一张都没选 → 明确给空(否则 create 会拿到一个不存在的键)
- $d['photos'] = [];
}
/* ---- v24 第 20 批 第 4 条:拖动**即时上传**的海报 / 票根 ----
@@ -2855,7 +2891,28 @@ switch ($r) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
+ $staged = (array)($_POST['stage_token'] ?? []);
$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']) {
// savePhotos 是整组替换 —— 现有键取出来再追加一张,位置接着排
$keys = array_map('strval', array_column(Movie::photos($id), 'key'));
diff --git a/templates/movies/form.php b/templates/movies/form.php
index 512b857..6af4c85 100644
--- a/templates/movies/form.php
+++ b/templates/movies/form.php
@@ -166,7 +166,7 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));