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
+35 -2
View File
@@ -32,6 +32,22 @@ final class Calendar
* 而且 kind ≠ 'custom' → Calendar::delete() 天然删不掉(用户要求"不可删除")。 * 而且 kind ≠ 'custom' → Calendar::delete() 天然删不掉(用户要求"不可删除")。
*/ */
public const DEFAULT_KINDS = [ public const DEFAULT_KINDS = [
/* 🔴 v24 第 21 批 第 7 条:**每人必须有"我自己的日程本"** —— 原来这里
只有 6 本**派生**本(观影/旅行/农历/节假日/传统节日/节气),
一本可写的普通本都没有。后果不只是"列表里缺一本":
· 新建日程的"放在哪本"里全是派生本,默认选中排最前的**观影** →
用户直接打字点"加上",建出来的其实是一条**观影记录**(不是日程)。
这是"建错东西",事后极难发现(日历里看着是多了条日程,
可观影列表里莫名多了一条记录);
· 想建一条真正的日程得先自己去"日历本"页建一本 —— 而用户根本没被告知。
所以把"我的日程"补进默认本里(kind='custom' = 可写)。
⚠️ 幂等:seedDefaults 按 `owner_id + kind + name` 去重,
老用户升级时(Schema 里会跑 seedDefaultsForFamily)补一本,不会重复建。
⚠️ 顺序:`mine()` 是 `ORDER BY kind='custom', position, id` ——
custom 排最前,所以它天然就是"新建日程"的默认本。 */
'custom' => ['我的日程', '#4A7C82'],
'movie' => ['观影', '#7A4E8C'], 'movie' => ['观影', '#7A4E8C'],
'trip' => ['旅行', '#3F7D5A'], 'trip' => ['旅行', '#3F7D5A'],
'lunar' => ['农历', '#8C6D3F'], 'lunar' => ['农历', '#8C6D3F'],
@@ -43,9 +59,26 @@ final class Calendar
]; ];
/** 派生型只读日历(内容不从 calendar_events 取,也不能往里加日程) */ /** 派生型只读日历(内容不从 calendar_events 取,也不能往里加日程) */
public const READONLY_KINDS = ['movie', 'trip', 'lunar', 'holiday', 'traditional', 'solarterm']; /**
* **纯派生、完全不能写入**的日历本:内容按日期算出来(农历/节假日/传统节日/节气)。
* 往这些本子里加日程毫无意义 —— 加了也不会显示(Calendar::month 只从
* kind='custom' 取 calendar_events),只会留下一堆"看不见的日程"。
*
* 🔴 v24 第 21 批 第 7 条:这里原来把 `movie` / `trip` 也算进来了,
* 而 `calendar/event_add` 里**紧跟着**就有两段专门处理它们的分支
* (「已加进观影」/「已加进旅程」)—— 那两段于是**永远走不到**:
* 校验先一步把请求打回去了。用户的日历本选择器里明明列着「观影」和「旅行」
* (它们是可写的:加一条 = 建一条真正的观影/旅程记录),
* 选中却必定失败 —— 这就是"选了观影本、点了加上、只得到一句错误"的根因。
*
* ⚠️ 判据要按**能不能写**来分,不是按"是不是派生":
* · lunar / holiday / traditional / solarterm —— 纯算出来,只读;
* · movie / trip —— 也是派生(内容来自观影/旅程表),但**通过各自的
* 业务路径可写**(日历这边只是把"加一条"转成"建一条记录")。
*/
public const READONLY_KINDS = ['lunar', 'holiday', 'traditional', 'solarterm'];
/** 这本是不是"派生只读"的(农历/节假日/观影/旅行都算) */ /** 这本日历的内容是不是"自动生成、不能直接往里写日程" */
public static function isReadonlyKind(string $kind): bool public static function isReadonlyKind(string $kind): bool
{ {
return in_array($kind, self::READONLY_KINDS, true); return in_array($kind, self::READONLY_KINDS, true);
+26 -2
View File
@@ -625,11 +625,35 @@ final class Trip
// ---------------- 行程节点 ---------------- // ---------------- 行程节点 ----------------
/** 这趟旅程的全部节点(plan / reality 两轨,按 position 排) */ /** 这趟旅程的全部节点(计划 / 实际 / 未成行,按**开始时间正序**排) */
public static function nodes(int $eventId, int $viewerId = 0): array public static function nodes(int $eventId, int $viewerId = 0): array
{ {
/* 🔴 v24 第 21 批 第 5 条:排序口径改成「按开始时间」。
用户原话:「旅程模块,旅程列表,行程列表,都是按照行程的开始时间排序的,
最新的在最上面,而不是按照编辑修改时间。」
⚠️ 原来排的是 `category, position, starts_at, id`:
`category` 只有 plan/reality 两个值(老的双轨字段,现在由 stage 派生),
`position` 是**默认同一个数**(旧迁移给的多是 0 或 100),
于是真正决定顺序的只剩 position —— 同一 position 里按 starts_at 排,
但 plan 组永远排在 reality 组前面。
实测线上 #222 就渲染成「返回青岛(10-5) → 出发去菏泽(9-30) → 去姥姥家(10-2)」,
既不是时间正序也不是倒序 —— 一条时间线上看,位置是"乱"的。
⚠️ 排序方向:**正序**(早的在前)。
用户对**旅程列表**的要求是"最新在最上面"(那是列表,见 Trip::all 的 DESC);
但**行程列表**是一趟旅行内部的行程单 —— 从出发读到返回才符合直觉。
这一点已跟用户确认过(第 21 批选的是"时间正序")。
⚠️ 两者方向不同是**刻意的**,不是漏改:一个是"挑哪趟旅行",
一个是"读这趟旅行的行程"。别为了"看起来统一"把其中一个拧过来。
⚠️ 没有开始时间的排在最后(否则 NULL 会被当成最小值顶到最前面)。
`(starts_at IS NULL)` 在 MySQL/MariaDB 里就是 1/0,用它当第一个键即可。
⚠️ `id` 兜底:同一时刻的两段(中转/换乘常常同分)要有稳定顺序,
否则每次渲染可能换位置。 */
$st = Database::get()->prepare( $st = Database::get()->prepare(
'SELECT * FROM trip_nodes WHERE event_id = ? ORDER BY category, position, starts_at, id' 'SELECT * FROM trip_nodes WHERE event_id = ?
ORDER BY (starts_at IS NULL), starts_at, id'
); );
$st->execute([$eventId]); $st->execute([$eventId]);
$rows = $st->fetchAll(); $rows = $st->fetchAll();
+160 -1
View File
@@ -588,7 +588,11 @@ textarea.input { min-height: 76px; resize: vertical; }
⚠️ 用 visibility:hidden 而不是 display:none —— 保留元素占位高度, ⚠️ 用 visibility:hidden 而不是 display:none —— 保留元素占位高度,
否则升级完成的那一瞬表单会"跳"一下(高度从 0 变 40px)。 */ 否则升级完成的那一瞬表单会"跳"一下(高度从 0 变 40px)。 */
html.js .dt-input:not(.is-semi) .dt-native { visibility: hidden; } html.js .dt-input:not(.is-semi) .dt-native { visibility: hidden; }
html.js .dt-range:not(.is-semi-range) > .dt-input { visibility: hidden; } /* ⚠️ v24 第 21 批 第 7 条:两端现在各自被 `.field` 包着,所以选择器要跟着下沉一层。
写成 `> .dt-input` 的话这条**永远不会命中** → 未升级时原生控件照旧可见,
与下面那块"占位骨架"叠在一起,会看到两套控件重影。 */
html.js .dt-range:not(.is-semi-range) .dt-input { visibility: hidden; }
html.js .dt-range:not(.is-semi-range) > .dt-range-label { visibility: hidden; }
/* Semi 未就绪前,先给一个同高的"占位骨架",让表单高度在升级前后保持一致 */ /* Semi 未就绪前,先给一个同高的"占位骨架",让表单高度在升级前后保持一致 */
html.js .dt-input:not(.is-semi)::after { html.js .dt-input:not(.is-semi)::after {
content: ''; display: block; flex: 1 1 150px; height: 40px; content: ''; display: block; flex: 1 1 150px; height: 40px;
@@ -613,8 +617,12 @@ html.js .dt-range.is-semi-range::before { display: none; }
由**一个** .dt-semi-range 顶上来。未升级(JS 没跑/加载失败)时, 由**一个** .dt-semi-range 顶上来。未升级(JS 没跑/加载失败)时,
里面两个原生控件照旧各占一行 —— 也就是"功能没变,只是不好看"。 */ 里面两个原生控件照旧各占一行 —— 也就是"功能没变,只是不好看"。 */
.dt-range { display: flex; flex-direction: column; gap: 8px; } .dt-range { display: flex; flex-direction: column; gap: 8px; }
/* 范围控件自己的标签(只有一个,见 _event_fields.php 的说明) */
.dt-range-label { font-size: 13px; color: var(--muted); font-weight: 500; }
.dt-range.is-semi-range { display: block; } .dt-range.is-semi-range { display: block; }
.dt-range.is-semi-range > .dt-input { display: none; } /* 两端都让位给范围控件 */ .dt-range.is-semi-range > .dt-input { display: none; } /* 两端都让位给范围控件 */
/* 两端被 .field 包着时的同一条规则(见上面第 21 批的说明) */
.dt-range.is-semi-range > .field { display: none; }
.dt-semi-range { display: none; width: 100%; max-width: 460px; } .dt-semi-range { display: none; width: 100%; max-width: 460px; }
.dt-range.is-semi-range .dt-semi-range { display: block; } .dt-range.is-semi-range .dt-semi-range { display: block; }
/* Semi 范围输入框:与站内 .input 同高同圆角;宽度撑开一点(要放两个时间) */ /* Semi 范围输入框:与站内 .input 同高同圆角;宽度撑开一点(要放两个时间) */
@@ -3741,3 +3749,154 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
/* v24 第 20 批 第 8 条:提醒多选的排版(勾选框多了要能换行,别挤成一长条) */ /* v24 第 20 批 第 8 条:提醒多选的排版(勾选框多了要能换行,别挤成一长条) */
.rem-picks { display: flex; flex-wrap: wrap; gap: 6px 14px; } .rem-picks { display: flex; flex-wrap: wrap; gap: 6px 14px; }
.rem-pick { font-size: 13px; } .rem-pick { font-size: 13px; }
/* ============================================================
v24 第 21 批 第 2 条:观影编辑页的**已存图片**要缩成缩略图
------------------------------------------------------------
用户原话:「观影模块,海报和票根以及照片都带进来了,但是没有缩放,
把页面撑破了,应该缩放成缩略图。」
🔴 根因:`.mv-slot` 这个类**在样式表里根本不存在**(第 20 批加模板时只写了
结构没写样式)。于是里面的 <img> 按**原始像素**渲染 —— 海报是 2:3 竖图
(上传时按 600×900 裁的),就在一个 236px 宽的格子里撑出
600×900 + 942px 高,把表单顶破、还让整页横向溢出(实测 docW 1297 > vw 1280)。
「带进来了」这个功能是对的,缺的是"把它当成缩略图看"这一层样式。
现在的规格:左侧固定尺寸缩略图(海报 2:3 → 108×162;票根按同一高度裁切),
右侧「删除这张」按钮,整体是"看得见、不占地方"的一张缩略图。
⚠️ `object-fit: cover` 是必须的:票根那种横长条塞进竖框里,
不用它就会被压扁变形(看着像图坏了)。
⚠️ 缩略图高度**写死**、不能再由图片自身比例决定 —— 那正是撑破的原因。
============================================================ */
.mv-slot {
display: flex; align-items: flex-start; gap: 10px;
margin-bottom: 10px;
}
.mv-slot > a {
display: block; flex: 0 0 auto;
width: 108px; height: 162px; overflow: hidden;
border: 1px solid var(--line); border-radius: var(--radius-s);
background: #F1E7D6;
}
.mv-slot > a img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.mv-slot > .btn { flex: 0 0 auto; align-self: center; }
/* 窄屏:缩略图再小一点,别把"删除这张"挤出去 */
@media (max-width: 480px) {
.mv-slot > a { width: 84px; height: 126px; }
}
/* ============================================================
v24 第 21 批 第 7 条:「点击或拖动进行上传」的那个框
------------------------------------------------------------
用户原话:「所有上传图片等附件的地方,都不再放按钮,而是放一个框,
点击这个框实现点击上传按钮的功能,拖动图片进这个框里,
实现拖动多个上传功能。框里面写一句话:点击或拖动进行上传,
其他的功能和现在保持一致。」
⚠️ 这是**全新的统一上传入口**,取代了原来的 `<input class="input" type=file>`
(那个原生控件长得像输入框、写着"选择文件 未选择任何文件",
与旁边那个拖放虚线框是**两个**入口 —— 用户看到的就是"到底点哪个")。
现在原生框整个 `.sr-only` 藏起来,页面上只有这**一个**框。
⚠️ 落点高亮 `.is-over` 由 app.js 的 bindZone 加(拖动经过时)——
与既有的 `.dropzone.is-over` 是同一套动作,只是这个框自己有底色,
高亮用"描边+底色变亮"表达(不用 outline,避免与自带虚线边重叠成双线)。
============================================================ */
.stage-drop-tip { display: none; } /* 旧的提示位:文案已并进框里,这里留个空占位规则 */
.upbox {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 6px; width: 100%; min-height: 96px; box-sizing: border-box;
padding: 16px 14px; cursor: pointer; text-align: center;
border: 1.5px dashed var(--line); border-radius: var(--radius-s);
background: #FDFBF7; color: var(--muted);
transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}
.upbox:hover { border-color: var(--brand-2); background: #FBF6F0; color: var(--brand); }
.upbox:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; }
/* 拖动经过时的高亮(app.js 的 bindZone 加 .is-over) */
.upbox.is-over {
border-color: var(--brand); border-style: solid;
background: #FDF3EC; color: var(--brand);
}
.upbox-ico { font-size: 22px; line-height: 1; font-weight: 300; }
.upbox-msg { font-size: 13.5px; }
/* 窄屏:框再矮一点,别把表单撑太长 */
@media (max-width: 480px) {
.upbox { min-height: 78px; padding: 12px; }
.upbox-ico { font-size: 18px; }
}
/* 空的提示行(暂存清单下面那句"已上传/失败"):没内容时不占地方 */
.stage-drop-tip:not([hidden]) { display: block; margin-top: 6px; font-size: 12px; color: var(--muted); }
/* ============================================================
v24 第 21 批 第 7 条:新建日程的**弹框**
------------------------------------------------------------
用户原话:「日历中新建日程,无论是点击按钮还是点击日历中间,都是采用弹框的形式
进行添加,这样不影响当前日历的显示,添加完成后,动态修改当前视图,
不要刷新。」
⚠️ 原来那张表单是**内联**在日历网格上方的卡片,一展开就把月视图推下去 ——
正是"影响当前日历的显示"。改成浮层后,底下的日历**一动不动**。
⚠️ 视觉沿用站内既有的浮层语言(遮罩 rgba(44,35,29,.55) + 圆角面板 + 阴影),
与选人弹窗 / 名册动作框 / 裁剪器是同一套,用户不用重新认。
============================================================ */
.cal-modal {
position: fixed; inset: 0; z-index: 200;
display: flex; align-items: flex-start; justify-content: center;
padding: 24px 16px; overflow-y: auto;
background: rgba(44, 35, 29, .55); overscroll-behavior: contain;
}
.cal-modal[hidden] { display: none; }
.cal-modal-panel {
width: min(680px, 100%); margin: auto;
background: var(--surface); border-radius: 14px;
box-shadow: var(--shadow-l); display: flex; flex-direction: column;
max-height: calc(100vh - 48px);
}
.cal-modal-head {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px 12px; border-bottom: 1px solid var(--line-2);
flex: 0 0 auto;
}
.cal-modal-head b { font-size: 15.5px; font-weight: 600; }
.cal-modal-x {
margin-left: auto; border: 0; background: transparent; cursor: pointer;
font-size: 15px; line-height: 1; color: var(--muted);
padding: 4px 7px; border-radius: 6px;
}
.cal-modal-x:hover { background: var(--hov-veil); color: var(--ink); }
.cal-modal-body { padding: 16px; overflow-y: auto; flex: 1 1 auto; }
.cal-modal-foot {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px 14px; border-top: 1px solid var(--line-2);
flex: 0 0 auto;
}
/* 提交键贴右(次要动作在左、主要动作在右,与全站一致) */
.cal-modal-foot .btn[type="submit"] { margin-left: auto; }
.cal-modal-tip { font-size: 12.5px; color: var(--muted); }
.cal-modal-tip.err { color: var(--red); }
/* 日历本:色块 + 下拉(原生 option 画不出色块,见模板里的说明) */
.cal-book-row { display: flex; align-items: center; gap: 8px; }
.cal-book-dot {
flex: 0 0 auto; width: 20px; height: 20px; border-radius: 6px;
border: 1px solid rgba(44, 35, 29, .12);
}
.cal-book-row .select { flex: 1 1 auto; }
/* ============================================================
v24 第 21 批 第 7 条:日程新建弹框的显隐(**必须放收尾**)
------------------------------------------------------------
⚠️ 为什么单独补这一条:本项目有一条全局兜底
`.field[hidden], .field.full[hidden], [data-mode-vehicle][hidden], … { display:none !important }`
(第 20 批为了修"车辆藏不住"加的)。而弹框的**面板里就有 `.field`** ——
面板一旦被隐藏,CSS 的 `display:flex` 与那条 `!important` 会打架,
加上 `hidden` 属性同时落在多个层级上,会出现"明明摘了 hidden 却还是不显示"。
这里用 `:not([hidden])` 把"打开态"显式钉死(后写者赢 + 特异性更高),
两种状态各归各的规则、互不牵连。
============================================================ */
.cal-modal:not([hidden]) { display: flex; }
.cal-modal[hidden] { display: none !important; }
+102 -17
View File
@@ -409,8 +409,16 @@
// 那样往"票根"里拖会跑到"海报"上(用户看到的是"拖到这儿,结果上到那儿")。 // 那样往"票根"里拖会跑到"海报"上(用户看到的是"拖到这儿,结果上到那儿")。
// 所以先找**同一个 .field 里**的那个,找不到才退回表单里的第一个。 // 所以先找**同一个 .field 里**的那个,找不到才退回表单里的第一个。
var stageBox = null; var stageBox = null;
var ownField = input.closest ? input.closest('.field') : null; var ownField = input.closest ? input.closest('.field') : '';
if (ownField) { stageBox = ownField.querySelector('[data-drop-stage]'); } if (ownField) { stageBox = ownField.querySelector('[data-drop-stage]'); }
// v24 第 21 批 第 7 条:新结构里文件框是 **sr-only、且与框互为兄弟**
// (不再包在 .field 里 —— 片段自己画框,调用方只 require 一行)。
// 所以再认一次"显式指向":框上写了 data-sd-for="<input的id>"。
if (!stageBox && input.id) {
stageBox = document.querySelector('[data-drop-stage][data-sd-for="' + input.id + '"]');
}
// 最后才退回"表单里第一个"(老结构;多上传区的表单**必须**能就近命中,
// 否则往「票根」拖会跑到「海报」上 —— 第 20 批踩过,注释见上)
if (!stageBox && input.form) { stageBox = input.form.querySelector('[data-drop-stage]'); } if (!stageBox && input.form) { stageBox = input.form.querySelector('[data-drop-stage]'); }
if (stageBox) { if (stageBox) {
queueDrop(stageBox, input, files); queueDrop(stageBox, input, files);
@@ -445,7 +453,7 @@
做法(三条链路,缺一不可): 做法(三条链路,缺一不可):
① 拖进来 → **立刻** POST 到 upload_stage.php(`?r=upload/stage` 由路由转过去), ① 拖进来 → **立刻** POST 到 upload_stage.php(`?r=upload/stage` 由路由转过去),
服务端落进暂存目录并回一个 `stoken`; 服务端落进暂存目录并回一个 `stoken`;
② 把 stoken 塞进隐藏字段 `<input type=hidden name="stage[]">`—— ② 把 stoken 塞进隐藏字段(名字由落点给,见 `data-stage-field`)——
表单提交时这些令牌就跟着走了,业务路由在 attach_save_files 里**认领**; 表单提交时这些令牌就跟着走了,业务路由在 attach_save_files 里**认领**;
③ 立刻画一张缩略图(指向 `media.php?stage=<stoken>`), ③ 立刻画一张缩略图(指向 `media.php?stage=<stoken>`),
让用户看得见"这一张已经上去了"。 让用户看得见"这一张已经上去了"。
@@ -463,8 +471,13 @@
function queueDrop(box, input, files) { function queueDrop(box, input, files) {
var form = input.form; var form = input.form;
if (!form) { return; } if (!form) { return; }
var list = box.querySelector('[data-stage-list]'); // ⚠️ v24 第 21 批 第 7 条:清单与提示行现在在框**外面**(同一个 `.upfield` 容器里)——
var tip = box.querySelector('[data-stage-tip]'); // 清单放框内的话,加了五张图那个虚线框就变成"大框套着五张图"。
// 所以先在容器里找,找不到才退回框自身(`.upfield` 不在时至少别静默失效:
// 没有清单 = 上传成功却什么都不显示,用户会以为没上去)。
var host = (box.closest && box.closest('.upfield')) || box.parentNode || box;
var list = host.querySelector('[data-stage-list]') || box.querySelector('[data-stage-list]');
var tip = host.querySelector('[data-stage-tip]') || box.querySelector('[data-stage-tip]');
// 队列状态挂在 box 上:**跨多次拖放**共享(用户拖一次、再拖一次,仍要串行) // 队列状态挂在 box 上:**跨多次拖放**共享(用户拖一次、再拖一次,仍要串行)
var st = box.__stage || (box.__stage = { pending: 0, chain: Promise.resolve() }); var st = box.__stage || (box.__stage = { pending: 0, chain: Promise.resolve() });
@@ -499,6 +512,9 @@
// 但整条清单是 display:none —— 用户看到的是"拖了没反应", // 但整条清单是 display:none —— 用户看到的是"拖了没反应",
// 而且缩略图也点不到(真机探针实测:卡片在 DOM 里、尺寸 0×0)。 // 而且缩略图也点不到(真机探针实测:卡片在 DOM 里、尺寸 0×0)。
list.hidden = false; list.hidden = false;
// 提示行同理(模板也给了 hidden)—— 它要说"已上传,可以继续拖"这类话,
// 藏着等于一个字都不说。
if (tip) { tip.hidden = false; }
} }
st.pending++; st.pending++;
@@ -544,10 +560,16 @@
// 令牌进隐藏字段 → 随表单提交,服务端在 attach_save_files 里认领它 // 令牌进隐藏字段 → 随表单提交,服务端在 attach_save_files 里认领它
var hid = document.createElement('input'); var hid = document.createElement('input');
hid.type = 'hidden'; hid.type = 'hidden';
// ⚠️ 字段名由落点决定:附件类是 `stage[]`(服务端一次认领一批), // ⚠️ 字段名由落点决定,**不能写死**:
// 海报/票根是 `stage_poster` / `stage_ticket`(各自单槽位、要按比例重裁)。 // · 通用附件是 `stage_token[]` —— 🔴 v24 第 21 批 第 4 条:
// 写死 `stage[]` 会让海报存成 1:1、页面上变形(见 _stage_drop.php 的说明)。 // 它原来叫 `stage[]`,与行程表单里那个 `stage`(plan/actual)
hid.name = box.getAttribute('data-stage-field') || 'stage[]'; // 同名 → PHP 把 `$_POST['stage']` 合并成**数组** →
// 路由 `(string)` 强转成 "Array" → 静默退回 'plan'
// (症状:拖过图的"实际行程"一保存就变回计划)。
// · 海报/票根是 `stage_poster` / `stage_ticket`(各自单槽位、要按比例重裁),
// 写死通用名会让海报存成 1:1、页面上变形。
// 两条都有详细说明在 templates/_stage_drop.php 的头部。
hid.name = box.getAttribute('data-stage-field') || 'stage_token[]';
hid.value = d.stoken; hid.value = d.stoken;
cell.appendChild(hid); cell.appendChild(hid);
// 撤回这张(令牌与卡片一起去掉;服务端的暂存由 TTL 清) // 撤回这张(令牌与卡片一起去掉;服务端的暂存由 TTL 清)
@@ -607,15 +629,50 @@
return false; return false;
} }
/** 一个 input:既给它自己挂落点,也给"指向它的那张预览图"挂(见 bindZone) */ /**
* 一个 input:既给它自己挂落点,也给"指向它的那张预览图 / 那个上传框"挂(见 bindZone)。
*
* ⚠️ v24 第 21 批 第 7 条:给「点击或拖动进行上传」那个框补**点击**。
* 用户原话:「点击这个框实现点击上传按钮的功能」——
* 框是 <div role=button>,它**不会**自己打开文件选择框(那是 <input>/<label> 的能力),
* 所以必须显式 `input.click()` 转发过去。
* ⚠️ 键盘同样要能操作(Tab 到框 + 回车/空格):只做鼠标的话,
* 键盘用户就完全用不了上传了。弹层/选人组件的既有做法也是这一套。
* ⚠️ 拖动由 bindZone 负责(它同时给了高亮);这里只管"点"。
*/
function bindBox(box, input) {
if (!box || box.getAttribute('data-sd-click') === '1') { return; }
box.setAttribute('data-sd-click', '1');
box.addEventListener('click', function (ev) {
// ⚠️ 点暂存清单里的「撤掉这张」时不要又弹一次文件选择框
// (那个 × 按钮在框**外面**,但保险起见还是挡一道 —— 结构将来可能挪)。
if (ev.target && ev.target.closest && ev.target.closest('button')) { return; }
input.click();
});
box.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'Spacebar') {
ev.preventDefault();
input.click();
}
});
}
/** 找"属于这个 input 的上传框/预览图",逐个挂上 */
function bindInput(input) { function bindInput(input) {
bindZone(input, input); bindZone(input, input);
var sel = input.id ? ('[data-pick-input="#' + input.id + '"]') : ''; var sel = input.id ? ('[data-pick-input="#' + input.id + '"]') : '';
if (sel) { if (sel) {
Array.prototype.forEach.call(document.querySelectorAll(sel), function (pv) { Array.prototype.forEach.call(document.querySelectorAll(sel), function (pv) {
bindZone(pv, input); bindZone(pv, input);
bindBox(pv, input);
}); });
} }
// 新结构:框自己声明 data-sd-for 指向这个 input
if (input.id) {
Array.prototype.forEach.call(
document.querySelectorAll('[data-drop-stage][data-sd-for="' + input.id + '"]'),
function (bx) { bindZone(bx, input); bindBox(bx, input); });
}
} }
function scan(scope) { function scan(scope) {
@@ -1274,11 +1331,22 @@
var host = document.createElement('span'); var host = document.createElement('span');
host.className = 'dt-semi dt-semi-range'; host.className = 'dt-semi dt-semi-range';
// ⚠️ 插到**第一个原生控件之前**,不能插到 group 的最前面。 /* 🔴 v24 第 21 批 第 7 条:这里原来写的是 `group.insertBefore(host, a)` ——
// 因为 group 就是那个 `.field`,`<label>` 也是它的子节点: **两端各自被 `.field` 包着**时 `a` 并不是 `group` 的直接子节点,
// 用 group.firstChild 会把控件塞到 label **上面**(第一版就是这么写的), insertBefore 会直接抛
// 用户看到的现象正是"开始/结束时间这几个字跑到日期控件下面去了"。 "Failed to execute 'insertBefore' on 'Node': The node before which the new
group.insertBefore(host, a); node is to be inserted is not a child of this node."
这个异常以前**从没被触发过**,因为上面那道"两端都得可见"的判断总是先 return
(结束那一端默认 hidden)→ 恰好把这一行挡住了。
一旦把两端都放出来,它就立刻炸:整个升级流程中断 → 范围控件永远挂不上
→ 用户看到一片空白。所以修"时间范围无法使用"必须**同时**修这里。
插到"第一个端点最外层包装"之前(包装拿不到就退回插在 a 前面),
这样控件出现在标签下方、两个端点位置,视觉顺序是 标签 → 控件。 */
var wrapA = (a.closest ? a.closest('.field') : null) || a;
var insertTarget = (wrapA !== a && group.contains(wrapA)) ? wrapA : a;
if (insertTarget.parentNode !== group) { insertTarget = a; }
group.insertBefore(host, insertTarget);
var fmt = 'yyyy-MM-dd HH:mm' + (secs ? ':ss' : ''); var fmt = 'yyyy-MM-dd HH:mm' + (secs ? ':ss' : '');
@@ -1314,8 +1382,13 @@
} }
group.__fbRemount = mountRange; group.__fbRemount = mountRange;
a.style.display = 'none'; /* ⚠️ 隐藏的是**端点最外层的包装**(那个 `.field`),不是里层的 `.dt-input`:
b.style.display = 'none'; 只藏里层的话,两端的 `<label>` 还留在页面上,会出现
"标签写着一套、下面只有一个控件"的错位。 */
var hideA = (a.closest ? a.closest('.field') : null) || a;
var hideB = (b.closest ? b.closest('.field') : null) || b;
hideA.style.display = 'none';
hideB.style.display = 'none';
// ⚠️ 范围里的两端同样要摘 required(隐藏的 required 会让整个表单提交不了) // ⚠️ 范围里的两端同样要摘 required(隐藏的 required 会让整个表单提交不了)
guardRequired(a); guardRequired(a);
guardRequired(b); guardRequired(b);
@@ -1505,7 +1578,19 @@
function (bx) { upgrade(bx); }); function (bx) { upgrade(bx); });
upgradeAllMonths(); upgradeAllMonths();
}, 200); }, 200);
}).observe(document.body, { childList: true, subtree: true }); }).observe(document.body, {
childList: true, subtree: true,
// 🔴 v24 第 21 批 第 7 条:**必须也盯 hidden 属性的变化**。
// upgradeRange() 有一道"两端都得可见"的判断(对隐藏容器里的控件跳过升级)。
// 而日程的「新建」弹框**初始就是 hidden 的** —— 里面那对起止控件在弹框打开前
// 永远不可见,于是范围控件**永远挂不上**:用户打开弹框看到的是一片空白
// (CSS 又把未升级的原生控件设成 visibility:hidden),点哪儿都没反应。
// 这正是"时间范围选择器功能无法使用"的真根因。
// 加上 attributeFilter 之后:弹框一被打开(hidden 被摘掉)→ 观察器触发 →
// 重新扫一遍 → 范围控件就挂上了。**通用**:任何"藏在容器里、稍后才露出来"
// 的日期控件(弹框、折叠面板、tab)都会自动升级,不必逐个写钩子。
attributes: true, attributeFilter: ['hidden'],
});
} }
/** /**
+167 -20
View File
@@ -287,7 +287,18 @@ function attach_save_files(int $familyId, string $type, int $targetId, string $f
// 保存时(这里)才真正认领 —— 于是"立刻上传"与"最后统一保存"两件事都成立。 // 保存时(这里)才真正认领 —— 于是"立刻上传"与"最后统一保存"两件事都成立。
// ⚠️ 认领放在临时文件之前:两者最终都进同一张附件表、按 position 顺序排, // ⚠️ 认领放在临时文件之前:两者最终都进同一张附件表、按 position 顺序排,
// 先认领的在前(也就是"拖动加进来的先出现"),符合操作顺序。 // 先认领的在前(也就是"拖动加进来的先出现"),符合操作顺序。
$staged = (array)($_POST['stage'] ?? []); //
// 🔴 v24 第 21 批 第 4 条:字段名从 `stage` 改成 **`stage_token`**。
// 原来叫 `stage` 时,它和行程表单里那个隐藏的 `stage`(plan/actual)
// **同名** —— PHP 解析表单会把两者合并进同一个 `$_POST['stage']`,
// 而且因为上传那侧带 `[]`(追加记号),合并结果是**数组**。
// 于是 `trips/node_update` 里 `(string)($_POST['stage'] ?? 'plan')`
// 拿到 "Array" → 不在 ['plan','actual'] 里 → 静默退回 'plan'。
// 症状:拖过图片的"实际行程"一保存就变回计划行程(不拖图时正常 → 时好时坏)。
// ⚠️ 改字段名是唯一正确的修法:同名合并是 PHP 的表单语义,
// 在路由里加"是数组就取第一个"只会把这个坑留给下一次加字段。
// 详细来龙去脉见 templates/_stage_drop.php 头部。
$staged = (array)($_POST['stage_token'] ?? []);
$stageWarns = []; $stageWarns = [];
if ($staged) { if ($staged) {
$res = \App\StagedUpload::claim($familyId, $uploaderId, $staged, $type, $targetId); $res = \App\StagedUpload::claim($familyId, $uploaderId, $staged, $type, $targetId);
@@ -1793,21 +1804,53 @@ switch ($r) {
csrf_check(); csrf_check();
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
$p = Person::find($id); $p = Person::find($id);
if ($p && (int)$p['family_id'] === (int)Auth::familyId()) { // ---- v24 第 21 批 第 1 条:名册页的删除改成**无刷新**(用户原话:
// 「删除一个联系人后,能否不刷新页面,删除成功后,直接把这个人,
// 从当前页面的 dom 中删除,页面保持不动。」)
//
// ⚠️ 同一个路由要同时服务两种调用者,所以**必须认 X-Requested-With**:
// · 名册页的 JS 带它 → 回 JSON,前端自己摘卡片;
// · 详情页底部那个删除**不带**(原生表单)→ 照旧 302 回名册。
// 判据不能用"有没有 POST 参数"之类的猜测 —— 那两处的参数是一样的。
//
// ⚠️ 每一条**失败**也要回 JSON(而且 msg 要说清为什么):
// 前端唯一的退路是"退回原生提交",而退回意味着刷页 ——
// "不能删自己"这种可预期的拒绝根本不该刷页。
$ajax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest')
|| (strpos((string)($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json') !== false);
$jsonOut = static function (bool $ok, string $msg, array $extra = []) use ($ajax) {
if (!$ajax) {
flash($ok ? 'ok' : 'err', $msg);
return false;
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode(array_merge(['ok' => $ok, 'msg' => $msg], $extra),
JSON_UNESCAPED_UNICODE);
exit;
};
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
// 原本这里是静默 redirect("点了没反应");现在明确回一句
if ($jsonOut(false, '找不到这个人,可能已经被删掉了。')) { }
redirect('people');
}
if ((int)$p['id'] === (int)Auth::personId()) { if ((int)$p['id'] === (int)Auth::personId()) {
// 这条原本是 redirect 到**详情页**(用的人自己);AJAX 时不该跳页
if ($jsonOut(false, '不能把自己从名册里移除。')) { }
flash('err', '不能把自己从名册里移除。'); flash('err', '不能把自己从名册里移除。');
redirect('people/show', ['id' => $id]); redirect('people/show', ['id' => $id]);
} }
// 不让最后一个管理员消失:系统里没人能管理家庭就锁死了 // 不让最后一个管理员消失:系统里没人能管理家庭就锁死了
$acc = Account::findByPerson($id); $acc = Account::findByPerson($id);
if ($acc && $acc['role'] === 'admin' && Account::adminCount() <= 1) { if ($acc && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
if ($jsonOut(false, '这是唯一的管理员,先把另一个人设为管理员再移除。')) { }
flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。'); flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。');
redirect('people/show', ['id' => $id]); redirect('people/show', ['id' => $id]);
} }
Upload::remove((int)Auth::familyId(), (string)$p['avatar']); Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
$gone = (string)$p['name'];
Person::delete($id); Person::delete($id);
flash('ok', '已从名册移除「' . $p['name'] . '」。'); if ($jsonOut(true, '已删除「' . $gone . '」。', ['id' => $id])) { }
} flash('ok', '已从名册移除「' . $gone . '」。');
redirect('people'); redirect('people');
break; break;
@@ -3281,6 +3324,35 @@ switch ($r) {
} }
$isEdit = $r === 'trips/node_update'; $isEdit = $r === 'trips/node_update';
$nid = $isEdit ? (int)($_POST['id'] ?? 0) : 0; $nid = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
/* ---- v24 第 21 批 第 4 条:**编辑行程绝不能悄悄改阶段** ----
用户原话:「旅程模块,实际行程编辑后,点击保存,竟然又变成计划行程了,
这个不合理,实际行程永远是实际行程,不会再变回计划行程了。」
这条违规的直接原因(`stage[]` 与 `stage` 同名合并 → 解析成 "Array")
已经通过改字段名修掉了(见 `_stage_drop.php` 头部)。
但这里再上一道**语义保险**:阶段只由「新建」和详情页那三个显式动作
(完成 / 标记未成行 / 标记为实际 / 恢复为计划)决定 ——
**编辑表单里根本没有让你改阶段的控件**,所以:
· 新建:认表单里那个隐藏 `stage`(值由「+ 计划行程 / + 实际行程」按钮决定,
服务端在 trips/node_new 里按 `?stage=` 渲染进去);
· 编辑:**一律沿用库里那一段自己的 stage**,不读表单。
这样即便将来又冒出某个字段名冲突、或有人往表单里加了个 `stage`,
也不可能把「实际」悄悄变回「计划」—— 那是最不该发生的静默数据损坏。
⚠️ 想改阶段请走详情页的显式按钮(它们各自有独立路由,语义清楚、可确认)。
⚠️ 新建这里的取值用 `!is_array` 兜一道:**同一个坑的通用防线** ——
`stage` 一旦因为任何原因变成数组(比如将来又有个 `stage[]`),
`(string)` 会得到 "Array" → `in_array` 为假 → 静默变成「计划」。
这里明确只接受标量,别的一律不认(宁可退回默认,也不把脏值写进库)。 */
$stageForSave = 'plan';
if ($isEdit) {
$curNode = Trip::node($nid);
$stageForSave = (string)($curNode['stage'] ?? 'plan');
} else {
$pick = $_POST['stage'] ?? 'plan';
$stageForSave = is_array($pick) ? 'plan' : (string)$pick;
}
// v24 第 16 批:表单照 TripLog 的节点,字段全量(用户第 7 条)。 // v24 第 16 批:表单照 TripLog 的节点,字段全量(用户第 7 条)。
// ⚠️ 第 17 批 用户第 3 条把「角色标注」去掉了("这个功能去掉,不要了"), // ⚠️ 第 17 批 用户第 3 条把「角色标注」去掉了("这个功能去掉,不要了"),
// 所以这里**不再读** roles;座位/登机口也不再从表单收(字段已从界面移除, // 所以这里**不再读** roles;座位/登机口也不再从表单收(字段已从界面移除,
@@ -3294,8 +3366,8 @@ switch ($r) {
'node_type' => (string)($_POST['node_type'] ?? 'other'), 'node_type' => (string)($_POST['node_type'] ?? 'other'),
'status' => (string)($_POST['status'] ?? 'planned'), 'status' => (string)($_POST['status'] ?? 'planned'),
'category' => (string)($_POST['category'] ?? 'plan'), 'category' => (string)($_POST['category'] ?? 'plan'),
// 新建时用户在表单里选的「计划 / 实际」;编辑时表单带的是隐藏 stage // 新建:认表单里选的「计划 / 实际」;**编辑:一律沿用库里的**(见上面那段说明)
'stage' => (string)($_POST['stage_pick'] ?? $_POST['stage'] ?? 'plan'), 'stage' => $stageForSave,
'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId), 'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId),
// ---- v21:交通(TripLog 同款)---- // ---- v21:交通(TripLog 同款)----
'travel_mode' => (string)($_POST['travel_mode'] ?? ''), 'travel_mode' => (string)($_POST['travel_mode'] ?? ''),
@@ -3938,6 +4010,23 @@ switch ($r) {
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
$me = (int)Auth::personId(); $me = (int)Auth::personId();
Auth::requireWrite(); Auth::requireWrite();
/* 🔴 v24 第 21 批 第 7 条:日历新建日程改成**无刷新**(用户原话:
「添加完成后,动态修改当前视图,不要刷新。」)。
同一个路由要同时服务两种调用者 → 必须认 X-Requested-With:
· 页面里的 JS 带它 → 回 JSON(含新日程的颜色/时间/链接,前端自己插进日历);
· 无 JS 的原生表单 → 照旧 302 回日历(渐进增强的兜底不变)。
⚠️ 判据不能用"有没有某个参数"之类的猜测 —— 两条路的参数完全一样。 */
$ajax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest')
|| (strpos((string)($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json') !== false);
$jsonFail = static function (string $msg) use ($ajax, &$ym) {
if ($ajax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'msg' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', $msg);
redirect('calendar', ['ym' => $ym]);
};
csrf_check(); csrf_check();
$ym = trim((string)($_POST['ym'] ?? '')); $ym = trim((string)($_POST['ym'] ?? ''));
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m'); $ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
@@ -3949,25 +4038,21 @@ switch ($r) {
// kind='custom' 取 calendar_events),却会在库里留下一堆"看不见的日程"。 // kind='custom' 取 calendar_events),却会在库里留下一堆"看不见的日程"。
// 用户的要求是"不可删除",这里顺带把"不可写入"也钉死。 // 用户的要求是"不可删除",这里顺带把"不可写入"也钉死。
if ($cal && Calendar::isReadonlyKind((string)$cal['kind'])) { if ($cal && Calendar::isReadonlyKind((string)$cal['kind'])) {
flash('err', '「' . $cal['name'] . '」是系统日历,内容自动生成,不能往里加日程。'); $jsonFail('「' . $cal['name'] . '」是系统日历,内容自动生成,不能往里加日程。');
redirect('calendar', ['view' => 'month', 'ym' => $ym]);
} }
if (!$cal || !Calendar::canWrite($cal, $me)) { if (!$cal || !Calendar::canWrite($cal, $me)) {
flash('err', '这本日历你没有写权限。'); $jsonFail('这本日历你没有写权限。');
redirect('calendar', ['ym' => $ym]);
} }
// v24 第 5、6 条:起止各是一个「日期+时分秒」的完整时刻(_datetime.php 控件), // v24 第 5、6 条:起止各是一个「日期+时分秒」的完整时刻(_datetime.php 控件),
// 不再拆成 date / end_date / start_time / end_time 四格。 // 不再拆成 date / end_date / start_time / end_time 四格。
$startsAt = read_dt_input('starts_at'); $startsAt = read_dt_input('starts_at');
if ($startsAt === null) { if ($startsAt === null) {
flash('err', '开始时间不对。'); $jsonFail('开始时间不对。');
redirect('calendar', ['ym' => $ym]);
} }
$endsAt = read_dt_input('ends_at'); $endsAt = read_dt_input('ends_at');
$title = trim((string)($_POST['title'] ?? '')); $title = trim((string)($_POST['title'] ?? ''));
if ($title === '') { if ($title === '') {
flash('err', '日程得有个标题。'); $jsonFail('日程得有个标题。');
redirect('calendar', ['ym' => $ym]);
} }
$allDay = !empty($_POST['all_day']); $allDay = !empty($_POST['all_day']);
if ($allDay) { if ($allDay) {
@@ -3980,21 +4065,55 @@ switch ($r) {
// ⚠️ 观影 / 旅行两本是派生的:往这两本里加日程 = 建一条真正的观影 / 旅程记录, // ⚠️ 观影 / 旅行两本是派生的:往这两本里加日程 = 建一条真正的观影 / 旅程记录,
// 而不是往 calendar_events 里塞一行(那样日历里有、观影列表里没有)。 // 而不是往 calendar_events 里塞一行(那样日历里有、观影列表里没有)。
$kind = (string)$cal['kind']; $kind = (string)$cal['kind'];
/* 🔴 v24 第 21 批 第 7 条:观影 / 旅行两本是**可写**的 —— 往它们里面"加一条"
等于建一条真正的观影 / 旅程记录(只往 calendar_events 里塞一行的话,
日历里有、观影列表里没有)。
⚠️ 这两段原来只 `redirect()`,AJAX 时会把 fetch 带着跳走:
客户端拿到的是一整页 HTML、`r.json()` 解析失败 → 退回原生提交 →
又整页刷新一次。所以这里也要**回 JSON**,让前端照旧"插进当前视图"。
⚠️ AJAX 时**不再自动跳到观影/旅程详情页** —— 用户的原话是
"添加完成后,动态修改当前视图,不要刷新",跳走就等于离开了日历。
改在提示行里给一个可点的链接(想看详情随手点,不点也不打扰)。 */
if ($kind === 'movie' || $kind === 'trip') {
if ($kind === 'movie') { if ($kind === 'movie') {
$newId = Movie::create($familyId, $me, [ $newId = Movie::create($familyId, $me, [
'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note, 'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
'attendees' => [$me], 'attendees' => [$me],
]); ]);
flash('ok', '已加进观影,日历里也能看到了。'); $okMsg = '已加进观影,日历里也能看到了。';
redirect('movies/show', ['id' => $newId]); $okHref = url('movies/show', ['id' => $newId]);
} } else {
if ($kind === 'trip') {
$newId = Trip::create($familyId, $me, [ $newId = Trip::create($familyId, $me, [
'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note, 'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
'attendees' => [$me], 'attendees' => [$me],
]); ]);
flash('ok', '已加进旅程,日历里也能看到了。'); $okMsg = '已加进旅程,日历里也能看到了。';
redirect('trips/show', ['id' => $newId]); $okHref = url('trips/show', ['id' => $newId]);
}
if ($ajax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'msg' => $okMsg,
'goto' => $okHref, /* 前端只用来在提示行里放一个链接,不自动跳 */
'event' => [
'id' => (int)$newId,
'title' => $title,
'starts_at' => (string)$startsAt,
'ends_at' => (string)($endsAt ?? ''),
'all_day' => $allDay ? 1 : 0,
'cal' => (int)$cal['id'],
'color' => (string)($cal['color'] ?? ''),
'time' => ($allDay || $startsAt === '') ? '' : substr((string)$startsAt, 11, 5),
'href' => $okHref,
],
], JSON_UNESCAPED_UNICODE);
exit;
}
flash('ok', $okMsg);
redirect($kind === 'movie' ? 'movies/show' : 'trips/show', ['id' => $newId]);
} }
$evId = Calendar::addEvent((int)$cal['id'], [ $evId = Calendar::addEvent((int)$cal['id'], [
'title' => $title, 'title' => $title,
@@ -4015,6 +4134,34 @@ switch ($r) {
'created_by' => $me, 'created_by' => $me,
]); ]);
$warns = attach_save_files($familyId, Calendar::ATTACH, $evId, 'attach', $me); $warns = attach_save_files($familyId, Calendar::ATTACH, $evId, 'attach', $me);
/* v24 第 21 批 第 7 条:AJAX 时回**够前端把它插进当前视图**的那几个字段。
⚠️ 字段必须与模板渲染那一行**完全一致**,否则"插进去的样子"与
"刷新一次之后的样子"会不同:
· time —— 月视图小条前面的 `<b>HH:MM</b>`(全天不显示);
· color —— 小条/时间块的底色(来自日历本,这正是用户要的"带上颜色");
· href —— 点它打开详情(与 Calendar::month 给的是同一个路由);
· cal —— data-cal,前端/样式按它分组。 */
if ($ajax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'msg' => '日程已加上。',
'warns' => $warns,
'event' => [
'id' => (int)$evId,
'title' => $title,
'starts_at' => (string)$startsAt,
'ends_at' => (string)($endsAt ?? ''),
'all_day' => $allDay ? 1 : 0,
'cal' => (int)$cal['id'],
'color' => (string)($cal['color'] ?? ''),
'time' => ($allDay || $startsAt === '') ? '' : substr((string)$startsAt, 11, 5),
'href' => url('calendar/event', ['id' => (int)$evId, 'd' => substr((string)$startsAt, 0, 10)]),
],
], JSON_UNESCAPED_UNICODE);
exit;
}
if ($warns) { if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns)); flash('err', '有附件没有保存:' . implode(';', $warns));
} }
+102 -30
View File
@@ -1,46 +1,118 @@
<?php <?php
/** /**
* 拖动即时上传的**落点 + 暂存清单**(v24 第 20 批 用户第 4 条)。 * 上传落点:「**点击或拖动进行上传**」的框 + 暂存清单 + 真正的文件框。
* *
* 用户原话:「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传, * ────────────────────────────────────────────────────────────────
* 拖动一个进去,就开始上传,还可以继续拖动,继续上传,最后统一保存即可。」 * v24 第 21 批 第 7 条(用户原话,两条一起看):
* 「所有上传图片等附件的地方,都不再放按钮,而是放一个框,点击这个框实现点击
* 上传按钮的功能,拖动图片进这个框里,实现拖动多个上传功能。框里面写一句话:
* 点击或拖动进行上传,其他的功能和现在保持一致。」
* (第 20 批的原话是「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,
* 拖动一个进去,就开始上传,还可以继续拖动,继续上传,最后统一保存即可。」)
* *
* 这个片段只负责**画出落点**(一个虚线框 + 一个空清单容器), * → 所以这个片段现在**同时负责三件事**,调用方只 require 一行:
* 真正的行为在 assets/js/app.js 的 `queueDrop()` 里: * ① 真正的 `<input type="file">`(sr-only,视觉上不存在,但表单里必须在);
* 拖进来 → 立刻 POST 到 upload/stage → 回来把 `stoken` 塞进隐藏字段 `stage[]`。 * ② 那个看得见的框(点击 = 触发①、拖文件进去 = 直接上传);
* ③ 暂存清单(拖进来的文件立刻上传后,在这里显示缩略图/状态)。
* *
* ⚠️ 为什么"落点"要和"文件框"分开: * ⚠️ 为什么把 `<input>` 也收进片段里(第 21 批的重构):
* 文件框(`<input type=file>`)仍然保留(点它一样能选文件), * 以前是「模板先写一个可见的 `<input class="input">`,片段再画一个额外的虚线框」——
* 拖动只是**多一种**输入方式;而且"拖到哪一块"需要一块**看得见**的区域 —— * 同一个地方出现**两个**上传入口(一个原生文件框 + 一个拖动框),
* 单靠文件框本身,用户不知道该往哪儿拖。 * 用户看到的就是"又让我选文件、又让我往框里拖,到底点哪个"。
* 现在输入框整个藏起来,**只有那个框**是入口 —— 这正是用户要的
* 「都不再放按钮,而是放一个框」。
* *
* ⚠️ 落点必须能被 app.js 找回来:`data-drop-stage` 挂在 `<div>` 上, * ⚠️ `.sr-only` 而不是 `display:none`:
* app.js 从「被拖的文件框」`input.form.querySelector('[data-drop-stage]')` 找它。 * `display:none` 的控件在表单校验时**不可聚焦**,带 `required` 会让 Chrome
* 所以**每个上传区都要有一个文件框**(哪怕隐藏)—— 这符合现状(都有)。 * 报 "An invalid form control is not focusable" —— 页面上什么都不发生
* (点了就没反应,也不弹提示)。`.sr-only` 是 1px 的可见元素,仍可聚焦。
* (时间控件那边为同一件事专门写了 guardRequired(),见 app.js。)
* *
* 用法(require 前设好,都可选): * ⚠️ 拖 / 点两条路都通到**同一个 input**:拖是 app.js 直接把 File 写进 input.files
* $sdHint 提示文案 * (老式表单提交路径),或走暂存上传(有 `data-drop-stage` 时的新式路径);
* 点是 `box.click() → input.click()`。所以服务端只认那一个字段名,零分支。
*
* 用法(require 前设好;除 $sdName 外都可选):
* $sdName **文件字段名**(如 'photo[]' / 'poster' / 'attach[]')—— 必填
* $sdId 文件框的 id(供 <label for> 与测试定位;不传就用 $sdName 净化后的值)
* $sdSlot 图片槽位键('poster' / 'ticket' / 'cover' / 'photo'…;决定图片怎么裁) * $sdSlot 图片槽位键('poster' / 'ticket' / 'cover' / 'photo'…;决定图片怎么裁)
* $sdMulti 是否允许多次/多个(默认 true) * $sdAccept accept 提示(如 'image/*')
* $sdAccept accept 提示(只影响选择框,不影响拖动) * $sdMultiple 允许多选(默认 true)
* $sdRequired 必填(默认 false;只有"专门用来补传附件"那种单一用途表单才需要)
* $sdLabel 给屏幕阅读器/测试用的说明(默认「点击或拖动进行上传」)
* $sdMsg 框里那句话(默认同上;用户点名要这句)
*/ */
$sdHint = (string)($sdHint ?? '也可以把文件直接拖到这块虚线区域里 —— 拖一个就开始上传,可以接着再拖,最后点「保存」一起记下。'); $sdName = (string)($sdName ?? '');
$sdSlot = (string)($sdSlot ?? ''); $sdSlot = (string)($sdSlot ?? '');
$sdMulti = $sdMulti ?? true;
$sdAccept = (string)($sdAccept ?? ''); $sdAccept = (string)($sdAccept ?? '');
/* ⚠️ **海报 / 票根走另一条路**:它们各自只有一个槽位、且比例不同 $sdMultiple = $sdMultiple ?? true;
(海报 2:3、票根 free)。如果也塞进 `stage[]` 一起认领, $sdRequired = (bool)($sdRequired ?? false);
服务端就只能统一按 'photo' 处理 —— 海报会被存成不是 2:3 的图, /* 用户点名要的那句话(第 7 条)。可覆盖,但默认就是它。 */
页面上就变形了。所以这两个用**独立的字段名**把令牌带回去, $sdMsg = (string)($sdMsg ?? '点击或拖动进行上传');
由 movies 路由按槽位重跑 Upload::image(见 index.php 里那一段)。 /* id:不传就按字段名净化一个(photo[] → sd-photo)。必须是**合法且唯一**的 id,
其余所有附件类上传(照片/资源/任务/日历)都走通用的 `stage[]`。 */ 否则 <label for> 指不到、测试也定位不到。 */
$sdField = in_array($sdSlot, ['poster', 'ticket'], true) ? ('stage_' . $sdSlot) : 'stage[]'; $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) ?>" <?php /* ① 真正的文件框:sr-only(见头部的说明,不能用 display:none)。
<?= $sdSlot !== '' ? 'data-stage-slot="' . e($sdSlot) . '"' : '' ?>> ⚠️ `data-sd-input` 是给 JS 找它的标记 —— 框上的点击要转发到它这里。 */ ?>
<span class="stage-drop-tip" data-stage-tip><?= e($sdHint) ?></span> <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> <div class="stage-list" data-stage-list hidden></div>
<span class="stage-drop-tip" data-stage-tip hidden></span>
</div> </div>
<?php <?php
// ⚠️ 只 unset 本片段自己的变量(与 _datetime.php / _loc_field.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> <br><span class="tip">再选一个文件会替换它,并按新文件重新解析正文。</span>
</p> </p>
<?php endif; ?> <?php endif; ?>
<input class="input" type="file" id="bk-file" name="bookfile" <?php $sdName = 'bookfile'; $sdId = 'bk-file';
accept=".epub,.txt,.pdf,.mobi,.azw3,.azw,.doc,.docx,application/epub+zip,text/plain,application/pdf"> $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"> <span class="tip">
EPUB 与 TXT 会自动读出书名 / 作者 / 简介 / 封面,并把正文拆成章节(可以直接在线读)。 EPUB 与 TXT 会自动读出书名 / 作者 / 简介 / 封面,并把正文拆成章节(可以直接在线读)。
PDF 只存文件(用浏览器自带的阅读器看)。MOBI / AZW3 存文件、正文解析不了。 PDF 只存文件(用浏览器自带的阅读器看)。MOBI / AZW3 存文件、正文解析不了。
+38 -83
View File
@@ -59,31 +59,43 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
不再拆成"日期一格 + 时刻一格"。两个控件长得一样,扫一眼就知道哪个是开始。 */ ?> 不再拆成"日期一格 + 时刻一格"。两个控件长得一样,扫一眼就知道哪个是开始。 */ ?>
<?php /* 用户 2026-10-04 第 7 条:日程的起止也是一个**范围** —— <?php /* 用户 2026-10-04 第 7 条:日程的起止也是一个**范围** ——
升级后由一个 Semi dateTimeRange 一次选完。服务端仍收两组 *_date/_hour/… 字段。 */ ?> 升级后由一个 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="dt-range">
<div class="field ev-dt" data-dt> <?php /* ⚠️ 标签只有**一个**,挂在范围容器上(不是各挂各的):
<label>开始 *</label> 升级成一个范围控件之后,"开始"与"结束"就是同一个控件的两个端点,
两个标签会变成"看着像两个字段、其实是一个"的错觉。 */ ?>
<span class="dt-range-label">开始 / 结束 *</span>
<div class="field ev-dt" data-dt>
<?php $dtName = 'starts_at'; $dtId = 'ev-start'; <?php $dtName = 'starts_at'; $dtId = 'ev-start';
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true; $dtSeconds = true; $dtValue = $evF['starts_at'] ?? null; $dtRequired = true; $dtSeconds = true;
$dtRange = 'start'; $dtRange = 'start';
require __DIR__ . '/../_datetime.php'; ?> require __DIR__ . '/../_datetime.php'; ?>
</div> </div>
<?php /* 用户 2026-10-03 第 6 条:「添加结束日期与时间」—— <div class="field ev-dt" data-dt id="ev-end-field">
结束是可选的:没填就表示"就一个时刻/就这一天",填了才画成一段。
所以给一个显式开关,而不是让人对着空控件猜"不填行不行"。 */ ?>
<?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'; <?php $dtName = 'ends_at'; $dtId = 'ev-end';
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true; $dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true;
$dtRange = 'end'; $dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?> require __DIR__ . '/../_datetime.php'; ?>
</div> </div>
</div><?php /* /dt-range */ ?> </div><?php /* /dt-range */ ?>
<div class="field" id="ev-end-add"> <span class="field-hint" style="display:block;margin:-4px 0 10px">
<label>结束时间</label> 结束留空就表示没有明确的结束(只占开始那一刻/那一天)。
<button type="button" class="btn btn-ghost btn-sm" id="ev-end-add-btn">+ 添加结束时间</button> </span>
<span class="field-hint">不填就表示没有明确的结束(只占开始那一刻/那一天)</span>
</div>
<div class="field"> <div class="field">
<label> <label>
@@ -154,8 +166,12 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
</div> </div>
<div class="field full"> <div class="field full">
<label for="ev-attach">附件(可多选)</label> <label>附件(可多选)</label>
<input class="input" type="file" id="ev-attach" name="attach[]" multiple> <?php /* v24 第 21 批 第 7 条:统一成「点击或拖动进行上传」的框。
⚠️ 文件框的 id 保持 `ev-attach`(原来就叫这个)—— e2e 与部署断言里
有按 id 定位的写法,改名会把它们静默打成假红。 */
$sdName = 'attach[]'; $sdId = 'ev-attach'; $sdSlot = 'photo';
require __DIR__ . '/../_stage_drop.php'; ?>
</div> </div>
<script> <script>
@@ -172,72 +188,11 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
box.addEventListener('change', sync); box.addEventListener('change', sync);
sync(); sync();
/* ---- 用户第 6 条:结束时间 ---- /* ⚠️ v24 第 21 批 第 7 条:原来这里有一整段「结束时间显隐 + 跟着开始时间走」的 JS。
① 「+ 添加结束时间」把结束控件放出来; 现在起止两端**始终可见**,并且由 Semi 的范围控件统一接管(一次选完两端),
② 结束时间**默认跟着开始时间走**:改了开始,若结束还没填(或原本等于旧开始), 所以那段"同步两端"的逻辑整个删掉 —— 留着不但没用,还会和范围控件抢着改那两个
就把结束同步过去 —— 常见用法是"改个开始,结束忘了改",结果时长莫名其妙。 原生字段(两处都写 → 值互相覆盖)。范围控件选完会自己把两端分别写回原生字段
但只要用户**自己动过结束**,就不再自动同步(别抢用户的输入)。 */ (见 app.js 的 onRange → writeDT),服务端契约不变。 */
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' ?>);
/* 重复:选了周期才显示"结束条件";结束条件选"次数"才显示次数框、选"到某天"才显示日期框。 /* 重复:选了周期才显示"结束条件";结束条件选"次数"才显示次数框、选"到某天"才显示日期框。
三个框默认都 hidden —— 不重复时一个都不该出现(少即是清楚)。 */ 三个框默认都 hidden —— 不重复时一个都不该出现(少即是清楚)。 */
+3 -1
View File
@@ -187,7 +187,9 @@ $canWrite = \App\Auth::canWrite();
</div> </div>
<div class="field"> <div class="field">
<label for="im-file">.ics 文件 *</label> <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> </div>
<div class="btn-row"> <div class="btn-row">
+320 -22
View File
@@ -130,23 +130,77 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
用户第 4 条:左边那一栏「我的日历本」已经撤掉(功能搬进右上角 ☰), 用户第 4 条:左边那一栏「我的日历本」已经撤掉(功能搬进右上角 ☰),
所以这里直接就是主区域,`.cal-wrap-full` 把网格调成"只有一列"。 */ ?> 所以这里直接就是主区域,`.cal-wrap-full` 把网格调成"只有一列"。 */ ?>
<div class="cal-main"> <div class="cal-main">
<?php /* 用户第 5 条:这张表单既服务「新建日程」按钮,也服务「点日历空白处」—— <?php /* 🔴 v24 第 21 批 第 7 条:新建日程改成**弹框**(用户原话:
后者由 JS 现填开始时间,所以表单本身不再写死 09:00。 */ ?> 「日历中新建日程,无论是点击按钮还是点击日历中间,都是采用弹框的形式进行添加,
这样不影响当前日历的显示,添加完成后,动态修改当前视图,不要刷新。」
⚠️ 原来是**内联卡片**(`#calAddCard`,就长在日历网格的正上方,展开时把整个
月视图往下推)—— 用户点一下"新建",正在看的日历就被顶走了,
填完还得自己找回来。这正是他说的"影响当前日历的显示"。
⚠️ 改成弹框用**既有的浮层形态**(与选人弹窗、名册动作框同一套 `.upfield`
不是,是 `.rm-modal`/`.crop-overlay` 那一路视觉:遮罩 + 居中面板),
不引第三方库 —— 原生 `<dialog>` 在部分旧 WebView 上行为不一致,
而且我们已有的浮层已经解决了"点遮罩关、Esc 关、滚动锁"这几件事。
⚠️ 表单本身**一个字没动**(字段名/服务端契约/CSRF 全照旧),
只把它的容器从"页面上的一格"换成"浮层里的一块"。
`calendar_id` 那个 select 也保留(第 7 条要求"选择日历本,带上颜色",
见下面给每个 option 加的色块)。 */ ?>
<?php if ($canWrite && $writable): ?> <?php if ($canWrite && $writable): ?>
<div class="card card-pad cal-add" id="calAddCard" hidden> <div class="cal-modal" id="calAddCard" role="dialog" aria-modal="true"
<h2 class="cinema-h2">新建日程</h2> aria-label="新建日程" hidden>
<form method="post" action="<?= e(url('calendar/event_add')) ?>" enctype="multipart/form-data"> <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() ?> <?= csrf_field() ?>
<input type="hidden" name="ym" value="<?= e($ym) ?>"> <input type="hidden" name="ym" value="<?= e($ym) ?>">
<div class="form-grid"> <input type="hidden" name="back_view" value="<?= e($view) ?>">
<div class="field"> <input type="hidden" name="back_anchor" value="<?= e($anchor) ?>">
<div class="cal-modal-body">
<div class="field full">
<label for="ce-cal">放在哪本</label> <label for="ce-cal">放在哪本</label>
<?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"> <select class="select" id="ce-cal" name="calendar_id">
<?php foreach ($writable as $c): ?> <?php foreach ($writable as $c): $ro = \App\Calendar::isReadonlyKind((string)$c['kind']);
<option value="<?= (int)$c['id'] ?>"><?= e((string)$c['name']) ?></option> $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; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
</div>
<?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。 <?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。
新建时「全天」默认**不勾**(用户原话)。开始时间默认: 新建时「全天」默认**不勾**(用户原话)。开始时间默认:
带了 new_at 就用它(点日历的那一格),否则今天 09:00。 */ ?> 带了 new_at 就用它(点日历的那一格),否则今天 09:00。 */ ?>
@@ -163,12 +217,15 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
require __DIR__ . '/_event_fields.php'; require __DIR__ . '/_event_fields.php';
?> ?>
</div> </div>
<div class="btn-row"> </div><?php /* /cal-modal-body */ ?>
<button class="btn btn-sm" type="submit">加上</button> <div class="cal-modal-foot">
<button class="btn btn-ghost btn-sm" type="button" id="calAddCancel">取消</button> <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> </div>
</form> </form>
</div> </div>
</div>
<?php endif; ?> <?php endif; ?>
<?php if ($view === 'week' || $view === 'day'): ?> <?php if ($view === 'week' || $view === 'day'): ?>
@@ -296,15 +353,23 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
(Calendar::hiddenCalendars,见路由里的说明),前端不再需要它。 (Calendar::hiddenCalendars,见路由里的说明),前端不再需要它。
留着会让人以为"改一下勾选就能切换",但那些勾选框已经不在这儿了。 */ 留着会让人以为"改一下勾选就能切换",但那些勾选框已经不在这儿了。 */
var btn = document.getElementById('calAddBtn'); var btn = document.getElementById('calAddBtn');
var card = document.getElementById('calAddCard'); var card = document.getElementById('calAddCard'); /* 现在是一个浮层 */
var cancel = document.getElementById('calAddCancel'); var cancel = document.getElementById('calAddCancel'); /* 头部 ✕ */
var cancel2 = document.getElementById('calAddCancel2'); /* 底部「取消」 */
if (btn && card) { if (btn && card) {
btn.addEventListener('click', function () { btn.addEventListener('click', function () { openNew(null, null, null); });
card.hidden = !card.hidden;
if (!card.hidden) { card.scrollIntoView({ block: 'nearest' }); }
});
} }
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 条:在日历里点一下 → 弹新建,开始时间 = 点的位置 ---- /* ---- 用户第 5 条:在日历里点一下 → 弹新建,开始时间 = 点的位置 ----
月视图:一格 = 一天 → 开始时间用那天 09:00("当日默认"比 00:00 更像个日程)。 月视图:一格 = 一天 → 开始时间用那天 09:00("当日默认"比 00:00 更像个日程)。
@@ -332,12 +397,21 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
if (startSec) { startSec.value = '00'; } if (startSec) { startSec.value = '00'; }
} }
/**
* 打开新建日程的**弹框**(v24 第 21 批 第 7 条)。
*
* ⚠️ 不再 `scrollIntoView` —— 那是"内联卡片"时代为了让用户找到卡片才需要的动作;
* 浮层固定在视口中央,底下的日历**一动不动**(这正是用户要的
* "不影响当前日历的显示")。
* ⚠️ dateStr 传 null 表示"从按钮进来":那时**不要再覆盖**已填的时间
* (用户可能刚填完改主意关掉、又点开;重置会把他填的东西抹掉)。
* 只有"点日历某一格/某一列"才带着具体时间进来,那次才 setStart。
*/
function openNew(dateStr, hh, mm) { function openNew(dateStr, hh, mm) {
if (!card) return; if (!card) return;
card.hidden = false; card.hidden = false;
setStart(dateStr, hh, mm); if (dateStr) { setStart(dateStr, hh, mm); }
card.scrollIntoView({ block: 'nearest' }); // 标题是第一个输入框,直接把光标放过去 —— 打开就能打字
// 标题是第一个输入框,直接把光标放过去 —— 点完就能打字
var t = document.getElementById('ev-title'); var t = document.getElementById('ev-title');
if (t) { t.focus(); } if (t) { t.focus(); }
} }
@@ -376,6 +450,230 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
openNew(parts[0], hm[0] || '09', hm[1] || '00'); 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 里(两个页面共用一份) */ /* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
})(); })();
+18 -15
View File
@@ -202,9 +202,13 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
onclick="return confirm('删掉现在这张海报?')">删除这张</button> onclick="return confirm('删掉现在这张海报?')">删除这张</button>
</div> </div>
<?php endif; ?> <?php endif; ?>
<input class="input" id="poster" name="poster" type="file" accept="image/*"> <?php /* v24 第 21 批 第 7 条:一个「点击或拖动」的框,取代原来的原生文件框。
<span class="tip"><?= $mHasPoster ? '想换就选一张新的(旧的会自动删掉)。' : '还没有海报。' ?></span> 换图仍然靠"选/拖一张新的"(服务端换新图时会顺手删掉旧对象)。 */
<?php $sdSlot = 'poster'; require __DIR__ . '/../_stage_drop.php'; ?> $sdName = 'poster'; $sdSlot = 'poster'; $sdAccept = 'image/*';
$sdMultiple = false;
$sdMsg = $mHasPoster ? '点击或拖动进行上传(换一张海报)' : '点击或拖动进行上传';
require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip"><?= $mHasPoster ? '选/拖一张新的会自动替换旧的那张。' : '还没有海报。' ?></span>
</div> </div>
<?php /* ---- 票根 ---- */ ?> <?php /* ---- 票根 ---- */ ?>
@@ -222,9 +226,11 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
onclick="return confirm('删掉现在这张票根?')">删除这张</button> onclick="return confirm('删掉现在这张票根?')">删除这张</button>
</div> </div>
<?php endif; ?> <?php endif; ?>
<input class="input" id="ticket" name="ticket" type="file" accept="image/*"> <?php $sdName = 'ticket'; $sdSlot = 'ticket'; $sdAccept = 'image/*';
<span class="tip"><?= $mHasTicket ? '想换就选一张新的(旧的会自动删掉)。' : '还没有票根。' ?></span> $sdMultiple = false;
<?php $sdSlot = 'ticket'; require __DIR__ . '/../_stage_drop.php'; ?> $sdMsg = $mHasTicket ? '点击或拖动进行上传(换一张票根)' : '点击或拖动进行上传';
require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip"><?= $mHasTicket ? '选/拖一张新的会自动替换旧的那张。' : '还没有票根。' ?></span>
</div> </div>
</div> </div>
@@ -248,15 +254,12 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($isEdit): ?> <?php /* v24 第 21 批 第 7 条:统一成「点击或拖动进行上传」的框(可多次)。 */ ?>
<input class="input" id="photo" name="photo[]" type="file" accept="image/*" multiple> <?php $sdName = 'photo[]'; $sdSlot = 'photo'; $sdAccept = 'image/*';
<span class="tip">可以继续加(可多选);删掉的在保存后生效。</span> $sdMsg = '点击或拖动进行上传';
<?php $sdSlot = 'photo'; $sdHint = '也可以把文件直接拖到这里 —— 拖一个就开始上传,可以接着再拖,最后点「保存」一起记下。'; require __DIR__ . '/../_stage_drop.php'; ?> require __DIR__ . '/../_stage_drop.php'; ?>
<?php else: ?> <span class="tip"><?= $isEdit ? '可以继续加(可多选);删掉的在保存后生效。'
<input class="input" id="photo" name="photo[]" type="file" accept="image/*" multiple> : '可以多选;之后也能在详情页里逐张补传。' ?></span>
<span class="tip">可以多选;之后也能在详情页里逐张补传。</span>
<?php $sdSlot = 'photo'; $sdHint = '也可以把文件直接拖到这里 —— 拖一个就开始上传,可以接着再拖,最后点「记下这场电影」一起记下。'; require __DIR__ . '/../_stage_drop.php'; ?>
<?php endif; ?>
</div> </div>
<div class="btn-row"> <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"> <form method="post" enctype="multipart/form-data" action="<?= e(url('movies/photo_add')) ?>" class="photo-add">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<label for="photo">补一张现场照片</label> <label>补一张现场照片</label>
<div class="photo-add-row"> <?php /* v24 第 21 批 第 7 条:统一成「点击或拖动进行上传」的框。
<input class="input" id="photo" name="photo" type="file" accept="image/*" required> ⚠️ 文件框 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> <button type="submit" class="btn btn-ghost btn-sm">上传</button>
</div> </div>
</form> </form>
+4 -2
View File
@@ -34,8 +34,10 @@ $canMove = \App\Auth::canWrite();
<div class="form-grid"> <div class="form-grid">
<div class="field full"> <div class="field full">
<label for="imp-file">联系人文件 *</label> <label for="imp-file">联系人文件 *</label>
<input class="input" type="file" id="imp-file" name="contacts" <?php $sdName = 'contacts'; $sdId = 'imp-file';
accept=".vcf,.csv,.txt,text/vcard,text/csv,text/plain" required> $sdAccept = '.vcf,.csv,.txt,text/vcard,text/csv,text/plain';
$sdMultiple = false; $sdRequired = true;
require __DIR__ . '/../_stage_drop.php'; ?>
</div> </div>
<div class="field"> <div class="field">
<label for="imp-group">加进哪一组</label> <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> <section class="group-block<?= $list ? '' : ' is-empty' ?>" data-group="<?= $gid ?>" data-filter-group>
<h2 class="group-head"> <h2 class="group-head">
<span class="group-title"><?= e($name) ?></span> <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> <span class="group-drop-hint">放到这里</span>
</h2> </h2>
<?php $renderGrid($list); ?> <?php $renderGrid($list); ?>
@@ -227,7 +229,11 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
<div class="page-head"> <div class="page-head">
<h1>家庭名册</h1> <h1>家庭名册</h1>
<span class="sub"> <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> </span>
<div class="actions"> <div class="actions">
<?php if ($canMove && $pickable > 0): ?> <?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> <button type="button" class="rm-x" data-rm-close aria-label="关闭">✕</button></div>
<?php /* ⚠️ 路由是 people/destroy(与详情页底部那个删除同一个)—— <?php /* ⚠️ 路由是 people/destroy(与详情页底部那个删除同一个)——
不要写成 people/delete,那会 404 且看起来像"点了没反应"。 */ ?> 不要写成 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() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value=""> <input type="hidden" name="id" value="">
<div class="rm-body"> <div class="rm-body">
@@ -422,6 +429,130 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
syncClear(); 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 重写)==================== <?php /* ==================== 卡片动作弹框的交互(v24 第 11 批 #2 重写)====================
一行只留按钮,真正的表单在 #rosterMenus 里(全页一份,见前面的说明)。 一行只留按钮,真正的表单在 #rosterMenus 里(全页一份,见前面的说明)。
这一版把"贴按钮的浮层"换成**居中模态框**(与选人弹窗同形态)—— 用户明确要求 这一版把"贴按钮的浮层"换成**居中模态框**(与选人弹窗同形态)—— 用户明确要求
+6 -3
View File
@@ -100,8 +100,8 @@ $canWrite = \App\Auth::canWrite();
</div> </div>
<div class="field full"> <div class="field full">
<label for="rs-file">附件(可多选)</label> <label for="rs-file">附件(可多选)</label>
<input class="input" type="file" id="rs-file" name="attach[]" multiple> <?php $sdName = 'attach[]'; $sdSlot = 'photo';
<?php $sdSlot = 'photo'; $sdHint = '也可以把文件直接拖到这里 —— 拖一个就开始上传,可以接着再拖,最后点「保存」/「新建」一起记下。'; require __DIR__ . '/../_stage_drop.php'; ?> require __DIR__ . '/../_stage_drop.php'; ?>
</div> </div>
</div> </div>
<div class="btn-row"> <div class="btn-row">
@@ -188,8 +188,11 @@ $canWrite = \App\Auth::canWrite();
action="<?= e(url('resources/attach_add')) ?>"> action="<?= e(url('resources/attach_add')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $rid ?>"> <input type="hidden" name="id" value="<?= $rid ?>">
<input type="file" name="attach[]" multiple required> <?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> <button class="btn btn-ghost btn-sm" type="submit">补传附件</button>
</div>
</form> </form>
<details class="res-share-add"> <details class="res-share-add">
+16 -12
View File
@@ -49,27 +49,31 @@ $rosterCount = $rosterCount ?? 0;
填坐标是我们要解决的实现细节(App\Geo 内置 197 个地级市离线查), 填坐标是我们要解决的实现细节(App\Geo 内置 197 个地级市离线查),
不该甩给用户。内置表里没有的(镇 / 村 / 海外)才需要填坐标。 */ ?> 不该甩给用户。内置表里没有的(镇 / 村 / 海外)才需要填坐标。 */ ?>
<div class="card card-pad" id="weather" style="margin-top:20px"> <div class="card card-pad" id="weather" style="margin-top:20px">
<h2 class="section-title"> <?php /* v24 第 21 批 第 3 条(用户原话):
天气 · 我所在的城市 「天气设置那里,把:只影响你自己首页上的那一格 / 填个城市名就行(如「青岛」)。
<?php /* v24 第 20 批 第 2 条:这里的 "只影响你自己首页上的那一格" 保留 —— 首页那句问候旁边会显示这儿的天气 —— 每人一份,孩子在外地读书,
用户点名要删的是页头那句副标题,不是这一处。 */ ?> 他填他的、你看你的。/ 留空就是不显示天气(不影响别的东西)。 这些提示,都删除。
<span class="hint">只影响你自己首页上的那一格</span> 把城市单独放一行,精度维度放在一行。」
</h2>
<p class="small muted" style="margin:0 0 16px"> → ① 标题旁那句 hint、标题下那整段说明,**全删**(三处提示);
填个城市名就行(如「青岛」)。首页那句问候旁边会显示这儿的天气 —— ② 版式改成两行:第一行「城市」独占一行(它是要填的那个,得宽敞),
<b>每人一份</b>,孩子在外地读书,他填他的、你看你的。 第二行「纬度 / 经度」并排一行。
<br>留空就是不显示天气(不影响别的东西)。 ⚠️ 删的只是**说明文字**:城市/纬度/经度三个输入框、保存按钮、
</p> 以及下面那句"现在按 XX 取天气"的状态回显**都保留** ——
那些是"填了之后发生了什么的反馈",不是啰嗦的提示。 */ ?>
<h2 class="section-title">天气 · 我所在的城市</h2>
<form method="post" action="<?= e(url('settings/weather')) ?>"> <form method="post" action="<?= e(url('settings/weather')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="form-grid"> <?php /* 第一行:城市单独一行(占满宽度) */ ?>
<div class="field"> <div class="field">
<label for="w-city">城市</label> <label for="w-city">城市</label>
<input class="input" id="w-city" name="city" maxlength="30" <input class="input" id="w-city" name="city" maxlength="30"
value="<?= e((string)($weatherCity ?? '')) ?>" value="<?= e((string)($weatherCity ?? '')) ?>"
placeholder="如:青岛 / 山东青岛 / 内蒙古包头" autocomplete="off"> placeholder="如:青岛 / 山东青岛 / 内蒙古包头" autocomplete="off">
</div> </div>
<?php /* 第二行:精度维度(纬度 / 经度)同一行 */ ?>
<div class="loc-row">
<div class="field"> <div class="field">
<label for="w-lat">纬度(可选)</label> <label for="w-lat">纬度(可选)</label>
<input class="input" id="w-lat" name="lat" value="<?= e((string)($weatherLat ?? '')) ?>" <input class="input" id="w-lat" name="lat" value="<?= e((string)($weatherLat ?? '')) ?>"
+7 -2
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')) ?>"> <form class="inline-form" method="post" enctype="multipart/form-data" action="<?= e(url('tasks/attach_add')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<input type="file" name="attach[]" multiple required> <?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> <button class="btn btn-sm" type="submit">上传附件</button>
<?php $sdSlot = 'photo'; $sdHint = '也可以把文件直接拖到这里 —— 拖一个就开始上传,可以接着再拖,最后点「上传附件」一起记下。'; require __DIR__ . '/../_stage_drop.php'; ?> </div>
</form> </form>
<?php endif; ?> <?php endif; ?>
</div> </div>
+3 -2
View File
@@ -297,9 +297,10 @@ $amap = \App\Geo::jsConfig();
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?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> <span class="tip">可多选;图片、视频都会存到这段行程下面。</span>
<?php $sdSlot = 'photo'; $sdHint = '也可以把文件直接拖到这里 —— 拖一个就开始上传,可以接着再拖,最后点「保存」一起记下。'; require __DIR__ . '/../_stage_drop.php'; ?>
</div> </div>
</div> </div>
+12 -3
View File
@@ -60,15 +60,24 @@ $groupMeta = [
+ 实际行程 + 实际行程
</a> </a>
<a class="btn btn-ghost" href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">编辑</a> <a class="btn btn-ghost" href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">编辑</a>
<?php /* 用户第 8 条:右上角增加一个**设置**按钮(车辆管理 / 出行方式 / 节点类型)。 <?php /* 🔴 v24 第 21 批 第 6 条:**设置按钮必须排到最后**。
按全站规矩:设置按钮只有图标(三条横线)、无文字、贴最右。 */ ?> 用户原话:「旅程的详情页面,右上角的按钮,设置在最右侧,和其他模块保持统一。」
<?php $trSetCurrent = ''; require __DIR__ . '/_settings_menu.php'; ?>
⚠️ 原来这里的顺序是「… → 设置(☰) → 删除(🗑)」—— 设置被删除键挤到了**倒数第二**,
所以它并**不在最右**(实测:动作区最右边缘贴着的是那个删除表单,
而设置按钮左边还空着一大截)。全站其它模块(观影 / 日程 / 任务 / 资源)
的约定都是「设置 ☰ 恒为动作区最后一个」,用户点惯了一个位置,
到这里却偏了一格,就是"不统一"的全部含义。
⚠️ 所以删除键挪到设置**前面**(它现在是倒数第二)。 */ ?>
<form method="post" action="<?= e(url('trips/destroy')) ?>" class="res-inline" <form method="post" action="<?= e(url('trips/destroy')) ?>" class="res-inline"
onsubmit="return confirm('确定删除「<?= e((string)$t['title']) ?>」吗?里面的行程、照片会一起删掉,不可恢复。')"> onsubmit="return confirm('确定删除「<?= e((string)$t['title']) ?>」吗?里面的行程、照片会一起删掉,不可恢复。')">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-icon" type="submit" title="删除这个旅程" aria-label="删除">🗑</button> <button class="btn btn-ghost btn-icon" type="submit" title="删除这个旅程" aria-label="删除">🗑</button>
</form> </form>
<?php /* 用户第 8 条:右上角一个**设置**按钮(车辆管理 / 出行方式 / 节点类型)。
全站规矩:设置只有图标(三条横线)、无文字、**恒在动作区最右**。 */ ?>
<?php $trSetCurrent = ''; require __DIR__ . '/_settings_menu.php'; ?>
<?php else: ?> <?php else: ?>
<span class="link-quiet">只读账号</span> <span class="link-quiet">只读账号</span>
<?php endif; ?> <?php endif; ?>