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:
@@ -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 里(全页一份,见前面的说明)。
|
||||
这一版把"贴按钮的浮层"换成**居中模态框**(与选人弹窗同形态)—— 用户明确要求
|
||||
|
||||
Reference in New Issue
Block a user