Files
FamilyBook/templates/tasks/index.php
T
laoyang e0562b8627 v24 第 5 轮:十条清单(天气填城市 / 输入即筛选 / 看板列=看板 / 账本盘点折线图 / 纪念日分类)
用户 2026-10-03 的十条,逐条落点:

1. 天气改为**个人设置里填城市**(每人一份):新增 app/Geo.php,内置 197 个
   地级市坐标(离线、逐条核对,生成脚本 _tooling/_fb_geo_gen.py)。填坐标仍
   作为内置表没有时的出口;老的家庭级坐标保留为兜底,升级当天天气不会凭空消失。
2. 所有"找个人"的地方**输入即筛选**:新增 assets/js/filter.js(纯前端显隐,
   不发请求、回车不刷新)+ app/PersonQuery.php 把服务端判据收敛成一份
   (名册 / 挑人 / 高级搜索共用,避免三处实现漂移)。
3. 名册列表:有昵称就不重复显示本名、行变矮、补齐手机/阴历阳历生日(带年份)/
   邮箱/工作单位;「清除」与「搜索」同款同尺寸。
4/5. 日程:撤掉左侧日历本栏改满宽,功能(显隐/颜色/导入/默认视图)全收进
   右侧 ☰;**色块高度改为按起止时长**(旧实现一律 30 分钟,三小时的会跟
   十五分钟的同高);点日历空白处即建日程,开始时间=点的那一格。
6/10. 资源:搜索提到最上;按"最近改动/最新新增"排序,默认只显示最新 10 条并
   给「显示全部」出口。
7. 任务:看板的**列=用户自定义的看板**(不再是写死的待办/进行中),
   拖动/左右键=换看板(改 board_id,不动 status)。
8. 财务:概览在上账目在下、记一笔改右侧按钮、账本设置(共享/改名/新建/删除)
   收进 ☰、默认账本名「xxx的账本」、"挂在谁名下"默认自己且用标准选人框;
   **新增盘点**(新增 ledger_entry_snapshots 只增不改的历史表,旧值不被清空)
   + 上面画金额变化折线图(app/chart.php 服务端画 SVG,零依赖)。
9. 纪念日:手动添加的与名册生日派生的**分两块**、手动的在上;右侧设置可增删
   分类(家庭级,删分类不强改已用的历史)。

本轮另外修掉/加固的四类问题(都由新增的静态或运行时守卫兜住):
- index.php 少写 2 个 use 导入(Pinyin / UserSetting)→ 名册与个人设置
  返回 200 但正文是 Fatal;顺带修了一处早已存在的 Attachment 未导入。
  → 新增 _fb_import_check.py(1f 段)。
- 一次不精确替换弄丢了 app/Calendar.php 开头的 `<?php`,整份源码被当 HTML
  吐给浏览器,而 `php -l` 报"无语法错误"(纯文本输出确实合法)。
  → 新增 _fb_php_open_check.py(1e 段)。
- 模板片段 unset 了宿主模板的变量(账本片段 unset($canWrite))→ 正文里一片
  Undefined variable 而 HTTP 仍 200。同 _datetime.php 早年 $t 的坑。
  → 新增 _fb_fragment_scope_check.py(1g 段)+ _fb_batch_e.py 的 26 页无告警巡检。
- 窄屏下页头动作区不换行 → 周视图横向溢出且按钮被挤成三种高度(用户提过三次
  "按钮要同高")→ .page-head .actions 加 flex-wrap。

验证战绩(全绿):
  e2e 773/0 · 第 5 轮专项 91/0 · 结构升级 126/0 · 几何 428/0 · 移动端 684/0 ·
  交互 97/0 · 家谱 26/0 · 按钮 60/0;三轮反向验证各 3/3 抓到,还原后复绿。
线上:schema v14、指纹一致(9df6c9fccc6796db)、snapshots 表已建、197 城、
  8 个关键文件 md5 与本地逐字节一致、app/ 403 无源码泄漏、错误日志无新增。
2026-10-03 15:01:38 +08:00

314 lines
14 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>
<select class="select" id="tk-assign" name="assignee_id">
<option value="0">不指定</option>
<?php foreach ($people as $p): ?>
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option>
<?php endforeach; ?>
</select>
</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>
<select class="select" id="tk-share" name="shares[]" multiple size="3">
<?php foreach ($people as $p): ?>
<?php if ((int)$p['id'] === (int)\App\Auth::personId()) { continue; } ?>
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option>
<?php endforeach; ?>
</select>
</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>