diff --git a/app/Trip.php b/app/Trip.php index 1383421..fd8a34c 100644 --- a/app/Trip.php +++ b/app/Trip.php @@ -660,12 +660,35 @@ final class Trip return $rows; } - public static function node(int $id): ?array + /** + * 一段行程(单条)。 + * + * 🔴 v24 第 19 批 第 5 条修的是这里:这个方法原来只 `SELECT * FROM trip_nodes`, + * **不带 `members` / `attachs`** —— 而 `Trip::nodes()`(列表)是带的。 + * 结果:编辑行程时 `$n['members']` 永远是空数组 → + * `_person_picker` 收到 `$ppSelected = []` → 参与人框**一片空白**。 + * 用户原话:「编辑行程,参与的人,没有带进来,还是空的。」 + * 同一处还让"编辑页的照片墙"永远不显示(attachs 也缺)。 + * + * ⚠️ 所以单条也要挂上这两样,与 `nodes()` **同一口径** —— + * 两个方法一个带、一个不带,正是这类"只有编辑页不显示"的根源。 + * + * @param int $viewerId 按"谁在看"取成员的昵称(编辑表单只用 id,传 0 也行) + */ + public static function node(int $id, int $viewerId = 0): ?array { $st = Database::get()->prepare('SELECT * FROM trip_nodes WHERE id = ?'); $st->execute([$id]); $r = $st->fetch(); - return $r ?: null; + if (!$r) { + return null; + } + $r['members'] = self::nodeMembersFor($id, $viewerId); + $r['attachs'] = \App\Attachment::list('trip_node', $id); + // 每段花了多少(与 nodes() 一致:详情页/编辑页的"这一段花了多少"读它) + $rows = [$r]; + \App\Expense::attachSums($rows); + return $rows[0]; } /** diff --git a/assets/css/app.css b/assets/css/app.css index 10d40ce..7601638 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -443,6 +443,17 @@ textarea.input { min-height: 76px; resize: vertical; } .btn-ghost:hover { background: #FAF7F1; } .btn-danger { background: #fff; color: var(--red); border-color: rgba(192,83,63,.35); } .btn-danger:hover { background: #FDF1EE; } +/* v24 第 19 批 用户第 7 条:行程行的动作按钮要**看得出颜色**—— + 「修改是黄色背景,完成绿色,未成行红色」。 + ⚠️ 这三个是**实心**色块(不是 .btn-danger 那种淡红描边):用户说的是背景色, + 要的是"一眼分得清哪个是哪个"。所以单独定义,不去改 .btn-danger 的既有语义 + (全站十来处「删除」都在用它,改成实心会到处变样)。 */ +.btn-warn { background: #E8A33D; color: #fff; border-color: transparent; } +.btn-warn:hover { background: #D79230; } +.btn-ok { background: #5E9E4E; color: #fff; border-color: transparent; } +.btn-ok:hover { background: #518B43; } +.btn-del { background: #C0533F; color: #fff; border-color: transparent; } +.btn-del:hover { background: #AC4835; } .btn-sm { padding: 5px 12px; font-size: 13px; } .btn-row { display: flex; gap: 10px; margin-top: 20px; } @@ -1928,11 +1939,22 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, } .att-doc { display: flex; flex-direction: column; align-items: center; justify-content: center; - gap: 4px; width: 100%; height: 100%; + gap: 4px; width: 100%; height: 100%; padding: 6px; font-size: 12px; font-weight: 700; letter-spacing: .5px; color: #fff; + text-align: center; box-sizing: border-box; } .att-doc::before { content: ""; width: 34px; height: 34px; border-radius: 6px; background: rgba(255, 255, 255, .22); + flex: 0 0 auto; +} +/* v24 第 19 批 用户第 6 条「如果是文件,则在图标上显示文件名」: + 文件名压着色块显示 —— 文件就是**读名字**的,名字才是最该看清的东西。 + ⚠️ 两行封顶 + 省略号:文件名可以很长(「学位学历证书_博士学位证书_2022-07-08-17-05-53.jpeg」), + 不截就会把图标撑变形、把相邻的格子挤歪。完整名字仍能从悬停 title / 下载看到。 */ +.att-doc-name { + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; word-break: break-all; line-height: 1.35; + max-width: 100%; font-size: 11px; font-weight: 600; letter-spacing: 0; } .att-doc-pdf { background: #B0342C; } .att-doc-doc { background: #2C5AA0; } @@ -1944,11 +1966,30 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 6px; margin-top: 4px; font-size: 12px; color: var(--muted); } +/* v24 第 19 批 用户第 6 条:缩略图下面**不再放文件名**(那串是上传时的原始名, + 图片/视频是"看"的,一串哈希既认不出内容也不比缩略图本身多信息)。 + `.att-fname` 的规则保留 —— 万一将来又要在某处显示一行名字,样式不用重写。 */ .att-fname { flex: 1 1 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); } .att-fsize { flex: 0 0 auto; } +/* ---- 拖动上传(v24 第 19 批 用户第 1 条)---- + 用户原话:「为所有的图片视频附件上传处,提供拖动上传的功能,就是允许用户, + 通过把文件拖动到那里进行上传。」 + ⚠️ 类名 `.dropzone` 由 app.js **自动给页面上每个 file 控件挂上**, + 并且给「`data-pick-input` 指向该控件的预览图」也挂一份 —— 题图/头像那类 + 控件的 input 是 sr-only(看不见),往旁边那张图上拖才是最自然的动作。 + 全站十几处上传点一次性都获得这个能力(不改任何模板)。 + ⚠️ 落在哪儿由 `.is-over` 高亮体现;真正的落文件逻辑在 app.js 里 + (DataTransfer → input.files → 手动派发 change)。 + ⚠️ 曾经想过再加一句常驻的「也可以把文件拖到这里」小字,**去掉了**: + 十几个上传点各多一行灰字,页面会明显变吵,而"拖动"本身不是必须被教导的能力。 */ +.dropzone { position: relative; } +.dropzone.is-over { + outline: 2px dashed var(--brand); outline-offset: 3px; + background: #FDF3EC; border-radius: var(--radius-s); +} .att-meta form { display: inline; margin: 0; } /* ---- 全局预览层 ---- */ .pv { @@ -1990,6 +2031,28 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, .pv-pdf { width: 92vw; height: 100%; border: 0; border-radius: 8px; background: #fff; } +/* ---- 左右翻页箭头(v24 第 19 批 用户第 6 条)---- + 用户原话:「预览的时候,左右有箭头,可以直接查看下一张。」 + ⚠️ 左右各一个圆形按钮,垂直居中、压在内容两侧。禁用态(第一张/最后一张) + 要**看得出来**(半透明 + 禁止光标),否则用户会以为"点了没反应"。 */ +.pv-nav { + position: fixed; top: 50%; transform: translateY(-50%); z-index: 5; + width: 46px; height: 46px; border-radius: 50%; border: 0; cursor: pointer; + background: rgba(255, 255, 255, .2); color: #fff; font-size: 30px; line-height: 1; + display: flex; align-items: center; justify-content: center; padding: 0 0 4px; +} +.pv-nav:hover:not(:disabled) { background: rgba(255, 255, 255, .34); } +.pv-nav:disabled { opacity: .28; cursor: default; } +/* ⚠️ 与 .pv 同一个老坑:这里自己声明了 display:flex,不补 [hidden] 就藏不住 —— + 只有一个可预览项时箭头必须消失,靠的就是这条。 */ +.pv-nav[hidden] { display: none; } +.pv-prev { left: 14px; } +.pv-next { right: 14px; } +@media (max-width: 560px) { + .pv-nav { width: 40px; height: 40px; font-size: 26px; } + .pv-prev { left: 6px; } + .pv-next { right: 6px; } +} @media (max-width: 560px) { .att-cell, .att-thumb { width: 108px; } .att-thumb { height: 80px; } @@ -2041,6 +2104,14 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, .pp-item:last-child { border-bottom: 0; } .pp-item:hover { background: var(--line-2); } .pp-item input { accent-color: var(--brand); flex: 0 0 auto; } +/* v24 第 19 批 用户第 8 条:「前面的复选框就没用了吧,去掉吧。」 + ⚠️ 多选时复选框**确实没用**了 —— 第 18 批起"选谁"靠点头像/名字(点击整行), + 勾选与否只由暂存区的 chip 表达,一个小小的方框反而是干扰。 + JS 给多选组件加 `.pp-compact` 才隐藏;**单选不隐藏**(单选的圆点是他唯一 + 能看见"现在选的是谁"的地方)。 + ⚠️ 只是 `display:none`,input **仍在 DOM 里** —— 表单提交全靠它。 + 千万别改成 remove()(那就变成"选了保存后没人")。 */ +.pp-compact .pp-item input { display: none; } .pp-item .pp-av { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; background: #F1E7D6; @@ -2342,6 +2413,14 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, .trip-title { font-size: 17px; font-weight: 600; margin: 0; } .trip-meta { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; color: var(--muted); } .trip-sum { margin: 0; font-size: 14px; color: var(--muted); line-height: 1.6; } +/* 老数据(只有 events.note、还没搬成 summary)时的一句提示 —— + 见 templates/trips/show.php 里第 19 批 第 4 条的说明。 */ +.trip-sum-legacy { + margin: 0; font-size: 13px; color: var(--muted); + background: #FDF3EC; border: 1px dashed rgba(168, 74, 40, .3); + border-radius: var(--radius-s); padding: 8px 10px; +} +.trip-sum-legacy a { font-weight: 600; } .trip-people { display: flex; flex-wrap: wrap; gap: 4px 8px; } .trip-hero { display: flex; gap: 20px; flex-wrap: wrap; } @@ -2384,7 +2463,16 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, .node-row.status-done .node-badge { background: #E6F0DC; color: #4A7226; } .node-place, .node-time { font-size: 12px; color: var(--muted); } .node-detail { margin: 0; font-size: 14px; line-height: 1.65; } -.node-ops { flex: 0 0 auto; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; } +/* v24 第 19 批 用户第 7 条:动作按钮**横着排列**、放在行程**右上角**。 + 「计划行程的三个按钮,修改、完成、未成行,横着排列,放在行程右上角」 + 「实际行程的编辑和删除按钮,也放在行程的右上角」 + ⚠️ 原来是 `flex-direction: column`(竖着一列、贴右下),用户要的是横排靠上。 + 改成 row + align-self:flex-start(贴着行的顶部对齐),窄屏再允许换行。 */ +.node-ops { + flex: 0 0 auto; display: flex; flex-direction: row; flex-wrap: wrap; + gap: 6px; align-items: flex-start; justify-content: flex-end; + align-self: flex-start; +} .node-edit > summary { cursor: pointer; } .node-add { margin-top: 14px; border-top: 1px solid var(--line-2); padding-top: 12px; } .node-add > summary { cursor: pointer; font-size: 14px; } @@ -3388,18 +3476,28 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, /* ---- 暂存区:在搜索框**下面**、候选清单**上面** ---- ⚠️ 用户说的是"选择框的上半部分"。搜索框在最顶(要一直能打字搜下一个人), 所以暂存区放在搜索框之下、清单之上 —— 视觉上仍然属于"上半部分", - 而且不会因为暂存区变高把搜索框挤走(搜索是要反复用的)。 */ + 而且不会因为暂存区变高把搜索框挤走(搜索是要反复用的)。 + ⚠️ v24 第 19 批 用户第 8 条:「暂存区太小了,高度太低,看不见人」—— + 原来只有 148px 且一行能塞很多 chip,看着像一条空横条。 + 现在:① 高度提到 min 132 / max 46vh(跟着弹框走,不再写死一个矮数); + ② chip 改成**大一点**(头像 28px、字号 13.5),并允许每行少放几个, + 让人**看得见是谁**;③ 有内容时才占位(没人暂存时不显示,见 JS)。 */ .pp-stage { border: 1px solid var(--line); border-radius: 8px; - background: #FDFBF7; padding: 8px 10px; margin-bottom: 8px; - max-height: 148px; overflow-y: auto; + background: #FDFBF7; padding: 10px 12px; margin-bottom: 8px; + min-height: 132px; max-height: 46vh; overflow-y: auto; + display: flex; flex-direction: column; } .pp-stage[hidden] { display: none; } .pp-stage-label { - display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; + display: block; font-size: 12px; color: var(--muted); margin-bottom: 8px; + flex: 0 0 auto; } .pp-stage-label b { color: var(--brand); font-variant-numeric: tabular-nums; } -.pp-stage-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.pp-stage-chips { + display: flex; flex-wrap: wrap; gap: 8px; align-content: flex-start; + flex: 1 1 auto; +} /* ---- 底部按钮:取消在左、确定在右(主操作靠右,与全站一致)---- */ .pp-actions { @@ -3408,11 +3506,11 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, } /* ---- 暂存区里的 chip 比外面的紧凑一点(弹窗里空间有限)---- */ -.pp-stage-chips .pp-chip { font-size: 12.5px; padding: 2px 5px 2px 3px; } -.pp-stage-chips .pp-chip img { width: 20px; height: 20px; } +.pp-stage-chips .pp-chip { font-size: 13px; padding: 3px 8px 3px 4px; } +.pp-stage-chips .pp-chip img { width: 28px; height: 28px; } /* ---- 窄屏:弹窗几乎占满,暂存区别太高(把清单挤没了就没法选了)---- */ @media (max-width: 480px) { - .pp-modal-box { max-height: 86vh; } - .pp-stage { max-height: 104px; } + .pp-modal-box { max-height: 88vh; } + .pp-stage { min-height: 108px; max-height: 38vh; } } diff --git a/assets/js/app.js b/assets/js/app.js index 0fb1db4..195111f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -328,6 +328,141 @@ } ); + /** + * 拖动上传(v24 第 19 批 用户第 1 条)。 + * + * 用户原话:「为所有的图片视频附件上传处,提供拖动上传的功能,就是允许用户, + * 通过把文件拖动到那里进行上传。」 + * + * 做法:**给页面上每一个 `` 自动挂上**拖动接收 —— 不改模板, + * 于是全站的附件上传点(旅程照片、观影海报/票根/现场照片、资源、任务、日程附件、 + * 图书封面与文件、名册头像与通讯录导入…)一次性都有了拖动能力。 + * 新出现的文件框也接得住:MutationObserver 补挂(时间控件那套用过同一招)。 + * + * 🔴 两个必须踩准的点: + * ① **必须真的把 drop 的文件写进 input.files**,不能只"显示个名字"。 + * 写回去要用 DataTransfer:`dt.items.add(file)` 再 `input.files = dt.files`。 + * 不这么做的话,用户以为拖上去了,一提交却发现什么都没上传。 + * ② 写回之后要**手动派发一次 change** —— 页面上挂的预览 / 裁剪器都监听 change, + * 不派发的话拖进来的图不会弹裁剪框、也不会更新预览("拖了没反应")。 + * 注意:`input.files = …` 本身**不会**触发 change(不是用户交互),必须手动补。 + * + * ⚠️ 只接拖动落点,不改 accept 语义:accept 是给选择框用的提示,浏览器**不**拿它 + * 过滤 drop(拖什么都进得来)。服务端本来就会再判一次 MIME,这里不重复造规则。 + * ⚠️ dragover 里必须 preventDefault,否则浏览器不认这里是"可放下"的(默认行为是拒收)。 + * ⚠️ 用计数器算 dragenter/dragleave:拖过子元素时也会触发 leave, + * 只按"进了/出了"切换高亮会让高亮疯狂闪。 + */ + (function () { + /** + * 给一个"落点元素"挂拖动接收。 + * + * @param {Element} zone 用户实际往上拖东西的那个元素(可见的那块) + * @param {Element} input 目标 + * + * ⚠️ 为什么落点与 input 要分开:题图/头像那类上传,`` 是 **sr-only** + * (看不见),用户能看见、能点的是旁边那张预览图。只把拖动挂在 input 上, + * 用户往图上拖是**没有任何反应**的 —— 而"拖到那块图上"才是最自然的动作。 + * 所以:有 `data-pick-input` 指向这个 input 的图,就把那图也当落点。 + */ + function bindZone(zone, input) { + if (!zone || !input || zone.getAttribute('data-drop-bound') === '1') { return; } + zone.setAttribute('data-drop-bound', '1'); + zone.className += (zone.className ? ' ' : '') + 'dropzone'; + + var depth = 0; // dragenter/dragleave 计数器(见上面的说明) + + function over(state) { + if (state) { zone.classList.add('is-over'); } else { zone.classList.remove('is-over'); } + } + + zone.addEventListener('dragenter', function (e) { + if (!hasFiles(e)) { return; } + e.preventDefault(); + depth++; + over(true); + }); + zone.addEventListener('dragover', function (e) { + if (!hasFiles(e)) { return; } + // 🔴 少了这句浏览器就不允许放下(默认是拒收),后头的 drop 根本不会来 + e.preventDefault(); + if (e.dataTransfer) { e.dataTransfer.dropEffect = 'copy'; } + over(true); + }); + zone.addEventListener('dragleave', function (e) { + if (!hasFiles(e)) { return; } + depth--; + if (depth <= 0) { depth = 0; over(false); } + }); + zone.addEventListener('drop', function (e) { + if (!hasFiles(e)) { return; } + e.preventDefault(); + depth = 0; + over(false); + var files = Array.prototype.slice.call(e.dataTransfer.files || []); + if (!files.length) { return; } + // 目标是普通文件框时,**单选的只取第一个**(多选也塞多个会与 accept 语义打架: + // 题图/头像那种单选框塞两个,浏览器提交的仍是第一个,但用户会以为两个都传了) + var multi = input.hasAttribute('multiple'); + var put = multi ? files : files.slice(0, 1); + try { + var dt = new DataTransfer(); + put.forEach(function (f) { dt.items.add(f); }); + input.files = dt.files; + } catch (err) { + // 老浏览器不支持给 input.files 赋值 → 至少把光标引到框上,不让用户白拖 + if (window.console) { console.warn('拖动上传:这个浏览器不支持把文件写进控件', err); } + input.focus(); + return; + } + // 🔴 手动派发 change:预览与裁剪器都听它(input.files 赋值不会自己触发) + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + } + + /** 这次拖动里有没有真文件(拖选中的文字/链接也会触发 drag 事件,那些不该管) */ + function hasFiles(e) { + var t = e.dataTransfer; + if (!t) { return false; } + if (t.types) { + for (var i = 0; i < t.types.length; i++) { + if (t.types[i] === 'Files') { return true; } + } + } + return false; + } + + /** 一个 input:既给它自己挂落点,也给"指向它的那张预览图"挂(见 bindZone) */ + function bindInput(input) { + bindZone(input, input); + var sel = input.id ? ('[data-pick-input="#' + input.id + '"]') : ''; + if (sel) { + Array.prototype.forEach.call(document.querySelectorAll(sel), function (pv) { + bindZone(pv, input); + }); + } + } + + function scan(scope) { + Array.prototype.forEach.call( + (scope || document).querySelectorAll('input[type="file"]'), bindInput); + } + + scan(); + // 动态加进来的文件框(工作经历那种"再来一段"、日历弹框里的附件…)也要接得住 + if (window.MutationObserver) { + new MutationObserver(function (muts) { + muts.forEach(function (m) { + Array.prototype.forEach.call(m.addedNodes || [], function (n) { + if (!n.querySelectorAll) { return; } + if (n.tagName === 'INPUT' && n.type === 'file') { bindInput(n); } + scan(n); + }); + }); + }).observe(document.body, { childList: true, subtree: true }); + } + })(); + // 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架) var fileInput = document.getElementById('avatar'); if (fileInput && !fileInput.hasAttribute('data-crop-ratio') && window.FileReader) { diff --git a/assets/js/person_picker.js b/assets/js/person_picker.js index b3f565e..83011b1 100644 --- a/assets/js/person_picker.js +++ b/assets/js/person_picker.js @@ -93,6 +93,13 @@ var emptyTip = root.querySelector('.pp-empty'); if (!modal || !list || !chips) { return; } + // ---- 第 19 批 用户第 8 条:多选弹框隐藏每行前面的复选框 ---- + // 用户原话:「弹框选择人员那里,暂存区太小了…前面的复选框就没用了吧,去掉吧。」 + // ⚠️ 只是**不显示**(CSS `display:none`),不是从 DOM 里删 —— 表单提交靠的 + // 就是这些 input.checked("一行不剩、一行不多"的语义载体,见上面的取舍说明)。 + // 单选**不隐藏**:单选没有暂存区,候选行上的圆点是他唯一能看见"当前选的是谁"的地方。 + if (isMulti) { root.className += (root.className ? ' ' : '') + 'pp-compact'; } + // ---- 第 18 批新增的暂存区 / 按钮(只有 multi 才渲染,所以可能为 null)---- var stage = root.querySelector('.pp-stage'); // 🔴 按 **class** 找(`.pp-stage-label b`),不要按 id 找。 @@ -147,46 +154,119 @@ } decorateItems(); - function syncChips() { - chips.innerHTML = ''; - var any = false; + /** + * 服务的"已选人数"是**列表里真正勾上的 input 数**;列表还没建时用属性里的。 + * + * ⚠️ 这两个是本次(v24 第 19 批 第 5 条)新加的,配套一个真 bug: + * 用户报"编辑行程/编辑观影时,参与的人(同行的人)没带进来,是空的"。 + * 根因不是服务端没渲染(服务端渲染了 chip),而是下面 syncChips() 在 + * **首屏**就把它们清空了 —— 见 syncChips 的注释。 + */ + var built = false; // 候选人清单是否已经建出来(懒建,见 buildList) + + /** + * 把"当前选中的那些人"解析成 [{id,name,avatar}],**不依赖列表 DOM**。 + * + * 名字/头像从哪来:① 服务端渲染的 chips(每个 chip 里有 .pp-name 与 , + * 且带 data-id)—— 这是"已经确定的选中项",首屏就有; + * ② 候选人清单。 + * 列表没建时只能靠 ① —— 所以**绝不能**在列表建出来之前清空 chips。 + */ + function selectedPeople() { + var out = []; + var seen = {}; + function add(id, name, av, ready) { + id = String(id); + if (id === '' || id === '0' || seen[id]) { return; } + seen[id] = true; + out.push({ id: id, name: name || id, av: av || '', ready: !!ready }); + } + // ① 列表里的勾选(列表建出来之后才看得到) Array.prototype.forEach.call(list.querySelectorAll('.pp-item input:checked'), function (box) { - if (box.value === '0') { return; } // 「不选」不进 chips + if (box.value === '0') { return; } var item = box.closest('.pp-item'); if (!item) { return; } - any = true; // ⚠️ 必须点名取 .pp-name:.pp-item 里第一个 span 是隐藏的首字母占位, // 早年靠「取第一个 span 当名字」碰巧成立,加了首字母徽标后 chip 就没名字了。 var nameEl = item.querySelector('.pp-name'); var av = item.querySelector('.pp-av'); + add(box.value, nameEl ? nameEl.textContent : box.value, + av ? av.getAttribute('src') : '', true); + }); + // ② 还没进列表的已选人:从服务端渲染的 chips 里补(**首屏唯一的来源**) + Array.prototype.forEach.call(chips.querySelectorAll('.pp-chip'), function (ch) { + var id = ch.getAttribute('data-id'); + if (id === null) { return; } // 占位 chip(未选择)没有 data-id + var nameEl = ch.querySelector('.pp-name'); + var im = ch.querySelector('img'); + add(id, nameEl ? nameEl.textContent : id, im ? im.getAttribute('src') : '', false); + }); + return out; + } + + /** 把一批人画成 chips 塞进某个容器(外面 chips / 暂存区共用一份画法) */ + function paintChips(into, people, withX) { + people.forEach(function (p) { var chip = document.createElement('span'); chip.className = 'pp-chip'; - chip.setAttribute('data-id', box.value); - if (av) { + chip.setAttribute('data-id', p.id); + if (p.av) { var im = document.createElement('img'); - im.src = av.getAttribute('src'); // 服务端已 e() 过,这里原样搬 + im.src = p.av; im.alt = ''; chip.appendChild(im); } var nm = document.createElement('span'); nm.className = 'pp-name'; - nm.textContent = nameEl ? nameEl.textContent : box.value; + nm.textContent = p.name; chip.appendChild(nm); - if (isMulti) { + if (withX) { var x = document.createElement('button'); x.type = 'button'; x.className = 'pp-x'; x.textContent = '×'; - x.title = '移除'; x.setAttribute('data-id', box.value); + x.title = '移除'; x.setAttribute('data-id', p.id); chip.appendChild(x); } - chips.appendChild(chip); + into.appendChild(chip); }); - // ⚠️ 多选且一个都没选时**不要**塞占位 chip:.pp-chip 自己写了 display:inline-flex, - // 再给它 hidden 也藏不掉(作者样式压过 UA 的 [hidden]),会留下一个空胶囊。 - if (!any && !isMulti) { - var ph = document.createElement('span'); - ph.className = 'pp-chip pp-chip-empty'; - ph.textContent = '未选择'; - chips.appendChild(ph); + } + + /** + * 重画**外面**的 chips(弹窗关上时用户看到的那一排)。 + * + * 🔴🔴 这里有个让用户报了两处的 bug(v24 第 19 批 第 5 条),改之前务必看懂: + * + * 用户原话:「编辑行程,参与的人,没有带进来,还是空的。同样的问题, + * 也出在观影编辑时,同行的人,也没有带进来。」 + * + * 症状很奇怪:**服务端渲染的 HTML 里 chip 明明在**(curl 能看到 3 个), + * 但浏览器里数出来是 0 或 1 个 —— 因为本函数在 `init()` 末尾被调了一次, + * 而它第一句就是 `chips.innerHTML = ''`,然后从**候选人清单**里重建; + * 可候选人是**懒建**的(`.pp-item` 要打开弹框才建),首屏 list 里一个 + * input 都没有 → 清空之后一个都补不回来,服务端渲染的那排 chip **全没了**。 + * 行程编辑页"看起来显示 1 个"是撞巧(那一页的 chips 刚好也被清了,而 + * chips 容器里还留着占位/其他元素)。 + * + * 修法:**列表还没建出来时,绝不动 chips** —— 服务端渲染的就是"已确定"的 + * 那一份,本来就是对的内容。列表建出来之后才由它重建(那时信息才全)。 + * 另外 chips 上可能挂过"未选择"占位(单选),列表建出来时要顺手清掉。 + */ + function syncChips() { + if (!built) { + return; // 🔴 懒建之前一律保留服务端渲染的 chips(见上面的长注释) + } + var ph = chips.querySelector('.pp-chip-empty'); + if (ph) { ph.parentNode.removeChild(ph); } // 「未选择」占位不再需要 + chips.innerHTML = ''; + var people = selectedPeople(); + if (!people.length && !isMulti) { + // ⚠️ 多选且一个都没选时**不要**塞占位 chip:.pp-chip 自己写了 display:inline-flex, + // 再给它 hidden 也藏不掉(作者样式压过 UA 的 [hidden]),会留下一个空胶囊。 + var e = document.createElement('span'); + e.className = 'pp-chip pp-chip-empty'; + e.textContent = '未选择'; + chips.appendChild(e); + } else { + paintChips(chips, people, isMulti); } } @@ -197,39 +277,17 @@ * ⚠️ 暂存区的内容**完全由 input.checked 推出来**,不另存一份"暂存数组" * (见文件头部的说明:两份状态必然飘)。 * ⚠️ 每个 chip 带一个 ×:用户在弹窗里就能改主意(不用先确定再回页面删)。 + * ⚠️ v24 第 19 批 第 8 条:**打开弹框时也要先填一次** —— 否则"已经选过的人" + * 只在点了别人才出现,用户会以为"我之前选的人丢了"(他进弹框第一眼就该看到)。 */ function syncStage() { if (!stagedMode) { return; } + var people = selectedPeople(); stageChips.innerHTML = ''; - var n = 0; - Array.prototype.forEach.call(list.querySelectorAll('.pp-item input:checked'), function (box) { - if (box.value === '0') { return; } - var item = box.closest('.pp-item'); - if (!item) { return; } - n++; - var nameEl = item.querySelector('.pp-name'); - var av = item.querySelector('.pp-av'); - var chip = document.createElement('span'); - chip.className = 'pp-chip'; - chip.setAttribute('data-id', box.value); - if (av) { - var im = document.createElement('img'); - im.src = av.getAttribute('src'); - im.alt = ''; - chip.appendChild(im); - } - var nm = document.createElement('span'); - nm.className = 'pp-name'; - nm.textContent = nameEl ? nameEl.textContent : box.value; - chip.appendChild(nm); - var x = document.createElement('button'); - x.type = 'button'; x.className = 'pp-x'; x.textContent = '×'; - x.title = '移除'; x.setAttribute('data-id', box.value); - chip.appendChild(x); - stageChips.appendChild(chip); - }); + paintChips(stageChips, people, true); + var n = people.length; if (stageN) { stageN.textContent = String(n); } - // 一个人都没暂存时不占地方(留一条空横条反而让人以为"这里坏了") + // 一个人都没有时不占地方(留一条空横条反而让人以为"这里坏了") stage.hidden = n === 0; // 确定按钮上带个人数,省得用户回头看"我到底选了几个" if (confirmBtn) { @@ -346,6 +404,9 @@ var empty = list.querySelector('.pp-empty'); list.insertBefore(frag, empty || null); list.setAttribute('data-pp-built', '1'); + // ⚠️ 标记"清单已建":syncChips 靠它决定能不能重建外面的 chips + // (没建之前重建 = 把服务端渲染的选中项全清掉,就是第 5 条那个 bug)。 + built = true; // 首字母要补算(组件的拼音筛选依赖它)—— 复用上面那套,口径只有一处 decorateItems(list); } @@ -423,6 +484,7 @@ stageChips.addEventListener('click', function (e) { var x = e.target.closest ? e.target.closest('.pp-x') : null; if (!x) { return; } + buildList(); // 清单没建时候选 input 都还不存在(见下面 chips 的说明) var gid = x.getAttribute('data-id'); Array.prototype.forEach.call(list.querySelectorAll('.pp-item input'), function (box) { if (box.value === gid) { box.checked = false; } @@ -434,6 +496,13 @@ chips.addEventListener('click', function (e) { var x = e.target.closest ? e.target.closest('.pp-x') : null; if (!x) { return; } + // 🔴 先保证候选清单建出来,再取消勾选。 + // 清单是**懒建**的(打开弹框才建),而这里的 × 在弹窗**外面** —— + // 用户完全可以不打开弹框、直接在页面上把某个 chip 撤掉(很自然的动作)。 + // 不先建的话 `list.querySelectorAll('.pp-item input')` 一个都找不到 → + // box.checked 无从设置 → 点了 × **毫无反应**(而且 syncChips 的守卫 + // 会让 chip 也留着)。这是第 19 批 第 5 条那次修复顺手带出的坑。 + buildList(); var gid = x.getAttribute('data-id'); Array.prototype.forEach.call(list.querySelectorAll('.pp-item input'), function (box) { if (box.value === gid) { box.checked = false; } @@ -501,6 +570,9 @@ // "box is not defined",而且是在**初始化末尾**抛,前面都正常, // 只有后面的 ppSetSelected 才失效(e2e 抓不到,真浏览器才报)。 root.__ppSync = refresh; + // 把"建候选人清单"也暴露出去:ppSetSelected 在灌人之前必须先建清单, + // 否则候选 input 还不存在,勾选状态无处可写(见 ppSetSelected 的注释)。 + root.ppBuild = buildList; } /** @@ -535,6 +607,14 @@ if (!host) { return false; } box = host; } + // ⚠️ 共享浮层里灌人之前,**清单必须先建出来**:候选人是懒建的 + // (打开弹框才建 DOM)。不建的话这里一个 `.pp-item input` 都找不到 → + // 归零后一个人都补不回来 —— 用户看到的就是"归属人没带进来"。 + // 兼容老渲染(没有 .pp-list 时 ppBuild 是 undefined/抛错,跳过即可)。 + var listEl = box.querySelector('.pp-list'); + if (listEl && listEl.getAttribute('data-pp-built') !== '1' && typeof box.ppBuild === 'function') { + try { box.ppBuild(); } catch (e) { /* 旧组件没有这个钩子:下面按老路子走 */ } + } var want = []; if (Array.isArray(value)) { want = value.map(String); diff --git a/assets/js/preview.js b/assets/js/preview.js index 7ce1c12..85c382d 100644 --- a/assets/js/preview.js +++ b/assets/js/preview.js @@ -1,5 +1,5 @@ /** - * 全局附件预览(v24 用户第 3 条) + * 全局附件预览(v24 用户第 3 条;第 19 批 第 6 条加左右翻页) * * 点缩略图 → **在本页**弹一层把内容放出来,不再新开标签页。 * · 图片 → @@ -11,6 +11,19 @@ * * 触发方式是数据属性,不用给每个地方写一遍初始化: * + * + * ──────────────────────────────────────────────────────────────── + * v24 第 19 批 用户第 6 条:「行程中的图片和视频,预览的时候,左右有箭头, + * 可以直接查看下一张。」 + * + * 做法:翻页的候选 = **同一页里所有可预览的附件**(`[data-prev]`),按文档顺序。 + * 为什么不做"只在本段行程内翻页": + * · 这个预览层是**全站一个**(图片/视频/PDF 共用),而 attachments 墙是按 + * 宿主分块渲染的;要"按块"就得给每块再编一次组号,收益很小、复杂度不小; + * · 用户要的行为是"看完这张接着看下一张",同页顺序翻页完全满足, + * 并且跨段也不会跳去别的页面 —— 翻到下一个旅程节点时用户能立刻看出来。 + * ⚠️ 只有**一个**可预览项时箭头不显示(点了没反应的元素比没有更糟)。 + * ⚠️ 键盘 ← → 也翻页;视频/PDF 里方向键有自己的含义,所以**只在图片**上接管方向键。 */ (function () { 'use strict'; @@ -22,19 +35,42 @@ var metaEl = box.querySelector('.pv-meta'); var dlEl = box.querySelector('.pv-dl'); var closeEl = box.querySelector('.pv-close'); + var prevEl = box.querySelector('.pv-prev'); + var nextEl = box.querySelector('.pv-next'); var opener = null; + /** 当前这一层里能看到的所有可预览项(文档顺序),以及"正在看第几个" */ + var items = []; + var cur = -1; + function clear() { // ⚠️ 换内容要**整个重建**节点,不能只改 src: // 改 src 不会自动卸载上一支(声音会叠着放), 同理。 stage.innerHTML = ''; } - function open(trigger) { + /** 收一份当前页面里所有可预览的附件(每次打开都重收,页面内容可能被改过) */ + function collect(trigger) { + items = Array.prototype.slice.call(document.querySelectorAll('[data-prev]')); + cur = items.indexOf(trigger); + if (cur < 0) { items = [trigger]; cur = 0; } + } + + /** 第一张 / 最后一张时箭头要禁用(循环翻页会让人不知道自己在哪儿) */ + function syncArrows() { + if (!prevEl || !nextEl) { return; } + var many = items.length > 1; + prevEl.hidden = !many; + nextEl.hidden = !many; + prevEl.disabled = !many || cur <= 0; + nextEl.disabled = !many || cur >= items.length - 1; + } + + function paint(trigger) { var kind = trigger.getAttribute('data-prev') || 'image'; var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || ''; var dl = trigger.getAttribute('data-dl') || src; - if (!src) { return; } + if (!src) { return false; } opener = trigger; clear(); @@ -62,23 +98,49 @@ titleEl.textContent = trigger.getAttribute('data-name') || ''; var sz = trigger.getAttribute('data-size') || ''; - metaEl.textContent = sz; + var counter = items.length > 1 ? (' ' + (cur + 1) + '/' + items.length) : ''; + metaEl.textContent = sz + counter; dlEl.setAttribute('href', dl); // 下载要带上真实文件名:download 属性 + 服务端 Content-Disposition 双保险 var nm = trigger.getAttribute('data-name') || ''; if (nm) { dlEl.setAttribute('download', nm); } else { dlEl.removeAttribute('download'); } + syncArrows(); + return true; + } + function open(trigger) { + collect(trigger); + if (!paint(trigger)) { return; } box.hidden = false; box.setAttribute('aria-hidden', 'false'); closeEl.focus(); } + /** + * 翻到上一张 / 下一张。 + * @param {number} dir -1 = 上一张,+1 = 下一张 + */ + function step(dir) { + if (items.length <= 1) { return; } + var n = cur + dir; + if (n < 0 || n >= items.length) { return; } + cur = n; + paint(items[cur]); + } + function close() { box.hidden = true; box.setAttribute('aria-hidden', 'true'); clear(); // 停掉视频 / 卸掉 PDF if (opener && opener.focus) { opener.focus(); } opener = null; + items = []; + cur = -1; + } + + /** 正在看的是不是图片(视频/PDF 里方向键另有含义,不能抢) */ + function isImage() { + return items[cur] && (items[cur].getAttribute('data-prev') || '') === 'image'; } document.addEventListener('click', function (e) { @@ -94,8 +156,13 @@ if (!box.hidden && (e.target === box || e.target === stage)) { close(); } }); + if (prevEl) { prevEl.addEventListener('click', function () { step(-1); }); } + if (nextEl) { nextEl.addEventListener('click', function () { step(1); }); } closeEl.addEventListener('click', close); document.addEventListener('keydown', function (e) { - if (!box.hidden && e.key === 'Escape') { close(); } + if (box.hidden) { return; } + if (e.key === 'Escape') { close(); return; } + if (e.key === 'ArrowLeft' && isImage()) { e.preventDefault(); step(-1); return; } + if (e.key === 'ArrowRight' && isImage()) { e.preventDefault(); step(1); } }); })(); diff --git a/index.php b/index.php index f5c541a..295175b 100644 --- a/index.php +++ b/index.php @@ -2913,7 +2913,9 @@ switch ($r) { $nd = null; $stage = 'plan'; if ($isEdit) { - $nd = Trip::node((int)($_GET['id'] ?? 0)); + // ⚠️ 必须带上 $viewerId:Trip::node() 现在会一并挂上 members(第 19 批 第 5 条: + // "编辑行程参与人是空的")。成员读取要在**看的人**视角下取昵称。 + $nd = Trip::node((int)($_GET['id'] ?? 0), (int)Auth::personId()); if (!$nd) { flash('err', '找不到这段行程。'); redirect('trips'); diff --git a/templates/_attachments.php b/templates/_attachments.php index 6fedbd6..f2b9bca 100644 --- a/templates/_attachments.php +++ b/templates/_attachments.php @@ -1,6 +1,6 @@ 1]); $attNm = (string)($attRow['name'] ?? ''); $attSz = \App\Attachment::sizeWord($attRow['size'] ?? 0); + // ① 图片 / 视频:下面不放文件名(见文件头说明) + $attIsMedia = ($attKind === 'image' || $attKind === 'video'); + // ② 文件:图标上显示文件名;没有名字时退回类别词(「PDF」「Word」…) + $attFileWord = $attNm !== '' ? $attNm : \App\Attachment::kindWord($attKind); ?> @@ -58,18 +76,25 @@ $attDelUrl = $attDelUrl ?? ''; ▶ - PDF + + + = e($attFileWord) ?> + - = e(\App\Attachment::kindWord($attKind)) ?> + + = e($attFileWord) ?> + - = e($attNm !== '' ? $attNm : \App\Attachment::kindWord($attKind)) ?> - = e($attSz) ?> + + + = e($attSz) ?> + 下载 @@ -87,6 +112,7 @@ $attDelUrl = $attDelUrl ?? ''; - + diff --git a/templates/layout.php b/templates/layout.php index adcf5d2..9985813 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -137,6 +137,12 @@ $isLoggedIn = \App\Auth::check(); ✕ + + ‹ + › diff --git a/templates/trips/show.php b/templates/trips/show.php index 955cd13..3d99f28 100644 --- a/templates/trips/show.php +++ b/templates/trips/show.php @@ -46,6 +46,10 @@ $groupMeta = [ = e((string)$t['title']) ?> + + 返回旅程 @@ -138,13 +142,34 @@ $groupMeta = [ - + = nl2br(e((string)$t['summary'])) ?> - - = nl2br(e((string)$t['note'])) ?> + + + + + 这段旅程的旧「备注」还没搬成简介 —— + 点这里补一段简介。 + + @@ -303,19 +328,39 @@ foreach ($bysStage as $lst) { $shown += count($lst); } + - 编辑 - 修正 + 修正 记录实际执行情况。这仍与上面那条计划是同一条记录, 原来的计划数据会保留在「修正记录」里,方便日后对比。 - 点完这里这段行程仍是计划;确认无误后再点「标记为实际」。 + 点完这里这段行程仍是计划;确认无误后再点「完成」。 - 完成 + 完成 = csrf_field() ?> - 标记未成行 + 未成行 0): ?> = csrf_field() ?> - 标记为实际 + 标记为实际 @@ -352,16 +397,16 @@ foreach ($bysStage as $lst) { $shown += count($lst); } = csrf_field() ?> - 恢复为计划 + 恢复为计划 + onsubmit="return confirm('彻底删除这段行程?连同它的修正记录一起消失,不可恢复。\n\n如果你只是想表达「这次没去成」,请用「未成行」。')"> = csrf_field() ?> - 删除 + 删除
= nl2br(e((string)$t['summary'])) ?>
= nl2br(e((string)$t['note'])) ?>
+ 这段旅程的旧「备注」还没搬成简介 —— + 点这里补一段简介。 +
记录实际执行情况。这仍与上面那条计划是同一条记录, 原来的计划数据会保留在「修正记录」里,方便日后对比。 - 点完这里这段行程仍是计划;确认无误后再点「标记为实际」。 + 点完这里这段行程仍是计划;确认无误后再点「完成」。