v24 第 21 批:无刷新删除与新建日程 / 实际行程不回退 / 行程按时间排 / 统一上传框 / 时间范围控件修复(用户 8 条)

用户 8 条原话要点:
  ①「名册页面,删除一个联系人后,能否不刷新页面,删除成功后,直接把这个人,
     从当前页面的 dom 中删除,页面保持不动。」
  ②「观影模块,海报和票根以及照片都带进来了,但是没有缩放,把页面撑破了,
     应该缩放成缩略图。」
  ③「个人设置页面,天气设置那里,把:只影响你自己首页上的那一格 / 填个城市名就行
     (如「青岛」)… 这些提示,都删除。把城市单独放一行,精度维度放在一行。」
  ④「旅程模块,实际行程编辑后,点击保存,竟然又变成计划行程了,这个不合理,
     实际行程永远是实际行程,不会再变回计划行程了。」
  ⑤「旅程模块,旅程列表,行程列表,都是按照行程的开始时间排序的,最新的在最上面,
     而不是按照编辑修改时间。」
  ⑥「旅程的详情页面,右上角的按钮,设置在最右侧,和其他模块保持统一。」
  ⑦「所有上传图片等附件的地方,都不再放按钮,而是放一个框,点击这个框实现点击上传
     按钮的功能,拖动图片进这个框里,实现拖动多个上传功能。框里面写一句话:
     点击或拖动进行上传,其他的功能和现在保持一致。」
  ⑧「日历中新建日程,无论是点击按钮还是点击日历中间,都是采用弹框的形式进行添加,
     这样不影响当前日历的显示,添加完成后,动态修改当前视图,不要刷新。添加日历日程,
     开始时间和结束时间那里,用我们统一的时间范围选择器重做,现在功能无法使用。
     选择日历本,带上颜色。」

🔴 五个真根因(全都不像 bug):

 ① 🔴 **④「实际行程变回计划」= 表单字段同名合并**(本批最要紧的一条)
    暂存上传的令牌字段原叫 `stage[]`,与行程表单里那个隐藏的 `stage`(plan/actual)
    **同名**。PHP 解析表单时同名会**合并进同一个键**:
        `stage=actual` + `stage[]=tok` → `$_POST['stage'] = ['actual','tok']`
    (`[]` 只是"往同一个键追加"的记号,**不构成另一个键名**。)
    于是路由里 `(string)($_POST['stage'] ?? 'plan')` 得到 `"Array"` →
    `in_array('Array', ['plan','actual'])` 为假 → **静默回落成 'plan'**。
    ⚠️ 只有"拖过图片的行程"会犯(没拖就没有 `stage[]`)→ 症状是"时好时坏"。
    修法:令牌字段改名成 `stage_token[]`(改的是**字段名本身**,不是"是数组就取第一个"
    绕过去 —— 同名合并是 PHP 的表单语义,绕过去只会把坑留给下一次加字段)。
    **另加一道语义保险**:编辑路径**一律沿用库里那条自己的 stage**,不读表单 ——
    编辑表单里根本没有改阶段的控件(改阶段走详情页那几个显式动作)。
    这样即便将来又冒出某个字段名冲突,也不可能把「实际」悄悄变回「计划」。

 ② 🔴 **⑧「时间范围选择器无法使用」= 三道门叠在一起**
    ① `_event_fields.php` 的**结束端默认带 `hidden`**(要点「+ 添加结束时间」才出现);
    ② `app.js` 的 `upgradeRange()` 有「**两端都得可见**才升级」的判断 → 永远不成立
       (实测 `data-semi-range-done` 恒为 `null`);
    ③ CSS `html.js .dt-range:not(.is-semi-range) .dt-input { visibility: hidden }` →
       未升级时原生控件**也是不可见的** ⇒ 屏幕上一片空白、点哪儿都没反应。
    修法:两端**始终可见**("没有结束"由清空后半段表达,不再靠开关);
    并把旧的「+ 添加结束时间 / 去掉结束时间」开关整套删掉。
    ⚠️ 顺带挖出:`upgradeRange()` 里 `group.insertBefore(host, a)` 在"两端各自被
    `.field` 包着"时 `a` 不是 `group` 的子节点 → 直接抛
    `Failed to execute 'insertBefore' on 'Node'`。这个异常**以前从没触发过**
    (被 ② 的提前 return 挡住了)—— 一旦放出来就炸。修成插在"端点最外层包装"之前。
    ⚠️ 观察器加了 `attributes: true, attributeFilter: ['hidden']`(并把 childList
    分支的 `return` 去掉):弹框/折叠面板/tab 里"稍后才露出来"的日期控件从此都能自动升级。

 ③ 🔴 **⑧「新建日程默认建出观影记录」= 产品缺口,不只是显示问题**
    `Calendar::DEFAULT_KINDS` 原来只有 6 本**派生**本(观影/旅行/农历/节假日/传统节日/
    节气),**一本可写的普通本都没有**。于是"放在哪本"里全是派生本、默认选中排最前的
    **观影** → 用户直接打字点「加上」,建出来的是一条**观影记录**
    (日历里看着是多了条日程,观影列表里莫名多一条)。这是"建错东西",事后极难发现。
    → ① 默认本补成「我的日程」(kind='custom'),模板里默认选中也改成**优先 custom 本**;
      ② 选择器里把**纯派生只读本**禁用并标「(自动生成)」
         (直接不列会让人以为本子丢了;列为可选又会白选一次再吃错误)。
    ⚠️ 顺带发现 `Calendar::READONLY_KINDS` 把 `movie`/`trip` 也算进去了,而
    `event_add` 里**紧跟着**就有两段专门处理它们的分支(「已加进观影」/「已加进旅程」)
    —— 那两段**永远走不到**(校验先一步把请求打回去了)。判据要按**能不能写**分,
    不是按"是不是派生"。

 ④ 🔴 **②「观影编辑页图片撑破」= `.mv-slot` 这个类在样式表里根本不存在**
    第 21 批加模板时只写了结构没写样式 → `<img>` 按**原始像素**渲染:600×900 的海报
    在 236px 宽的格子里撑出 942px 高,还把整页横向顶破(实测 docW 1297 > vw 1280)。
    → 补 `.mv-slot` 规格:固定 108×162 缩略图 + `object-fit: cover`。
    ⚠️ **高度必须写死** —— 不写死就又会由原图比例决定,等于没修。

 ⑤ **⑤行程列表排序 = `category, position` 主导**
    `position` 默认同一个数(旧迁移给的多是 0/100),于是顺序被 position 挤乱。
    实测线上 #222 渲染成「返回青岛(10-5) → 出发去菏泽(9-30) → 去姥姥家(10-2)」。
    → 改成 `ORDER BY (starts_at IS NULL), starts_at, id`。
    ⚠️ 方向是**正序**(早的在前,已跟用户确认);旅程列表仍是 DESC(最新在最上)。
       两者方向不同是**刻意的**:一个是"挑哪趟旅行",一个是"读这趟旅行的行程"。
    ⚠️ 详情页**按阶段分三组**渲染(计划/实际/未成行),排序只需在**组内**有序。

本批新增能力:
- **统一上传框**(⑦):`templates/_stage_drop.php` 现在**同时**负责
  「sr-only 的真文件框 + 看得见的上传框 + 暂存清单」,调用方只 require 一行。
  框是 `role="button" tabindex="0"`,JS 转发 `input.click()`(键盘 Enter/Space 也通),
  拖文件进框走既有的暂存上传(拖一个立刻传、可续拖、失败标红、未传完拦住保存)。
  ⚠️ 框用 `<div>` 不是 `<button>`:里面要放暂存清单(含「撤掉这张」按钮),
     按钮里嵌按钮是非法 HTML(第 21 批刚在 `<form>` 嵌套上栽过同一个跟头)。
  ⚠️ 文件框用 `.sr-only` 不是 `display:none`:后者不可聚焦,带 `required` 时
     Chrome 会报 "An invalid form control is not focusable" —— 点了什么都不发生。
  落了 11 处:行程照片 / 观影海报·票根·合影 / 资源 / 任务 / 日程附件 / 书籍 /
  联系人导入 / ics 导入 / 观影补传 / 资源补传。
  ⚠️ 清单与提示行移到框**外面**(放框内会变成"一个大框套五张图"),
     所以 app.js 改从 `.upfield` 容器里找它们。
- **无刷新删除(①)与无刷新新建日程(⑧)**:路由按 `X-Requested-With`
  (或 `Accept: application/json`)区分调用者 —— 带它回 JSON、不带照旧 302
  (无 JS 的兜底**不变**,两边的反向都写了断言)。
  · 名册删除:成功 → 摘卡片 + 就地改「共 N 人」与分组人数 + 派发 `input` 让
    filter.js 自己重算(**不抄它的判据**);失败 → 把原因说出来(不刷页);
    拿不到 JSON → **退回原生提交**(绝不静默失败)。
  · 日历新建:JSON 带齐前端插入当前视图要用的字段(时间/颜色/链接/是否全天/cal)。
    月视图**按时间插入**(不能 append —— 服务端只渲染前 3 条、按时间序取,
    append 会让"新加的早间日程"掉进「+N 条」里看不见,刷新后又跑到第一位);
    周/日视图按"24h = 100%"算 top/height 插块。
  · 观影 / 旅行本是**可写派生本**:AJAX 时不再跳详情页(跳走就离开了日历),
    改在提示行给一个「去看详情 →」链接。
- **⑧日历本带颜色**:原生 `<option>` 画不出色块(浏览器不允许在 option 里放元素),
  所以做成「色块 + 下拉」并排,色块跟着当前选中的本子变色。
- **③天气卡片**:三句提示全删;城市单独一行、纬度/经度同一行(`.loc-row`)。
  删的只是说明文字,三个输入框 + 保存 + 状态回显都保留。
- **⑥设置按钮贴最右**:原来顺序是「… → 设置 ☰ → 删除 🗑」,设置被删除键挤到**倒数第二**;
  改成删除在前、设置恒为最后一个(与观影/日程/任务/资源同一个手势)。

验证(本地全套):
- e2e **1059/0**(本批新增约 40 条断言)· 结构升级 149/0 · 桌面几何 428/0
- **真机探针 75/0**(`_fb_b21_ui.py`):AJAX 删除真的没导航 / 卡片真被摘掉 /
  滚动位置保持;缩略图几何(108×162,不再溢出);上传框(框在、原生框不可见、
  点框真打开文件选择框、连拖两张都上去且清单可见);日历弹框(打开时网格一动不动、
  范围控件真的挂上且可见、能弹面板、色块跟着换、无刷新提交后当前视图里立刻出现、
  周视图也能插时间块、全程无 JS 报错)。
- **反向验证 12 条**(`_fb_b21_falsify.py`):逐条把修复改回旧行为看断言是否变红 ——
  令牌字段改回 stage[] / 编辑读表单 stage / 排序改回 position / 设置按钮挪走 /
  框不转发点击 / 文件框不再 sr-only / 删缩略图规格 / 删除不回 JSON /
  结束端改回 hidden / 日历本默认改回第一个 / 天气提示加回 / 弹框改回内联。
