diff --git a/app/Task.php b/app/Task.php index b42bf2f..9bb9c58 100644 --- a/app/Task.php +++ b/app/Task.php @@ -721,6 +721,34 @@ final class Task * * @return int 因循环而新生成的任务 id(没有则 0) */ + /** + * 只改「截止时间」(#74)。 + * + * 🔴 用户 2026-11-05:「点开之后,就可以修改任务的所有信息,包括… + * 修改截止时间。当然,如果现在已经有截止时间,就把它带进来。」 + * + * ⚠️ **不要复用 `update()`**:那个方法是全字段覆盖(title/note/status/ + * assignee/repeat/parent/board/category 一起写),表单只交了 `due_at` + * 的话,其余字段会被 `title('')`、`textOrNull(null)` 之类的默认值抹成空 —— + * "只想改个日期,结果标题和备注没了",而且这种破坏不可恢复。 + * ⇒ 单独一条只写 `due_at` 的 UPDATE。 + * + * 传空串 = 清掉截止时间(`dtOrNull` 会转成 NULL)。 + * + * @return bool 是否真的更新了(任务不存在 / 不属于这个家庭时为 false) + */ + public static function setDue(int $familyId, int $id, string $dueAt): bool + { + $old = self::row($familyId, $id); + if (!$old) { + return false; + } + Database::get()->prepare( + 'UPDATE tasks SET due_at = ? WHERE id = ? AND family_id = ?' + )->execute([self::dtOrNull($dueAt), $id, $familyId]); + return true; + } + public static function setStatus(int $familyId, int $id, string $status, int $position = 0): int { $old = self::row($familyId, $id); diff --git a/assets/css/app.css b/assets/css/app.css index 2588d68..d31faab 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -6920,6 +6920,16 @@ h6.md-h { font-size: 17px; color: var(--muted); } /* 「状态」「看板」这两段是"标签 + 一排可点的东西",横排。 ⚠️ 用 flex-wrap:看板可能是七八个,窄屏要能折行而不是撑出面板。 */ .tk-sec-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; } +/* 🔴 #74:面板里的「截止时间」AntD 控件。 + `.dt-input` 是 `display:flex; flex-wrap:wrap`,放进 `.tk-sec-inline` + (也是 flex-wrap)里会被当成一个弹性项 —— 不给宽度约束时它会按内容撑, + 在窄屏上可能把「截止」标签挤到上一行、或者控件自己溢出。 + `flex: 1 1 auto` + `min-width: 0` 让它占满标签右侧的剩余宽度, + `max-width` 兜住极宽屏(AntD 的 picker 本身有固定宽度)。 */ +.tk-sec-inline > .dt-input { flex: 1 1 auto; min-width: 0; max-width: 320px; } +/* 「还剩 3 天」那类小字:贴在控件右边,不与控件抢宽度 */ +.tk-due-word { flex: 0 0 auto; font-size: 12.5px; color: var(--faint); white-space: nowrap; } +.tk-due-word.kb-late { color: var(--red); } .tk-sec-lb { flex: 0 0 34px; font-size: 12.5px; color: var(--faint); } .tk-seg { display: flex; gap: 6px; flex-wrap: wrap; } /* 面板里的状态徽标比卡片上的大一档(这里是要点的按钮,不是标签) */ diff --git a/assets/js/task_panel.js b/assets/js/task_panel.js index 02a6f12..c36389b 100644 --- a/assets/js/task_panel.js +++ b/assets/js/task_panel.js @@ -78,9 +78,70 @@ var ti2 = b.querySelector('.tk-inline-form input[name="title"]'); if (ti2) { ti2.value = draftTitle; } } + /* 🔴 #74(用户 2026-11-05):「点开之后,就可以修改任务的所有信息, + 包括…修改截止时间。」 + + ⇒ 面板里现在有 AntD 的单点日期控件(`[data-dt-antd-single]`)。 + 这段 HTML 是 AJAX 换进来的,**不会自己跑初始化** —— + `bootAntd()` 只在页面加载时扫过一次 `[data-dt-antd-single]`, + 那时面板还不存在 ⇒ 控件会是一片原生下拉(或空白)。 + ⇒ 每次重画面板都要补挂一次。 + + ⚠️ `mountAntd*` 有 `data-antd-done` 幂等标记,重复调用不会挂两遍。 + ⚠️ 必须放在 `innerHTML` 之后 —— 要挂的节点得先真的在 DOM 里。 */ + if (typeof window.fbBootAntd === 'function') { + try { window.fbBootAntd(); } catch (err) { /* 挂不上就留在原生下拉,不阻断面板 */ } + } + /* 重画之后节点是新的 ⇒ 监听要重新绑一遍(旧的已经随旧节点一起没了) */ + bindDue(); return true; } + /** + * 监听「截止时间」控件的变化,改完**立即保存**(#74)。 + * + * 为什么不用 `data-tk-act="due"` 那种按钮: + * 面板里其它动作都是"点一下就生效",截止时间是**选完时间**就该生效, + * 再让用户点一次「保存」会让这一处与其它处不一致。 + * + * 为什么监听**隐藏字段的 change** 而不是 AntD 的回调: + * · AntD 挂载时会把选中的值写回 `.dt-date` / `.dt-hour` / `.dt-minute` + * 这些原生字段,并 copy 一个 `change` 事件(见 app.js 的 writeDT); + * · 原生下拉那一档(AntD 没挂上时的兜底)也是同一批发 `change`; + * · ⇒ 监听一次就能覆盖**两种形态**,不用分别处理。 + * + * ⚠️ `data-fb-due-bound` 幂等标记:`bindDue()` 在每次 paint 后都会跑, + * 没有它会给同一个节点挂上多个监听 ⇒ 改一次时间发好几条请求。 + */ + function bindDue() { + var b = bodyEl(); + if (!b) { return; } + var box = b.querySelector('.dt-input[data-dt]'); + if (!box) { return; } + var cfg = {}; + try { cfg = JSON.parse(box.getAttribute('data-dt') || '{}'); } catch (e) { return; } + if (cfg.name !== 'due_at') { return; } + if (box.getAttribute('data-fb-due-bound') === '1') { return; } + box.setAttribute('data-fb-due-bound', '1'); + + /* 用 `change`(不是 `input`):AntD 选完日期/时间才写回, + 中途打字不该触发几十次请求。 */ + box.addEventListener('change', function () { + var d = box.querySelector('.dt-date'); + var date = d ? String(d.value || '').trim() : ''; + if (date === '') { + /* 清空 = 去掉截止时间(allowClear 会走到这里) */ + act(urlOf('due'), { due_at: '' }); + return; + } + var h = box.querySelector('.dt-hour'); + var m = box.querySelector('.dt-minute'); + var hh = h && String(h.value || '').trim() !== '' ? String(h.value) : '00'; + var mm = m && String(m.value || '').trim() !== '' ? String(m.value) : '00'; + act(urlOf('due'), { due_at: date + ' ' + hh + ':' + mm + ':00' }); + }); + } + /** * 把面板里的最新状态**顺手**同步到首页那张卡上(v78)。 * diff --git a/index.php b/index.php index dbfac71..5d47c60 100644 --- a/index.php +++ b/index.php @@ -7277,6 +7277,55 @@ $want = array_keys($want); redirect('tasks'); break; + /* 🔴 #74(用户 2026-11-05):「点开之后,就可以修改任务的所有信息, + 包括…修改截止时间。当然,如果现在已经有截止时间,就把它带进来。」 + + ⇒ 首页任务面板里**直接改截止时间**,不再需要点「编辑」进那张整页表单。 + 与 status / move 同一套约定:`ajax_panel=1` 就回**整块新面板**, + 否则按老规矩 flash + 302(无 JS 的兜底)。 + + ⚠️ 用 `Task::setDue()` 而**不是** `Task::update()`:后者是全字段覆盖, + 只交 due_at 会把 title/note/assignee 等全部抹空(详见那个方法的注释)。 + ⚠️ 字段名沿用 `due_at`(与 `_edit_form.php` 一致), + 空串 = 清掉截止时间。 */ + case 'tasks/due': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $dueAt = trim((string)($_POST['due_at'] ?? '')); + $t = Task::find($familyId, $id, $me); + if (!$t || !Task::canWrite($t, $me)) { + if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') { + header('Content-Type: application/json; charset=utf-8', true, 403); + echo json_encode(['ok' => false, 'msg' => '这项任务不能改。'], JSON_UNESCAPED_UNICODE); + exit; + } + flash('err', '这项任务不能改。'); + redirect('tasks'); + } + Task::setDue($familyId, $id, $dueAt); + $msg = $dueAt !== '' + ? '截止时间改成 ' . substr($dueAt, 0, 16) . '。' + : '已清掉截止时间。'; + if (task_panel_wanted()) { + task_panel_json($id, $msg); + } + if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => true, 'msg' => $msg], JSON_UNESCAPED_UNICODE); + exit; + } + flash('ok', $msg); + $back = (string)($_POST['back'] ?? ''); + if ($back === 'show') { + redirect('tasks/show', ['id' => $id]); + } elseif ($back === 'home') { + redirect('home'); + } + redirect('tasks'); + case 'tasks/comment_add': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); diff --git a/templates/tasks/_panel.php b/templates/tasks/_panel.php index 9645155..6db5785 100644 --- a/templates/tasks/_panel.php +++ b/templates/tasks/_panel.php @@ -75,7 +75,9 @@ $tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpD 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')) ?>"> + data-tk-url-attach="= e(url('tasks/attach_add')) ?>" + + data-tk-url-due="= e(url('tasks/due')) ?>"> @@ -123,13 +125,43 @@ $tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpD +