Files
FamilyBook/templates/tasks/_panel.php
T
laoyang 40fd6c501c v78 服务端:首页任务改成「就地处理」面板 + 新建任务搬到首页
用户原话:
「网页端,首页点击一个任务后,除了展示信息,应该有相应的功能,可以修改状态:
  待办、进行中,可以挪动分组,可以拖动上传附件,可以添加评论,添加子任务等。
  其目地,是为了让用户不离开首页,就能对任务进行简单的处理。」
「另外,在全部任务旁边,放一个新建任务的功能,其实就是把任务中的新建和编辑功能,
  搬过来,用弹出框来处理。」

一、首页任务浮层由「只读信息」改成「可操作面板」
- 新增 templates/tasks/_panel.php:状态三段 / 看板 chips(带看板色)/
  子任务(复用 _task_check.php)/ 附件拖放框(复用 _attachments.php)/ 讨论
- 新增路由 tasks/panel:只吐一块 HTML(找不到 404)
- 既有七个动作路由各加一条 ajax_panel 回包分支(状态 / 挪分组 / 新建子任务 /
  评论增删 / 附件增删),没有新开写接口
- helpers.php 新增 task_panel_wanted() / task_panel_json();
  fb_entry_json() 的 payload 增加 panel 字段(只有任务会给)
- 每次动作回**整块新面板**:一次动作会牵动子任务计数、附件张数、看板选中态,
  逐处同步 DOM 必然漏一处
- 拖进来的附件**立刻上传**(任务已存在,不该再让用户点一次保存),
  不复用 _stage_drop.php 那套「暂存 + 认领」

二、新建任务搬到首页
- 从 templates/tasks/index.php 抽出公共片段 templates/tasks/_add_modal.php
  (字段与原来一字未改),首页与任务页共用同一份
- 新增 assets/js/task_add.js 管开关(两页共用,幂等绑定);
  打开那一刻才 fbUpgradeRange() + fbBootAntd()(hidden 时宽高为 0,antd 会定位错乱)
- App\HomeCards 登记表里加 add => 新建任务,首页卡头与「全部任务」并排
- tasks/create 支持 back=home(白名单),新建完回首页

三、其它
- assets/js/task_panel.js:事件委托;动作后整块换面板;
  顺手同步首页那张卡的状态徽标与复选框(关掉浮层不能还是旧状态);
  传附件时保住用户写了一半的留言与子任务标题;取不到面板时退回只读信息
- app.css 追加面板样式(含 color-mix 的 @supports 兜底)与卡片窄到宽 2 时允许折行

验证:本地 e2e 1558/0、几何 428/0 + 660/0、v78 反向验证 11/0、
片段作用域体检 35/0、php -l 与 node --check 全过
2026-10-22 12:40:14 +08:00

282 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
/**
* 首页「近期任务」的就地面板(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 /* 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 /* ---- 只读的事实(有才显示) ---- */ ?>
<dl class="kv tk-kv">
<?php if ($tpDue !== ''): ?>
<div class="kv-row"><dt>截止</dt>
<dd class="<?= $tpLate ? 'kb-late' : '' ?>"><?= e($tpDue) ?><?= $tpDueWord !== '' ? '(' . e($tpDueWord) . ')' : '' ?></dd>
</div>
<?php endif; ?>
<?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);
?>