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:
+10
-1
@@ -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(编辑时可能还没建过这行) */
|
||||||
|
|||||||
@@ -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
@@ -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(); }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user