Files
FamilyBook/templates/tasks/index.php
T
laoyang a5e559b2b3 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() 不再报错。
2026-10-07 11:35:31 +08:00

511 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 任务 · 看板(v12;v24 用户 2026-10-03 第 7 条重做列的含义)。
*
* ⚠️ 第 7 条原话:「任务模块跟我想的不同,每个人自定义的看板,不是显示在现在的看板上面,
* 而是**直接代替现在的待办和进行中**,是看板,而不是看板的分类。」
*
* 于是这一页的样子变了:
* · 以前:顶上一条「看板切换」,下面是固定两列「待办 / 进行中」;
* · 现在:**一列 = 一个看板**(你自己建的那些),全都在同一屏上,
* 拖动/左右箭头 = **把卡搬到另一个看板**。
*
* 三条保留的纪律:
* · **已完成的任务不在看板上**(用户第 10 条:直接消失)→ 去「任务设置 → 已完成」看;
* · 拖动是渐进增强:每张卡下面还有 ← → 按钮,没有 JS / 手机上照样能搬;
* · 看板里只有父任务:子任务挂在父任务下面(点卡片进详情看/勾)。
*
* @param array $cols 看板 id => 该列的任务(键 0 = 没有看板归属的兜底列)
* @param array $boards 看板清单(顺序即列顺序)
* @param array $people 名册(选负责人 / 共享给别人)
*/
$canWrite = \App\Auth::canWrite();
$boards = $boards ?? [];
$cols = $cols ?? [];
$categories = $categories ?? [];
/* 列清单:先按 boards 的顺序,最后补上兜底列(键 0,且只在真有任务时才画)。
⚠️ 兜底列的标题不能叫「未分类」——「分类」在任务模块里是另一回事(task_categories),
叫「未分类」会让人以为是按分类分的列。这里它表示"还没归到任何看板",叫「其他」。 */
$colList = [];
foreach ($boards as $b) {
$bid = (int)$b['id'];
$colList[] = ['id' => $bid, 'name' => (string)$b['name'], 'rows' => $cols[$bid] ?? []];
}
if (!empty($cols[0])) {
$colList[] = ['id' => 0, 'name' => '其他', 'rows' => $cols[0]];
}
// 一个看板都没有(也不该发生:ensureDefaultBoard 保证至少一个)→ 给一句人话
if (!$colList) {
$colList[] = ['id' => 0, 'name' => '任务', 'rows' => $cols[0] ?? []];
}
$firstColId = (int)($colList[0]['id'] ?? 0);
$lastColIdx = count($colList) - 1;
?>
<div class="page-head">
<h1>任务</h1>
<span class="sub">
<?= (int)$total ?> 项在做<?= count($boards) > 1 ? ',' . count($boards) . ' 个看板' : '' ?>
</span>
<div class="actions">
<?php if ($canWrite): ?>
<button class="btn" type="button" id="taskAddBtn">新建任务</button>
<?php else: ?>
<span class="link-quiet">只读账号</span>
<?php endif; ?>
<?php $setCurrent = 'board'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<?php if ($canWrite): ?>
<div class="card card-pad" id="taskAddCard" hidden>
<h2 class="cinema-h2">新建任务</h2>
<form method="post" action="<?= e(url('tasks/create')) ?>">
<?= csrf_field() ?>
<div class="form-grid">
<div class="field full">
<label for="tk-title">要做什么 *</label>
<input class="input" id="tk-title" name="title" required placeholder="如:周末大扫除">
</div>
<?php /* v24 第 5 条:截止时刻也走全局统一的「日期 + 时分秒」控件。
秒这一档保留(与其它模块同一口径),默认 23:59:59 —— "这天之前做完"
比"这天 00:00 之前"更符合直觉。 */ ?>
<div class="field">
<label for="tk-due">截止时间(有才会出现在日历里)</label>
<?php $dtName = 'due_at'; $dtId = 'tk-due'; $dtValue = null;
$dtRequired = false; $dtSeconds = true;
require __DIR__ . '/../_datetime.php'; ?>
</div>
<div class="field">
<label for="tk-assign">负责人</label>
<?php
$ppName = 'assignee_id';
$ppPeople = $people;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择负责人';
$ppTitle = '谁负责这件事';
$ppEmptyText = '不指定';
$ppAllowEmpty = true;
require __DIR__ . '/../_person_picker.php';
?>
</div>
<?php /* v24 第 14 批 用户第 2 条:新建时就能定状态(默认「待办」)。
⚠️ 选「已完成»的话它不会出现在看板上(已完成已从看板撤走),
只在「任务设置 → 已完成」里能翻到 —— 这句提示必须写上,
否则用户会以为"刚建的任务丢了"。 */ ?>
<div class="field">
<label for="tk-status">状态</label>
<select class="select" id="tk-status" name="status">
<?php foreach (\App\Task::STATUS as $sk => $sv): ?>
<option value="<?= e($sk) ?>" <?= $sk === 'todo' ? 'selected' : '' ?>><?= e($sv) ?></option>
<?php endforeach; ?>
</select>
<span class="tip">选「已完成」的话,它只在「已完成」里出现,看板上不再显示。</span>
</div>
<div class="field">
<label for="tk-repeat">循环</label>
<select class="select" id="tk-repeat" name="repeat_rule">
<?php foreach (\App\Task::REPEAT as $rk => $rv): ?>
<option value="<?= e($rk) ?>"><?= e($rv) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="tk-cat">分类</label>
<select class="select" id="tk-cat" name="category_id">
<option value="0">未分类</option>
<?php foreach ($categories as $c): ?>
<option value="<?= (int)$c['id'] ?>"><?= e((string)$c['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php /* 用户第 7 条:看板 = 任务"归属哪摊事",新建时直接选(不再是顶上的切换条) */ ?>
<div class="field">
<label for="tk-board">看板</label>
<select class="select" id="tk-board" name="board_id">
<?php foreach ($boards as $b): ?>
<option value="<?= (int)$b['id'] ?>"><?= e((string)$b['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="tk-share">共享给(可多选,任何人完成都算完成)</label>
<?php /* 第 8 条的同一诉求:人多时不要下拉里瞪眼找。换成弹窗 + 首字母联想。
⚠️ 候选里去掉**自己**——「共享给自己」是空操作;组件没有"排除项",
所以在传进去之前先过滤掉。 */ ?>
<?php
$ppName = 'shares[]';
$ppPeople = array_values(array_filter($people,
fn($p) => (int)$p['id'] !== (int)\App\Auth::personId()));
$ppSelected = [];
$ppMode = 'multi';
$ppOpenBtn = '+ 添加人';
$ppTitle = '共享给谁';
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field full">
<label for="tk-note">备注</label>
<textarea class="input" id="tk-note" name="note" rows="2"></textarea>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit">新建</button>
<button class="btn btn-ghost" type="button" id="taskAddCancel">收起</button>
</div>
</form>
</div>
<?php endif; ?>
<?php /* 拖动的提交目标:JS 把 id / 目标**看板** / 参照顺序填进来再 submit。
放在看板**外面**,避免被任何一张卡片的 form 套住。 */ ?>
<form method="post" action="<?= e(url('tasks/move')) ?>" id="taskMoveForm" class="hidden-form">
<?= csrf_field() ?>
<input type="hidden" name="id" id="mvId">
<input type="hidden" name="board" id="mvBoard">
<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 —— 名册就是这么被密码管理器拖垮的。 */ ?>
<form method="post" action="<?= e(url('tasks/status')) ?>" id="taskStatusForm" class="hidden-form">
<?= csrf_field() ?>
<input type="hidden" name="id" id="stId">
<input type="hidden" name="status" id="stStatus">
<input type="hidden" name="back" value="tasks">
</form>
<div class="kanban" data-cols="<?= count($colList) ?>">
<?php foreach ($colList as $ci => $col): $cid = (int)$col['id']; ?>
<section class="kb-col" data-board="<?= $cid ?>">
<header class="kb-head">
<b><?= e((string)$col['name']) ?></b>
<span class="kb-n"><?= count($col['rows']) ?></span>
</header>
<div class="kb-body" data-board="<?= $cid ?>">
<?php foreach ($col['rows'] as $t): ?>
<?php
$tid = (int)$t['id'];
$kids = (int)($t['child_total'] ?? 0);
$doneN = (int)($t['child_done'] ?? 0);
$overdue = \App\Task::isOverdue($t['due_at'] ?? null);
?>
<div class="kb-card<?= $overdue ? ' is-overdue' : '' ?>" data-id="<?= $tid ?>"
draggable="<?= $canWrite ? 'true' : 'false' ?>">
<?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 条「每个任务都应该有不同的状态」)。
点一下推进到下一个状态:待办 → 进行中 → 已完成 → 回到待办
(最后一档做成可回头,是"点错了能改回来",完成本身另有详情页的按钮)。
⚠️ 只读账号给的是**静态**徽标(span),点了没反应还容易误会。 */ ?>
<?php
$stNow = (string)($t['status'] ?? 'todo');
$stNext = ['todo' => 'doing', 'doing' => 'done', 'done' => 'todo'][$stNow] ?? 'doing';
?>
<div class="kb-meta">
<?php if ($canWrite): ?>
<button class="kb-st is-<?= e($stNow) ?>" type="button"
data-st-id="<?= $tid ?>" data-st="<?= e($stNow) ?>" data-next="<?= e($stNext) ?>"
title="现在:<?= e(\App\Task::statusWord($stNow)) ?>;点一下改成「<?= e(\App\Task::statusWord($stNext)) ?>」">
<?= e(\App\Task::statusWord($stNow)) ?>
</button>
<?php else: ?>
<span class="kb-st is-<?= e($stNow) ?> is-static"><?= e(\App\Task::statusWord($stNow)) ?></span>
<?php endif; ?>
<?php if (($t['due_at'] ?? '') !== ''): ?>
<span class="kb-due<?= $overdue ? ' is-late' : '' ?>">
<?= e(\App\Task::dueWord($t['due_at'] ?? null)) ?>
</span>
<?php endif; ?>
<?php if (!empty($t['repeat_rule'])): ?>
<span class="node-badge"><?= e(\App\Task::repeatWord($t['repeat_rule'])) ?></span>
<?php endif; ?>
<?php if ((int)($t['comment_count'] ?? 0) > 0): ?>
<span class="kb-ico" title="评论">💬 <?= (int)$t['comment_count'] ?></span>
<?php endif; ?>
<?php if ((int)($t['attach_count'] ?? 0) > 0): ?>
<span class="kb-ico" title="附件">📎 <?= (int)$t['attach_count'] ?></span>
<?php endif; ?>
<?php if (($t['category_name'] ?? '') !== ''): ?>
<span class="node-badge" title="分类"><?= e((string)$t['category_name']) ?></span>
<?php endif; ?>
</div>
<?php if ($kids > 0): ?>
<div class="kb-sub">
<span class="kb-bar"><i style="width: <?= (int)round($doneN * 100 / max(1, $kids)) ?>%"></i></span>
<span class="kb-sub-n"><?= $doneN ?>/<?= $kids ?></span>
</div>
<?php endif; ?>
<div class="kb-foot">
<?php if (!empty($t['assignee_name'])): ?>
<?= person_chip([
'id' => (int)$t['assignee_id'], 'name' => (string)$t['assignee_name'],
'my_nickname' => (string)($t['assignee_nick'] ?? ''),
'avatar' => $t['avatar'] ?? null, 'avatar_color' => $t['avatar_color'] ?? null,
]) ?>
<?php endif; ?>
<?php if ($canWrite): ?>
<span class="kb-move">
<?php if ($ci > 0): ?>
<button class="btn btn-ghost btn-sm" type="button"
data-move="left" data-id="<?= $tid ?>">←</button>
<?php endif; ?>
<?php if ($ci < $lastColIdx): ?>
<button class="btn btn-ghost btn-sm" type="button"
data-move="right" data-id="<?= $tid ?>">→</button>
<?php endif; ?>
</span>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php if (!$col['rows']): ?>
<p class="empty-hint kb-empty">这一列是空的</p>
<?php endif; ?>
</div>
</section>
<?php endforeach; ?>
</div>
<script>
(function () {
var addBtn = document.getElementById('taskAddBtn');
var addCard = document.getElementById('taskAddCard');
var addCancel = document.getElementById('taskAddCancel');
if (addBtn && addCard) {
addBtn.addEventListener('click', function () {
addCard.hidden = !addCard.hidden;
if (!addCard.hidden) { addCard.scrollIntoView({ block: 'nearest' }); }
});
}
if (addCancel && addCard) { addCancel.addEventListener('click', function () { addCard.hidden = true; }); }
/* ---- 左右搬(不需要拖也能用)----
⚠️ 列的集合**从 DOM 现取**,不要写死数组:看板是用户自己建的,
"待办/进行中/已完成"那套常量在这儿已经不成立了。 */
var form = document.getElementById('taskMoveForm');
if (!form) return;
var colIds = Array.prototype.map.call(
document.querySelectorAll('.kb-body[data-board]'),
function (b) { return b.getAttribute('data-board'); }
);
function submitMove(id, board, before) {
document.getElementById('mvId').value = id;
document.getElementById('mvBoard').value = board;
document.getElementById('mvBefore').value = before || '';
form.submit();
}
/* ---- 状态徽标:点一下推进到下一个状态(用户第 2 条)----
走的是**全页一份**的 #taskStatusForm(卡片上只有 data-st-*),
与搬卡那份表单同一思路 —— 不这么做的话,几十张卡就是几十份 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();
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');
var from = card.closest('.kb-body').getAttribute('data-board');
var i = colIds.indexOf(from) + (btn.getAttribute('data-move') === 'right' ? 1 : -1);
if (i < 0 || i >= colIds.length) { return; }
submitMove(card.getAttribute('data-id'), colIds[i], '');
});
});
/* ---- 拖动(渐进增强:拖不动还有上面的左右按钮)---- */
/* ⚠️ v24(用户 2026-10-02 第 8 条):拖完一张卡,松手那一下浏览器还会
补一个 click 给卡片里的标题链接 —— 于是"刚拖到位就立刻跳进详情页",
看板直接没法用了。
修法:拖结束就立个牌子 dragEnded,标题链接的 click 看到牌子就吞掉。
牌子在**下一次 mousedown** 时摘掉(真想点进去总得再按一次鼠标),
所以不会误伤正常点击 —— 比用 setTimeout 猜时间准得多。 */
var dragging = null;
var dragEnded = false;
document.addEventListener('mousedown', function () { dragEnded = false; }, true);
Array.prototype.forEach.call(document.querySelectorAll('.kb-card[draggable="true"]'), function (card) {
card.addEventListener('dragstart', function (ev) {
dragging = card;
dragEnded = false;
card.classList.add('is-dragging');
if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; }
});
card.addEventListener('dragend', function () {
card.classList.remove('is-dragging');
dragging = null;
dragEnded = true;
Array.prototype.forEach.call(document.querySelectorAll('.kb-body.is-over'), function (el) {
el.classList.remove('is-over');
});
});
});
Array.prototype.forEach.call(document.querySelectorAll('.kb-card .kb-title'), function (a) {
a.addEventListener('click', function (ev) {
if (dragEnded) { ev.preventDefault(); ev.stopPropagation(); }
});
});
Array.prototype.forEach.call(document.querySelectorAll('.kb-body'), function (body) {
body.addEventListener('dragover', function (ev) {
if (!dragging) return;
ev.preventDefault();
body.classList.add('is-over');
});
body.addEventListener('dragleave', function () { body.classList.remove('is-over'); });
body.addEventListener('drop', function (ev) {
if (!dragging) return;
ev.preventDefault();
body.classList.remove('is-over');
/* 放在谁前面:找松手位置下方最近的那张卡(排除自己),没有就排到末尾 */
var after = null;
var kids = Array.prototype.slice.call(body.querySelectorAll('.kb-card'));
for (var i = 0; i < kids.length; i++) {
if (kids[i] === dragging) continue;
var box = kids[i].getBoundingClientRect();
if (ev.clientY < box.top + box.height / 2) { after = kids[i]; break; }
}
submitMove(dragging.getAttribute('data-id'), body.getAttribute('data-board'),
after ? after.getAttribute('data-id') : '');
});
});
})();
</script>