v24 第 19 批:拖动上传 / 返还按钮 / TripLog 补迁 / 参与人回填 / 预览翻页 / 行程按钮(用户 8 条)

用户 8 条原话要点:
  ①「为所有的图片视频附件上传处,提供拖动上传的功能」
  ②「旅程详情页面,增加返回按钮,点击后返回到旅程列表」
  ③「已有行程的出行方式,包括航班号,怎么没从 triplog 系统迁移过来?」
  ④「题图右侧有一句话显示,但点编辑进去却看不到这句话」
  ⑤「编辑行程,参与的人没有带进来,还是空的。同样的问题也出在观影编辑时」
  ⑥「预览的时候左右有箭头可以直接看下一张;md5 的文件名放在预览图下面没有意义,去掉;
     如果是文件,则在图标上显示文件名」
  ⑦「计划行程的修改/完成/未成行横着排列放行程右上角,修改黄、完成绿、未成行红;
     实际行程的编辑删除也放右上角,编辑黄、删除红」
  ⑧「选人弹框暂存区太小了看不见人;前面的复选框没用,去掉」

🔴 抓到的两个真根因(都不是"没写对",是两处看似无关的地方):
  1. **⑤ 参与人不回填 = 两个独立原因叠加**
     (a) `Trip::node()`(单条)只 `SELECT *`,**不带 members/attachs**;而
         `Trip::nodes()`(列表)是带的。编辑页走前者 → `$ppSelected=[]` 永远空。
         同一处还让编辑页的照片墙永远不显示。→ 单条也挂 members/attachs/花销。
     (b) `person_picker.js` 的 `init()` 末尾**无条件** `refresh()` → `syncChips()`
         第一句 `chips.innerHTML=''` 再从**懒建**的候选清单重建 —— 首屏清单还没建,
         一个 input 都没有 → **服务端渲染的 chips 全被清掉、补不回来**。
         症状极隐蔽:curl 看到的 HTML 里有 3 个 chip,浏览器里数出来是 0。
         → `syncChips` 加 `if (!built) return;` 守卫 + `ppSetSelected` 前先 `box.ppBuild()`。
  2. **④ 那句话 = 详情页读了两个字段**:`display:summary` + `events.note`(老字段,
     表单里早没有输入框)→ 老数据的 note 在详情页冒出来、却无从修改。
     → 详情页只渲 summary;老数据给一句"点这里补简介"的提示;
        由 `_fb_b19_backfill.php` 一次性搬进 summary。

本批新增能力:
- **拖动上传**(第 1 条):app.js 自动扫全页 `input[type=file]` 挂 DnD,**不改任何模板**
  → 全站十几处上传点一次到位;`data-pick-input` 指向的**预览图**也当落点
  (题图/头像的 input 是 sr-only,往图上拖才自然)。三个必须踩准的点:
  `dragover` 必须 preventDefault;`dt.items.add` 真写回 `input.files`;
  写回后**手动派发 `change`**(`input.files=` 不会自己触发,预览/裁剪器都听 change)。
  MutationObserver 补挂动态出现的框。
- **预览左右翻页**(第 6 条):候选 = 同页全部 `[data-prev]`(文档顺序),
  `‹ ›` 按钮 + 键盘 ←→(**只在图片上**接管方向键,视频/PDF 里方向键另有含义);
  只有一个附件时箭头隐藏(点了没反应比没有更糟);右上角带"第几张/共几张"。
- **附件墙**(第 6 条):缩略图下**不再写**(哈希)文件名;文件的名字压到**图标里**;
  `att-doc-name` 两行封顶 + 省略号(长名不撑变形)。
- **行程按钮**(第 7 条):`.node-ops` 由 `column` 改 `row` + 贴顶;新增
  `.btn-warn/.btn-ok/.btn-del`(**实心**黄/绿/红)—— 与全站淡红描边的 `.btn-danger`
  刻意分开(改它会连带改掉十来处「删除」的样子)。「编辑」保留(改标题/加花销的唯一入口)。