- 部署脚本静态断言段本地空跑 **307/0**。
This commit is contained in:
2026-10-06 12:08:40 +08:00
parent ea99968035
commit f0437e6c93
19 changed files with 1209 additions and 270 deletions
+105 -33
View File
@@ -1,46 +1,118 @@
<?php
/**
* 拖动即时上传的**落点 + 暂存清单**(v24 第 20 批 用户第 4 条)。
* 上传落点:「**点击或拖动进行上传**」的框 + 暂存清单 + 真正的文件框。
*
* 用户原话:「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,
* 拖动一个进去,就开始上传,还可以继续拖动,继续上传,最后统一保存即可。」
* ────────────────────────────────────────────────────────────────
* v24 第 21 批 第 7 条(用户原话,两条一起看):
* 「所有上传图片等附件的地方,都不再放按钮,而是放一个框,点击这个框实现点击
* 上传按钮的功能,拖动图片进这个框里,实现拖动多个上传功能。框里面写一句话:
* 点击或拖动进行上传,其他的功能和现在保持一致。」
* (第 20 批的原话是「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,
* 拖动一个进去,就开始上传,还可以继续拖动,继续上传,最后统一保存即可。」)
*
* 这个片段只负责**画出落点**(一个虚线框 + 一个空清单容器),
* 真正的行为在 assets/js/app.js 的 `queueDrop()` 里:
* 拖进来 → 立刻 POST 到 upload/stage → 回来把 `stoken` 塞进隐藏字段 `stage[]`。
* → 所以这个片段现在**同时负责三件事**,调用方只 require 一行:
* ① 真正的 `<input type="file">`(sr-only,视觉上不存在,但表单里必须在);
* ② 那个看得见的框(点击 = 触发①、拖文件进去 = 直接上传);
* ③ 暂存清单(拖进来的文件立刻上传后,在这里显示缩略图/状态)。
*
* ⚠️ 为什么"落点"要和"文件框"分开:
* 文件框(`<input type=file>`)仍然保留(点它一样能选文件),
* 拖动只是**多一种**输入方式;而且"拖到哪一块"需要一块**看得见**的区域 ——
* 单靠文件框本身,用户不知道该往哪儿拖。
* ⚠️ 为什么把 `<input>` 也收进片段里(第 21 批的重构):
* 以前是「模板先写一个可见的 `<input class="input">`,片段再画一个额外的虚线框」——
* 同一个地方出现**两个**上传入口(一个原生文件框 + 一个拖动框),
* 用户看到的就是"又让我选文件、又让我往框里拖,到底点哪个"。
* 现在输入框整个藏起来,**只有那个框**是入口 —— 这正是用户要的
* 「都不再放按钮,而是放一个框」。
*
* ⚠️ 落点必须能被 app.js 找回来:`data-drop-stage` 挂在 `<div>` 上,
* app.js 从「被拖的文件框」`input.form.querySelector('[data-drop-stage]')` 找它。
* 所以**每个上传区都要有一个文件框**(哪怕隐藏)—— 这符合现状(都有)。
* ⚠️ `.sr-only` 而不是 `display:none`:
* `display:none` 的控件在表单校验时**不可聚焦**,带 `required` 会让 Chrome
* 报 "An invalid form control is not focusable" —— 页面上什么都不发生
* (点了就没反应,也不弹提示)。`.sr-only` 是 1px 的可见元素,仍可聚焦。
* (时间控件那边为同一件事专门写了 guardRequired(),见 app.js。)
*
* 用法(require 前设好,都可选):
* $sdHint 提示文案
* $sdSlot 图片槽位键('poster' / 'ticket' / 'cover' / 'photo'…;决定图片怎么裁)
* $sdMulti 是否允许多次/多个(默认 true)
* $sdAccept accept 提示(只影响选择框,不影响拖动)
* ⚠️ 拖 / 点两条路都通到**同一个 input**:拖是 app.js 直接把 File 写进 input.files
* (老式表单提交路径),或走暂存上传(有 `data-drop-stage` 时的新式路径);
* 点是 `box.click() → input.click()`。所以服务端只认那一个字段名,零分支。
*
* 用法(require 前设好;除 $sdName 外都可选):
* $sdName **文件字段名**(如 'photo[]' / 'poster' / 'attach[]')—— 必填
* $sdId 文件框的 id(供 <label for> 与测试定位;不传就用 $sdName 净化后的值)
* $sdSlot 图片槽位键('poster' / 'ticket' / 'cover' / 'photo'…;决定图片怎么裁)
* $sdAccept accept 提示(如 'image/*')
* $sdMultiple 允许多选(默认 true)
* $sdRequired 必填(默认 false;只有"专门用来补传附件"那种单一用途表单才需要)
* $sdLabel 给屏幕阅读器/测试用的说明(默认「点击或拖动进行上传」)
* $sdMsg 框里那句话(默认同上;用户点名要这句)
*/
$sdHint = (string)($sdHint ?? '也可以把文件直接拖到这块虚线区域里 —— 拖一个就开始上传,可以接着再拖,最后点「保存」一起记下。');
$sdSlot = (string)($sdSlot ?? '');
$sdMulti = $sdMulti ?? true;
$sdAccept = (string)($sdAccept ?? '');
/* ⚠️ **海报 / 票根走另一条路**:它们各自只有一个槽位、且比例不同
(海报 2:3、票根 free)。如果也塞进 `stage[]` 一起认领,
服务端就只能统一按 'photo' 处理 —— 海报会被存成不是 2:3 的图,
页面上就变形了。所以这两个用**独立的字段名**把令牌带回去,
由 movies 路由按槽位重跑 Upload::image(见 index.php 里那一段)。
其余所有附件类上传(照片/资源/任务/日历)都走通用的 `stage[]`。 */
$sdField = in_array($sdSlot, ['poster', 'ticket'], true) ? ('stage_' . $sdSlot) : 'stage[]';
$sdName = (string)($sdName ?? '');
$sdSlot = (string)($sdSlot ?? '');
$sdAccept = (string)($sdAccept ?? '');
$sdMultiple = $sdMultiple ?? true;
$sdRequired = (bool)($sdRequired ?? false);
/* 用户点名要的那句话(第 7 条)。可覆盖,但默认就是它。 */
$sdMsg = (string)($sdMsg ?? '点击或拖动进行上传');
/* id:不传就按字段名净化一个(photo[] → sd-photo)。必须是**合法且唯一**的 id,
否则 <label for> 指不到、测试也定位不到。 */
$sdId = (string)($sdId ?? ('sd-' . preg_replace('/[^A-Za-z0-9_-]/', '', $sdName)));
/* 🔴 v24 第 21 批 第 4 条:令牌字段名不能叫 `stage[]`(与「计划/实际」那个 stage 撞名)。
用户原话:「旅程模块,实际行程编辑后,点击保存,竟然又变成计划行程了,
这个不合理,实际行程永远是实际行程,不会再变回计划行程了。」
根因:行程表单里原本有一个隐藏字段 `name="stage"`(值 plan/actual),
而暂存上传原本把令牌塞进 `name="stage[]"`。**PHP 解析表单时同名会合并**:
`stage=actual` + `stage[]=tok` → `$_POST['stage']` 变成**数组**
`['actual', 'tok']`(`[]` 只是"追加"记号,不构成另一个键名)。
路由里 `(string)($_POST['stage'] ?? 'plan')` 拿到数组 → 强转成 "Array"
→ `in_array('Array', ['plan','actual'])` 为假 → **静默退回 'plan'**。
⚠️ 所以症状是「拖过图片的实际行程,一保存就变回计划」——
不拖图片时一切正常(没有 `stage[]`)→ 看起来"时好时坏"。
修法:令牌换一个**不冲突**的字段名。改的必须是**字段名本身**(服务端认的就是它),
不能在 PHP 里加个"是数组就取第一个"的判断绕过去 ——
同名合并是 PHP 的表单语义,绕过去只会把同一个坑留给下一次加字段。
⚠️ 海报 / 票根仍然各自用独立字段名(`stage_poster` / `stage_ticket`):
它们各自只有一个槽位、且比例不同(海报 2:3),若也塞进通用那一组,
服务端就只能统一按 'photo' 处理 → 海报存成不是 2:3 的图,页面上会变形。 */
$sdField = in_array($sdSlot, ['poster', 'ticket'], true)
? ('stage_' . $sdSlot)
: 'stage_token[]';
?>
<div class="stage-drop" data-drop-stage data-stage-field="<?= e($sdField) ?>"
<?= $sdSlot !== '' ? 'data-stage-slot="' . e($sdSlot) . '"' : '' ?>>
<span class="stage-drop-tip" data-stage-tip><?= e($sdHint) ?></span>
<?php /* ① 真正的文件框:sr-only(见头部的说明,不能用 display:none)。
⚠️ `data-sd-input` 是给 JS 找它的标记 —— 框上的点击要转发到它这里。 */ ?>
<input class="sr-only" type="file" id="<?= e($sdId) ?>" name="<?= e($sdName) ?>"
data-sd-input<?= $sdAccept !== '' ? ' accept="' . e($sdAccept) . '"' : '' ?>
<?= $sdMultiple ? ' multiple' : '' ?><?= $sdRequired ? ' required' : '' ?>>
<?php /* 整个"上传区"包一层容器(`.upfield`):
框 + 暂存清单 + 提示行都在里面。
⚠️ 这一层不是装饰 —— app.js 的 queueDrop() 要在这里面找
`[data-stage-list]` / `[data-stage-tip]`。原来这两样在框**里面**,
现在挪到框外面(见下面第 ③ 点的说明),所以必须给它们一个共同的宿主;
否则 JS 会找不到清单,拖进来的文件**上传成功却什么都不显示**。 */ ?>
<div class="upfield">
<?php /* ② 框本身:整个框都是落点,也是"点击选文件"的按钮。
⚠️ `role="button"` + `tabindex="0"`:键盘用户 Tab 到它、回车/空格就能选文件
(JS 里绑了 keydown)。不然这个框只能用鼠标 —— 那是无障碍倒退。
⚠️ 不能写成 `<button>`:里面要放暂存清单(含「撤掉这张」按钮),
按钮里嵌按钮是非法 HTML(第 20 批刚在 <form> 嵌套上栽过同一个跟头)。 */ ?>
<div class="upbox" data-drop-stage data-sd-for="<?= e($sdId) ?>"
data-stage-field="<?= e($sdField) ?>"
<?= $sdSlot !== '' ? 'data-stage-slot="' . e($sdSlot) . '"' : '' ?>
role="button" tabindex="0"
aria-label="<?= e($sdMsg) ?>">
<span class="upbox-ico" aria-hidden="true">+</span>
<span class="upbox-msg"><?= e($sdMsg) ?></span>
</div>
<?php /* ③ 暂存清单:拖进来的文件**立刻上传**后在这里显示(缩略图 / 上传中 / 失败)。
⚠️ 放在框**外面**(下面是独立一行):
放进框里的话,加了五张图那个虚线框就变成一个巨大的框套着五张图,
既不好看,也让人以为"还要往这里面拖"。
⚠️ 初始 `hidden`:一张都没有时不占地方(JS 填内容时会 un-hide,
清空后再藏回去 —— 第 20 批在这上面栽过"卡片建了但整条是 display:none")。 */ ?>
<div class="stage-list" data-stage-list hidden></div>
<span class="stage-drop-tip" data-stage-tip hidden></span>
</div>
<?php
// ⚠️ 只 unset 本片段自己的变量(与 _datetime.php / _loc_field.php 同一约定)
unset($sdHint, $sdSlot, $sdMulti, $sdAccept, $sdField);
unset($sdName, $sdSlot, $sdAccept, $sdMultiple, $sdRequired, $sdMsg, $sdId, $sdField);
+4 -2
View File
@@ -58,8 +58,10 @@ $ownerSel = $isEdit && (int)($b['owner_id'] ?? 0) > 0 ? [(int)$b['owner_id']] :
<br><span class="tip">再选一个文件会替换它,并按新文件重新解析正文。</span>
</p>
<?php endif; ?>
<input class="input" type="file" id="bk-file" name="bookfile"
accept=".epub,.txt,.pdf,.mobi,.azw3,.azw,.doc,.docx,application/epub+zip,text/plain,application/pdf">
<?php $sdName = 'bookfile'; $sdId = 'bk-file';
$sdAccept = '.epub,.txt,.pdf,.mobi,.azw3,.azw,.doc,.docx,application/epub+zip,text/plain,application/pdf';
$sdMultiple = false;
require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip">
EPUB 与 TXT 会自动读出书名 / 作者 / 简介 / 封面,并把正文拆成章节(可以直接在线读)。
PDF 只存文件(用浏览器自带的阅读器看)。MOBI / AZW3 存文件、正文解析不了。
+46 -91
View File
@@ -59,31 +59,43 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
不再拆成"日期一格 + 时刻一格"。两个控件长得一样,扫一眼就知道哪个是开始。 */ ?>
<?php /* 用户 2026-10-04 第 7 条:日程的起止也是一个**范围** ——
升级后由一个 Semi dateTimeRange 一次选完。服务端仍收两组 *_date/_hour/… 字段。 */ ?>
<?php /* 🔴 v24 第 21 批 第 7 条:「开始时间和结束时间那里,用我们统一的时间范围选择器
重做,现在功能无法使用。」
「功能无法使用」的真根因(真机探针实测出来的,不是猜的):
app.js 的 upgradeRange() 有一道「**两端都得可见**才升级成范围控件」的判断
(`boxes.every(bx => bx.offsetParent !== null)`)。而这一版**结束那一端默认
带 `hidden`**(要点「+ 添加结束时间」才出现)→ 判断永远不成立 →
范围控件**从来没被挂上去过**(实测 data-semi-range-done 恒为 null)。
更糟的是 CSS 还有 `html.js .dt-range:not(.is-semi-range) > .dt-input
{ visibility: hidden }` —— 两端于是都是**不可见**的:
屏幕上一片空白、点哪儿都没反应。这就是「无法使用」的全部。
修法:**结束那一端不再默认藏起来**,范围控件一次性就挂上。
起止成对本来就该一起填。想表达"只有一个时刻"的话,范围控件 clear 一下即可。
⚠️ 顺带删掉「+ 添加结束时间 / 去掉结束时间」那套开关:
它切换显隐会让范围控件反复重挂;范围控件自己就能表达"没有结束"。 */ ?>
<div class="dt-range">
<div class="field ev-dt" data-dt>
<label>开始 *</label>
<?php $dtName = 'starts_at'; $dtId = 'ev-start';
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true; $dtSeconds = true;
$dtRange = 'start';
require __DIR__ . '/../_datetime.php'; ?>
</div>
<?php /* 用户 2026-10-03 第 6 条:「添加结束日期与时间」——
结束是可选的:没填就表示"就一个时刻/就这一天",填了才画成一段。
所以给一个显式开关,而不是让人对着空控件猜"不填行不行"。 */ ?>
<?php $hasEnd = trim((string)($evF['ends_at'] ?? '')) !== ''; ?>
<div class="field ev-dt" data-dt id="ev-end-field"<?= $hasEnd ? '' : ' hidden' ?>>
<label>结束</label>
<?php $dtName = 'ends_at'; $dtId = 'ev-end';
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true;
$dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?>
</div>
<?php /* ⚠️ 标签只有**一个**,挂在范围容器上(不是各挂各的):
升级成一个范围控件之后,"开始"与"结束"就是同一个控件的两个端点,
两个标签会变成"看着像两个字段、其实是一个"的错觉。 */ ?>
<span class="dt-range-label">开始 / 结束 *</span>
<div class="field ev-dt" data-dt>
<?php $dtName = 'starts_at'; $dtId = 'ev-start';
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true; $dtSeconds = true;
$dtRange = 'start';
require __DIR__ . '/../_datetime.php'; ?>
</div>
<div class="field ev-dt" data-dt id="ev-end-field">
<?php $dtName = 'ends_at'; $dtId = 'ev-end';
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true;
$dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?>
</div>
</div><?php /* /dt-range */ ?>
<div class="field" id="ev-end-add">
<label>结束时间</label>
<button type="button" class="btn btn-ghost btn-sm" id="ev-end-add-btn">+ 添加结束时间</button>
<span class="field-hint">不填就表示没有明确的结束(只占开始那一刻/那一天)</span>
</div>
<span class="field-hint" style="display:block;margin:-4px 0 10px">
结束留空就表示没有明确的结束(只占开始那一刻/那一天)。
</span>
<div class="field">
<label>
@@ -154,8 +166,12 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
</div>
<div class="field full">
<label for="ev-attach">附件(可多选)</label>
<input class="input" type="file" id="ev-attach" name="attach[]" multiple>
<label>附件(可多选)</label>
<?php /* v24 第 21 批 第 7 条:统一成「点击或拖动进行上传」的框。
⚠️ 文件框的 id 保持 `ev-attach`(原来就叫这个)—— e2e 与部署断言里
有按 id 定位的写法,改名会把它们静默打成假红。 */
$sdName = 'attach[]'; $sdId = 'ev-attach'; $sdSlot = 'photo';
require __DIR__ . '/../_stage_drop.php'; ?>
</div>
<script>
@@ -172,72 +188,11 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
box.addEventListener('change', sync);
sync();
/* ---- 用户第 6 条:结束时间 ----
① 「+ 添加结束时间」把结束控件放出来;
② 结束时间**默认跟着开始时间走**:改了开始,若结束还没填(或原本等于旧开始),
就把结束同步过去 —— 常见用法是"改个开始,结束忘了改",结果时长莫名其妙。
但只要用户**自己动过结束**,就不再自动同步(别抢用户的输入)。 */
var startBox = document.querySelector('[data-dt] input.dt-date');
var endField = document.getElementById('ev-end-field');
var endAdd = document.getElementById('ev-end-add');
var endAddBtn = document.getElementById('ev-end-add-btn');
var startField = document.querySelector('.field.ev-dt');
function endInputs() {
if (!endField) return [];
return Array.prototype.slice.call(endField.querySelectorAll('input, select'));
}
function showEnd(on) {
if (endField) { endField.hidden = !on; }
if (endAdd) { endAdd.hidden = on; }
// 结束时间刚被显示出来 → 现在两端都可见了,可以合并成一个范围控件(用户第 7 条)。
// 收起时不动已有的范围(它本来就只显示在第一端上,不影响使用)。
if (on && typeof window.fbUpgradeRange === 'function') {
window.fbUpgradeRange();
}
}
var endTouched = false;
endInputs().forEach(function (el) {
el.addEventListener('change', function () { endTouched = true; });
});
if (endAddBtn) {
endAddBtn.addEventListener('click', function () { showEnd(true); endTouched = false; });
}
// 移除结束(改回"没有结束")
if (endField && !endField.querySelector('.ev-end-del')) {
var del = document.createElement('button');
del.type = 'button';
del.className = 'btn btn-ghost btn-sm ev-end-del';
del.textContent = '去掉结束时间';
del.addEventListener('click', function () {
endInputs().forEach(function (el) {
if (el.tagName === 'SELECT') { el.value = '00'; }
else { el.value = ''; }
});
showEnd(false);
endTouched = false;
});
endField.appendChild(del);
}
// 开始变了 → 结束(若没被手动改过)跟着走同一时刻
if (startField) {
startField.addEventListener('change', function () {
if (endTouched || !endField || endField.hidden) return;
var sd = startField.querySelector('input.dt-date');
var sh = startField.querySelector('select.dt-hour');
var sm = startField.querySelector('select.dt-minute');
var ss = startField.querySelector('select.dt-second');
var ed = endField.querySelector('input.dt-date');
var eh = endField.querySelector('select.dt-hour');
var em = endField.querySelector('select.dt-minute');
var es = endField.querySelector('select.dt-second');
if (sd && ed) { ed.value = sd.value; }
if (sh && eh) { eh.value = sh.value; }
if (sm && em) { em.value = sm.value; }
if (ss && es) { es.value = ss.value; }
});
}
// 初值由服务端决定(有 ends_at 就是展开的),JS 只负责"之后"的显隐
showEnd(<?= $hasEnd ? 'true' : 'false' ?>);
/* ⚠️ v24 第 21 批 第 7 条:原来这里有一整段「结束时间显隐 + 跟着开始时间走」的 JS。
现在起止两端**始终可见**,并且由 Semi 的范围控件统一接管(一次选完两端),
所以那段"同步两端"的逻辑整个删掉 —— 留着不但没用,还会和范围控件抢着改那两个
原生字段(两处都写 → 值互相覆盖)。范围控件选完会自己把两端分别写回原生字段
(见 app.js 的 onRange → writeDT),服务端契约不变。 */
/* 重复:选了周期才显示"结束条件";结束条件选"次数"才显示次数框、选"到某天"才显示日期框。
三个框默认都 hidden —— 不重复时一个都不该出现(少即是清楚)。 */
+3 -1
View File
@@ -187,7 +187,9 @@ $canWrite = \App\Auth::canWrite();
</div>
<div class="field">
<label for="im-file">.ics 文件 *</label>
<input class="input" type="file" id="im-file" name="ics" accept=".ics,text/calendar" required>
<?php $sdName = 'ics'; $sdId = 'im-file'; $sdAccept = '.ics,text/calendar';
$sdMultiple = false; $sdRequired = true;
require __DIR__ . '/../_stage_drop.php'; ?>
</div>
</div>
<div class="btn-row">
+327 -29
View File
@@ -130,22 +130,76 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
用户第 4 条:左边那一栏「我的日历本」已经撤掉(功能搬进右上角 ☰),
所以这里直接就是主区域,`.cal-wrap-full` 把网格调成"只有一列"。 */ ?>
<div class="cal-main">
<?php /* 用户第 5 条:这张表单既服务「新建日程」按钮,也服务「点日历空白处」——
后者由 JS 现填开始时间,所以表单本身不再写死 09:00。 */ ?>
<?php /* 🔴 v24 第 21 批 第 7 条:新建日程改成**弹框**(用户原话:
「日历中新建日程,无论是点击按钮还是点击日历中间,都是采用弹框的形式进行添加,
这样不影响当前日历的显示,添加完成后,动态修改当前视图,不要刷新。」
⚠️ 原来是**内联卡片**(`#calAddCard`,就长在日历网格的正上方,展开时把整个
月视图往下推)—— 用户点一下"新建",正在看的日历就被顶走了,
填完还得自己找回来。这正是他说的"影响当前日历的显示"。
⚠️ 改成弹框用**既有的浮层形态**(与选人弹窗、名册动作框同一套 `.upfield`
不是,是 `.rm-modal`/`.crop-overlay` 那一路视觉:遮罩 + 居中面板),
不引第三方库 —— 原生 `<dialog>` 在部分旧 WebView 上行为不一致,
而且我们已有的浮层已经解决了"点遮罩关、Esc 关、滚动锁"这几件事。
⚠️ 表单本身**一个字没动**(字段名/服务端契约/CSRF 全照旧),
只把它的容器从"页面上的一格"换成"浮层里的一块"。
`calendar_id` 那个 select 也保留(第 7 条要求"选择日历本,带上颜色",
见下面给每个 option 加的色块)。 */ ?>
<?php if ($canWrite && $writable): ?>
<div class="card card-pad cal-add" id="calAddCard" hidden>
<h2 class="cinema-h2">新建日程</h2>
<form method="post" action="<?= e(url('calendar/event_add')) ?>" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="hidden" name="ym" value="<?= e($ym) ?>">
<div class="form-grid">
<div class="field">
<div class="cal-modal" id="calAddCard" role="dialog" aria-modal="true"
aria-label="新建日程" hidden>
<div class="cal-modal-panel">
<div class="cal-modal-head">
<b>新建日程</b>
<button type="button" class="cal-modal-x" id="calAddCancel"
aria-label="关闭">✕</button>
</div>
<form method="post" action="<?= e(url('calendar/event_add')) ?>"
enctype="multipart/form-data" id="calAddForm">
<?= csrf_field() ?>
<input type="hidden" name="ym" value="<?= e($ym) ?>">
<input type="hidden" name="back_view" value="<?= e($view) ?>">
<input type="hidden" name="back_anchor" value="<?= e($anchor) ?>">
<div class="cal-modal-body">
<div class="field full">
<label for="ce-cal">放在哪本</label>
<select class="select" id="ce-cal" name="calendar_id">
<?php foreach ($writable as $c): ?>
<option value="<?= (int)$c['id'] ?>"><?= e((string)$c['name']) ?></option>
<?php endforeach; ?>
</select>
<?php /* v24 第 21 批 第 7 条:「选择日历本,带上颜色」。
⚠️ 原生 <select> 的 <option> **画不出色块**(浏览器不让你在
option 里放元素,background 也基本不生效)→ 所以做成
「色块 + 下拉」并排:左边一块跟着当前选中的本子变色,
右边还是那个原生下拉(保持零依赖、手机上也能滚)。
切换时由下面那段 JS 同步色块颜色。 */
/* 🔴 v24 第 21 批 第 7 条:**默认必须选"我自己的普通日历本"**。
原来直接取 `$writable[0]` —— 而顺序是按 id,观影 / 旅行两本
通常排在最前面。于是用户打开"新建日程"、直接打字、点"加上",
建出来的其实是一条**观影记录**(或旅程)。
这不是"显示不对",是**建错东西**,而且事后很难发现
—— 用户以为日历里多了条日程,其实观影列表里多了一条记录。
所以:优先把自己建的 kind='custom' 本子排在前面当默认;
没有普通本时才退到第一个可写的本子。 */
$firstCal = null;
foreach ($writable as $c) {
if ((string)($c['kind'] ?? '') === 'custom') { $firstCal = $c; break; }
}
if ($firstCal === null) { $firstCal = $writable ? $writable[0] : null; } ?>
<div class="cal-book-row">
<span class="cal-book-dot" id="ce-cal-dot"
style="background: <?= e((string)($firstCal['color'] ?? '#4A7C82')) ?>;"></span>
<select class="select" id="ce-cal" name="calendar_id">
<?php foreach ($writable as $c): $ro = \App\Calendar::isReadonlyKind((string)$c['kind']);
$isDef = $firstCal && (int)$c['id'] === (int)$firstCal['id']; ?>
<?php /* v24 第 21 批 第 7 条:纯派生的本子(农历/节假日/传统节日/节气)
列出来但**禁用** —— 直接不列的话,用户会以为"我的本子丢了";
列为可选项又会让他白选一次再吃一个错误提示。
禁用 + 一句 title 说明,是这个取舍里最不费解的一种。
观影 / 旅行**不禁用**:它们可写(加一条 = 建一条观影/旅程)。 */ ?>
<option value="<?= (int)$c['id'] ?>"
data-color="<?= e((string)$c['color']) ?>"
<?= $isDef ? ' selected' : '' ?>
<?= $ro ? 'disabled title="系统自动生成,不能直接加日程"' : '' ?>><?= e((string)$c['name']) ?><?= $ro ? '(自动生成)' : '' ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。
新建时「全天」默认**不勾**(用户原话)。开始时间默认:
@@ -163,11 +217,14 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
require __DIR__ . '/_event_fields.php';
?>
</div>
<div class="btn-row">
<button class="btn btn-sm" type="submit">加上</button>
<button class="btn btn-ghost btn-sm" type="button" id="calAddCancel">取消</button>
</div>
</form>
</div><?php /* /cal-modal-body */ ?>
<div class="cal-modal-foot">
<span class="cal-modal-tip" id="calAddTip"></span>
<button class="btn btn-sm" type="submit" id="calAddSubmit">加上</button>
<button class="btn btn-ghost btn-sm" type="button" id="calAddCancel2">取消</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
@@ -296,15 +353,23 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
(Calendar::hiddenCalendars,见路由里的说明),前端不再需要它。
留着会让人以为"改一下勾选就能切换",但那些勾选框已经不在这儿了。 */
var btn = document.getElementById('calAddBtn');
var card = document.getElementById('calAddCard');
var cancel = document.getElementById('calAddCancel');
var card = document.getElementById('calAddCard'); /* 现在是一个浮层 */
var cancel = document.getElementById('calAddCancel'); /* 头部 ✕ */
var cancel2 = document.getElementById('calAddCancel2'); /* 底部「取消」 */
if (btn && card) {
btn.addEventListener('click', function () {
card.hidden = !card.hidden;
if (!card.hidden) { card.scrollIntoView({ block: 'nearest' }); }
});
btn.addEventListener('click', function () { openNew(null, null, null); });
}
if (cancel && card) { cancel.addEventListener('click', function () { card.hidden = true; }); }
/* 关:✕ / 取消 / 点遮罩 / Esc 四条路都要通(与站内其它浮层一致)。
⚠️ 点遮罩才关、点面板内不关 —— 用 e.target === card 判,不用"点哪儿都关"。 */
function closeNew() { if (card) { card.hidden = true; } }
if (cancel) { cancel.addEventListener('click', closeNew); }
if (cancel2) { cancel2.addEventListener('click', closeNew); }
if (card) {
card.addEventListener('click', function (e) { if (e.target === card) { closeNew(); } });
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && card && !card.hidden) { closeNew(); }
});
/* ---- 用户第 5 条:在日历里点一下 → 弹新建,开始时间 = 点的位置 ----
月视图:一格 = 一天 → 开始时间用那天 09:00("当日默认"比 00:00 更像个日程)。
@@ -332,12 +397,21 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
if (startSec) { startSec.value = '00'; }
}
/**
* 打开新建日程的**弹框**(v24 第 21 批 第 7 条)。
*
* ⚠️ 不再 `scrollIntoView` —— 那是"内联卡片"时代为了让用户找到卡片才需要的动作;
* 浮层固定在视口中央,底下的日历**一动不动**(这正是用户要的
* "不影响当前日历的显示")。
* ⚠️ dateStr 传 null 表示"从按钮进来":那时**不要再覆盖**已填的时间
* (用户可能刚填完改主意关掉、又点开;重置会把他填的东西抹掉)。
* 只有"点日历某一格/某一列"才带着具体时间进来,那次才 setStart。
*/
function openNew(dateStr, hh, mm) {
if (!card) return;
card.hidden = false;
setStart(dateStr, hh, mm);
card.scrollIntoView({ block: 'nearest' });
// 标题是第一个输入框,直接把光标放过去 —— 点完就能打字
if (dateStr) { setStart(dateStr, hh, mm); }
// 标题是第一个输入框,直接把光标放过去 —— 打开就能打字
var t = document.getElementById('ev-title');
if (t) { t.focus(); }
}
@@ -376,6 +450,230 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
openNew(parts[0], hm[0] || '09', hm[1] || '00');
}
}
/* ============================================================
v24 第 21 批 第 7 条:日历本色块 + **无刷新提交 + 动态改当前视图**
------------------------------------------------------------
用户原话:「添加完成后,动态修改当前视图,不要刷新。」
⚠️ 做法:拦掉表单提交 → fetch POST(带 X-Requested-With,服务端据此回 JSON)
→ 拿到新日程的 {starts_at, ends_at, title, all_day, color, href, cal} →
**直接往当前视图里插入那一条**(月视图找对应格子塞小条、周/日视图按时间
算 top/height 插块),然后关掉弹框。整个过程中页面不重载、滚动位置不动。
⚠️ 任何一步不成立(拿不到 JSON / 字段缺 / 当前视图不好插)→ **退回原生提交**。
"插入失败还假装成功"是最坏的结果:用户以为加上了、切到别的月份才发现没有。
⚠️ 插入用的定位方式必须与**服务端渲染的完全一致**(月视图按 data-new-at 找格子、
周/日视图按"24h = 100%"算 top/height),否则刷新一次小条就会跳位置。
这里刻意照抄模板里的选择器与算式,别另发明一套。
============================================================ */
(function () {
var calDot = document.getElementById('ce-cal-dot');
var calSel = document.getElementById('ce-cal');
if (calDot && calSel) {
var syncDot = function () {
var o = calSel.options[calSel.selectedIndex];
var c = o ? o.getAttribute('data-color') : '';
if (c) { calDot.style.background = c; }
};
calSel.addEventListener('change', syncDot);
syncDot();
}
var form = document.getElementById('calAddForm');
var tip = document.getElementById('calAddTip');
if (!form || !card || !window.fetch) { return; }
function sayErr(msg) {
if (tip) { tip.textContent = msg; tip.className = 'cal-modal-tip err'; }
}
/** 月视图:往 data-new-at="YYYY-MM-DD" 那个格子里塞一条小条 */
function putMonth(ev) {
var day = String(ev.starts_at || '').slice(0, 10);
var cell = document.querySelector('.cal-cell[data-new-at="' + day + '"]');
if (!cell) { return false; }
var box = cell.querySelector('.cal-evs');
if (!box) { return false; }
var a = document.createElement('a');
a.className = 'cal-ev' + (ev.all_day ? ' is-allday' : '');
a.setAttribute('data-cal', String(ev.cal || ''));
a.style.background = ev.color || '';
a.setAttribute('href', ev.href || '#');
a.setAttribute('title', ev.title || '');
/* ⚠️ 时间前缀的形状必须与服务端一致(<b>HH:MM</b> 后面一个空格)——
不一致的话刷新一次就会看出差别。 */
if (ev.time) {
var b = document.createElement('b');
b.textContent = ev.time;
a.appendChild(b);
a.appendChild(document.createTextNode(' '));
}
a.appendChild(document.createTextNode(ev.title || ''));
/* 🔴 插到**正确的时间位置**,不能 append 到末尾。
为什么必须按时间插:服务端只渲染前 3 条(第 4 条起收进"+N 条"),
而"哪 3 条"是按**时间顺序**取的。若这边一律 append:
· 新加一条**早上 7 点**的日程,它会被排到最后 → 落到"+N 条"里看不见,
可刷新一次又会跑到第一位 —— 这正是"插进去和刷新后长得不一样"。
⚠️ 判据与服务端渲染同一套:先比 <b>HH:MM</b>,没有时间前缀的(全天)排前面
(与 Calendar::month 给 all_day 的处理一致),再比标题兜底稳定性。 */
var newTime = ev.time || '';
var placed = false;
var existing = Array.prototype.slice.call(box.querySelectorAll('.cal-ev'));
for (var k = 0; k < existing.length; k++) {
var tEl = existing[k].querySelector('b');
var tTxt = tEl ? tEl.textContent.trim() : '';
if (tTxt === '' && newTime !== '') { continue; } /* 空时间(全天)在前 */
if (tTxt !== '' && newTime === '') { box.insertBefore(a, existing[k]); placed = true; break; }
if (tTxt > newTime) { box.insertBefore(a, existing[k]); placed = true; break; }
}
if (!placed) { box.appendChild(a); }
/* 再按"只显示前 3 条"收口,与服务端渲染对齐(否则会多出一条)。 */
var links = box.querySelectorAll('.cal-ev');
var more = box.querySelector('.cal-more');
for (var j = 0; j < links.length; j++) {
links[j].style.display = (j < 3) ? '' : 'none';
}
if (links.length > 3) {
var extra = links.length - 3;
if (!more) {
more = document.createElement('span');
more.className = 'cal-more';
box.appendChild(more);
}
more.textContent = '+' + extra + ' 条';
} else if (more) {
more.parentNode.removeChild(more);
}
return true;
}
/** 周/日视图:按"列高 = 24 小时"算 top/height 插时间块 */
function putTimed(ev) {
if (ev.all_day) {
var ad = document.querySelector('.tg-adcell');
if (!ad) { return false; }
var box2 = document.querySelector('.tg-allday');
var col = null;
if (box2) {
/* 找到"那一天"那一列:列的顺序与 .tg-dayname 一致,
用 data-new-at 反查(服务端给每列写了当天日期)。 */
var cols = Array.prototype.slice.call(document.querySelectorAll('.tg-col[data-new-at]'));
var day2 = String(ev.starts_at || '').slice(0, 10);
var idx = -1;
cols.forEach(function (c, i) { if (c.getAttribute('data-new-at') === day2) { idx = i; } });
if (idx >= 0) {
var cells = document.querySelectorAll('.tg-adcell');
col = cells[idx] || null;
}
}
var host = col || ad;
if (!host) { return false; }
var a2 = document.createElement('a');
a2.className = 'cal-ev is-allday';
a2.setAttribute('data-cal', String(ev.cal || ''));
a2.style.background = ev.color || '';
a2.setAttribute('href', ev.href || '#');
a2.setAttribute('title', ev.title || '');
a2.textContent = ev.title || '';
host.appendChild(a2);
return true;
}
var cols3 = Array.prototype.slice.call(document.querySelectorAll('.tg-col[data-new-at]'));
var day3 = String(ev.starts_at || '').slice(0, 10);
var ci = -1;
cols3.forEach(function (c, i) { if (c.getAttribute('data-new-at') === day3) { ci = i; } });
if (ci < 0) { return false; }
var target = cols3[ci];
var minsOf = function (t) {
var m = String(t || '').match(/[ T](\d{1,2}):(\d{1,2})/);
return m ? (+m[1]) * 60 + (+m[2]) : 0;
};
var st = minsOf(ev.starts_at), en = minsOf(ev.ends_at);
if (en <= st) { en = st + 30; } /* 没填结束就给半小时 —— 与服务端一致 */
var top = (st / 1440) * 100, hgt = ((en - st) / 1440) * 100;
var blk = document.createElement('a');
blk.className = 'tg-block';
blk.setAttribute('data-cal', String(ev.cal || ''));
blk.style.background = ev.color || '';
blk.style.top = top.toFixed(4) + '%';
blk.style.height = hgt.toFixed(4) + '%';
blk.setAttribute('href', ev.href || '#');
blk.setAttribute('title', ev.title || '');
blk.textContent = ev.title || '';
target.appendChild(blk);
return true;
}
function insert(ev) {
/* 先看当前视图是哪一种:页面上有 .cal-grid 就是月视图;
有 .tg-col 就是周/日视图。两者都不在(理论上不会)→ 退回刷新。 */
if (document.querySelector('.cal-grid')) { return putMonth(ev); }
if (document.querySelector('.tg-col[data-new-at]')) { return putTimed(ev); }
return false;
}
form.addEventListener('submit', function (e) {
if (form.getAttribute('data-native-submit') === '1') { return; }
e.preventDefault();
var sub = document.getElementById('calAddSubmit');
if (sub) { sub.disabled = true; }
if (tip) { tip.textContent = ''; tip.className = 'cal-modal-tip'; }
fetch(form.getAttribute('action'), {
method: 'POST', body: new FormData(form), credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json' }
}).then(function (r) { return r.json().catch(function () { return null; }); })
.then(function (d) {
if (sub) { sub.disabled = false; }
if (d && d.ok && d.event) {
var ok = false;
try { ok = insert(d.event); } catch (err) { ok = false; }
if (ok) {
/* 观影 / 旅行本是**派生**的(内容来自观影/旅程表):AJAX 时服务端
不再自动跳详情页(跳走就离开了日历),改在提示行给一个链接。 */
if (d.goto && tip) {
tip.className = 'cal-modal-tip';
tip.textContent = (d.msg || '已加上。') + ' ';
var ga = document.createElement('a');
ga.href = d.goto;
ga.textContent = '去看详情 →';
tip.appendChild(ga);
setTimeout(function () {
if (tip) { tip.textContent = ''; }
}, 6000);
}
closeNew();
/* 重置"只填一次的字段",但**保留**日历本的选择(用户往往连加几条)。
⚠️ 标题必须清掉,否则下一条会带着上一条的标题(很容易漏掉)。 */
var t = document.getElementById('ev-title');
var lo = document.getElementById('ev-location');
var no = document.getElementById('ev-note');
if (t) { t.value = ''; }
if (lo) { lo.value = ''; }
if (no) { no.value = ''; }
return;
}
/* 插入不成功 = "没刷新却也没看到" → 宁可刷新一次,别假装成功 */
form.setAttribute('data-native-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
return;
}
if (d && d.msg) { sayErr(d.msg); return; }
/* 拿不到 JSON:会话过期 / 500 → 退回原生提交,让服务端说话 */
form.setAttribute('data-native-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
})
.catch(function () {
if (sub) { sub.disabled = false; }
form.setAttribute('data-native-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
});
});
})();
/* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
})();
+18 -15
View File
@@ -202,9 +202,13 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
onclick="return confirm('删掉现在这张海报?')">删除这张</button>
</div>
<?php endif; ?>
<input class="input" id="poster" name="poster" type="file" accept="image/*">
<span class="tip"><?= $mHasPoster ? '想换就选一张新的(旧的会自动删掉)。' : '还没有海报。' ?></span>
<?php $sdSlot = 'poster'; require __DIR__ . '/../_stage_drop.php'; ?>
<?php /* v24 第 21 批 第 7 条:一个「点击或拖动」的框,取代原来的原生文件框。
换图仍然靠"选/拖一张新的"(服务端换新图时会顺手删掉旧对象)。 */
$sdName = 'poster'; $sdSlot = 'poster'; $sdAccept = 'image/*';
$sdMultiple = false;
$sdMsg = $mHasPoster ? '点击或拖动进行上传(换一张海报)' : '点击或拖动进行上传';
require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip"><?= $mHasPoster ? '选/拖一张新的会自动替换旧的那张。' : '还没有海报。' ?></span>
</div>
<?php /* ---- 票根 ---- */ ?>
@@ -222,9 +226,11 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
onclick="return confirm('删掉现在这张票根?')">删除这张</button>
</div>
<?php endif; ?>
<input class="input" id="ticket" name="ticket" type="file" accept="image/*">
<span class="tip"><?= $mHasTicket ? '想换就选一张新的(旧的会自动删掉)。' : '还没有票根。' ?></span>
<?php $sdSlot = 'ticket'; require __DIR__ . '/../_stage_drop.php'; ?>
<?php $sdName = 'ticket'; $sdSlot = 'ticket'; $sdAccept = 'image/*';
$sdMultiple = false;
$sdMsg = $mHasTicket ? '点击或拖动进行上传(换一张票根)' : '点击或拖动进行上传';
require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip"><?= $mHasTicket ? '选/拖一张新的会自动替换旧的那张。' : '还没有票根。' ?></span>
</div>
</div>
@@ -248,15 +254,12 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($isEdit): ?>
<input class="input" id="photo" name="photo[]" type="file" accept="image/*" multiple>
<span class="tip">可以继续加(可多选);删掉的在保存后生效。</span>
<?php $sdSlot = 'photo'; $sdHint = '也可以把文件直接拖到这里 —— 拖一个就开始上传,可以接着再拖,最后点「保存」一起记下。'; require __DIR__ . '/../_stage_drop.php'; ?>
<?php else: ?>
<input class="input" id="photo" name="photo[]" type="file" accept="image/*" multiple>
<span class="tip">可以多选;之后也能在详情页里逐张补传。</span>
<?php $sdSlot = 'photo'; $sdHint = '也可以把文件直接拖到这里 —— 拖一个就开始上传,可以接着再拖,最后点「记下这场电影」一起记下。'; require __DIR__ . '/../_stage_drop.php'; ?>
<?php endif; ?>
<?php /* v24 第 21 批 第 7 条:统一成「点击或拖动进行上传」的框(可多次)。 */ ?>
<?php $sdName = 'photo[]'; $sdSlot = 'photo'; $sdAccept = 'image/*';
$sdMsg = '点击或拖动进行上传';
require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip"><?= $isEdit ? '可以继续加(可多选);删掉的在保存后生效。'
: '可以多选;之后也能在详情页里逐张补传。' ?></span>
</div>
<div class="btn-row">
+7 -3
View File
@@ -277,9 +277,13 @@ $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">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<label for="photo">补一张现场照片</label>
<div class="photo-add-row">
<input class="input" id="photo" name="photo" type="file" accept="image/*" required>
<label>补一张现场照片</label>
<?php /* v24 第 21 批 第 7 条:统一成「点击或拖动进行上传」的框。
⚠️ 文件框 id 保持 `photo`(原来就叫这个),改名会让既有断言假红。 */
$sdName = 'photo'; $sdId = 'photo'; $sdAccept = 'image/*';
$sdMultiple = false; $sdRequired = true;
require __DIR__ . '/../_stage_drop.php'; ?>
<div class="btn-row" style="margin-top:12px">
<button type="submit" class="btn btn-ghost btn-sm">上传</button>
</div>
</form>
+4 -2
View File
@@ -34,8 +34,10 @@ $canMove = \App\Auth::canWrite();
<div class="form-grid">
<div class="field full">
<label for="imp-file">联系人文件 *</label>
<input class="input" type="file" id="imp-file" name="contacts"
accept=".vcf,.csv,.txt,text/vcard,text/csv,text/plain" required>
<?php $sdName = 'contacts'; $sdId = 'imp-file';
$sdAccept = '.vcf,.csv,.txt,text/vcard,text/csv,text/plain';
$sdMultiple = false; $sdRequired = true;
require __DIR__ . '/../_stage_drop.php'; ?>
</div>
<div class="field">
<label for="imp-group">加进哪一组</label>
+134 -3
View File
@@ -215,7 +215,9 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
<section class="group-block<?= $list ? '' : ' is-empty' ?>" data-group="<?= $gid ?>" data-filter-group>
<h2 class="group-head">
<span class="group-title"><?= e($name) ?></span>
<span class="group-n"><span class="n"><?= count($list) ?></span> 人</span>
<?php /* data-group-n 上挂的是**数字节点**:无刷新删除后 JS 直接改它的文本,
不必重算/重绘整栏。 */ ?>
<span class="group-n"><span class="n" data-group-n="<?= $gid ?>"><?= count($list) ?></span> 人</span>
<span class="group-drop-hint">放到这里</span>
</h2>
<?php $renderGrid($list); ?>
@@ -227,7 +229,11 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
<div class="page-head">
<h1>家庭名册</h1>
<span class="sub">
共 <?= $total ?> 人<?= $groups && $q === '' ? ',分 ' . count($groups) . ' 个关系分组' : '' ?>
<?php /* v24 第 21 批 第 1 条:无刷新删除后要就地更新这个数 ——
所以「共 N 人」那个 N 单独包一个 <b>(加 data-roster-total 供 JS 定位)。
⚠️ 不能把整段文字都塞进去:后面的「,分 6 个关系分组」是**另一件事**,
删一个人不该动它。 */ ?>
共 <b data-roster-total><?= $total ?></b> 人<?= $groups && $q === '' ? ',分 ' . count($groups) . ' 个关系分组' : '' ?>
</span>
<div class="actions">
<?php if ($canMove && $pickable > 0): ?>
@@ -368,7 +374,8 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
<button type="button" class="rm-x" data-rm-close aria-label="关闭">✕</button></div>
<?php /* ⚠️ 路由是 people/destroy(与详情页底部那个删除同一个)——
不要写成 people/delete,那会 404 且看起来像"点了没反应"。 */ ?>
<form method="post" action="<?= e(url('people/destroy')) ?>" class="pd-form">
<form method="post" action="<?= e(url('people/destroy')) ?>" class="pd-form"
data-ajax-del>
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<div class="rm-body">
@@ -422,6 +429,130 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
syncClear();
})();
<?php /* ==================== v24 第 21 批 第 1 条:删除**不刷新页面** ====================
用户原话:「名册页面,删除一个联系人后,能否不刷新页面,删除成功后,直接把这个人,
从当前页面的 dom 中删除,页面保持不动。」
做法:拦掉 #rmDel 那个表单的提交(它带 data-ajax-del,见上面浮层),
改成 fetch POST;成功后**只把那一张卡片从 DOM 里摘掉**并就地更新计数,
页面滚动位置、搜索框里的字、展开的分栏全都保持原样。
⚠️ 四个必须踩准的点:
① **成功后必须真的重跑一次筛选**:摘掉卡片之后,如果搜索框里还有字,
或某一栏刚好空掉,分组栏的显隐/计数就与现状不符了。
这里派发一个 input 事件让 filter.js 自己重算 —— 不重复实现它的逻辑。
(filter.js 的那套判据只在它自己那儿,抄一份必然飘。)
② **删的是"当前看的人"时不能只摘卡片**:还是留给他自己那颗"不能删自己"的红字提示
(服务端会回 ok=false + 原因),所以走的是底下那条"失败就退回原生提交"。
③ **失败一律退回原生提交**:网络断了 / 会话过期 / 权限不足 ——
任何一种都不能静默失败("点了删除没反应"比"刷一下"糟得多)。
退回原生提交,用户至少能看到服务端给的红字提示。
④ **提交期间禁用按钮**:防连点(两次 DELETE 第二次会拿到"找不到这个人",
弹出来会让人以为第一次失败了)。
============================================================ */ ?>
(function () {
var form = document.querySelector('form[data-ajax-del]');
if (!form || !window.fetch || !window.FormData) { return; }
var wrap = document.getElementById('rosterMenus');
var panel = document.getElementById('rmDel');
// 一次性把要用的计数节点找好(删完要就地改它们,别再全页扫)
var totalEl = document.querySelector('[data-roster-total]');
var searchInput = document.getElementById('rosterSearch');
/** 摘掉一个人 = 摘卡片 + 更新"共 N 人" + 更新他那一栏的人数 */
function removePerson(pid) {
var card = document.querySelector('.person[data-person="' + pid + '"]');
if (!card) { return false; }
var block = card.closest('.group-block');
card.parentNode.removeChild(card);
if (totalEl) {
var n = parseInt(totalEl.textContent || '0', 10) || 0;
totalEl.textContent = String(Math.max(0, n - 1));
}
if (block) {
var gid = block.getAttribute('data-group');
var nEl = document.querySelector('[data-group-n="' + gid + '"]');
if (nEl) {
var m = parseInt(nEl.textContent || '0', 10) || 0;
nEl.textContent = String(Math.max(0, m - 1));
}
}
// ① 让 filter.js 重算筛选/空态/分栏显隐(它监听的正是 input 事件)。
// 没有 filter.js(极老浏览器)时什么也不做 —— 只是栏头人数会略旧,
// 卡片本身已经摘掉了,不影响用户看到的结果。
if (searchInput) {
try { searchInput.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {}
}
return true;
}
/** 提示一句(用页面自己的 flash 样式,不弹 alert) */
function say(ok, msg) {
// ⚠️ 结构与 layout.php 里那段完全一致(.flash > .flash-item[.err])——
// 自己另发明一套 class 会得不到那条淡红/淡绿的样式。
var host = document.querySelector('.flash');
if (!host) {
host = document.createElement('div');
host.className = 'flash';
var main = document.querySelector('main') || document.body;
main.insertBefore(host, main.firstChild);
}
var el = document.createElement('div');
el.className = 'flash-item' + (ok ? '' : ' err');
el.setAttribute('role', 'status');
el.textContent = msg;
host.insertBefore(el, host.firstChild);
setTimeout(function () { if (el.parentNode) { el.parentNode.removeChild(el); } }, 4000);
}
form.addEventListener('submit', function (ev) {
// 已经退回原生提交了就别再拦(避免打转)
if (form.getAttribute('data-ajax-fallback') === '1') { return; }
ev.preventDefault();
var pid = (form.querySelector('input[name="id"]') || {}).value || '';
var btn = form.querySelector('button[type="submit"]');
if (btn) { btn.disabled = true; }
fetch(form.getAttribute('action'), {
method: 'POST',
body: new FormData(form),
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) {
return r.json().catch(function () { return null; });
}).then(function (d) {
if (btn) { btn.disabled = false; }
if (d && d.ok) {
// ① 先关浮层(它盖在页面上,不收掉会让人以为还在等)
if (wrap) { wrap.hidden = true; }
if (panel) { panel.hidden = true; }
removePerson(String(pid));
say(true, d.msg || '已删除。');
return;
}
// ③ 服务端明确说不行(删自己 / 唯一管理员)→ 把原因说出来,但不刷新
if (d && d.msg) {
if (wrap) { wrap.hidden = true; }
if (panel) { panel.hidden = true; }
say(false, d.msg);
return;
}
// ④ 拿不到 JSON(会话过期 / 500 / 网络坏)→ 退回原生提交,让服务端说话
form.setAttribute('data-ajax-fallback', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
}).catch(function () {
if (btn) { btn.disabled = false; }
form.setAttribute('data-ajax-fallback', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
});
});
})();
<?php /* ==================== 卡片动作弹框的交互(v24 第 11 批 #2 重写)====================
一行只留按钮,真正的表单在 #rosterMenus 里(全页一份,见前面的说明)。
这一版把"贴按钮的浮层"换成**居中模态框**(与选人弹窗同形态)—— 用户明确要求
+7 -4
View File
@@ -100,8 +100,8 @@ $canWrite = \App\Auth::canWrite();
</div>
<div class="field full">
<label for="rs-file">附件(可多选)</label>
<input class="input" type="file" id="rs-file" name="attach[]" multiple>
<?php $sdSlot = 'photo'; $sdHint = '也可以把文件直接拖到这里 —— 拖一个就开始上传,可以接着再拖,最后点「保存」/「新建」一起记下。'; require __DIR__ . '/../_stage_drop.php'; ?>
<?php $sdName = 'attach[]'; $sdSlot = 'photo';
require __DIR__ . '/../_stage_drop.php'; ?>
</div>
</div>
<div class="btn-row">
@@ -188,8 +188,11 @@ $canWrite = \App\Auth::canWrite();
action="<?= e(url('resources/attach_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $rid ?>">
<input type="file" name="attach[]" multiple required>
<button class="btn btn-ghost btn-sm" type="submit">补传附件</button>
<?php $sdName = 'attach[]'; $sdSlot = 'photo';
require __DIR__ . '/../_stage_drop.php'; ?>
<div class="btn-row" style="margin-top:12px">
<button class="btn btn-ghost btn-sm" type="submit">补传附件</button>
</div>
</form>
<details class="res-share-add">
+22 -18
View File
@@ -49,27 +49,31 @@ $rosterCount = $rosterCount ?? 0;
填坐标是我们要解决的实现细节(App\Geo 内置 197 个地级市离线查),
不该甩给用户。内置表里没有的(镇 / 村 / 海外)才需要填坐标。 */ ?>
<div class="card card-pad" id="weather" style="margin-top:20px">
<h2 class="section-title">
天气 · 我所在的城市
<?php /* v24 第 20 批 第 2 条:这里的 "只影响你自己首页上的那一格" 保留 ——
用户点名要删的是页头那句副标题,不是这一处。 */ ?>
<span class="hint">只影响你自己首页上的那一格</span>
</h2>
<p class="small muted" style="margin:0 0 16px">
填个城市名就行(如「青岛」)。首页那句问候旁边会显示这儿的天气 ——
<b>每人一份</b>,孩子在外地读书,他填他的、你看你的。
<br>留空就是不显示天气(不影响别的东西)。
</p>
<?php /* v24 第 21 批 第 3 条(用户原话):
「天气设置那里,把:只影响你自己首页上的那一格 / 填个城市名就行(如「青岛」)。
首页那句问候旁边会显示这儿的天气 —— 每人一份,孩子在外地读书,
他填他的、你看你的。/ 留空就是不显示天气(不影响别的东西)。 这些提示,都删除。
把城市单独放一行,精度维度放在一行。」
→ ① 标题旁那句 hint、标题下那整段说明,**全删**(三处提示);
② 版式改成两行:第一行「城市」独占一行(它是要填的那个,得宽敞),
第二行「纬度 / 经度」并排一行。
⚠️ 删的只是**说明文字**:城市/纬度/经度三个输入框、保存按钮、
以及下面那句"现在按 XX 取天气"的状态回显**都保留** ——
那些是"填了之后发生了什么的反馈",不是啰嗦的提示。 */ ?>
<h2 class="section-title">天气 · 我所在的城市</h2>
<form method="post" action="<?= e(url('settings/weather')) ?>">
<?= csrf_field() ?>
<div class="form-grid">
<div class="field">
<label for="w-city">城市</label>
<input class="input" id="w-city" name="city" maxlength="30"
value="<?= e((string)($weatherCity ?? '')) ?>"
placeholder="如:青岛 / 山东青岛 / 内蒙古包头" autocomplete="off">
</div>
<?php /* 第一行:城市单独一行(占满宽度) */ ?>
<div class="field">
<label for="w-city">城市</label>
<input class="input" id="w-city" name="city" maxlength="30"
value="<?= e((string)($weatherCity ?? '')) ?>"
placeholder="如:青岛 / 山东青岛 / 内蒙古包头" autocomplete="off">
</div>
<?php /* 第二行:精度维度(纬度 / 经度)同一行 */ ?>
<div class="loc-row">
<div class="field">
<label for="w-lat">纬度(可选)</label>
<input class="input" id="w-lat" name="lat" value="<?= e((string)($weatherLat ?? '')) ?>"
+8 -3
View File
@@ -257,9 +257,14 @@ $isChild = !empty($t['parent_id']);
<form class="inline-form" method="post" enctype="multipart/form-data" action="<?= e(url('tasks/attach_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<input type="file" name="attach[]" multiple required>
<button class="btn btn-sm" type="submit">上传附件</button>
<?php $sdSlot = 'photo'; $sdHint = '也可以把文件直接拖到这里 —— 拖一个就开始上传,可以接着再拖,最后点「上传附件」一起记下。'; require __DIR__ . '/../_stage_drop.php'; ?>
<?php /* v24 第 21 批 第 7 条:不再放原生文件框 + 按钮,改成一个「点击或拖动」的框。
⚠️ 这个表单**唯一**的用途就是传附件,所以提交键仍然要有 ——
"点击/拖动"只是选文件的方式,最后还得点一下才能提交。 */
$sdName = 'attach[]'; $sdSlot = 'photo';
require __DIR__ . '/../_stage_drop.php'; ?>
<div class="btn-row" style="margin-top:12px">
<button class="btn btn-sm" type="submit">上传附件</button>
</div>
</form>
<?php endif; ?>
</div>
+3 -2
View File
@@ -297,9 +297,10 @@ $amap = \App\Geo::jsConfig();
<?php endforeach; ?>
</div>
<?php endif; ?>
<input class="input" type="file" id="nf-photo" name="photo[]" accept="image/*" multiple>
<?php /* v24 第 21 批 第 7 条:统一成「点击或拖动进行上传」的框(可多次)。 */ ?>
<?php $sdName = 'photo[]'; $sdSlot = 'photo'; $sdAccept = 'image/*';
require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip">可多选;图片、视频都会存到这段行程下面。</span>
<?php $sdSlot = 'photo'; $sdHint = '也可以把文件直接拖到这里 —— 拖一个就开始上传,可以接着再拖,最后点「保存」一起记下。'; require __DIR__ . '/../_stage_drop.php'; ?>
</div>
</div>
+12 -3
View File
@@ -60,15 +60,24 @@ $groupMeta = [
+ 实际行程
</a>
<a class="btn btn-ghost" href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">编辑</a>
<?php /* 用户第 8 条:右上角增加一个**设置**按钮(车辆管理 / 出行方式 / 节点类型)。
按全站规矩:设置按钮只有图标(三条横线)、无文字、贴最右。 */ ?>
<?php $trSetCurrent = ''; require __DIR__ . '/_settings_menu.php'; ?>
<?php /* 🔴 v24 第 21 批 第 6 条:**设置按钮必须排到最后**。
用户原话:「旅程的详情页面,右上角的按钮,设置在最右侧,和其他模块保持统一。」
⚠️ 原来这里的顺序是「… → 设置(☰) → 删除(🗑)」—— 设置被删除键挤到了**倒数第二**,
所以它并**不在最右**(实测:动作区最右边缘贴着的是那个删除表单,
而设置按钮左边还空着一大截)。全站其它模块(观影 / 日程 / 任务 / 资源)
的约定都是「设置 ☰ 恒为动作区最后一个」,用户点惯了一个位置,
到这里却偏了一格,就是"不统一"的全部含义。
⚠️ 所以删除键挪到设置**前面**(它现在是倒数第二)。 */ ?>
<form method="post" action="<?= e(url('trips/destroy')) ?>" class="res-inline"
onsubmit="return confirm('确定删除「<?= e((string)$t['title']) ?>」吗?里面的行程、照片会一起删掉,不可恢复。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-icon" type="submit" title="删除这个旅程" aria-label="删除">🗑</button>
</form>
<?php /* 用户第 8 条:右上角一个**设置**按钮(车辆管理 / 出行方式 / 节点类型)。
全站规矩:设置只有图标(三条横线)、无文字、**恒在动作区最右**。 */ ?>
<?php $trSetCurrent = ''; require __DIR__ . '/_settings_menu.php'; ?>
<?php else: ?>
<span class="link-quiet">只读账号</span>
<?php endif; ?>