Files
FamilyBook/templates/tasks/_panel.php
T

364 lines
18 KiB
PHP
Raw Normal View History

<?php
/**
* 首页「近期任务」的就地面板(v78 用户 2026-10-22)。
*
* 用户原话:「网页端,首页点击一个任务后,除了展示信息,应该有相应的功能,
* 可以修改状态:待办、进行中,可以挪动分组,可以拖动上传附件,可以添加评论,
* 添加子任务等。其目地,是为了让用户不离开首页,就能对任务进行简单的处理。」
*
* ────────────────────────────────────────────────────────────────
* 这个片段**只在浮层里用**(`#fbEntryBody`,由 `tasks/panel` 路由吐出)。
* 它的每一个动作都打到**既有路由**(tasks/status、tasks/move、tasks/create、
* tasks/comment_add、tasks/comment_del、tasks/attach_add、tasks/attach_del),
* 一个字节的新契约都没有 —— 只是在每个表单里多带一个 `ajax_panel=1`,
* 服务端据此把"flash + 跳转"换成"一段新面板 + 一句人话"。
*
* 三条纪律(每条都对应一次踩过的坑):
* ① **每个表单自带 CSRF**:这段 HTML 是 AJAX 塞进 DOM 的,不在
* `_entry_popup.php` 那颗 `.fb-entry-csrf` 的表单里,靠外层拿不到。
* ② **动作按钮用 `data-tk-act` 而不是"每行一个 form"**:一条任务可能有十几条
* 子任务与几十条评论,每行一个 form 就是名册页那个"2369 个 form"的坑
* (密码管理器会把页面拖到几秒一帧)。这里只在真正需要输入的三处用 form:
* 加子任务 / 发评论 / 传附件。
* ③ **不做任何"猜"的显示**:拿不到 `cover`、拿不到人、拿不到看板就不画那一行,
* 与 `tasks/show.php` 的口径一致(那里也是"有才显示")。
*
* 用法(`task_panel_json()` 里已经备好):
* $t 任务(Task::find 的形状,含 children / comments / attachs)
* $people 名册
* $names @人 高亮用的名字表
* $boards 看板清单
* $categories 分类清单
* $canWrite 我能不能改这一条
* $meId 我是谁
*
* ⚠️ 局部变量一律 `tp` 前缀:这段会被 renderPartial 塞进浮层,宿主页面
* (首页)里也有 `$tasks` / `$t` 这类名字,用裸名就是静默覆盖。
*/
$tpT = is_array($t ?? null) ? $t : [];
$tpId = (int)($tpT['id'] ?? 0);
if ($tpId <= 0) {
return;
}
$tpBoards = (array)($boards ?? []);
$tpCats = (array)($categories ?? []);
$tpPeople = (array)($people ?? []);
$tpNames = (array)($names ?? []);
$tpCan = !empty($canWrite);
$tpMe = (int)($meId ?? 0);
$tpStatus = \App\Task::normalizeStatus((string)($tpT['status'] ?? 'todo'));
$tpBoardId = (int)($tpT['board_id'] ?? 0);
$tpKids = (array)($tpT['children'] ?? []);
$tpAtts = (array)($tpT['attachs'] ?? []);
$tpCmts = (array)($tpT['comments'] ?? []);
$tpKidDone = 0;
foreach ($tpKids as $tpKid) {
if ((string)($tpKid['status'] ?? '') === 'done') {
$tpKidDone++;
}
}
$tpIsChild = (int)($tpT['parent_id'] ?? 0) > 0;
/* 分类名:Task::find 不带 join,这里按 id 在清单里找(清单一次查询就够,
比再跑一条 SQL 划算;找不到就当未分类)。 */
$tpCatName = '';
foreach ($tpCats as $tpCat) {
if ((int)$tpCat['id'] === (int)($tpT['category_id'] ?? 0)) {
$tpCatName = (string)$tpCat['name'];
break;
}
}
$tpDue = (string)($tpT['due_at'] ?? '');
$tpDueWord = \App\Task::dueWord($tpDue !== '' ? $tpDue : null);
$tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpDue);
?>
<div class="tk-panel" data-tk-panel="<?= $tpId ?>"
data-tk-url-status="<?= e(url('tasks/status')) ?>"
data-tk-url-move="<?= e(url('tasks/move')) ?>"
data-tk-url-cmt-del="<?= e(url('tasks/comment_del')) ?>"
data-tk-url-attach="<?= e(url('tasks/attach_add')) ?>"
<?php /* #74:面板里直接改截止时间(不必点「编辑」进整页表单) */ ?>
data-tk-url-due="<?= e(url('tasks/due')) ?>"
<?php /* #76:截止/负责人/分类 三项一起保存(用户要显式「保存」按钮) */ ?>
data-tk-url-save="<?= e(url('tasks/panel_save')) ?>">
<?php /* CSRF 放在面板里(见头部第 ① 条):表单各自带一份,JS 里的按钮动作
从这里取一份 —— 两处同一个值,`csrf_token()` 天然幂等。 */ ?>
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>" data-tk-csrf>
<?php /* ---- 状态:三段平铺,点一下就改(不是下拉 —— 手机上少一次展开) ---- */ ?>
<div class="tk-sec tk-sec-inline">
<span class="tk-sec-lb">状态</span>
<div class="tk-seg">
<?php foreach (\App\Task::STATUS as $tpSk => $tpSv): ?>
<?php if ($tpCan): ?>
<button type="button"
class="kb-st is-<?= e($tpSk) ?><?= $tpSk === $tpStatus ? ' is-on' : '' ?>"
data-tk-act="status" data-status="<?= e($tpSk) ?>"><?= e($tpSv) ?></button>
<?php else: ?>
<span class="kb-st is-<?= e($tpSk) ?><?= $tpSk === $tpStatus ? ' is-on' : '' ?> is-static"><?= e($tpSv) ?></span>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php /* ---- 看板("挪动分组"就是挪到另一个看板) ---- */ ?>
<?php if ($tpBoards): ?>
<div class="tk-sec tk-sec-inline">
<span class="tk-sec-lb">看板</span>
<div class="tk-chips">
<?php foreach ($tpBoards as $tpB): ?>
<?php
$tpBid = (int)$tpB['id'];
$tpBOn = $tpBid === $tpBoardId;
$tpBCol = \App\Task::boardColor($tpB['color'] ?? null, (string)$tpB['name']);
?>
<?php if ($tpCan): ?>
<button type="button" class="tk-chip<?= $tpBOn ? ' is-on' : '' ?>"
style="--bc: <?= e($tpBCol) ?>;"
data-tk-act="board" data-board="<?= $tpBid ?>">
<i class="tk-chip-dot" aria-hidden="true"></i><?= e((string)$tpB['name']) ?>
</button>
<?php else: ?>
<span class="tk-chip is-static<?= $tpBOn ? ' is-on' : '' ?>" style="--bc: <?= e($tpBCol) ?>;">
<i class="tk-chip-dot" aria-hidden="true"></i><?= e((string)$tpB['name']) ?>
</span>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php /* ---- 更多信息:截止 / 负责人 / 分类 ---- 可编辑 + 保存(#76) ----
🔴 用户 2026-11-05:「时间控件是对的,但是现在没有保存按钮,只有下面一个
评论的发表按钮。另外,负责人和分类都没法修改,按道理讲都应该可以修改才对。」
改之前的状态:
· 截止:上一版做成"选完立即保存",用户不认可 —— 他要显式保存按钮;
· 负责人 / 分类:**只读的 `dl.kv` 行**,而且值为空时整行都不渲染
(`if (!empty($tpT['assignee_name']))` / `if ($tpCatName !== '')`)
⇒ 用户不仅改不了,连"这一项存在"都看不出来。
⇒ 三项合成一个带「保存」的小节:先改完,点一次提交。
`Task::setAttrs()` 只写这三列,不碰 title/note/status(见那里的说明)。
⚠️ 负责人用原生 `<select>` 而不是 `_person_picker.php` 的弹窗选人:
面板本身已在浮层里,再弹一层模态 = 两层遮罩,手机上很容易点错;
而且 picker 需要 `fbInitPickers()` 初始化,`loadPanel()` 目前没调它。
家庭成员通常 3~10 人,select 够用。**人多了再换弹窗**,那时记得
把 `fbInitPickers` 也补进 `loadPanel()`。 */ ?>
<div class="tk-sec">
<div class="tk-sec-h">更多信息</div>
<div class="tk-attrs">
<div class="tk-attr-row">
<span class="tk-attr-lb">截止</span>
<?php if ($tpCan): ?>
<?php
/* ⚠️ 变量名必须与 `_datetime.php` 的约定一致;
它内部一律用 `dt` 前缀的临时变量,不会踩到这里的 $tp* 或宿主页的 $t。 */
$dtName = 'due_at';
$dtValue = $tpDue;
$dtPlugin = 'antd-single';
$dtRequired = false;
$dtId = 'tkp-due';
require __DIR__ . '/../_datetime.php';
?>
<?php if ($tpDueWord !== ''): ?>
<span class="tk-due-word<?= $tpLate ? ' kb-late' : '' ?>"><?= e($tpDueWord) ?></span>
<?php endif; ?>
<?php else: ?>
<span class="<?= $tpLate ? 'kb-late' : '' ?>"><?= $tpDue !== '' ? e($tpDue) : '未设' ?></span>
<?php endif; ?>
</div>
<div class="tk-attr-row">
<span class="tk-attr-lb">负责人</span>
<?php if ($tpCan): ?>
<select class="select" name="assignee_id" id="tkp-assignee">
<option value="0">不指定</option>
<?php foreach ($tpPeople as $tpP): ?>
<?php $tpPid = (int)($tpP['id'] ?? 0); ?>
<option value="<?= $tpPid ?>"
<?= $tpPid === (int)($tpT['assignee_id'] ?? 0) ? ' selected' : '' ?>>
<?= e(person_name($tpP)) ?>
</option>
<?php endforeach; ?>
</select>
<?php else: ?>
<span><?= !empty($tpT['assignee_name'])
? e(person_name(['name' => $tpT['assignee_name'],
'my_nickname' => $tpT['assignee_nick'] ?? '']))
: '不指定' ?></span>
<?php endif; ?>
</div>
<div class="tk-attr-row">
<span class="tk-attr-lb">分类</span>
<?php if ($tpCan): ?>
<select class="select" name="category_id" id="tkp-category">
<option value="0">未分类</option>
<?php foreach ($tpCats as $tpC): ?>
<?php $tpCid = (int)($tpC['id'] ?? 0); ?>
<option value="<?= $tpCid ?>"
<?= $tpCid === (int)($tpT['category_id'] ?? 0) ? ' selected' : '' ?>>
<?= e((string)($tpC['name'] ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
<?php else: ?>
<span><?= $tpCatName !== '' ? e($tpCatName) : '未分类' ?></span>
<?php endif; ?>
</div>
<?php if ($tpCan): ?>
<div class="tk-attr-actions">
<button type="button" class="btn btn-sm" data-tk-act="save-attrs">保存</button>
<span class="tk-attr-tip" data-tk-save-tip hidden>已保存</span>
</div>
<?php endif; ?>
</div>
</div>
<?php /* ---- 其余只读事实(循环 / 备注) ---- */ ?>
<dl class="kv tk-kv">
<?php if (!empty($tpT['repeat_rule'])): ?>
<div class="kv-row"><dt>循环</dt><dd><?= e(\App\Task::repeatWord((string)$tpT['repeat_rule'])) ?></dd></div>
<?php endif; ?>
<?php if (trim((string)($tpT['note'] ?? '')) !== ''): ?>
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e((string)$tpT['note'])) ?></dd></div>
<?php endif; ?>
</dl>
<?php /* ---- 子任务(子任务自己不再挂子任务,与详情页同一口径) ---- */ ?>
<?php if (!$tpIsChild): ?>
<div class="tk-sec">
<div class="tk-sec-h">
子任务
<span class="group-n"><?= (int)$tpKidDone ?>/<?= count($tpKids) ?></span>
</div>
<?php if ($tpKids): ?>
<ul class="sub-list">
<?php foreach ($tpKids as $tpKidRow): ?>
<li class="sub-row<?= (string)$tpKidRow['status'] === 'done' ? ' is-done' : '' ?>">
<?php
/* 复选框用全站那一颗(`_task_check.php`)—— 但这里**不接**首页那份
`#taskDoneForm`:面板是 AJAX 塞进来的,那份表单的监听绑在加载时
存在的元素上。面板里的复选框由 task_panel.js 委托处理,
表单提交目标仍是 tasks/status(只是多带 ajax_panel=1)。 */
$tkId = (int)$tpKidRow['id'];
$tkStatus = (string)$tpKidRow['status'];
$tkBack = 'home';
$tkStatic = !$tpCan;
/* 无障碍说明:片段自己会渲染成「<label>:<状态词>」——
所以这里只给标题,别把"子任务"三个字再拼一遍
(否则读屏会念成「子任务子任务甲:待办」)。 */
$tkLabel = (string)$tpKidRow['title'];
require __DIR__ . '/../_task_check.php';
?>
<span class="sub-title"><?= e((string)$tpKidRow['title']) ?></span>
<?php if ((string)($tpKidRow['due_at'] ?? '') !== ''): ?>
<span class="node-time<?= \App\Task::isOverdue($tpKidRow['due_at'] ?? null) && $tkStatus !== 'done' ? ' kb-late' : '' ?>">
<?= e(\App\Task::dueWord($tpKidRow['due_at'] ?? null)) ?>
</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="empty-hint">还没有子任务。</p>
<?php endif; ?>
<?php if ($tpCan): ?>
<form method="post" action="<?= e(url('tasks/create')) ?>" class="tk-inline-form">
<?= csrf_field() ?>
<input type="hidden" name="parent_id" value="<?= $tpId ?>">
<input type="hidden" name="board_id" value="<?= $tpBoardId ?>">
<input type="hidden" name="ajax_panel" value="1">
<input class="input" name="title" required maxlength="200" placeholder="加一项子任务,回车即可">
<button class="btn btn-sm" type="submit">加上</button>
</form>
<?php endif; ?>
</div>
<?php endif; ?>
<?php /* ---- 附件:缩略图墙复用全站那一份;拖框是"拖进来立刻传" ---- */ ?>
<div class="tk-sec">
<div class="tk-sec-h">附件 <span class="group-n"><?= count($tpAtts) ?></span></div>
<?php if ($tpAtts): ?>
<?php
/* ⚠️ 删除表单走 `_attachments.php`(全站唯一画法),多带一个 `ajax_panel`
让服务端回"新面板"而不是跳详情页 —— 由 task_panel.js 拦下提交。 */
$attRows = $tpAtts;
$attCanEdit = $tpCan;
$attDelUrl = 'tasks/attach_del';
$attDelField = 'id';
$attExtra = ['ajax_panel' => '1'];
require __DIR__ . '/../_attachments.php';
?>
<?php else: ?>
<p class="empty-hint">还没有附件。</p>
<?php endif; ?>
<?php if ($tpCan): ?>
<?php /* 🔴 这里**有意不用** `_stage_drop.php`:那个片段是"拖进来先落暂存区、
点保存时才记到这条上",因为它服务的都是"表单还没提交"的新建场景。
而这一条任务**已经存在**,用户拖进来就该立刻挂上去 ——
多一次"再点上传"是多余的一步(用户原话:「可以拖动上传附件」)。
框的样子与那句话仍然一模一样(`.upfield / .upbox`)。 */ ?>
<div class="upfield tk-drop" data-tk-drop>
<div class="upbox" data-tk-drop-box role="button" tabindex="0"
aria-label="点击或拖动进行上传">
<span class="upbox-ico" aria-hidden="true">+</span>
<span class="upbox-msg">点击或拖动进行上传</span>
</div>
<input class="sr-only" type="file" multiple data-tk-drop-input
aria-label="选择要上传的附件">
<div class="stage-list" data-tk-drop-list hidden></div>
<span class="stage-drop-tip" data-tk-drop-tip hidden></span>
</div>
<?php endif; ?>
</div>
<?php /* ---- 讨论 ---- */ ?>
<div class="tk-sec">
<div class="tk-sec-h">讨论 <span class="group-n"><?= count($tpCmts) ?></span></div>
<?php if ($tpCmts): ?>
<ul class="cmt-list">
<?php foreach ($tpCmts as $tpCmt): ?>
<li class="cmt-row">
<div class="cmt-who">
<?= person_chip($tpCmt) ?>
<span class="cmt-time"><?= e((string)$tpCmt['created_at']) ?></span>
<?php if ((int)$tpCmt['person_id'] === $tpMe && $tpCan): ?>
<?php /* `btn-inline` 是"长得像链接的按钮"(见 app.css 注释):
少了它,<button> 会带上浏览器的灰底与内边距。 */ ?>
<button type="button" class="link-quiet btn-inline cmt-x"
data-tk-act="cmt-del" data-cmt="<?= (int)$tpCmt['id'] ?>">删除</button>
<?php endif; ?>
</div>
<div class="cmt-body"><?= \App\Task::commentHtml((string)$tpCmt['content'], $tpNames) ?></div>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="empty-hint">还没有留言。写 @名字 可以点名。</p>
<?php endif; ?>
<?php if ($tpCan): ?>
<form method="post" action="<?= e(url('tasks/comment_add')) ?>" class="cmt-form">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $tpId ?>">
<input type="hidden" name="ajax_panel" value="1">
<textarea class="input" name="content" rows="2" required
placeholder="说点什么… 写 @名字 可以点名"></textarea>
<div class="btn-row"><button class="btn btn-sm" type="submit">发表</button></div>
</form>
<?php endif; ?>
</div>
</div>
<?php
/* ⚠️ 只清本片段自己造的变量:`$t` / `$people` / `$boards` 这些是宿主的大局变量,
清掉之后宿主后半段会静默拿不到它们(`_edit_form.php` 头部记着这次事故)。 */
unset($tpT, $tpBoards, $tpCats, $tpPeople, $tpNames, $tpCan, $tpMe, $tpStatus,
$tpBoardId, $tpKids, $tpAtts, $tpCmts, $tpKidDone, $tpIsChild, $tpCatName,
$tpDue, $tpDueWord, $tpLate, $tpId,
$tpKid, $tpCat, $tpB, $tpBid, $tpBOn, $tpBCol, $tpKidRow, $tpCmt,
$attRows, $attCanEdit, $attDelUrl, $attDelField, $attExtra);
?>