用户 2026-11-05:「点开之后,就可以修改任务的所有信息,包括:
1. 上传附件;2. 添加评论;3. 添加子任务;4. 修改截止时间
(已有值要带进来);5. 改状态、挪看板。这些操作全部都在这一个页面完成。」
盘点发现:附件 / 评论 / 子任务 / 状态 / 看板 **本来就有**(_panel.php 里
早就实现了),只有「截止时间」是只读的一行 dd。所以只补这一项。
四处改动:
① Task::setDue()(新增)
不复用 update():那是全字段覆盖(title/note/status/assignee/repeat/
parent/board/category 一起写)。表单只交 due_at 的话,其余字段会被
默认值抹成空 ——「只想改个日期,结果标题和备注没了」,不可恢复。
⇒ 单独一条只写 due_at 的 UPDATE。
② tasks/due 路由(新增,紧跟 tasks/status)
与 status/move 同一套约定:ajax_panel=1 回整块新面板,否则 flash+302。
空串 = 清掉截止时间。
③ _panel.php:只读「截止」行 → AntD 单点控件
原来:
<?php if ($tpDue !== ): ?>…<dd>截止 …</dd>…<?php endif; ?>
**没有截止时间时整行都不显示** —— 用户根本不知道该去哪填。
现在不管有没有值都画控件,$dtValue = $tpDue 自动带出已有值。
有值且在逾期时,右侧补一行「还剩 N 天」小字(.tk-due-word)。
④ task_panel.js
· paint() 后调 fbBootAntd():面板是 AJAX 换进来的,bootAntd 首屏
扫过一次 [data-dt-antd-single],那时面板还不存在 ⇒ 必须补挂。
· bindDue():监听 .dt-input 的 change 立即保存。
不用按钮式 data-tk-act(面板里其它动作都是"点一下/选完就生效",
再让用户点一次保存会与其它处不一致)。
监听隐藏字段的 change 而不是 AntD 回调 ⇒ 覆盖 AntD 与原生下拉两种形态。
data-fb-due-bound 幂等标记防止重复绑监听(否则改一次发好几条请求)。
⑤ CSS:.tk-sec-inline > .dt-input 加 flex/min-width/max-width ——
.dt-input 是 flex-wrap,放进也是 flex-wrap 的 .tk-sec-inline 里
会被当弹性项按内容撑,窄屏上可能把标签挤走或控件溢出。
实测:
setDue 设值 => [2026-12-25 18:30:00] ✅
标题仍为原值 ✅ 没被清空(证明没用 update())
setDue 清空 => NULL ✅
面板渲染:data-tk-url-due / data-dt-antd-single / due_at_date 全在,
带出的日期值 = [2026-10-22] ✅
状态/附件/子任务/评论 均仍在
314 lines
16 KiB
PHP
314 lines
16 KiB
PHP
<?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 /* 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 /* ---- 截止时间:**可直接改**(#74) ----
|
||
用户 2026-11-05:「点开之后,就可以修改任务的所有信息,包括…
|
||
修改截止时间。当然,如果现在已经有截止时间,就把它带进来。」
|
||
|
||
🔴 原来是**只读的一行**:
|
||
<?php if ($tpDue !== ''): ?><div class="kv-row"><dt>截止</dt><dd>…</dd></div><?php endif; ?>
|
||
而且**没有截止时间时整行连显示都不显示** —— 用户根本不知道该去哪填。
|
||
⇒ 换成与 `_edit_form.php` 同一套的 AntD 单点控件(`dtPlugin=antd-single`),
|
||
并且**不管有没有值都画出来**(空着就是"还没设",比"没有这个字段"清楚)。
|
||
`$dtValue = $tpDue` ⇒ 已有截止时间会带进控件(用户明确要求)。
|
||
|
||
⚠️ 控件里改完**立即保存**(`data-tk-act="due"`,见 task_panel.js),
|
||
与状态/看板/子任务/评论/附件同一套交互 —— 面板里不该有"保存按钮"。
|
||
⚠️ 这个面板是 AJAX 塞进来的,AntD 要靠 `fbBootAntd()` 补挂,
|
||
见 task_panel.js 的 paint()。 */ ?>
|
||
<div class="tk-sec tk-sec-inline">
|
||
<span class="tk-sec-lb">截止</span>
|
||
<?php if ($tpCan): ?>
|
||
<?php
|
||
/* ⚠️ 变量名必须与 `_datetime.php` 的约定一致;
|
||
它内部一律用 `dt` 前缀的临时变量,不会踩到这里的 $tp* 或宿主页的 $t。 */
|
||
$dtName = 'due_at';
|
||
$dtValue = $tpDue;
|
||
$dtPlugin = 'antd-single';
|
||
$dtRequired = false;
|
||
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>
|
||
|
||
<?php /* ---- 只读的事实(有才显示) ---- */ ?>
|
||
<dl class="kv tk-kv">
|
||
<?php if (!empty($tpT['assignee_name'])): ?>
|
||
<div class="kv-row"><dt>负责人</dt>
|
||
<dd><?= e(person_name(['name' => $tpT['assignee_name'], 'my_nickname' => $tpT['assignee_nick'] ?? ''])) ?></dd>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php if ($tpCatName !== ''): ?>
|
||
<div class="kv-row"><dt>分类</dt><dd><?= e($tpCatName) ?></dd></div>
|
||
<?php endif; ?>
|
||
<?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);
|
||
?>
|