Files
FamilyBook/templates/tasks/index.php
T
laoyang 864e281265 v24 第 8 条:凡「选人」都走同一套弹窗 + 拼音首字母(补上唯一漏做的一条)
用户原话:「名册详情页面,添加关系那里,选择一个人,应该也是和观影人一样,
点击弹窗,用拼音首字母选择,否则人多的时候,这个选择起来太麻烦了。」

- 成员详情页:①「添加关系 → 对方」 ②「分享他的名片给…」→ 全局选人组件(单选)
- 任务:③ 列表页「共享给(多选)」 ④ 列表页「负责人」 ⑤ 详情页「负责人」
  (共享候选人先把自己过滤掉 —— 共享给自己是空操作)
- 纪念日:「与谁相关」→ 单选,且保留原下拉第一项「不指定」(ppAllowEmpty)
- 「对方」是必填,但组件里是原生 radio(HTML 没有"必选一个 radio"的可靠约束)
  → 页面补一道 submit 拦截,否则用户点了按钮会静默无反应

顺带修一个用户没提、我自己抓到的真 bug:
_person_picker.php 原用**文件顶层**的 `static $ppSeq` 做 id 计数 —— PHP 里被
include 的文件,顶层 static 初值**每次 include 都重新绑定**(与函数体内的
static 不同)→ 同一页 include 两次都拿 pp1 → DOM id 撞车(getElementById /
<label for> 全抓到第一个)。实测 include 三次得到 pp1,pp1,pp1。改用
$GLOBALS['ppSeq']。这个坑只有同一页出现两个组件时才暴露,且资源页第二个组件
只有真有资源时才渲染 —— 所以写成**单元级**探针(同一请求 include 两次)。

另修一个构建层隐患(本轮部署时踩到,白查了一轮):
本机 core.autocrlf=true,一次 git stash 往返就把 6 个文件全改成 CRLF;而部署
打包取的是工作树 → 包里带 CRLF 上去 → 部署脚本里 grep -qxF '<整行 CSS 选择器>'
的校验在 Linux 上一条都匹配不到(报错却指向"CSS 少了某条规则",极易误诊)。
→ .gitattributes 加 `* text=auto eol=lf`(必须排在 -text 那几条之前),
  并在打包前加一道行尾体检 _fb_eol_check.py(失败时点名文件)。

验证:反向验证对着 git stash 出来的真·改动前状态跑 → 11 通过 / 17 失败;
新增 _fb_picker_unify.py 28/0;e2e 785/0;几何 428/0;移动端 688/0;交互 97/0;
升级 126/0;家谱 34/0;专项 91/0;导入 21/0;豆瓣 22/0;行尾体检 + 两道新静态
门禁均实测可失败。线上 6 文件 md5 一致、CR=0。
2026-10-03 21:19:34 +08:00

326 lines
15 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>
<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>
<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' ?>">
<a class="kb-title" href="<?= e(url('tasks/show', ['id' => $tid])) ?>">
<?= e((string)$t['title']) ?>
</a>
<div class="kb-meta">
<?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();
}
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>