Files
FamilyBook/templates/tasks/show.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

407 lines
18 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):正文 + 子任务 + 评论(@人)+ 附件 + 共享。
*
* @人:评论里写「@老杨」会高亮(Task::commentHtml),**不会**真的发通知 ——
* 家庭场景下"点名"主要是让人一眼看到在说谁,做通知要先解决推送渠道,先不做。
*
* @param array $t 任务(带 children / comments / attachs / shares)
* @param array $people 名册(负责人 / 共享 / @人 提示)
* @param array $parents 可选的父任务候选(把当前任务挂到别的父任务下)
*/
$canWrite = \App\Auth::canWrite() && \App\Task::canWrite($t, (int)\App\Auth::personId());
$meId = (int)\App\Auth::personId();
$names = [];
foreach ($people as $p) {
$names[] = person_name($p);
$nm = trim((string)($p['name'] ?? ''));
if ($nm !== '') {
$names[] = $nm;
}
}
$names = array_values(array_unique(array_filter($names)));
$isChild = !empty($t['parent_id']);
?>
<div class="page-head">
<h1>
<?php /* 用户第 6 条:「所有的任务,前面都带一个复选框,在任何地方,点这个复选框,
这个任务就完成了。」→ 详情页也算"任何地方",标题前也给它一颗。 */ ?>
<?php
$tkId = (int)$t['id']; $tkStatus = (string)$t['status'];
$tkBack = 'show'; $tkStatic = !$canWrite;
require __DIR__ . '/../_task_check.php';
?>
<?= e((string)$t['title']) ?>
</h1>
<span class="sub"><?= e(\App\Task::statusWord((string)$t['status'])) ?></span>
<div class="actions">
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('tasks/move')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<?php if ($t['status'] !== 'done'): ?>
<input type="hidden" name="status" value="done">
<button class="btn" type="submit">标记完成</button>
<?php else: ?>
<input type="hidden" name="status" value="doing">
<button class="btn btn-ghost" type="submit">重新打开</button>
<?php endif; ?>
</form>
<?php endif; ?>
<a class="btn btn-ghost" href="<?= e(url('tasks')) ?>">返回任务</a>
</div>
</div>
<div class="card card-pad">
<dl class="kv">
<?php /* v24 第 14 批 用户第 2 条:状态要**能选**(不只是"标记完成/重新打开"两档)。
这里给完整的三档下拉,与看板卡片上那个徽标走同一个路由。 */ ?>
<div class="kv-row">
<dt>状态</dt>
<dd>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('tasks/status')) ?>" class="res-inline st-form">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<input type="hidden" name="back" value="show">
<select class="select" name="status" aria-label="任务状态">
<?php foreach (\App\Task::STATUS as $sk => $sv): ?>
<option value="<?= e($sk) ?>" <?= (string)$t['status'] === $sk ? 'selected' : '' ?>>
<?= e($sv) ?>
</option>
<?php endforeach; ?>
</select>
<button class="btn btn-sm" type="submit">改</button>
</form>
<?php else: ?>
<span class="kb-st is-<?= e((string)$t['status']) ?> is-static">
<?= e(\App\Task::statusWord((string)$t['status'])) ?>
</span>
<?php endif; ?>
</dd>
</div>
<?php if (($t['due_at'] ?? '') !== ''): ?>
<div class="kv-row"><dt>截止</dt>
<dd class="<?= \App\Task::isOverdue($t['due_at'] ?? null) && $t['status'] !== 'done' ? 'kb-late' : '' ?>">
<?= e((string)$t['due_at']) ?><?= \App\Task::dueWord($t['due_at'] ?? null) !== '' ? '(' . e(\App\Task::dueWord($t['due_at'] ?? null)) . ')' : '' ?>
</dd>
</div>
<?php endif; ?>
<?php if (!empty($t['repeat_rule'])): ?>
<div class="kv-row"><dt>循环</dt><dd><?= e(\App\Task::repeatWord($t['repeat_rule'])) ?></dd></div>
<?php endif; ?>
<?php if (!empty($t['assignee_name'])): ?>
<div class="kv-row"><dt>负责人</dt>
<dd><?= e(person_name(['name' => $t['assignee_name'], 'my_nickname' => $t['assignee_nick'] ?? ''])) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($t['creator_name'])): ?>
<div class="kv-row"><dt>谁建的</dt>
<dd><?= e(person_name(['name' => $t['creator_name'], 'my_nickname' => $t['creator_nick'] ?? ''])) ?></dd>
</div>
<?php endif; ?>
<?php if ($isChild && !empty($t['parent_title'])): ?>
<div class="kv-row"><dt>属于</dt>
<dd><a href="<?= e(url('tasks/show', ['id' => (int)$t['parent_id']])) ?>"><?= e((string)$t['parent_title']) ?></a></dd>
</div>
<?php endif; ?>
<?php if (!empty($t['note'])): ?>
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e((string)$t['note'])) ?></dd></div>
<?php endif; ?>
</dl>
<?php if ($canWrite): ?>
<details class="node-add">
<summary class="link-quiet">编辑这项任务</summary>
<form method="post" action="<?= e(url('tasks/update')) ?>" class="node-form">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<div class="nf-grid">
<div class="field full">
<label>标题</label>
<input class="input" name="title" required value="<?= e((string)$t['title']) ?>">
</div>
<div class="field">
<label>截止时间(留空=不上日历)</label>
<?php $dtName = 'due_at'; $dtValue = $t['due_at'] ?? null;
$dtRequired = false; $dtSeconds = true;
require __DIR__ . '/../_datetime.php'; ?>
</div>
<div class="field">
<label>负责人</label>
<?php /* 第 8 条同一诉求:负责人也是「选人」,人多时用弹窗 + 首字母更快。
保留原下拉的「不指定」(pp-item-none + value=0)。 */ ?>
<?php
$ppName = 'assignee_id';
$ppPeople = $people;
$ppSelected = [(int)($t['assignee_id'] ?? 0)];
$ppMode = 'single';
$ppOpenBtn = '更换负责人';
$ppTitle = '谁负责这件事';
$ppEmptyText = '不指定';
$ppAllowEmpty = true;
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field">
<label>循环</label>
<select class="select" name="repeat_rule">
<?php foreach (\App\Task::REPEAT as $rk => $rv): ?>
<option value="<?= e($rk) ?>"<?= (string)($t['repeat_rule'] ?? '') === (string)$rk ? ' selected' : '' ?>>
<?= e($rv) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>挂到哪个父任务下</label>
<select class="select" name="parent_id">
<option value="0">(这是一项独立的任务)</option>
<?php foreach ($parents as $p): ?>
<option value="<?= (int)$p['id'] ?>"<?= (int)($t['parent_id'] ?? 0) === (int)$p['id'] ? ' selected' : '' ?>>
<?= e((string)$p['title']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php /* v24 第 9、10 条:分类与看板(看板把任务放到哪一块,分类是标签) */ ?>
<div class="field">
<label>分类</label>
<select class="select" name="category_id">
<option value="0">未分类</option>
<?php foreach (($categories ?? []) as $c): ?>
<option value="<?= (int)$c['id'] ?>"<?= (int)($t['category_id'] ?? 0) === (int)$c['id'] ? ' selected' : '' ?>>
<?= e((string)$c['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>看板</label>
<select class="select" name="board_id">
<?php foreach (($boards ?? []) as $b): ?>
<option value="<?= (int)$b['id'] ?>"<?= (int)($t['board_id'] ?? 0) === (int)$b['id'] ? ' selected' : '' ?>>
<?= e((string)$b['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field full">
<label>备注</label>
<textarea class="input" name="note" rows="2"><?= e((string)($t['note'] ?? '')) ?></textarea>
</div>
</div>
<div class="btn-row"><button class="btn btn-sm" type="submit">保存</button></div>
</form>
</details>
<?php endif; ?>
</div>
<?php /* ---- 子任务 ---- */ ?>
<?php if (!$isChild): ?>
<div class="card card-pad">
<h2 class="cinema-h2">子任务 <span class="group-n"><?= count($t['children']) ?> 项</span></h2>
<?php if (!$t['children']): ?>
<p class="empty-hint">还没有子任务。把一件大事拆成几件小事,进度才看得见。</p>
<?php else: ?>
<ul class="sub-list">
<?php foreach ($t['children'] as $c): ?>
<li class="sub-row<?= $c['status'] === 'done' ? ' is-done' : '' ?>">
<?php /* v24 第 25 批 用户第 6 条:子任务的复选框**改用全站那一颗**
—— 原来是"每行一个 form + ☐/☑ 字符":
· 每行一个 form = 子任务一多就是几十个 form(名册页那个坑);
· ☐/☑ 是普通字符,跨字体基线会歪,且与看板上的复选框长得不一样。
现在卡片上只放 data-tk-done,表单全页一份(见页尾)。 */ ?>
<?php
$tkId = (int)$c['id']; $tkStatus = (string)$c['status'];
$tkBack = 'show'; $tkStatic = !$canWrite;
$tkLabel = '子任务' . (string)$c['title'];
require __DIR__ . '/../_task_check.php';
?>
<a class="sub-title" href="<?= e(url('tasks/show', ['id' => (int)$c['id']])) ?>">
<?= e((string)$c['title']) ?>
</a>
<?php if (($c['due_at'] ?? '') !== ''): ?>
<span class="node-time<?= \App\Task::isOverdue($c['due_at'] ?? null) && $c['status'] !== 'done' ? ' kb-late' : '' ?>">
<?= e(\App\Task::dueWord($c['due_at'] ?? null)) ?>
</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($canWrite): ?>
<details class="node-add">
<summary class="link-quiet">+ 加一项子任务</summary>
<form method="post" action="<?= e(url('tasks/create')) ?>" class="inline-form">
<?= csrf_field() ?>
<input type="hidden" name="parent_id" value="<?= (int)$t['id'] ?>">
<input class="input" name="title" required placeholder="如:擦玻璃">
<?php $dtName = 'due_at'; $dtValue = null; $dtRequired = false; $dtSeconds = true;
require __DIR__ . '/../_datetime.php'; ?>
<button class="btn btn-sm" type="submit">加上</button>
</form>
</details>
<?php endif; ?>
</div>
<?php endif; ?>
<?php /* ---- 附件 ---- */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">附件</h2>
<?php if (!$t['attachs']): ?>
<p class="empty-hint">还没有附件。</p>
<?php else: ?>
<?php
$attRows = $t['attachs'];
$attCanEdit = $canWrite;
$attDelUrl = 'tasks/attach_del';
$attDelField = 'id';
$attExtra = [];
require __DIR__ . '/../_attachments.php';
?>
<?php endif; ?>
<?php if ($canWrite): ?>
<form class="inline-form" method="post" enctype="multipart/form-data" action="<?= e(url('tasks/attach_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<?php /* v24 第 21 批 第 7 条:不再放原生文件框 + 按钮,改成一个「点击或拖动」的框。
⚠️ 这个表单**唯一**的用途就是传附件,所以提交键仍然要有 ——
"点击/拖动"只是选文件的方式,最后还得点一下才能提交。 */
$sdName = 'attach[]'; $sdSlot = 'photo';
require __DIR__ . '/../_stage_drop.php'; ?>
<div class="btn-row" style="margin-top:12px">
<button class="btn btn-sm" type="submit">上传附件</button>
</div>
</form>
<?php endif; ?>
</div>
<?php /* ---- 共享 ---- */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">共享</h2>
<?php if (!$t['shares']): ?>
<p class="empty-hint">还没共享给别人。共享之后,任何一个人完成都算完成。</p>
<?php else: ?>
<div class="res-shares">
<?php foreach ($t['shares'] as $s): ?>
<?= person_chip($s) ?>
<span class="node-badge"><?= ($s['perm'] ?? '') === 'write' ? '可写' : '只读' ?></span>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('tasks/unshare')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<input type="hidden" name="person_id" value="<?= (int)$s['person_id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">取消</button>
</form>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($canWrite): ?>
<form class="inline-form" method="post" action="<?= e(url('tasks/share')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<?php
$ppName = 'person_id';
$ppPeople = array_values(array_filter($people, fn($p) => (int)$p['id'] !== $meId));
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择要共享给谁';
$ppTitle = '共享给谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
<select class="select" name="perm">
<option value="write">可写</option>
<option value="read">只读</option>
</select>
<button class="btn btn-sm" type="submit">共享</button>
</form>
<?php endif; ?>
</div>
<?php /* ---- 评论 ---- */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">讨论 <span class="group-n"><?= count($t['comments']) ?> 条</span></h2>
<?php if (!$t['comments']): ?>
<p class="empty-hint">还没有留言。写 @名字 可以点名。</p>
<?php else: ?>
<ul class="cmt-list">
<?php foreach ($t['comments'] as $c): ?>
<li class="cmt-row">
<div class="cmt-who">
<?= person_chip($c) ?>
<span class="cmt-time"><?= e((string)$c['created_at']) ?></span>
</div>
<div class="cmt-body"><?= \App\Task::commentHtml((string)$c['content'], $names) ?></div>
<?php if ((int)$c['person_id'] === $meId): ?>
<form method="post" action="<?= e(url('tasks/comment_del')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<input type="hidden" name="task_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit" onclick="return confirm('删掉这条留言?')">删除</button>
</form>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('tasks/comment_add')) ?>" class="cmt-form">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<textarea class="input" name="content" rows="2" required
placeholder="说点什么… 写 @名字 可以点名"><?= e(old('content', '')) ?></textarea>
<div class="btn-row"><button class="btn btn-sm" type="submit">发表</button></div>
</form>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<div class="task-danger">
<form method="post" action="<?= e(url('tasks/destroy')) ?>"
onsubmit="return confirm('删除「<?= e((string)$t['title']) ?>」?子任务、附件与留言会一起删掉。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-danger btn-sm" type="submit">删除这项任务</button>
</form>
</div>
<?php endif; ?>
<?php /* 完成复选框的**全页一份**表单(用户第 6 条)—— 本页可能出现多颗
(任务自己 + 每条子任务),所以更要"卡片上只放数据、表单只此一份"。 */ ?>
<?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="show">
</form>
<?php /* ⚠️ 本页的复选框走**原生提交**(不 AJAX):
详情页是一次性动作,改完最常见的是"看一眼就返回";
就地改反而要在页面上同步三处(标题旁的状态、kv 里的状态、子任务进度)。
原生提交 + back=show → 服务端 302 回本页,状态永远与服务端一致。
代价只是刷一次页 —— 详情页本来就"打开看某一条",没有滚动位置要保住
(那是看板/首页才有的诉求)。
⚠️ 但**不能只渲染 form 不绑事件**:那样点了按钮毫无反应
("按钮坏了"比"刷一下"糟得多)。这里只做一件最小的事:
把"点的是哪一条 / 改成什么"灌进表单再提交。 */ ?>
<script>
(function () {
var form = document.getElementById('taskDoneForm');
if (!form) { return; }
Array.prototype.forEach.call(document.querySelectorAll('.tk-check[data-tk-done]'), function (b) {
b.addEventListener('click', function (ev) {
ev.preventDefault();
var now = b.getAttribute('data-tk-status') || 'todo';
document.getElementById('doneId').value = b.getAttribute('data-tk-done');
document.getElementById('doneStatus').value = (now === 'done') ? 'todo' : 'done';
document.getElementById('doneBack').value = b.getAttribute('data-tk-back') || 'show';
form.submit();
});
});
})();
</script>
<?php endif; ?>