v25 第 25 批(9 条):名册分组不刷页 / 纪念日多人 / 首页等高 / 移出不回流 /
名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标
用户本轮 9 条需求(原话见 templates/ 与 app/ 内的逐条注释):
1. 名册:列表里给某人分组改为 AJAX 就地挪卡片(不再整页刷新)。
2. 纪念日:关联人改**多选**(复用 _person_picker,字段 person_ids[]);
自建纪念日的按钮与其它模块统一(改=蓝 .btn、删=黄 .btn-warn);
列表新增「关联人」列并给人名前加头像。
3. 首页:日程/任务两块**完全等高**(align-items:stretch + 固定总高 + flex 子项
min-height:0);修「0 件待办」——数字与右侧列表同源(原来各算各的)。
4. 🔴 名册里被移出的人过一阵又回来:根因是 PersonRoster::seedFromPersons()
**每次 Schema::ensure() 都无条件重灌**(线上实测每跑一次 +1 行)。
修:给它加自守卫(只有在 persons 还留着 group_id/sort_order 的**老库**才跑)。
5. 名册:点击=看详情、**只有拖 ⠿ 手柄**才是排序(原来整行既能点又能拖,语义混乱)。
6. 任务:所有任务前加复选框,任何位置点勾即完成;
状态语义照业界整理为 To Do / Doing / Done 并写出说明(用户要求参考别人的做法)。
7. 财务:类别标签改**多选非互斥**(再点一次取消该类别),上下两图 + 图例 + 表格
全部按同一个集合联动;高亮条刻意不给「合计」(多选下各类金额不能相加)。
8. 微信读书:新增**懒同步**(访问时顺手做,6h TTL,只补空字段不覆盖用户填的)、
封面图**拉下来存本站存储**(原来只存外链 → 看不到)、接官方 /readdata/detail 统计。
9. 导航:线上数据只搬了一半(5 板块 29 站 → 7 板块 67 站 + 67 图标)、
展示页头像不显示 / 搜索按钮错位 / 点登录 404。三个真根因:
· 相对地址在 /nav/ 下全 404 → 新增 Nav::abs();
· 搜索容器丢了 id="search"(CSS 是 #search button.submit 绝对定位);
· Nav::iconKey() 与 SimpleNav 的 icon_key() **算法不一致**(少一次 md5 归一化)
→ 搬来的图标文件名对不上;且 uploads/ 整目录 Require all denied。
新增两个**免登录公开出口** nav_icon.php / nav_avatar.php。
顺带修掉一个「数据损坏级」的真 bug(做第 4 条线上反证时发现的,之前一直没人注意):
app/Region.php 三处用中文标点当 trim/ltrim 的 charlist,而 PHP 的 charlist 是
**按字节**匹配的 —— 「。」= E3 80 **82** → 字节 82 进了可剥集合 →
「层」= E5 B1 **82** 被切掉尾字节 → 非法 UTF-8 → backfillAddress 的 UPDATE 报
`1366 Incorrect string value` → **地址拆分升级从上线起一直在失败被跳过**(每请求报一次)。
改成字符级 preg_replace('/…/u');线上实测 ensure() 现在无报错、待拆行数真的在下降。
验证战绩:
- e2e 1289/0(新增 Region 字节级用例等)
- 真机探针:选人 19/0 · 首页/导航 24/0 · 导航模块 34/0 · b18 暂存+确定 10/0 ·
第23批 52/0
- 桌面几何 428/0 · 移动端几何 680/0 · 交互 92/0 · 窄屏 24/0
- 老库升级 153/0(含 anniversary_people 迁移 + 「再跑 ensure() 名册行数不变」)
- 部署静态断言 180 OK / 0 FAIL · 反向验证 3/3 + Region 三变体各被两条断言同时抓住
- 线上 26 个文件与本地**逐字节一致**;展示页 200、图标 66/66 200、登录 200、
越权/不存在均 404、签发短语后 ensure() 不再报错。
This commit is contained in:
+136
-7
@@ -167,6 +167,33 @@ $lastColIdx = count($colList) - 1;
|
||||
<input type="hidden" name="before" id="mvBefore">
|
||||
</form>
|
||||
|
||||
<?php /* v24 第 25 批 用户第 6 条:**每张卡前面一颗「完成」复选框**。
|
||||
用户原话:「任务相关的模块,所有的任务,前面都带一个复选框,在任何地方,
|
||||
点这个复选框,这个任务就完成了。」
|
||||
⚠️ 卡片上只放 `data-tk-done`(见 _task_check.php),真正的表单**全页一份**,
|
||||
放在这下面 —— 与搬卡/改状态那两份同一个思路:
|
||||
几十张卡就是几十份 form,那是名册页被密码管理器拖垮的同一个坑。 */ ?>
|
||||
<?php if ($canWrite): ?>
|
||||
<form method="post" action="<?= e(url('tasks/status')) ?>" id="taskDoneForm" class="hidden-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" id="doneId">
|
||||
<input type="hidden" name="status" id="doneStatus">
|
||||
<input type="hidden" name="back" id="doneBack" value="tasks">
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* 状态到底是什么(用户第 6 条:「待办和进行中,有什么区别?」)——
|
||||
把三个状态的一句话解释直接摆在看板上方,而不是藏在 title 里。
|
||||
他问出这句话,就说明界面上没有一处讲清楚过。 */ ?>
|
||||
<div class="tk-legend">
|
||||
<?php foreach (\App\Task::STATUS as $sk => $sv): ?>
|
||||
<span class="tk-lg-item">
|
||||
<span class="kb-st is-<?= e($sk) ?> is-static"><?= e($sv) ?></span>
|
||||
<span class="tk-lg-hint"><?= e(\App\Task::statusHint($sk)) ?></span>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php /* v24 第 14 批 用户第 2 条:**每张卡上都能看到并改状态**。
|
||||
⚠️ 与上面搬卡那份同理:表单全页只有一份,卡片上只放 `data-st-*`,
|
||||
不然几十张卡就是几十份 form —— 名册就是这么被密码管理器拖垮的。 */ ?>
|
||||
@@ -194,9 +221,19 @@ $lastColIdx = count($colList) - 1;
|
||||
?>
|
||||
<div class="kb-card<?= $overdue ? ' is-overdue' : '' ?>" data-id="<?= $tid ?>"
|
||||
draggable="<?= $canWrite ? 'true' : 'false' ?>">
|
||||
<a class="kb-title" href="<?= e(url('tasks/show', ['id' => $tid])) ?>">
|
||||
<?= e((string)$t['title']) ?>
|
||||
</a>
|
||||
<?php /* 用户第 6 条:**任务前面带复选框**,点它就是"完成"。
|
||||
⚠️ 复选框与标题必须包在 `.kb-top` 里**横排** ——
|
||||
卡片是 flex-column,不包的话复选框独占一行、标题被挤下去。 */ ?>
|
||||
<div class="kb-top">
|
||||
<?php
|
||||
$tkId = $tid; $tkStatus = (string)($t['status'] ?? 'todo');
|
||||
$tkBack = 'tasks'; $tkStatic = !$canWrite;
|
||||
require __DIR__ . '/../_task_check.php';
|
||||
?>
|
||||
<a class="kb-title" href="<?= e(url('tasks/show', ['id' => $tid])) ?>">
|
||||
<?= e((string)$t['title']) ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php /* 状态徽标:每一张卡都有(用户第 2 条「每个任务都应该有不同的状态」)。
|
||||
点一下推进到下一个状态:待办 → 进行中 → 已完成 → 回到待办
|
||||
(最后一档做成可回头,是"点错了能改回来",完成本身另有详情页的按钮)。
|
||||
@@ -301,19 +338,111 @@ $lastColIdx = count($colList) - 1;
|
||||
|
||||
/* ---- 状态徽标:点一下推进到下一个状态(用户第 2 条)----
|
||||
走的是**全页一份**的 #taskStatusForm(卡片上只有 data-st-*),
|
||||
与搬卡那份表单同一思路 —— 不这么做的话,几十张卡就是几十份 form。 */
|
||||
与搬卡那份表单同一思路 —— 不这么做的话,几十张卡就是几十份 form。
|
||||
⚠️ v24 第 25 批 用户第 6 条:改成 **AJAX 就地改**(原来是整页提交)。
|
||||
理由是"复选框在任何地方点一下就完成"这件事,如果每次都刷整页,
|
||||
在首页点完会跳回首页顶部、滚动位置全丢 —— 那不是"点一下",是"打一次断"。
|
||||
失败仍退回原生提交(见 catch / 无 JSON 分支),所以没有 JS 照样能用。 */
|
||||
var stForm = document.getElementById('taskStatusForm');
|
||||
if (stForm) {
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.kb-st[data-st-id]'), function (b) {
|
||||
b.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.getElementById('stId').value = b.getAttribute('data-st-id');
|
||||
document.getElementById('stStatus').value = b.getAttribute('data-next');
|
||||
stForm.submit();
|
||||
submitStatus(b.getAttribute('data-st-id'), b.getAttribute('data-next'), b);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- 完成复选框(用户第 6 条):点一下 = 完成 ---- */
|
||||
var doneForm = document.getElementById('taskDoneForm');
|
||||
function submitStatus(id, status, btn) {
|
||||
if (!doneForm) { return; }
|
||||
document.getElementById('doneId').value = id;
|
||||
document.getElementById('doneStatus').value = status;
|
||||
document.getElementById('doneBack').value = (btn && btn.getAttribute('data-tk-back')) || 'tasks';
|
||||
// 没有 fetch(极老浏览器)→ 直接原生提交,不假装成功
|
||||
if (!window.fetch || !window.FormData) {
|
||||
doneForm.submit();
|
||||
return;
|
||||
}
|
||||
if (btn) { btn.disabled = true; }
|
||||
fetch(doneForm.getAttribute('action'), {
|
||||
method: 'POST',
|
||||
body: new FormData(doneForm),
|
||||
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) {
|
||||
applyStatus(String(d.id), String(d.status), String(d.word || ''));
|
||||
return;
|
||||
}
|
||||
if (d && d.msg) { say(d.msg, false); return; }
|
||||
// 拿不到 JSON(会话过期 / 500)→ 退回原生提交,让服务端说话
|
||||
doneForm.submit();
|
||||
}).catch(function () {
|
||||
if (btn) { btn.disabled = false; }
|
||||
doneForm.submit();
|
||||
});
|
||||
}
|
||||
|
||||
/** 把一张卡的状态**就地**改掉:复选框、状态徽标、卡片样式一起动。
|
||||
* ⚠️ 完成之后看板上还有没有这张卡,交给用户刷新时由服务端决定 ——
|
||||
* 不在这里偷偷摘卡片:任务是"刚做完",正想看一眼自己刚完成什么,
|
||||
* 立刻消失会让人怀疑"是不是点到删除了"。 */
|
||||
function applyStatus(id, status, word) {
|
||||
var card = document.querySelector('.kb-card[data-id="' + id + '"]');
|
||||
if (!card) { return; }
|
||||
var chk = card.querySelector('.tk-check[data-tk-done]');
|
||||
if (chk) {
|
||||
chk.setAttribute('data-tk-status', status);
|
||||
chk.classList.toggle('is-done', status === 'done');
|
||||
chk.setAttribute('aria-pressed', status === 'done' ? 'true' : 'false');
|
||||
}
|
||||
var badge = card.querySelector('.kb-st[data-st-id]');
|
||||
if (badge) {
|
||||
var next = status === 'todo' ? 'doing' : (status === 'doing' ? 'done' : 'todo');
|
||||
badge.className = 'kb-st is-' + status;
|
||||
badge.setAttribute('data-st', status);
|
||||
badge.setAttribute('data-next', next);
|
||||
badge.textContent = word;
|
||||
badge.setAttribute('title', '现在:' + word + ';点一下改成「'
|
||||
+ ({todo: '待办', doing: '进行中', done: '已完成'}[next] || '') + '」');
|
||||
}
|
||||
card.classList.toggle('is-done', status === 'done');
|
||||
say('已标成「' + word + '」。', true);
|
||||
}
|
||||
|
||||
/** 页内提示(用页面自己的 flash 样式,不弹 alert) */
|
||||
function say(msg, ok) {
|
||||
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); } }, 3200);
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.tk-check[data-tk-done]'), function (b) {
|
||||
b.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
var now = b.getAttribute('data-tk-status') || 'todo';
|
||||
// 勾上 = 完成;已完成的再点一下 = 取消(回到「待办」)——
|
||||
// 用户可能点错了,必须能退回来(这与"没有任何撤销"的删除是两回事)。
|
||||
submitStatus(b.getAttribute('data-tk-done'), now === 'done' ? 'todo' : 'done', b);
|
||||
});
|
||||
});
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-move]'), function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var card = btn.closest('.kb-card');
|
||||
|
||||
Reference in New Issue
Block a user