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 全过
This commit is contained in:
2026-10-22 12:40:14 +08:00
parent 036e208809
commit 40fd6c501c
13 changed files with 1216 additions and 178 deletions
+7
View File
@@ -78,6 +78,13 @@ final class HomeCards
'name' => '近期任务', 'desc' => '进行中与待办(含看板与状态)',
'w' => 4, 'h' => 8, 'route' => 'tasks', 'more' => '全部任务', 'n' => 0,
'mod' => 'tasks',
/* 🔴 v78(用户 2026-10-22):「在全部任务旁边,放一个新建任务的功能,
其实就是把任务中的新建和编辑功能,搬过来,用弹出框来处理。」
⇒ 卡片右上角在这一张上多一颗按钮;它打开的是**与任务页同一张**弹框
(`templates/tasks/_add_modal.php`)。
⚠️ 登记在这里而不是在模板里写死:以后哪张卡要"新建"就加一行,
模板只负责按有没有这个字段决定画不画。 */
'add' => '新建任务',
],
/* 🔴 v71c(用户 2026-10-21):「通知消息这个,也是首页的卡片,可以调整顺序和大小。」
⇒ 它从"网格外的写死区块"变成**正规卡片**:可拖顺序、可调宽高、可关掉。
+81
View File
@@ -443,6 +443,87 @@ function fb_entry_json(array $ev, string $detail = '', string $edit = '', array
'info' => $evOwn ? url('calendar/event', ['id' => $evRef, 'embed' => 'detail']) : '',
'repeat' => (string)($ev['repeat'] ?? '') !== '',
'occ' => (string)($ev['occ'] ?? ''),
/* 🔴 v78(用户 2026-10-22):**就地面板**的地址。
首页「近期任务」点开时不再只给一行行只读信息,而是直接给出这张
面板(状态 / 看板 / 子任务 / 附件 / 评论 都能就地改),用户不必离开首页。
⚠️ 只有**任务**会给这个字段(`_card_tasks.php` 里传进来);
日程/观影/旅程仍然走原来的"详情 + 编辑/删除"三颗按钮 ——
它们没有"在首页上简单处理一下"的诉求,硬塞一个面板只会让浮层变复杂。 */
'panel' => (string)($ev['panel'] ?? ''),
];
return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT);
}
/**
* 这次请求是不是「首页任务面板」发来的(v78)。
*
* 面板里的每一个动作都打**既有路由**,只多带一个 `ajax_panel=1`;
* 服务端据此把"flash + 302 跳转"换成"一段新面板 + 一句人话"。
*
* ⚠️ 判据只认 POST 里那个字段,**不要**顺手用 `X-Requested-With` 代替:
* `tasks/status` 早就用那个头区分"看板上的就地改(只要状态)"和"表单提交",
* 两件事混在一个判据里,改一处必然破坏另一处。
*/
function task_panel_wanted(): bool
{
return (string)($_POST['ajax_panel'] ?? '') === '1';
}
/**
* 把「这一条任务的最新面板」当 JSON 回给前端(v78)。
*
* 为什么**每次都重画整块面板**,而不是让前端自己把那一行改掉:
* · 一个动作的影响面比看起来大 —— 勾一条子任务会改"2/3"这个计数;
* 传一个附件会改附件张数与缩略图墙;挪看板会改上面那排 chip 的选中态。
* 逐处同步(本项目在 v55 的状态下拉上已经手写过一次)是"漏一处就自相矛盾"。
* · 面板很小(一次 Task::find + 一个片段),重画一次比维护 N 处 DOM 同步便宜。
*
* ⚠️ `html` 在失败(ok=false)时**也照样给**:失败的典型原因是"权限不够/内容为空",
* 这正是最需要让用户看到"现在的真实状态"的时候,回一个空壳反而把旧状态留在屏幕上。
*
* @param int $taskId 要重画哪一条(子任务新增时传**父任务**,否则会把用户弹到子任务上)
* @param string $msg 给用户看的一句话(落在浮层底栏左侧)
* @param bool $ok 这次动作成不成功(只影响提示语气与 HTTP 码,不影响 html)
*/
function task_panel_json(int $taskId, string $msg = '', bool $ok = true): void
{
$familyId = (int)\App\Auth::familyId();
$me = (int)\App\Auth::personId();
$t = \App\Task::find($familyId, $taskId, $me);
if (!$t) {
header('Content-Type: application/json; charset=utf-8', true, 404);
echo json_encode(['ok' => false, 'msg' => '找不到这项任务。'], JSON_UNESCAPED_UNICODE);
exit;
}
$people = \App\Person::roster($familyId, $me, 'member', '');
/* @人 高亮的候选名:与 tasks/show.php 同一份口径(真名 + 我的昵称都算),
否则同一句话在详情页高亮、在首页面板不高亮。 */
$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)));
$html = \App\View::renderPartial('tasks/_panel', [
't' => $t,
'people' => $people,
'names' => $names,
'boards' => \App\Task::boards($familyId),
'categories' => \App\Task::categories($familyId),
'canWrite' => \App\Auth::canWrite() && \App\Task::canWrite($t, $me),
'meId' => $me,
]);
header('Content-Type: application/json; charset=utf-8', true, $ok ? 200 : 422);
echo json_encode([
'ok' => $ok,
'msg' => $msg,
'id' => $taskId,
'status' => (string)($t['status'] ?? ''),
'word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
'html' => $html,
], JSON_UNESCAPED_UNICODE);
exit;
}
+73
View File
@@ -6615,3 +6615,76 @@ h5.md-h, h6.md-h { font-size: 14px; }
.hc-set-dim .select { width: 66px; }
.hc-set-n { width: 64px; }
.hc-set-null { width: 0; }
/* ============================================================
v78(用户 2026-10-22):首页「近期任务」的就地面板
------------------------------------------------------------
用户原话:「网页端,首页点击一个任务后,除了展示信息,应该有相应的功能,
可以修改状态:待办、进行中,可以挪动分组,可以拖动上传附件,可以添加评论,
添加子任务等。其目地,是为了让用户不离开首页,就能对任务进行简单的处理。」
这一段只画**面板本体**(壳、遮罩、底栏都沿用 `.cal-modal*` / `.fb-entry-panel`)。
三块强调色都不新造:状态用既有的 `.kb-st`(三档颜色是它定义的唯一来源),
看板 chip 用看板自己的 `--bc`(与看板列头、卡片上的点同一个色)。
============================================================ */
.tk-panel { display: flex; flex-direction: column; gap: 14px; }
.tk-sec { display: flex; flex-direction: column; gap: 8px; }
/* 段落之间的那条细线:面板里段落多(状态/看板/信息/子任务/附件/讨论),
不划线会糊成一整块;用 line-2(比正文分隔线更淡)以免像个表单边框。 */
.tk-sec + .tk-sec { border-top: 1px solid var(--line-2); padding-top: 14px; }
.tk-sec-h {
display: flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 600; color: var(--ink);
}
.tk-sec-h .group-n { margin-left: 0; }
/* 「状态」「看板」这两段是"标签 + 一排可点的东西",横排。
⚠️ 用 flex-wrap:看板可能是七八个,窄屏要能折行而不是撑出面板。 */
.tk-sec-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; }
.tk-sec-lb { flex: 0 0 34px; font-size: 12.5px; color: var(--faint); }
.tk-seg { display: flex; gap: 6px; flex-wrap: wrap; }
/* 面板里的状态徽标比卡片上的大一档(这里是要点的按钮,不是标签) */
.tk-seg .kb-st { padding: 3px 12px; font-size: 12.5px; }
.tk-chips { display: flex; gap: 6px; flex-wrap: wrap; }
.tk-chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 2px 10px; border-radius: 999px;
border: 1px solid var(--line); background: var(--surface);
font-size: 12px; color: var(--muted); cursor: pointer;
max-width: 100%;
}
.tk-chip:hover { border-color: var(--brand-2); color: var(--brand-ink); }
/* 选中的看板:用**它自己的颜色**描边 + 淡底 —— 与看板列头那颗点是同一个色,
用户不用读字就知道"这条现在在哪一摊事里"。 */
.tk-chip.is-on {
border-color: var(--bc, var(--brand));
background: color-mix(in srgb, var(--bc, var(--brand)) 12%, transparent);
color: var(--ink); font-weight: 600;
}
/* ⚠️ color-mix 在老浏览器上整条声明无效 → 底色会是透明的。
补一条纯 rgba 兜底(不指定色,统一淡灰),保证"选中"至少看得出来。 */
@supports not (background: color-mix(in srgb, red 10%, transparent)) {
.tk-chip.is-on { background: var(--line-2); }
}
.tk-chip.is-static { cursor: default; }
.tk-chip-dot {
width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto;
background: var(--bc, var(--faint));
}
/* 只读那几行(截止/负责人/分类/备注):面板里空间紧,行高比详情页收一点 */
.tk-kv .kv-row { padding: 6px 0; }
/* 加子任务那一行:输入框吃掉剩余宽度,按钮不缩 */
.tk-inline-form { display: flex; align-items: center; gap: 8px; }
.tk-inline-form .input { flex: 1 1 auto; min-width: 0; }
.tk-inline-form .btn { flex: 0 0 auto; }
/* 讨论里那条"删除":长得像链接(`.btn-inline` 负责去掉按钮的灰底,
`.link-quiet` 负责颜色),这里只把它推到本行最右。 */
.cmt-who .cmt-x { margin-left: auto; }
.tk-drop { margin-top: 4px; }
.tk-drop .upbox { min-height: 64px; }
@media (max-width: 640px) {
.tk-sec-lb { flex: 0 0 auto; width: 100%; }
}
/* v78:卡片窄到"宽 2"时,标题 +「全部任务」+「新建任务」三样挤不下 ——
允许折行,别把标题压成 0 宽(那会只剩两颗按钮,看不出这是哪张卡片)。 */
.hc-card.hc-w2 .hc-head { flex-wrap: wrap; }
+44 -1
View File
@@ -42,6 +42,11 @@
就地摘一条会漏;且这条系列在视图里可能横跨多天),以及
拿不到 JSON(老服务端 / 出错)。
⑦ **Esc / 点遮罩只关最上面那层**:antd 面板开着时先让它收(v38/v39 的坑)。
⑧ **v78 新增"面板态"**(任务专属):`data-fb-entry` 里带 `panel` 时,正文换成
`tasks/panel` 吐的那块**可操作面板**(状态 / 看板 / 子任务 / 附件 / 评论
都能就地改),底栏仍是「详情」那颗(跳任务详情页)。
面板里的动作由 `task_panel.js` 委托处理 —— **别在这里加**:
两个文件各管一段,谁也不会把谁的监听覆盖掉。
⚠️ 本脚本**全站加载**(layout.php):三处入口分散在不同页面,按需加载的判据
会在这些页面之间来回漏;它只有几 KB,不值得为省这点流量赌漏加载。
============================================================================ */
@@ -120,7 +125,9 @@
var isEvent = (data.source === 'event');
if (titleEl()) { titleEl().textContent = data.title || '详情'; }
if (bodyEl()) { bodyEl().innerHTML = '<dl class="kv">' + rowsHtml(data.rows) + '</dl>'; }
if (panelEl()) { panelEl().classList.remove('is-wide'); }
/* 🔴 v78:任务带 `panel`(可就地操作的那块)—— 面板比只读信息宽一号,
所以这里按**有没有 panel** 决定宽度,而不是按 source。 */
if (panelEl()) { panelEl().classList.toggle('is-wide', !!data.panel); }
setTip('');
/* ⚠️ 顺序要紧:**先** footShow('detail')(它会把所有按钮收掉、只点亮「详情」),
@@ -146,6 +153,42 @@
if (ed) { ed.hidden = !(isEvent && data.edit); }
var de = byId('fbEntryDelete');
if (de) { de.hidden = !(isEvent && data.del); }
/* 🔴 v78:任务面板 —— 把正文换成"能直接动手"的那一块。
放在最后(上面的只读 rows 已经画好),取不到就保留那份只读信息。 */
if (data.panel) { loadPanel(data.panel, data); }
}
/**
* 取回任务面板并塞进正文(v78)。
*
* 用户原话:「网页端,首页点击一个任务后,除了展示信息,应该有相应的功能…
* 其目地,是为了让用户不离开首页,就能对任务进行简单的处理。」
*
* ⚠️ 失败时**不要清空正文**:退回 `rows` 那份只读信息(用户至少还能看清这条是什么),
* 并且把宽度收回去 —— 一块空的宽面板比"只有信息"更像坏了。
* ⚠️ 面板里的事件全部由 `task_panel.js` 委托处理(动态换 HTML 不重绑),
* 这里只负责"取回来放进去"。
*/
function loadPanel(url, data) {
if (!url || !window.fetch) { return; }
if (bodyEl()) { bodyEl().innerHTML = '<p class="empty-hint">正在载入…</p>'; }
fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) {
if (!r.ok) { throw new Error('panel'); }
return r.text();
})
.then(function (html) {
if (!bodyEl() || html.indexOf('data-tk-panel') < 0) { throw new Error('panel'); }
bodyEl().innerHTML = html;
runInlineScripts(bodyEl(), html);
})
.catch(function () {
if (!bodyEl()) { return; }
bodyEl().innerHTML = '<dl class="kv">' + rowsHtml(data && data.rows) + '</dl>';
if (panelEl()) { panelEl().classList.remove('is-wide'); }
setTip('这条任务的快捷面板没载入,先看基本信息。', true);
});
}
function restoreDetail() {
+62
View File
@@ -0,0 +1,62 @@
/* ============================================================================
「新建任务」弹框的开关(v78)
----------------------------------------------------------------------------
用户原话(2026-10-22):「在全部任务旁边,放一个新建任务的功能,其实就是把任务中的
新建和编辑功能,搬过来,用弹出框来处理。」
两个入口、一张弹框:
· 任务看板页(`tasks/index.php`)页头那颗「新建任务」;
· 首页「近期任务」卡片右上角那颗(在「全部任务」右边)。
两处都是 `data-tk-add-open`,弹框本体是 `templates/tasks/_add_modal.php`。
为什么单独成一个脚本(而不是各页写一段内联):
· 内联脚本在 AJAX 换正文时**不会被重跑**(v42/v47 连着栽过两次);
· 两个入口各写一段,必然有一处忘了在**打开那一刻**再 boot 一次日期控件 ——
而弹框初始是 `hidden`,antd 面板拿到 0 尺寸会定位到屏幕左上角
(`tasks/show.php` 的编辑弹框就是为这个专门 boot 过,这里照抄同一手法)。
⚠️ 全站加载,但有幂等闸门、页面里没有这张弹框时**什么都不做**(几行判断而已)。
============================================================================ */
(function () {
'use strict';
if (window.__fbTkAddBound) { return; }
window.__fbTkAddBound = true;
function card() { return document.getElementById('taskAddCard'); }
function setOpen(on) {
var c = card();
if (!c) { return; }
c.hidden = !on;
if (!on) { return; }
/* 打开这一刻才升级日期控件(hidden 时宽高为 0,antd 面板会定位错乱)。
两步都要:先绑自建下拉(兜底),再挂 antd(正主);两个函数都自判幂等。 */
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
var t = document.getElementById('tk-title');
if (t) { t.focus(); }
}
function antdOpen() {
var d = document.querySelector('.ant-picker-dropdown');
return !!(d && !d.classList.contains('ant-picker-dropdown-hidden'));
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
if (t.closest('[data-tk-add-open]')) { e.preventDefault(); setOpen(true); return; }
if (t.closest('[data-tk-add-close]')) { e.preventDefault(); setOpen(false); return; }
var c = card();
/* 点遮罩关、点面板内不关 —— 与站内其它浮层同一套行为 */
if (c && t === c) { setOpen(false); }
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') { return; }
var c = card();
if (!c || c.hidden) { return; }
if (antdOpen()) { return; } // 让 antd 先收它自己的面板
setOpen(false);
});
})();
+319
View File
@@ -0,0 +1,319 @@
/* ============================================================================
首页任务面板 —— 就地改状态 / 挪看板 / 加子任务 / 传附件 / 发评论(v78)
----------------------------------------------------------------------------
用户原话(2026-10-22):
「网页端,首页点击一个任务后,除了展示信息,应该有相应的功能,可以修改状态:
待办、进行中,可以挪动分组,可以拖动上传附件,可以添加评论,添加子任务等。
其目地,是为了让用户不离开首页,就能对任务进行简单的处理。」
这块面板由 `tasks/panel` 吐 HTML、`entry_popup.js` 塞进 `#fbEntryBody`。
本脚本只做一件事:**把面板里的每个动作发出去,再把服务端回来的新面板换上去**。
三条纪律(每条都对应一次踩过的坑):
① **一律事件委托挂 document**:面板 HTML 每次动作后整块换掉
(换完就是一批全新的节点),逐个 `addEventListener` 必然漏第二块。
② **动作之后重画整块面板**,不自己改 DOM:
勾一条子任务会改「2/3」这个计数、传一个附件会改附件张数与缩略图墙、
挪看板会改上面那排 chip 的选中态 —— 逐处同步必然漏一处(v55 手写过一次,
结局是"下拉改了、页头还写着旧状态")。面板很小,重画比同步便宜且不会自相矛盾。
③ **拿不到 JSON 就整页重载**:宁可刷一次,也不要在屏幕上留一个"看着成功了、
其实服务端没记上"的假状态。这与全站"没有 JS 也能用"的口径是同一条。
============================================================================ */
(function () {
'use strict';
function bodyEl() { return document.getElementById('fbEntryBody'); }
function panelEl() {
var b = bodyEl();
return b ? b.querySelector('[data-tk-panel]') : null;
}
function tipEl() { return document.getElementById('fbEntryTip'); }
function setTip(msg, err) {
var t = tipEl();
if (!t) { return; }
t.textContent = msg || '';
t.className = 'cal-modal-tip' + (err ? ' err' : '');
}
/** CSRF:面板自己带一份(`data-tk-csrf`);没有就退回浮层遮罩里那份 */
function csrf() {
var p = panelEl();
var el = p ? p.querySelector('[data-tk-csrf]') : null;
if (!el) { el = document.querySelector('#fbEntryModal .fb-entry-csrf input[name="_csrf"]'); }
return el ? el.value : '';
}
function taskId() {
var p = panelEl();
return p ? (p.getAttribute('data-tk-panel') || '0') : '0';
}
/** 面板在 HTML 里声明自己的动作地址 —— 脚本里**不写死路由名**(改名不会漏改这一处) */
function urlOf(key) {
var p = panelEl();
return p ? (p.getAttribute('data-tk-url-' + key) || '') : '';
}
/** 用服务端回来的新面板替换旧的(成功失败都换:失败时服务端给的是**真实状态**) */
function paint(d, keepDraft) {
var b = bodyEl();
if (!b || !d || typeof d.html !== 'string' || d.html === '') { return false; }
/* ⚠️ 传附件时**保住**用户还没发出去的那段留言:
拖一张图就把写了一半的评论清空,是那种"不报错、但让人想骂人"的丢失。
其他动作(发评论/加子任务)不保 —— 那是他自己刚提交的内容,回填反而是错的。 */
var draft = '', draftTitle = '';
if (keepDraft) {
var ta = b.querySelector('textarea[name="content"]');
if (ta) { draft = ta.value; }
var ti = b.querySelector('.tk-inline-form input[name="title"]');
if (ti) { draftTitle = ti.value; }
}
b.innerHTML = d.html;
if (draft !== '') {
var ta2 = b.querySelector('textarea[name="content"]');
if (ta2) { ta2.value = draft; }
}
if (draftTitle !== '') {
var ti2 = b.querySelector('.tk-inline-form input[name="title"]');
if (ti2) { ti2.value = draftTitle; }
}
return true;
}
/**
* 把面板里的最新状态**顺手**同步到首页那张卡上(v78)。
*
* 为什么要有这一步:面板是浮层,背后那张任务是"带着旧状态"站在那儿的。
* 用户改完关掉浮层,如果卡片上还写着「待办」,他会怀疑"到底改上了没有"。
* 只改状态徽标与复选框 —— 看板徽标/逾期色这些要重算的东西留给下次刷新,
* 不在这里猜(猜错比不猜更糟)。
*/
function syncRow(d) {
if (!d || !d.status) { return; }
var row = document.querySelector('[data-task-row="' + taskId() + '"]');
if (!row) { return; }
var badge = row.querySelector('.kb-st');
if (badge) {
badge.className = 'kb-st is-' + d.status + ' is-static';
if (d.word) { badge.textContent = d.word; }
}
var chk = row.querySelector('.tk-check[data-tk-done]');
if (chk) {
chk.setAttribute('data-tk-status', d.status);
chk.classList.toggle('is-done', d.status === 'done');
chk.setAttribute('aria-pressed', d.status === 'done' ? 'true' : 'false');
}
}
/**
* 发一个动作。
* @param {string} url 动作地址(空 = 这条路由没登记,直接放弃)
* @param {FormData} fd 已经带好 `_csrf` / `ajax_panel` 的请求体
* @param {boolean} keepDraft 换面板时要不要保住用户正在打的字(传附件时为 true)
*/
function send(url, fd, keepDraft) {
if (!url) { setTip('这一项暂时不能在首页改。', true); return; }
if (!window.fetch || !window.FormData) { window.location.reload(); return; }
fetch(url, {
method: 'POST',
body: fd,
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) {
return r.json().catch(function () { return null; });
}).then(function (d) {
if (!d) { window.location.reload(); return; } // 会话过期 / 500:整页重载最诚实
paint(d, keepDraft);
syncRow(d);
setTip(d.msg || '', !d.ok);
}).catch(function () { window.location.reload(); });
}
/** 把「id + 几个附加字段」包成一个动作请求 */
function act(url, extra) {
var fd = new FormData();
fd.append('_csrf', csrf());
fd.append('id', taskId());
fd.append('ajax_panel', '1');
Object.keys(extra || {}).forEach(function (k) { fd.append(k, extra[k]); });
send(url, fd);
}
/* ---- ① 表单提交(加子任务 / 发评论 / 删附件)------------------------------
面板里**只有需要打字或选文件的地方**才用真正的 <form>(见 _panel.php 头部第 ② 条),
它们各自带 `ajax_panel=1` —— 这里按这个字段认人,别的表单(页面上的)不碰。 */
document.addEventListener('submit', function (e) {
var f = e.target;
if (!f || !f.closest || !f.closest('#fbEntryBody')) { return; }
if (!f.querySelector('input[name="ajax_panel"]')) { return; }
e.preventDefault();
send(f.getAttribute('action') || '', new FormData(f));
});
/* ---- ② 按钮动作(状态 / 看板 / 删评论 / 子任务复选框)------------------- */
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest || !t.closest('#fbEntryBody')) { return; }
/* 子任务复选框:走**状态**那条路由(与看板/详情页那颗复选框同一个入口)。
⚠️ 必须 stopPropagation:首页列表那套监听是按"加载时存在的元素"绑的,
但万一以后改成委托,这里不拦就会一次点击发两个请求。
⚠️ 勾的是**子任务**,所以 id 用复选框自己的 —— 不能套 `act()`(它默认带父任务 id)。
再补一个 `panel_of`:服务端据此重画**父任务**的面板(拿子任务 id 去画,
得到的是"一块只有它自己的面板",用户会觉得父任务不见了)。 */
var chk = t.closest('.tk-check[data-tk-done]');
if (chk) {
e.preventDefault();
e.stopPropagation();
var now = chk.getAttribute('data-tk-status') || 'todo';
var kidFd = new FormData();
kidFd.append('_csrf', csrf());
kidFd.append('id', chk.getAttribute('data-tk-done') || '0');
kidFd.append('status', now === 'done' ? 'todo' : 'done');
kidFd.append('ajax_panel', '1');
kidFd.append('panel_of', taskId());
send(urlOf('status'), kidFd);
return;
}
var btn = t.closest('[data-tk-act]');
if (!btn) { return; }
var what = btn.getAttribute('data-tk-act');
if (what === 'status') {
e.preventDefault();
if (btn.classList.contains('is-on')) { return; } // 已经是这个状态,别白跑一趟
act(urlOf('status'), { status: btn.getAttribute('data-status') || 'todo' });
return;
}
if (what === 'board') {
e.preventDefault();
if (btn.classList.contains('is-on')) { return; }
act(urlOf('move'), { board: btn.getAttribute('data-board') || '0' });
return;
}
if (what === 'cmt-del') {
e.preventDefault();
if (!window.confirm('删掉这条留言?')) { return; }
/* 删留言的契约是 `id`=留言 id、`task_id`=所属任务(既有路由就这么收的),
所以这里自己拼,不套 act()。 */
var fd = new FormData();
fd.append('_csrf', csrf());
fd.append('id', btn.getAttribute('data-cmt') || '0');
fd.append('task_id', taskId());
fd.append('ajax_panel', '1');
send(urlOf('cmt-del'), fd);
return;
}
});
/* ---- ③ 附件:点击 / 拖动 → **立刻**上传,不用再点一次"上传" -------------
为什么是"立刻":这条任务已经存在了,不像新建表单那样还要等整体提交
(`_stage_drop.php` 那套暂存令牌是给"表单还没提交"准备的,见 _panel.php 的说明)。 */
function pickInput() {
var b = bodyEl();
return b ? b.querySelector('[data-tk-drop-input]') : null;
}
function dropBox() {
var b = bodyEl();
return b ? b.querySelector('[data-tk-drop-box]') : null;
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
if (!t.closest('[data-tk-drop-box]')) { return; }
e.preventDefault();
var inp = pickInput();
if (inp) { inp.click(); }
});
/* 键盘也能用(框上有 tabindex="0" + role="button") */
document.addEventListener('keydown', function (e) {
var t = e.target;
if (!t || !t.closest || !t.closest('[data-tk-drop-box]')) { return; }
if (e.key !== 'Enter' && e.key !== ' ') { return; }
e.preventDefault();
var inp = pickInput();
if (inp) { inp.click(); }
});
/* 拖到框上要给 dragover preventDefault,否则浏览器不认这是落点(还会当"打开文件") */
document.addEventListener('dragover', function (e) {
var t = e.target;
if (!t || !t.closest || !t.closest('[data-tk-drop-box]')) { return; }
e.preventDefault();
t.closest('[data-tk-drop-box]').classList.add('is-over');
});
document.addEventListener('dragleave', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var box = t.closest('[data-tk-drop-box]');
if (box) { box.classList.remove('is-over'); }
});
document.addEventListener('drop', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var box = t.closest('[data-tk-drop-box]');
if (!box) { return; }
e.preventDefault();
box.classList.remove('is-over');
var dt = e.dataTransfer;
if (!dt || !dt.files || !dt.files.length) { return; }
upload(dt.files);
});
document.addEventListener('change', function (e) {
var t = e.target;
if (!t || !t.hasAttribute || !t.hasAttribute('data-tk-drop-input')) { return; }
if (!t.files || !t.files.length) { return; }
upload(t.files);
t.value = ''; // 同一个文件再选一次也要能触发 change
});
/**
* 逐个上传(串行)。**全部传完才重画一次**面板 ——
* 每传一张就重画会把正在用的落点框换掉,用户拖第二张时框已经是个新节点。
*/
function upload(files) {
var url = urlOf('attach');
if (!url) { setTip('这一项暂时不能传附件。', true); return; }
var tip = bodyEl() ? bodyEl().querySelector('[data-tk-drop-tip]') : null;
var total = files.length, i = 0, last = null, bad = 0;
if (tip) { tip.hidden = false; tip.textContent = '正在上传 0/' + total + '…'; }
function step() {
if (i >= total) {
if (last) { paint(last, true); setTip(last.msg || '', !last.ok); }
if (tip) {
tip.hidden = false;
tip.textContent = bad > 0
? ('有 ' + bad + ' 个文件没传上去。')
: ('已传 ' + total + ' 个。');
}
if (!last && bad > 0) { setTip('附件没传上去,再试一次。', true); }
return;
}
var f = files[i++];
var fd = new FormData();
fd.append('_csrf', csrf());
fd.append('id', taskId());
fd.append('ajax_panel', '1');
fd.append('attach[]', f);
if (tip) { tip.textContent = '正在上传 ' + (i < total ? i + 1 : total) + '/' + total + '…'; }
fetch(url, {
method: 'POST',
body: fd,
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) {
return r.json().catch(function () { return null; });
}).then(function (d) {
if (d && d.html) { last = d; } else { bad++; }
step();
}).catch(function () { bad++; step(); });
}
step();
}
})();
+116
View File
@@ -915,6 +915,26 @@ switch ($r) {
$weather = $weatherLoc === null ? null
: Home::weather($familyId, $weatherLoc['lat'], $weatherLoc['lon'], $weatherLoc['place']);
/* 🔴 v78(用户 2026-10-22):「在全部任务旁边,放一个新建任务的功能,
其实就是把任务中的新建和编辑功能,搬过来,用弹出框来处理。」
⇒ 那张弹框(`templates/tasks/_add_modal.php`)需要看板 / 分类 / 名册三样。
⚠️ 只有「近期任务」卡真的摆在首页上、且当前账号可写时才准备:
卡片关掉就没有入口,不该让每个首页请求白跑三条查询。
⚠️ 弹框本身也在模板里按 `$tkAdd` 是否为空决定渲不渲染 —— 两处一个判据。 */
$tkAdd = null;
if (Auth::canWrite()) {
foreach ($homeCards as $hcRow) {
if ((string)($hcRow['k'] ?? '') === 'tasks') {
$tkAdd = [
'boards' => Task::boards($familyId),
'categories' => Task::categories($familyId),
'people' => Person::roster($familyId, $me, 'member', ''),
];
break;
}
}
}
View::render('home', [
'title' => '首页',
'family' => Family::find($familyId),
@@ -948,6 +968,8 @@ switch ($r) {
// v62 卡片体系
'homeCards' => $homeCards,
'cardData' => $cardData,
// v78:首页「新建任务」弹框的数据(卡片没摆/只读账号时为 null)
'tkAdd' => $tkAdd,
/* 🔴 v71c(用户 2026-10-21):「通知消息这个,也是首页的卡片,可以调整
顺序和大小。」
⇒ 通知**不再是**这里单独传的两个变量(`notifs` / `notifUnread`),
@@ -6327,6 +6349,51 @@ $want = array_keys($want);
redirect('tasks/settings');
break;
/**
* 首页「近期任务」的就地面板(v78 用户 2026-10-22)。
*
* 用户原话:「网页端,首页点击一个任务后,除了展示信息,应该有相应的功能,
* 可以修改状态:待办、进行中,可以挪动分组,可以拖动上传附件,可以添加评论,
* 添加子任务等。其目地,是为了让用户不离开首页,就能对任务进行简单的处理。」
*
* ⇒ 这个路由只吐**一块 HTML**(`templates/tasks/_panel.php`),由
* `entry_popup.js` 塞进条目浮层的正文里。动作全部走既有路由
* (status / move / create / comment_add / attach_add…),这里不新开写接口。
*
* ⚠️ 找不到就回 404 纯文本:调用方(浮层)拿到非 200 会退回"只有详情"的形态,
* 不会把一段错误页塞进面板。
*/
case 'tasks/panel':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$t = Task::find($familyId, (int)($_GET['id'] ?? 0), $me);
if (!$t) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo '找不到这项任务。';
exit;
}
$people = Person::roster($familyId, $me, 'member', '');
$names = [];
foreach ($people as $p) {
$names[] = person_name($p);
$nm = trim((string)($p['name'] ?? ''));
if ($nm !== '') {
$names[] = $nm;
}
}
header('Content-Type: text/html; charset=utf-8');
echo View::renderPartial('tasks/_panel', [
't' => $t,
'people' => $people,
'names' => array_values(array_unique(array_filter($names))),
'boards' => Task::boards($familyId),
'categories' => Task::categories($familyId),
'canWrite' => Auth::canWrite() && Task::canWrite($t, $me),
'meId' => $me,
]);
exit;
case 'tasks/show':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
@@ -6399,6 +6466,12 @@ $want = array_keys($want);
}
$title = trim((string)($_POST['title'] ?? ''));
if ($title === '') {
/* 🔴 v78:面板里"加子任务"是一条 AJAX 动作 —— 失败也得回面板,
否则用户被跳到看板(人在首页,却被扔去了另一个模块)。 */
if (task_panel_wanted()) {
$panelId = (int)($_POST['parent_id'] ?? 0) ?: (int)($_POST['id'] ?? 0);
task_panel_json($panelId, '任务得写清楚要做什么。', false);
}
flash('err', '任务得写清楚要做什么。');
redirect('tasks');
}
@@ -6447,6 +6520,18 @@ $want = array_keys($want);
if (!$isEdit && (string)($_POST['back'] ?? '') === 'tasks') {
redirect('tasks');
}
/* 🔴 v78:首页那张「新建任务」弹框提交时带 `back=home` —— 回首页,
卡片上立刻能看到刚建的这条(用户是"不离开首页就把事办了")。 */
if (!$isEdit && (string)($_POST['back'] ?? '') === 'home') {
redirect('home');
}
/* 🔴 v78:面板里"加子任务"—— 回**父任务**的面板。
⚠️ 不能回子任务自己(`$tid`):那等于把用户从"这件事"弹到"其中一小步",
而且面板里子任务不挂子任务,回过去看到的是一块没有被加进去的界面。 */
if (!$isEdit && task_panel_wanted()) {
$panelId = (int)($_POST['parent_id'] ?? 0) > 0 ? (int)$_POST['parent_id'] : $tid;
task_panel_json($panelId, '子任务已加上。');
}
redirect('tasks/show', ['id' => $tid]);
break;
@@ -6469,6 +6554,12 @@ $want = array_keys($want);
// (状态那条保留给任务详情页的"标记完成"和子任务用)。
if (array_key_exists('board', $_POST)) {
Task::moveToBoard($familyId, $id, (int)$_POST['board'], $before, $me);
/* 🔴 v78:面板里"挪动分组"(换看板)走这里 —— 回新面板,别跳走。
⚠️ 提示语要说出**挪到哪儿**:看板名在服务端取(前端那排 chip 的文字
可能被用户改过名,用 POST 回来的 id 现查一次才是真的)。 */
if (task_panel_wanted()) {
task_panel_json($id, '已挪到「' . Task::boardName($familyId, (int)$_POST['board']) . '」。');
}
redirect('tasks');
break;
}
@@ -6524,6 +6615,13 @@ $want = array_keys($want);
// v24 第 25 批:勾复选框 / 点状态徽标都走这条 ——
// 带 X-Requested-With 时回 JSON(前端就地改那张卡,不刷页);
// 普通表单提交照旧 302(没有 JS 时的兜底)。
// 🔴 v78:首页任务面板(`ajax_panel=1`)要的是**整块新面板**,
// 它比"只回一个状态"多带了子任务进度、看板选中态等一整页的一致性。
// ⚠️ 面板里勾的是**子任务**,重画时要用 `panel_of`(父任务)而不是子任务自己。
if (task_panel_wanted()) {
$panelId = (int)($_POST['panel_of'] ?? 0);
task_panel_json($panelId > 0 ? $panelId : $id, $msg);
}
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
@@ -6564,8 +6662,14 @@ $want = array_keys($want);
$cid = Task::addComment($id, $me, (string)($_POST['content'] ?? ''));
if ($cid > 0) {
attach_save_files($familyId, 'task_comment', $cid, 'attach', $me);
if (task_panel_wanted()) {
task_panel_json($id, '留言已发表。');
}
flash('ok', '留言已发表。');
} else {
if (task_panel_wanted()) {
task_panel_json($id, '留言是空的。', false);
}
flash('err', '留言是空的。');
}
redirect('tasks/show', ['id' => $id]);
@@ -6578,6 +6682,9 @@ $want = array_keys($want);
csrf_check();
$taskId = (int)($_POST['task_id'] ?? 0);
Task::deleteComment((int)($_POST['id'] ?? 0), $me); // 内部只删自己的
if (task_panel_wanted()) {
task_panel_json($taskId, '留言已删除。');
}
flash('ok', '留言已删除。');
redirect('tasks/show', ['id' => $taskId]);
break;
@@ -6594,6 +6701,12 @@ $want = array_keys($want);
redirect('tasks');
}
$warns = attach_save_files($familyId, Task::ATTACH, $id, 'attach', $me);
/* 🔴 v78:面板里"拖动上传"是**一个文件一条请求**(拖几张就是几次),
所以这里每次都要把最新面板回过去 —— 缩略图墙与"附件 n"随之长出来。 */
if (task_panel_wanted()) {
task_panel_json($id, $warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。',
!$warns);
}
flash($warns ? 'err' : 'ok', $warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。');
redirect('tasks/show', ['id' => $id]);
break;
@@ -6613,6 +6726,9 @@ $want = array_keys($want);
redirect('tasks');
}
\App\Attachment::remove($aid);
if (task_panel_wanted()) {
task_panel_json($tid, '附件已删除。');
}
flash('ok', '附件已删除。');
redirect('tasks/show', ['id' => $tid]);
break;
+32
View File
@@ -120,6 +120,17 @@
<a class="btn btn-sm hd-more<?= $hcColor === '' ? ' btn-ghost' : '' ?>"
href="<?= e($hcMore) ?>"<?= $hcColor !== '' ? ' style="--hd-c: ' . e($hcColor) . ';"' : '' ?>><?= e((string)$hcDef['more']) ?></a>
<?php endif; ?>
<?php /* 🔴 v78(用户 2026-10-22):「在全部任务旁边,放一个新建任务的功能,
其实就是把任务中的新建和编辑功能,搬过来,用弹出框来处理。」
⇒ 卡片登记表里登记了 `add` 的(目前只有「近期任务」)就在
「全部任务」**右边**再多一颗 —— 同一套 `.hd-more` 配色,
所以它是任务模块的主题色,不会跟旁边那颗抢眼。
⚠️ 按钮本身只带 `data-tk-add-open`:开关逻辑在 `assets/js/task_add.js`,
那张弹框的 HTML 在页面末尾(`tasks/_add_modal.php`)。 */ ?>
<?php if ((string)($hcDef['add'] ?? '') !== '' && \App\Auth::canWrite()): ?>
<button type="button" class="btn btn-sm hd-more<?= $hcColor === '' ? ' btn-ghost' : '' ?>"
data-tk-add-open<?= $hcColor !== '' ? ' style="--hd-c: ' . e($hcColor) . ';"' : '' ?>><?= e((string)$hcDef['add']) ?></button>
<?php endif; ?>
</div>
<?php require $hcTpl; ?>
</section>
@@ -198,3 +209,24 @@
})();
</script>
<?php endif; ?>
<?php /* ==================== 「新建任务」弹框(v78 用户 2026-10-22)====================
用户原话:「在全部任务旁边,放一个新建任务的功能,其实就是把任务中的新建和编辑功能,
搬过来,用弹出框来处理。」
⚠️ 与任务页(`templates/tasks/index.php`)**共用同一份** `tasks/_add_modal.php` ——
两处各写一张表单,字段必然有一天不同步(`_edit_form.php` 头部记着同一条教训)。
⚠️ `$tkAdd` 只在「近期任务」卡片真的摆在首页、且当前账号可写时才由路由准备
(见 index.php 的 home 分支):卡片被关掉就没有入口,也就不该白跑三条查询。
⚠️ 新建完回**首页**(`back=home`),卡片上立刻能看到刚建的那条 ——
用户要的是"不离开首页就把事办了"。 */ ?>
<?php if (!empty($tkAdd)): ?>
<?php
$tkAddBack = 'home';
$tkAddBoards = (array)($tkAdd['boards'] ?? []);
$tkAddCats = (array)($tkAdd['categories'] ?? []);
$tkAddPeople = (array)($tkAdd['people'] ?? []);
require __DIR__ . '/tasks/_add_modal.php';
unset($tkAddBack, $tkAddBoards, $tkAddCats, $tkAddPeople, $tkAdd);
?>
<?php endif; ?>
+10
View File
@@ -10,6 +10,14 @@
* ⚠️ 每行前面有复选框,点了就地完成(AJAX,不跳页);表单与脚本在 home.php 末尾
* 各一份 —— 那份**必须留着**,别跟着卡片一起删。
* ⚠️ 标题的颜色跟着**看板色**(不是任务模块的主题色),见 v32 第 4 条那条注释。
*
* 🔴🔴 v78(用户 2026-10-22):「首页点击一个任务后,除了展示信息,应该有相应的功能,
* 可以修改状态:待办、进行中,可以挪动分组,可以拖动上传附件,可以添加评论,
* 添加子任务等。其目地,是为了让用户不离开首页,就能对任务进行简单的处理。」
* ⇒ 每一行除 `data-fb-entry`(浮层要的基础信息)外,再给一个 `panel` 地址:
* 浮层拿到它就把正文换成**可操作的面板**(`templates/tasks/_panel.php`),
* 地址是 `tasks/panel?id=`,动作全走既有路由(见 helpers.php 的 task_panel_*)。
* 面板取不到(404 / 网络)时,浮层会退回 `rows` 那份只读信息 —— 不会白屏。
*/
?>
<div class="hc-body">
@@ -35,6 +43,8 @@
'title' => (string)$t['title'],
'status_word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
'note' => (string)($t['note'] ?? ''),
/* v78:浮层把正文换成这块面板(可就地改状态/看板/子任务/附件/评论) */
'panel' => url('tasks/panel', ['id' => (int)$t['id']]),
], url('tasks/show', ['id' => (int)$t['id']]),
\App\Auth::canWrite() ? url('tasks/edit', ['id' => (int)$t['id']]) : '',
[['截止', ($t['due_at'] ?? '') !== '' ? substr((string)$t['due_at'], 0, 16) : ''],
+7
View File
@@ -251,6 +251,13 @@ require __DIR__ . '/_entry_popup.php'; ?>
三处入口分散在首页/日程页,按需加载的判据会在这些页面之间来回漏;
它只有几 KB,不值得为省这点流量赌"某个页面漏加载"。 */ ?>
<script src="<?= e(asset('/assets/js/entry_popup.js')) ?>" defer></script>
<?php /* 🔴 v78:首页任务面板的动作脚本(状态/看板/子任务/附件/评论)。
同样全站加载:面板是浮层的一部分,而浮层本身就挂在本布局里;
脚本自带"面板不在就什么都不做"的判据。 */ ?>
<script src="<?= e(asset('/assets/js/task_panel.js')) ?>" defer></script>
<?php /* 🔴 v78:「新建任务」弹框的开关。任务页与首页共用同一张弹框,
所以开关也只有一份 —— 两处各写一段,必然有一处忘了打开时 boot 日期控件。 */ ?>
<script src="<?= e(asset('/assets/js/task_add.js')) ?>" defer></script>
<?php /* 🔴 v32 第 3 条:浏览器通知(站内消息 + 日程到期提醒)。
⚠️ **只在已登录时加载** —— 登录页轮询会一直 302 去登录页,白跑。
同理也不在"免登录的导航展示页"上跑(那页没有会话)。
+169
View File
@@ -0,0 +1,169 @@
<?php
/**
* 「新建任务」弹框(v78 从 `tasks/index.php` 里抽出来的)。
*
* 为什么抽出来:用户 2026-10-22 说「在全部任务旁边,放一个新建任务的功能,
* 其实就是把任务中的新建和编辑功能,搬过来,用弹出框来处理。」
* ⇒ 首页也要有这张弹框。**绝不能各写一张**:
* 两张表单的字段迟早不同步(本项目在 `_edit_form.php` 上已经栽过一次,
* 见那份文件头部"为什么独立出来"的说明)。
*
* 用法(require 之前设好这几个变量):
* $tkAddBack 新建完回哪儿:'tasks'(看板,默认)/ 'home'(首页)
* $tkAddBoards 看板清单(Task::boards 的结果)
* $tkAddCats 分类清单(Task::categories 的结果)
* $tkAddPeople 名册(Person::roster 的结果)
*
* ⚠️ 表单字段名 / action / 契约与原来**一字未改**(服务端 `tasks/create` 收的就是这些)。
* ⚠️ 打开/关闭由 `assets/js/task_add.js` 管(全站加载,页面里没有这张弹框就什么都不做)——
* 两页共用一份脚本,不在这里写内联 script(内联脚本在 AJAX 换正文时不会被重跑,
* 这个坑 v42/v47 连着踩过两次)。
*/
$tkAddBack = (string)($tkAddBack ?? 'tasks');
$tkAddBoards = (array)($tkAddBoards ?? []);
$tkAddCats = (array)($tkAddCats ?? []);
$tkAddPeople = (array)($tkAddPeople ?? []);
?>
<?php if (\App\Auth::canWrite()): ?>
<div class="cal-modal" id="taskAddCard" role="dialog" aria-modal="true"
aria-label="新建任务" hidden>
<div class="cal-modal-panel">
<div class="cal-modal-head">
<b>新建任务</b>
<button type="button" class="cal-modal-x" data-tk-add-close aria-label="关闭">✕</button>
</div>
<?php /* ⚠️ enctype 必须有:本批新增了**附件**上传(用户第 6 条第 2 点
「新建任务时,竟然没有添加附件,这个应该加上」)。
没有它的话文件根本不会随表单发出去,而且 $_FILES 是空的。 */ ?>
<form method="post" action="<?= e(url('tasks/create')) ?>"
enctype="multipart/form-data" id="taskAddForm">
<?= csrf_field() ?>
<?php /* ⚠️ 新建后回**看板**(用户:「新建完之后刷新…看板页面」)。
`back=tasks` / `back=home` 走服务端白名单,见 index.php 的 tasks/create。 */ ?>
<input type="hidden" name="back" value="<?= e($tkAddBack) ?>">
<div class="cal-modal-body">
<?php /* 🔴 v44(用户 2026-10-12 第 5 条):「新建任务页面的排版还没有统一,
应该把标题放在前面,树框放在后面。」
⇒ 用观影/行程/日程那套 **`.mv-grid`**(标签 62px 右对齐 + CSS `::after` 冒号 +
控件占满剩余宽度),标题那一行在最前、其余字段依次跟上。 */ ?>
<div class="mv-grid">
<div class="mv-row">
<span class="mv-lb">任务名称<i>*</i></span>
<input class="input" id="tk-title" name="title" required
placeholder="如:周末大扫除">
</div>
<?php /* v24 第 5 条:截止时刻也走全局统一的「日期 + 时分秒」控件。
秒这一档保留(与其它模块同一口径),默认 23:59:59 —— "这天之前做完"
比"这天 00:00 之前"更符合直觉。
🔴 v44(用户第 5 条):「截止时间也用我们最新的日历控件,
记得把现在这个控件删掉。」
⇒ 换成 AntD 的**单点** DatePicker(带时刻):
`$dtPlugin='antd-single'`(见 app.js 的 mountAntdSingleDT)。 */ ?>
<div class="mv-row">
<span class="mv-lb">截止时间</span>
<?php $dtName = 'due_at'; $dtId = 'tk-due'; $dtValue = null;
$dtRequired = false; $dtPlugin = 'antd-single';
require __DIR__ . '/../_datetime.php'; ?>
</div>
<?php /* 🔴 v44(用户第 5 条):「负责人默认是自己。」
⇒ 默认选中当前登录的人(原来 `$ppSelected = []`,空着)。 */ ?>
<div class="mv-row">
<span class="mv-lb">负责人</span>
<?php
$ppName = 'assignee_id';
$ppPeople = $tkAddPeople;
$ppSelected = [(int)\App\Auth::personId()];
$ppMode = 'single';
$ppOpenBtn = '更换负责人';
$ppTitle = '谁负责这件事';
$ppEmptyText = '不指定';
$ppAllowEmpty = true;
require __DIR__ . '/../_person_picker.php';
?>
</div>
<?php /* 🔴 v44(用户第 5 条):「状态下面选"已完成"的话,它只在"已完成"里出现,
看板上不再显示,这句话也去掉。」⇒ 删掉那句 `.tip`(状态语义由徽标本身表达)。 */ ?>
<div class="mv-row">
<span class="mv-lb">状态</span>
<select class="select" id="tk-status" name="status">
<?php foreach (\App\Task::STATUS as $sk => $sv): ?>
<option value="<?= e($sk) ?>" <?= $sk === 'todo' ? 'selected' : '' ?>><?= e($sv) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mv-row">
<span class="mv-lb">循环</span>
<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="mv-row">
<span class="mv-lb">分类</span>
<select class="select" id="tk-cat" name="category_id">
<option value="0">未分类</option>
<?php foreach ($tkAddCats as $c): ?>
<option value="<?= (int)$c['id'] ?>"><?= e((string)$c['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php /* 用户第 7 条:看板 = 任务"归属哪摊事",新建时直接选(不是顶上的切换条) */ ?>
<div class="mv-row">
<span class="mv-lb">看板</span>
<select class="select" id="tk-board" name="board_id">
<?php foreach ($tkAddBoards as $b): ?>
<option value="<?= (int)$b['id'] ?>"><?= e((string)$b['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php /* 🔴 v44(用户第 5 条):「"共享给"后面有个括号,可多选,任何人完成都算完成,
这个括号里面的内容也去掉。」⇒ 标签只留「共享给」。
第 8 条的同一诉求:人多时不要下拉里瞪眼找。换成弹窗 + 首字母联想。
⚠️ 候选里去掉**自己**——「共享给自己」是空操作。 */ ?>
<div class="mv-row">
<span class="mv-lb">共享给</span>
<?php
$ppName = 'shares[]';
$ppPeople = array_values(array_filter($tkAddPeople,
fn($p) => (int)$p['id'] !== (int)\App\Auth::personId()));
$ppSelected = [];
$ppMode = 'multi';
$ppOpenBtn = '+ 添加人';
$ppTitle = '共享给谁';
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="mv-row">
<span class="mv-lb">备注</span>
<textarea class="input" id="tk-note" name="note" rows="2"></textarea>
</div>
<?php /* 🔴 v37 用户第 6 条第 2 点:「新建任务时,竟然没有添加附件,这个应该加上。」
走全站统一的「点击或拖动进行上传」框(templates/_stage_drop.php);
字段名 `attach[]` 与详情页的附件上传**一致**。 */ ?>
<div class="mv-row">
<span class="mv-lb">附件</span>
<?php $sdName = 'attach[]'; $sdId = 'sd-tk-attach'; $sdSlot = 'photo';
require __DIR__ . '/../_stage_drop.php'; ?>
</div>
</div><?php /* /.mv-grid */ ?>
</div><?php /* /.cal-modal-body */ ?>
<div class="cal-modal-foot">
<span class="cal-modal-tip" id="taskAddTip"></span>
<button class="btn btn-sm" type="submit" form="taskAddForm" id="taskAddSubmit">新建</button>
<button class="btn btn-ghost btn-sm" type="button" data-tk-add-close>取消</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
<?php
/* ⚠️ 只清本片段自己的变量:宿主页面后面还要用 $boards / $people 这些。
(`_edit_form.php` 头部记着"把宿主变量 unset 掉 → 子任务与评论区静默消失"那次事故。) */
unset($tkAddBack, $tkAddBoards, $tkAddCats, $tkAddPeople,
$dtName, $dtId, $dtValue, $dtRequired, $dtPlugin,
$ppName, $ppPeople, $ppSelected, $ppMode, $ppOpenBtn, $ppTitle,
$ppEmptyText, $ppAllowEmpty,
$sdName, $sdId, $sdSlot);
?>
+281
View File
@@ -0,0 +1,281 @@
<?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);
?>
+15 -177
View File
@@ -57,7 +57,7 @@ $lastColIdx = count($colList) - 1;
</span>
<div class="actions">
<?php if ($canWrite): ?>
<button class="btn" type="button" id="taskAddBtn">新建任务</button>
<button class="btn" type="button" data-tk-add-open>新建任务</button>
<?php else: ?>
<span class="link-quiet">只读账号</span>
<?php endif; ?>
@@ -65,158 +65,17 @@ $lastColIdx = count($colList) - 1;
</div>
</div>
<?php if ($canWrite): ?>
<?php /* 🔴🔴 v37 用户第 6 条第 3 点:「新建任务也和新建日程一样,新建时弹出一个框,
新建完之后刷新新建任务的列表、看板页面。」
⇒ 从"页面上展开的一张卡片"改成**浮层弹框**(与日程弹框同一套形态 / 类名),
提交后照旧 302 回看板刷新 —— 用户要的"刷新看板页面"就是这样。
⚠️ 表单字段本身**一个字没动**(除了新加的附件框),服务端契约照旧。 */ ?>
<div class="cal-modal" id="taskAddCard" role="dialog" aria-modal="true"
aria-label="新建任务" hidden>
<div class="cal-modal-panel">
<div class="cal-modal-head">
<b>新建任务</b>
<button type="button" class="cal-modal-x" id="taskAddX" aria-label="关闭">✕</button>
</div>
<?php /* ⚠️ enctype 必须有:本批新增了**附件**上传(用户第 6 条第 2 点
「新建任务时,竟然没有添加附件,这个应该加上」)。
没有它的话文件根本不会随表单发出去,而且 $_FILES 是空的。 */ ?>
<form method="post" action="<?= e(url('tasks/create')) ?>"
enctype="multipart/form-data" id="taskAddForm">
<?= csrf_field() ?>
<?php /* ⚠️ 新建后回**看板**(用户:「新建完之后刷新…看板页面」)。
原来服务端一律 redirect('tasks/show'),新建完会跳去详情页。
`back=tasks` 走白名单。 */ ?>
<input type="hidden" name="back" value="tasks">
<div class="cal-modal-body">
<?php /* 🔴 v44(用户 2026-10-12 第 5 条):「新建任务页面的排版还没有统一,
应该把标题放在前面,树框放在后面。」
⇒ 用观影/行程/日程那套 **`.mv-grid`**(标签 62px 右对齐 + CSS `::after` 冒号 +
控件占满剩余宽度),标题那一行在最前、其余字段依次跟上 ——
"标题在前、后面的框跟上"就是这套排版的自然顺序。 */ ?>
<div class="mv-grid">
<div class="mv-row">
<span class="mv-lb">任务名称<i>*</i></span>
<input class="input" id="tk-title" name="title" required
placeholder="如:周末大扫除">
</div>
<?php /* v24 第 5 条:截止时刻也走全局统一的「日期 + 时分秒」控件。
秒这一档保留(与其它模块同一口径),默认 23:59:59 —— "这天之前做完"
比"这天 00:00 之前"更符合直觉。
🔴 v44(用户第 5 条):「截止时间也用我们最新的日历控件,
记得把现在这个控件删掉。」
⇒ 换成 AntD 的**单点** DatePicker(带时刻):
`$dtPlugin='antd-single'`(见 app.js 的 mountAntdSingleDT)。
原生那几档下拉由 CSS **真删掉**(不是 clip 藏起来)。
标签去掉那个括号(见下一条)。 */ ?>
<div class="mv-row">
<span class="mv-lb">截止时间</span>
<?php $dtName = 'due_at'; $dtId = 'tk-due'; $dtValue = null;
$dtRequired = false; $dtPlugin = 'antd-single';
require __DIR__ . '/../_datetime.php'; ?>
</div>
<?php /* 🔴 v44(用户第 5 条):「负责人默认是自己。」
⇒ 默认选中当前登录的人(原来 `$ppSelected = []`,空着)。
⚠️ 用 `Auth::personId()` 取"我",与后端"负责人为空"的兜底一致。 */ ?>
<div class="mv-row">
<span class="mv-lb">负责人</span>
<?php
$ppName = 'assignee_id';
$ppPeople = $people;
$ppSelected = [(int)\App\Auth::personId()];
$ppMode = 'single';
$ppOpenBtn = '更换负责人';
$ppTitle = '谁负责这件事';
$ppEmptyText = '不指定';
$ppAllowEmpty = true;
require __DIR__ . '/../_person_picker.php';
?>
</div>
<?php /* 🔴 v44(用户第 5 条):「状态下面选"已完成"的话,它只在"已完成"里出现,
看板上不再显示,这句话也去掉。」
⇒ 删掉那句 `.tip`(状态语义由状态徽标本身表达)。
⚠️ 用户同条要求「任务页面,任务下面有一行字…这个提示不要了」——
那是**看板顶部**那块「状态说明」(见上方 legend 那段)。 */ ?>
<div class="mv-row">
<span class="mv-lb">状态</span>
<select class="select" id="tk-status" name="status">
<?php foreach (\App\Task::STATUS as $sk => $sv): ?>
<option value="<?= e($sk) ?>" <?= $sk === 'todo' ? 'selected' : '' ?>><?= e($sv) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mv-row">
<span class="mv-lb">循环</span>
<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="mv-row">
<span class="mv-lb">分类</span>
<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="mv-row">
<span class="mv-lb">看板</span>
<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>
<?php /* 🔴 v44(用户第 5 条):「"共享给"后面有个括号,可多选,任何人完成都算完成,
这个括号里面的内容也去掉。」⇒ 标签只留「共享给」。
第 8 条的同一诉求:人多时不要下拉里瞪眼找。换成弹窗 + 首字母联想。
⚠️ 候选里去掉**自己**——「共享给自己」是空操作;组件没有"排除项",
所以在传进去之前先过滤掉。 */ ?>
<div class="mv-row">
<span class="mv-lb">共享给</span>
<?php
$ppName = 'shares[]';
$ppPeople = array_values(array_filter($people,
fn($p) => (int)$p['id'] !== (int)\App\Auth::personId()));
$ppSelected = [];
$ppMode = 'multi';
$ppOpenBtn = '+ 添加人';
$ppTitle = '共享给谁';
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="mv-row">
<span class="mv-lb">备注</span>
<textarea class="input" id="tk-note" name="note" rows="2"></textarea>
</div>
<?php /* 🔴 v37 用户第 6 条第 2 点:「新建任务时,竟然没有添加附件,
这个应该加上。」
⚠️ 走全站统一的「点击或拖动进行上传」框(templates/_stage_drop.php);
字段名 `attach[]` 与详情页的附件上传**一致** ——
服务端 attach_save_files(..., 'attach', ...) 零改动就能收。
⚠️ 拖进来的走暂存(stage_token[]),随表单一起认领;
直接点选走 $_FILES 路径。两条路服务端都接(见 attach_save_files)。 */ ?>
<div class="mv-row">
<span class="mv-lb">附件</span>
<?php $sdName = 'attach[]'; $sdId = 'sd-tk-attach'; $sdSlot = 'photo';
require __DIR__ . '/../_stage_drop.php'; ?>
</div>
</div><?php /* /.mv-grid */ ?>
</div><?php /* /.cal-modal-body */ ?>
<div class="cal-modal-foot">
<span class="cal-modal-tip" id="taskAddTip"></span>
<button class="btn btn-sm" type="submit" form="taskAddForm" id="taskAddSubmit">新建</button>
<button class="btn btn-ghost btn-sm" type="button" id="taskAddCancel">取消</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
<?php /* 🔴 v78(用户 2026-10-22):新建任务弹框与**首页**共用同一份
`templates/tasks/_add_modal.php`(不许各写一张 —— 字段迟早不同步)。
⚠️ 打开/关闭的脚本也搬到了 `assets/js/task_add.js`(两页共用一份)。
⚠️ 这里把本页已有的 `$boards` / `$categories` / `$people` 原样传进去;
新建完回看板(`back=tasks`)。 */
$tkAddBack = 'tasks';
$tkAddBoards = $boards;
$tkAddCats = $categories;
$tkAddPeople = $people;
require __DIR__ . '/_add_modal.php';
unset($tkAddBack, $tkAddBoards, $tkAddCats, $tkAddPeople); ?>
<?php /* 拖动的提交目标:JS 把 id / 目标**看板** / 参照顺序填进来再 submit。
放在看板**外面**,避免被任何一张卡片的 form 套住。 */ ?>
@@ -375,30 +234,9 @@ $lastColIdx = count($colList) - 1;
<script>
(function () {
/* ---- "新建任务"弹框(v37:从内联卡片改成浮层)----
⚠️ 不再 scrollIntoView —— 那是"卡片长在页面上"时代才需要的动作;
浮层固定在视口中央,底下的看板一动不动。 */
var addBtn = document.getElementById('taskAddBtn');
var addCard = document.getElementById('taskAddCard');
var addX = document.getElementById('taskAddX');
var addCancel = document.getElementById('taskAddCancel');
function openAdd() {
if (!addCard) { return; }
addCard.hidden = false;
var t = document.getElementById('tk-title');
if (t) { t.focus(); }
}
function closeAdd() { if (addCard) { addCard.hidden = true; } }
if (addBtn && addCard) { addBtn.addEventListener('click', openAdd); }
if (addX) { addX.addEventListener('click', closeAdd); }
if (addCancel) { addCancel.addEventListener('click', closeAdd); }
/* 点遮罩关(点面板内不关)/ Esc 关 —— 与站内其它浮层同一套行为。 */
if (addCard) {
addCard.addEventListener('click', function (e) { if (e.target === addCard) { closeAdd(); } });
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && addCard && !addCard.hidden) { closeAdd(); }
});
/* 🔴 v78:「新建任务」弹框的开关搬到了 `assets/js/task_add.js`
—— 首页那张卡也要开同一张弹框,两处各写一份必然有一处忘了 `fbBootAntd`。
本文件从这一行往下只剩**看板**自己的交互(搬卡 / 状态 / 拖动)。 */
/* ---- 左右搬(不需要拖也能用)----
⚠️ 列的集合**从 DOM 现取**,不要写死数组:看板是用户自己建的,