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:
+35
-2
@@ -32,6 +32,22 @@ final class Calendar
|
||||
* 而且 kind ≠ 'custom' → Calendar::delete() 天然删不掉(用户要求"不可删除")。
|
||||
*/
|
||||
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'],
|
||||
'trip' => ['旅行', '#3F7D5A'],
|
||||
'lunar' => ['农历', '#8C6D3F'],
|
||||
@@ -43,9 +59,26 @@ final class Calendar
|
||||
];
|
||||
|
||||
/** 派生型只读日历(内容不从 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
|
||||
{
|
||||
return in_array($kind, self::READONLY_KINDS, true);
|
||||
|
||||
+26
-2
@@ -625,11 +625,35 @@ final class Trip
|
||||
|
||||
// ---------------- 行程节点 ----------------
|
||||
|
||||
/** 这趟旅程的全部节点(plan / reality 两轨,按 position 排) */
|
||||
/** 这趟旅程的全部节点(计划 / 实际 / 未成行,按**开始时间正序**排) */
|
||||
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(
|
||||
'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]);
|
||||
$rows = $st->fetchAll();
|
||||
|
||||
+160
-1
@@ -588,7 +588,11 @@ textarea.input { min-height: 76px; resize: vertical; }
|
||||
⚠️ 用 visibility:hidden 而不是 display:none —— 保留元素占位高度,
|
||||
否则升级完成的那一瞬表单会"跳"一下(高度从 0 变 40px)。 */
|
||||
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 未就绪前,先给一个同高的"占位骨架",让表单高度在升级前后保持一致 */
|
||||
html.js .dt-input:not(.is-semi)::after {
|
||||
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-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 > .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-range.is-semi-range .dt-semi-range { display: block; }
|
||||
/* 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 条:提醒多选的排版(勾选框多了要能换行,别挤成一长条) */
|
||||
.rem-picks { display: flex; flex-wrap: wrap; gap: 6px 14px; }
|
||||
.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
@@ -409,8 +409,16 @@
|
||||
// 那样往"票根"里拖会跑到"海报"上(用户看到的是"拖到这儿,结果上到那儿")。
|
||||
// 所以先找**同一个 .field 里**的那个,找不到才退回表单里的第一个。
|
||||
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]'); }
|
||||
// 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) {
|
||||
queueDrop(stageBox, input, files);
|
||||
@@ -445,7 +453,7 @@
|
||||
做法(三条链路,缺一不可):
|
||||
① 拖进来 → **立刻** POST 到 upload_stage.php(`?r=upload/stage` 由路由转过去),
|
||||
服务端落进暂存目录并回一个 `stoken`;
|
||||
② 把 stoken 塞进隐藏字段 `<input type=hidden name="stage[]">`——
|
||||
② 把 stoken 塞进隐藏字段(名字由落点给,见 `data-stage-field`)——
|
||||
表单提交时这些令牌就跟着走了,业务路由在 attach_save_files 里**认领**;
|
||||
③ 立刻画一张缩略图(指向 `media.php?stage=<stoken>`),
|
||||
让用户看得见"这一张已经上去了"。
|
||||
@@ -463,8 +471,13 @@
|
||||
function queueDrop(box, input, files) {
|
||||
var form = input.form;
|
||||
if (!form) { return; }
|
||||
var list = box.querySelector('[data-stage-list]');
|
||||
var tip = box.querySelector('[data-stage-tip]');
|
||||
// ⚠️ v24 第 21 批 第 7 条:清单与提示行现在在框**外面**(同一个 `.upfield` 容器里)——
|
||||
// 清单放框内的话,加了五张图那个虚线框就变成"大框套着五张图"。
|
||||
// 所以先在容器里找,找不到才退回框自身(`.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 上:**跨多次拖放**共享(用户拖一次、再拖一次,仍要串行)
|
||||
var st = box.__stage || (box.__stage = { pending: 0, chain: Promise.resolve() });
|
||||
|
||||
@@ -499,6 +512,9 @@
|
||||
// 但整条清单是 display:none —— 用户看到的是"拖了没反应",
|
||||
// 而且缩略图也点不到(真机探针实测:卡片在 DOM 里、尺寸 0×0)。
|
||||
list.hidden = false;
|
||||
// 提示行同理(模板也给了 hidden)—— 它要说"已上传,可以继续拖"这类话,
|
||||
// 藏着等于一个字都不说。
|
||||
if (tip) { tip.hidden = false; }
|
||||
}
|
||||
|
||||
st.pending++;
|
||||
@@ -544,10 +560,16 @@
|
||||
// 令牌进隐藏字段 → 随表单提交,服务端在 attach_save_files 里认领它
|
||||
var hid = document.createElement('input');
|
||||
hid.type = 'hidden';
|
||||
// ⚠️ 字段名由落点决定:附件类是 `stage[]`(服务端一次认领一批),
|
||||
// 海报/票根是 `stage_poster` / `stage_ticket`(各自单槽位、要按比例重裁)。
|
||||
// 写死 `stage[]` 会让海报存成 1:1、页面上变形(见 _stage_drop.php 的说明)。
|
||||
hid.name = box.getAttribute('data-stage-field') || 'stage[]';
|
||||
// ⚠️ 字段名由落点决定,**不能写死**:
|
||||
// · 通用附件是 `stage_token[]` —— 🔴 v24 第 21 批 第 4 条:
|
||||
// 它原来叫 `stage[]`,与行程表单里那个 `stage`(plan/actual)
|
||||
// 同名 → 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;
|
||||
cell.appendChild(hid);
|
||||
// 撤回这张(令牌与卡片一起去掉;服务端的暂存由 TTL 清)
|
||||
@@ -607,15 +629,50 @@
|
||||
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) {
|
||||
bindZone(input, input);
|
||||
var sel = input.id ? ('[data-pick-input="#' + input.id + '"]') : '';
|
||||
if (sel) {
|
||||
Array.prototype.forEach.call(document.querySelectorAll(sel), function (pv) {
|
||||
bindZone(pv, input);
|
||||
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) {
|
||||
@@ -1274,11 +1331,22 @@
|
||||
|
||||
var host = document.createElement('span');
|
||||
host.className = 'dt-semi dt-semi-range';
|
||||
// ⚠️ 插到**第一个原生控件之前**,不能插到 group 的最前面。
|
||||
// 因为 group 就是那个 `.field`,`<label>` 也是它的子节点:
|
||||
// 用 group.firstChild 会把控件塞到 label **上面**(第一版就是这么写的),
|
||||
// 用户看到的现象正是"开始/结束时间这几个字跑到日期控件下面去了"。
|
||||
group.insertBefore(host, a);
|
||||
/* 🔴 v24 第 21 批 第 7 条:这里原来写的是 `group.insertBefore(host, a)` ——
|
||||
**两端各自被 `.field` 包着**时 `a` 并不是 `group` 的直接子节点,
|
||||
insertBefore 会直接抛
|
||||
"Failed to execute 'insertBefore' on 'Node': The node before which the new
|
||||
node is to be inserted is not a child of this node."
|
||||
这个异常以前**从没被触发过**,因为上面那道"两端都得可见"的判断总是先 return
|
||||
(结束那一端默认 hidden)→ 恰好把这一行挡住了。
|
||||
一旦把两端都放出来,它就立刻炸:整个升级流程中断 → 范围控件永远挂不上
|
||||
→ 用户看到一片空白。所以修"时间范围无法使用"必须**同时**修这里。
|
||||
|
||||
插到"第一个端点最外层包装"之前(包装拿不到就退回插在 a 前面),
|
||||
这样控件出现在标签下方、两个端点位置,视觉顺序是 标签 → 控件。 */
|
||||
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' : '');
|
||||
|
||||
@@ -1314,8 +1382,13 @@
|
||||
}
|
||||
group.__fbRemount = mountRange;
|
||||
|
||||
a.style.display = 'none';
|
||||
b.style.display = 'none';
|
||||
/* ⚠️ 隐藏的是**端点最外层的包装**(那个 `.field`),不是里层的 `.dt-input`:
|
||||
只藏里层的话,两端的 `<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 会让整个表单提交不了)
|
||||
guardRequired(a);
|
||||
guardRequired(b);
|
||||
@@ -1505,7 +1578,19 @@
|
||||
function (bx) { upgrade(bx); });
|
||||
upgradeAllMonths();
|
||||
}, 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'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -287,7 +287,18 @@ function attach_save_files(int $familyId, string $type, int $targetId, string $f
|
||||
// 保存时(这里)才真正认领 —— 于是"立刻上传"与"最后统一保存"两件事都成立。
|
||||
// ⚠️ 认领放在临时文件之前:两者最终都进同一张附件表、按 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 = [];
|
||||
if ($staged) {
|
||||
$res = \App\StagedUpload::claim($familyId, $uploaderId, $staged, $type, $targetId);
|
||||
@@ -1793,21 +1804,53 @@ switch ($r) {
|
||||
csrf_check();
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$p = Person::find($id);
|
||||
if ($p && (int)$p['family_id'] === (int)Auth::familyId()) {
|
||||
if ((int)$p['id'] === (int)Auth::personId()) {
|
||||
flash('err', '不能把自己从名册里移除。');
|
||||
redirect('people/show', ['id' => $id]);
|
||||
// ---- 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;
|
||||
}
|
||||
// 不让最后一个管理员消失:系统里没人能管理家庭就锁死了
|
||||
$acc = Account::findByPerson($id);
|
||||
if ($acc && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
|
||||
flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。');
|
||||
redirect('people/show', ['id' => $id]);
|
||||
}
|
||||
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
|
||||
Person::delete($id);
|
||||
flash('ok', '已从名册移除「' . $p['name'] . '」。');
|
||||
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()) {
|
||||
// 这条原本是 redirect 到**详情页**(用的人自己);AJAX 时不该跳页
|
||||
if ($jsonOut(false, '不能把自己从名册里移除。')) { }
|
||||
flash('err', '不能把自己从名册里移除。');
|
||||
redirect('people/show', ['id' => $id]);
|
||||
}
|
||||
// 不让最后一个管理员消失:系统里没人能管理家庭就锁死了
|
||||
$acc = Account::findByPerson($id);
|
||||
if ($acc && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
|
||||
if ($jsonOut(false, '这是唯一的管理员,先把另一个人设为管理员再移除。')) { }
|
||||
flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。');
|
||||
redirect('people/show', ['id' => $id]);
|
||||
}
|
||||
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
|
||||
$gone = (string)$p['name'];
|
||||
Person::delete($id);
|
||||
if ($jsonOut(true, '已删除「' . $gone . '」。', ['id' => $id])) { }
|
||||
flash('ok', '已从名册移除「' . $gone . '」。');
|
||||
redirect('people');
|
||||
break;
|
||||
|
||||
@@ -3281,6 +3324,35 @@ switch ($r) {
|
||||
}
|
||||
$isEdit = $r === 'trips/node_update';
|
||||
$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 条)。
|
||||
// ⚠️ 第 17 批 用户第 3 条把「角色标注」去掉了("这个功能去掉,不要了"),
|
||||
// 所以这里**不再读** roles;座位/登机口也不再从表单收(字段已从界面移除,
|
||||
@@ -3294,8 +3366,8 @@ switch ($r) {
|
||||
'node_type' => (string)($_POST['node_type'] ?? 'other'),
|
||||
'status' => (string)($_POST['status'] ?? 'planned'),
|
||||
'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),
|
||||
// ---- v21:交通(TripLog 同款)----
|
||||
'travel_mode' => (string)($_POST['travel_mode'] ?? ''),
|
||||
@@ -3938,6 +4010,23 @@ switch ($r) {
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
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();
|
||||
$ym = trim((string)($_POST['ym'] ?? ''));
|
||||
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
|
||||
@@ -3949,25 +4038,21 @@ switch ($r) {
|
||||
// kind='custom' 取 calendar_events),却会在库里留下一堆"看不见的日程"。
|
||||
// 用户的要求是"不可删除",这里顺带把"不可写入"也钉死。
|
||||
if ($cal && Calendar::isReadonlyKind((string)$cal['kind'])) {
|
||||
flash('err', '「' . $cal['name'] . '」是系统日历,内容自动生成,不能往里加日程。');
|
||||
redirect('calendar', ['view' => 'month', 'ym' => $ym]);
|
||||
$jsonFail('「' . $cal['name'] . '」是系统日历,内容自动生成,不能往里加日程。');
|
||||
}
|
||||
if (!$cal || !Calendar::canWrite($cal, $me)) {
|
||||
flash('err', '这本日历你没有写权限。');
|
||||
redirect('calendar', ['ym' => $ym]);
|
||||
$jsonFail('这本日历你没有写权限。');
|
||||
}
|
||||
// v24 第 5、6 条:起止各是一个「日期+时分秒」的完整时刻(_datetime.php 控件),
|
||||
// 不再拆成 date / end_date / start_time / end_time 四格。
|
||||
$startsAt = read_dt_input('starts_at');
|
||||
if ($startsAt === null) {
|
||||
flash('err', '开始时间不对。');
|
||||
redirect('calendar', ['ym' => $ym]);
|
||||
$jsonFail('开始时间不对。');
|
||||
}
|
||||
$endsAt = read_dt_input('ends_at');
|
||||
$title = trim((string)($_POST['title'] ?? ''));
|
||||
if ($title === '') {
|
||||
flash('err', '日程得有个标题。');
|
||||
redirect('calendar', ['ym' => $ym]);
|
||||
$jsonFail('日程得有个标题。');
|
||||
}
|
||||
$allDay = !empty($_POST['all_day']);
|
||||
if ($allDay) {
|
||||
@@ -3980,21 +4065,55 @@ switch ($r) {
|
||||
// ⚠️ 观影 / 旅行两本是派生的:往这两本里加日程 = 建一条真正的观影 / 旅程记录,
|
||||
// 而不是往 calendar_events 里塞一行(那样日历里有、观影列表里没有)。
|
||||
$kind = (string)$cal['kind'];
|
||||
if ($kind === 'movie') {
|
||||
$newId = Movie::create($familyId, $me, [
|
||||
'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
|
||||
'attendees' => [$me],
|
||||
]);
|
||||
flash('ok', '已加进观影,日历里也能看到了。');
|
||||
redirect('movies/show', ['id' => $newId]);
|
||||
}
|
||||
if ($kind === 'trip') {
|
||||
$newId = Trip::create($familyId, $me, [
|
||||
'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
|
||||
'attendees' => [$me],
|
||||
]);
|
||||
flash('ok', '已加进旅程,日历里也能看到了。');
|
||||
redirect('trips/show', ['id' => $newId]);
|
||||
|
||||
/* 🔴 v24 第 21 批 第 7 条:观影 / 旅行两本是**可写**的 —— 往它们里面"加一条"
|
||||
等于建一条真正的观影 / 旅程记录(只往 calendar_events 里塞一行的话,
|
||||
日历里有、观影列表里没有)。
|
||||
|
||||
⚠️ 这两段原来只 `redirect()`,AJAX 时会把 fetch 带着跳走:
|
||||
客户端拿到的是一整页 HTML、`r.json()` 解析失败 → 退回原生提交 →
|
||||
又整页刷新一次。所以这里也要**回 JSON**,让前端照旧"插进当前视图"。
|
||||
⚠️ AJAX 时**不再自动跳到观影/旅程详情页** —— 用户的原话是
|
||||
"添加完成后,动态修改当前视图,不要刷新",跳走就等于离开了日历。
|
||||
改在提示行里给一个可点的链接(想看详情随手点,不点也不打扰)。 */
|
||||
if ($kind === 'movie' || $kind === 'trip') {
|
||||
if ($kind === 'movie') {
|
||||
$newId = Movie::create($familyId, $me, [
|
||||
'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
|
||||
'attendees' => [$me],
|
||||
]);
|
||||
$okMsg = '已加进观影,日历里也能看到了。';
|
||||
$okHref = url('movies/show', ['id' => $newId]);
|
||||
} else {
|
||||
$newId = Trip::create($familyId, $me, [
|
||||
'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
|
||||
'attendees' => [$me],
|
||||
]);
|
||||
$okMsg = '已加进旅程,日历里也能看到了。';
|
||||
$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'], [
|
||||
'title' => $title,
|
||||
@@ -4015,6 +4134,34 @@ switch ($r) {
|
||||
'created_by' => $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) {
|
||||
flash('err', '有附件没有保存:' . implode(';', $warns));
|
||||
}
|
||||
|
||||
+105
-33
@@ -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);
|
||||
@@ -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 存文件、正文解析不了。
|
||||
|
||||
@@ -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 —— 不重复时一个都不该出现(少即是清楚)。 */
|
||||
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 里(全页一份,见前面的说明)。
|
||||
这一版把"贴按钮的浮层"换成**居中模态框**(与选人弹窗同形态)—— 用户明确要求
|
||||
|
||||
@@ -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
@@ -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 ?? '')) ?>"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
Reference in New Issue
Block a user