- **选人弹框**(第 8 条):暂存区 148px 死高度 → `min-height:132px; max-height:46vh`,
  chip 头像 20→28px;多选加 `.pp-compact` 隐藏复选框(**只 `display:none`,
  input 仍在 DOM** —— 提交语义全靠它,绝不能 remove)。

上线补迁(`_tooling/_fb_b19_backfill.php`,幂等、不覆盖已有值):
- ③ TripLog 的 `travel_modes.mode_type` + `trip_number/line_name/carrier`
  → FamilyBook 的专列。`drive_own/drive_rental` 都映射成 `drive`(FamilyBook 的
  "自驾"是一个方式,"哪台车"由 vehicle_id 表达);车辆按**车牌**对上;
  顺手删掉 detail 里那段重复的「班次:…」。线上实跑 18 条、跳过 2 条。
- ④ `events.note` → `trip_details.summary`(只搬 summary 为空、note 有内容的,2 条)。

验证(本地全套 + 线上):
- e2e **960/0**(+23 条本批断言,含"成员清空后编辑页也不显示"的反向)· 升级 149/0 ·
  城市解析 32/32 · 桌面几何 428/0 · 真机 UI **27/0**(拖动/翻页/按钮几何与配色)·
  真机选人 chip **2/0**。
- **反向验证 4/4**:①`Trip::node` 去掉 members ②`syncChips` 去掉守卫
  ③详情页把 note 渲回来 ④附件墙把文件名写回去 —— 四种坏法全部被抓到,
  且还原后 e2e 复绿 960/0。
- 线上:部署成功(双版本 l 通过 + 19 条站点自检全过);补迁后 18 段行程有出行方式、
  7 段有车次、10 段挂车、2 个旅程有简介;线上 `Trip::node()` 实调
  members/attachs 都回来了(部署前是 0)。
This commit is contained in:
2026-10-05 22:56:49 +08:00
parent a2b447bee4
commit 1756c6265d
9 changed files with 566 additions and 84 deletions
+25 -2
View File
@@ -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];
}
/**
+109 -11
View File
@@ -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; }
}
+135
View File
@@ -328,6 +328,141 @@
}
);
/**
* 拖动上传(v24 第 19 批 用户第 1 条)。
*
* 用户原话:「为所有的图片视频附件上传处,提供拖动上传的功能,就是允许用户,
* 通过把文件拖动到那里进行上传。」
*
* 做法:**给页面上每一个 `<input type="file">` 自动挂上**拖动接收 —— 不改模板,
* 于是全站的附件上传点(旅程照片、观影海报/票根/现场照片、资源、任务、日程附件、
* 图书封面与文件、名册头像与通讯录导入…)一次性都有了拖动能力。
* 新出现的文件框也接得住: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 type="file">
*
* ⚠️ 为什么落点与 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) {
+127 -47
View File
@@ -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 与 <img>,
* 且带 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);
+72 -5
View File
@@ -1,5 +1,5 @@
/**
* 全局附件预览(v24 用户第 3 条)
* 全局附件预览(v24 用户第 3 条;第 19 批 第 6 条加左右翻页)
*
* 点缩略图 → **在本页**弹一层把内容放出来,不再新开标签页。
* · 图片 → <img>
@@ -11,6 +11,19 @@
*
* 触发方式是数据属性,不用给每个地方写一遍初始化:
* <a href="…" data-prev="image|video|pdf" data-src="…" data-dl="…" data-name="…">
*
* ────────────────────────────────────────────────────────────────
* 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:
// <video> 改 src 不会自动卸载上一支(声音会叠着放),<iframe> 同理。
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); }
});
})();
+3 -1
View File
@@ -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');
+32 -6
View File
@@ -1,6 +1,6 @@
<?php
/**
* 附件缩略图墙(v24 用户第 3 条)
* 附件缩略图墙(v24 用户第 3 条;第 19 批 第 6 条改)。
*
* 一处渲染、全站通用:旅程节点 / 资源 / 任务 / 日程的附件都长一样。
*
@@ -11,6 +11,20 @@
* (浏览器渲染不了的东西,绝不装一个"点了没反应"的预览)
* · 预览框**右上角**恒有一个下载按钮
*
* ────────────────────────────────────────────────────────────────
* v24 第 19 批 用户第 6 条(本次改的三点):
* 「图片和视频,文件名都是 md5 的,放在图片预览图下面没有任何意义,去掉吧。」
* 「如果是文件,则在图标上显示文件名。」
*
* ① 图片 / 视频**下面不再写文件名与大小** —— 那串是用户手机上传时的原始名,
* 而照片/视频是**看**的,一串 `9d3fcc70fe46161275f850b93470e0bb.mp4`
* 既认不出内容、也不比缩略图本身多任何信息。
* (图与视频仍带 title 悬停提示,「下载」按钮也还在 —— 功能没少,只是不占版面。)
* ② 非图片的**文件**(PDF/Word/Excel/PPT/文本):文件名挪到**图标里面**,
* 压着色块显示。文件是**读名字**的,「学位学历证书_博士学位证书.pdf」
* 这行字本身就是最重要的信息,放在图标下方的小灰字里反而看不清。
* ③ 删除按钮与下载入口保留在缩略图下方(.att-meta 仍在,只是不再放文件名/大小)。
*
* 用法(先准备变量再 require):
* $attRows 附件行(Attachment::list 的结果)
* $attCanEdit 是否显示删除按钮
@@ -39,6 +53,10 @@ $attDelUrl = $attDelUrl ?? '';
$attDl = media_url((string)$attRow['key'], ['dl' => 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);
?>
<div class="att-cell">
<?php if ($canPrev): ?>
@@ -58,18 +76,25 @@ $attDelUrl = $attDelUrl ?? '';
<video src="<?= e($attView) ?>#t=0.1" preload="metadata" muted playsinline></video>
<span class="att-play" aria-hidden="true">▶</span>
<?php else: ?>
<span class="att-doc att-doc-pdf">PDF</span>
<?php /* PDF:文件名压在图标上(用户第 6 条) */ ?>
<span class="att-doc att-doc-pdf">
<span class="att-doc-name"><?= e($attFileWord) ?></span>
</span>
<?php endif; ?>
</a>
<?php else: ?>
<a class="att-thumb att-kind-<?= e($attKind) ?>" href="<?= e($attDl) ?>"
title="<?= e($attNm) ?>(点击下载)">
<span class="att-doc att-doc-<?= e($attKind) ?>"><?= e(\App\Attachment::kindWord($attKind)) ?></span>
<span class="att-doc att-doc-<?= e($attKind) ?>">
<span class="att-doc-name"><?= e($attFileWord) ?></span>
</span>
</a>
<?php endif; ?>
<div class="att-meta">
<span class="att-fname" title="<?= e($attNm) ?>"><?= e($attNm !== '' ? $attNm : \App\Attachment::kindWord($attKind)) ?></span>
<span class="att-fsize"><?= e($attSz) ?></span>
<?php /* 图片/视频不显示那串哈希名;文件的名字已经在图标上了,这里也不必再抄一遍 */ ?>
<?php if (!$attIsMedia): ?>
<span class="att-fsize"><?= e($attSz) ?></span>
<?php endif; ?>
<a class="link-quiet att-dl" href="<?= e($attDl) ?>">下载</a>
<?php if ($attCanEdit && !empty($attDelUrl)): ?>
<form method="post" action="<?= e(url($attDelUrl)) ?>" class="att-del">
@@ -87,6 +112,7 @@ $attDelUrl = $attDelUrl ?? '';
<?php endforeach; ?>
<?php /* ⚠️ 循环里用到的临时变量一律用完即清:宿主模板里很可能也有 $attRow / $attKind /
$attView / $attDl / $attNm / $attSz(各模块页面都爱用这种短名),留着就是静默覆盖。 */ ?>
<?php unset($attRow, $attKind, $canPrev, $attView, $attDl, $attNm, $attSz); ?>
<?php unset($attRow, $attKind, $canPrev, $attView, $attDl, $attNm, $attSz,
$attIsMedia, $attFileWord); ?>
</div>
<?php endif; ?>
+6
View File
@@ -137,6 +137,12 @@ $isLoggedIn = \App\Auth::check();
<button type="button" class="pv-close" aria-label="关闭">✕</button>
</div>
<div class="pv-stage"></div>
<?php /* v24 第 19 批 用户第 6 条:左右箭头,直接看下一张。
⚠️ 箭头用 `hidden` 属性控显隐(见 preview.js 的 syncArrows)——
只有一个可预览项时不该出现(点了没反应比没有更糟)。
与 .pv 一样,自己声明了 display 的元素要藏就得补 [hidden] 规则。 */ ?>
<button type="button" class="pv-nav pv-prev" aria-label="上一张" hidden>‹</button>
<button type="button" class="pv-nav pv-next" aria-label="下一张" hidden>›</button>
</div>
<script src="<?= e(asset('/assets/js/app.js')) ?>"></script>
+57 -12
View File
@@ -46,6 +46,10 @@ $groupMeta = [
<h1><?= e((string)$t['title']) ?></h1>
<?php /* ⚠️ 用户第 6 条:标题旁边**不放**时间。时间在下面题图右侧的信息区里。 */ ?>
<div class="actions">
<?php /* 用户第 19 批 第 2 条:「旅程详情页面,增加返回按钮,点击后返回到旅程列表。」
按全站规矩放在动作区最左(右起是:设置 → 删除 → 动作),
文案与观影页统一成「返回旅程」。 */ ?>
<a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回旅程</a>
<?php if ($canWrite): ?>
<?php /* 用户第 6 条:添加计划行程 / 实际行程的按钮都放**右上角**,
点击打开**新页面**(第 5 条:不再内联,内联会把页面撑破)。 */ ?>
@@ -138,13 +142,34 @@ $groupMeta = [
</div>
<?php endif; ?>
<?php /* ---- 简介:用户第 2 条明确"简介那一段话保留" ---- */ ?>
<?php /* ---- 简介(用户第 2 条明确"简介那一段话保留")。
🔴 v24 第 19 批 第 4 条修的是这里:用户报「详情页题图右侧有一句话显示,
但点击编辑进去,却看不到这句话」。原因就是**这句话读的字段和表单存的
字段不是同一个**:
· 旅程表单存的是 `trip_details.summary`(编辑页那个「一段简介」框);
· 而这里先读 `summary`,再读 `events.note`(**老字段**,表单里早就没有
这个输入框了),两句都用 .trip-sum 的画法渲染 ——
于是老数据里的 `note` 会在详情页冒出来,编辑页却无从修改/删除。
现在只渲染 `summary` 一个来源:**页面显示的每一句话,编辑页都能改**。
`note` 不再出现在详情页(数据仍留在库里,不删)。
老数据里"只填过备注、没填过简介"的那批由
`_tooling/_fb_b19_backfill.php` 一次性搬进 summary。 */ ?>
<?php if (!empty($t['summary'])): ?>
<p class="info-text trip-sum"><?= nl2br(e((string)$t['summary'])) ?></p>
<?php endif; ?>
<?php if (!empty($t['note'])): ?>
<p class="remark-text"><?= nl2br(e((string)$t['note'])) ?></p>
<?php /* 老数据兜底(v24 第 19 批 第 4 条):只填过「备注」、没填过「简介」的旅程,
简介这里就是空的。**不能把 note 直接当简介显示** —— 那正是用户报的
"详情页有、编辑页看不到"。但也不能让用户觉得"我的字丢了",
所以给一句能点进去补的提示;真正的内容会被 `_fb_b19_backfill.php`
一次性搬进 summary(搬完这条提示自然消失)。 */ ?>
<?php if (empty($t['summary']) && !empty($t['note'])): ?>
<?php if ($canWrite): ?>
<p class="trip-sum-legacy">
这段旅程的旧「备注」还没搬成简介 ——
<a href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">点这里补一段简介</a>。
</p>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
@@ -303,19 +328,39 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
</div>
<?php if ($canWrite): ?>
<?php /* ==================== 动作按钮(v24 第 19 批 用户第 7 条)====================
用户原话:
· 「计划行程的三个按钮,修改、完成、未成行,横着排列,放在行程右上角,
修改是黄色背景,完成绿色,未成行红色。」
· 「实际行程的编辑和删除按钮,也放在行程的右上角,编辑黄色,删除红色。」
⚠️ 于是配色按**颜色**来(不再是"主/次"那套):
· 修正 / 编辑 → `.btn-warn`(黄)
· 完成 → `.btn-ok`(绿)
· 未成行 / 删除 → `.btn-del`(红)
⚠️ 用户说的"修改"就是这里的「修正」(第 13 批定的名字)——
我们**保留产品名**不改,但把它染成用户要的黄色。
⚠️ 「编辑」用户没提,但它不能丢:改标题/时间/类型/出行方式与**加花销**
都只走它(花销面板在编辑页里)。按用户给的颜色语义染黄。
⚠️ 「标记为实际」是"修正过之后才出现"的收尾动作,也按黄色(同属"改")。
⚠️ 删除按钮:用户明确要求**红色**。这里用 `.btn-del`(实心红),
与全站 `.btn-danger`(淡红描边)刻意分开 —— 改 `.btn-danger`
会连带改掉十来处「删除」按钮的样子。
⚠️ 计划行的删除入口**保留**:`skipNode`(未成行)是主路径,
但"记错了、这段根本不该存在"时仍需要真删(hint 里也这么写)。 */ ?>
<div class="node-ops">
<?php /* 用户第 5 条:**编辑行程打开新页面**(内联的编辑面板会把页面撑破) */ ?>
<a class="btn btn-ghost btn-sm"
<a class="btn btn-warn btn-sm"
href="<?= e(url('trips/node_edit', ['id' => $nid])) ?>">编辑</a>
<?php if ($sg === 'plan'): ?>
<details class="rev-open">
<summary class="btn btn-ghost btn-sm">修正</summary>
<summary class="btn btn-warn btn-sm">修正</summary>
<div class="rev-panel">
<p class="rev-tip">
记录实际执行情况。这仍与上面那条计划是<b>同一条记录</b>,
原来的计划数据会保留在「修正记录」里,方便日后对比。
点完这里<b>这段行程仍是计划</b>;确认无误后再点「标记为实际」。
点完这里<b>这段行程仍是计划</b>;确认无误后再点「完成」。
</p>
<?php
$rvNode = $n; $rvTripId = (int)$t['id'];
@@ -330,21 +375,21 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $nid ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-sm" type="submit">完成</button>
<button class="btn btn-ok btn-sm" type="submit">完成</button>
</form>
<form method="post" action="<?= e(url('trips/node_skip')) ?>" class="res-inline"
onsubmit="return confirm('标记为「未成行」?它不会再出现在日程与往年今日,但在这个旅程里仍然看得到。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $nid ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">标记未成行</button>
<button class="btn btn-del btn-sm" type="submit">未成行</button>
</form>
<?php if ((int)($n['revision_n'] ?? 0) > 0): ?>
<form method="post" action="<?= e(url('trips/node_actual')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $nid ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-sm btn-primary" type="submit">标记为实际</button>
<button class="btn btn-ok btn-sm" type="submit">标记为实际</button>
</form>
<?php endif; ?>
<?php elseif ($sg === 'skipped'): ?>
@@ -352,16 +397,16 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $nid ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">恢复为计划</button>
<button class="btn btn-ok btn-sm" type="submit">恢复为计划</button>
</form>
<?php endif; ?>
<form method="post" action="<?= e(url('trips/node_del')) ?>" class="res-inline"
onsubmit="return confirm('彻底删除这段行程?连同它的修正记录一起消失,不可恢复。\n\n如果你只是想表达「这次没去成」,请用「标记未成行」。')">
onsubmit="return confirm('彻底删除这段行程?连同它的修正记录一起消失,不可恢复。\n\n如果你只是想表达「这次没去成」,请用「未成行」。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $nid ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">删除</button>
<button class="btn btn-del btn-sm" type="submit">删除</button>
</form>
</div>
<?php endif; ?>