diff --git a/app/Task.php b/app/Task.php index c5c9204..6696d24 100644 --- a/app/Task.php +++ b/app/Task.php @@ -788,6 +788,20 @@ final class Task $sets[] = 'category_id = ?'; $args[] = self::categoryIdOf($familyId, (int)($d['category_id'] ?? 0)); } + /* 🔴 #78(用户 2026-11-05):「现在在首页上点一个任务,直接就是编辑页面啊。 + 所以不需要再加一个编辑按钮了。」 + ⇒ 删掉「编辑」按钮后,标题/备注也得能在这里改。 + ⚠️ 走 `self::title()` / `self::textOrNull()` 而不是直接塞原值 —— + 与 `update()` 同一套清洗口径(去首尾空白、空备注存 NULL), + 否则同一条任务从两个入口改会得到不同形态的数据。 */ + if (array_key_exists('title', $d)) { + $sets[] = 'title = ?'; + $args[] = self::title((string)($d['title'] ?? '')); + } + if (array_key_exists('note', $d)) { + $sets[] = 'note = ?'; + $args[] = self::textOrNull((string)($d['note'] ?? '')); + } if (!$sets) { return true; // 三个都没传:什么都不做,但也算"成功"(幂等) } diff --git a/assets/css/app.css b/assets/css/app.css index 76e13a3..3f0ac85 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -6953,6 +6953,40 @@ h6.md-h { font-size: 17px; color: var(--muted); } padding-left: 56px; } .tk-attr-tip { font-size: 12px; color: var(--green, #2E7D4F); } + +/* #78:面板里的选人组件(负责人单选 / 协作人多选)。 + 组件本身按"整页宽"设计,塞进 .tk-attr-row 的弹性项里要收一下: + - 不加 min-width:0 的话,chips 一多会把标签挤到上一行; + - max-width 与其它控件对齐(300px),别让它撑满整行。 */ +.tk-attr-row > .pp-root { flex: 1 1 auto; min-width: 0; max-width: 340px; } +.tk-attr-row > .pp-root .pp-chips { margin-bottom: 6px; } +/* 标签在多行控件(备注、协作人)上方对齐,而不是垂直居中 */ +.tk-attr-row-top { align-items: flex-start; } +.tk-attr-row-top > .tk-attr-lb { padding-top: 8px; } +/* 只读态的协作人 chips */ +.tk-share-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.tk-share-chip { + font-size: 12.5px; padding: 3px 10px; border-radius: 999px; + background: var(--surface-2, #F2F0EC); color: var(--ink); +} + +/* #78:评论里的「添加附件」。 + 用原生 file input(视觉隐藏),点 label 触发 —— 与站内其它上传入口同一手法。 */ +.cmt-attach-row { + display: flex; align-items: center; gap: 8px; + margin-top: 6px; +} +.cmt-attach-pick { + display: inline-flex; align-items: center; gap: 5px; + font-size: 12.5px; color: var(--muted); cursor: pointer; + padding: 4px 10px; border-radius: 999px; + border: 1px dashed var(--line); background: transparent; +} +.cmt-attach-pick:hover { color: var(--brand); border-color: var(--brand); } +.cmt-attach-name { + font-size: 12px; color: var(--faint); + max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} .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/entry_popup.js b/assets/js/entry_popup.js index fae36a6..cd8b8df 100644 --- a/assets/js/entry_popup.js +++ b/assets/js/entry_popup.js @@ -176,7 +176,16 @@ /* 🔴 用户:「来自其他模块时,下面就没有编辑、也没有删除,就只有一个详情」—— 所以这两颗**只对 source='event'** 显(且各要有地址)。 */ var ed = byId('fbEntryEdit'); - if (ed) { ed.hidden = !(isEvent && data.edit); } + /* 🔴 #78(用户 2026-11-05):「现在在首页上点一个任务,直接就是编辑页面啊。 + 所以不需要再加一个编辑按钮了。」 + + ⇒ 任务(带 `panel`)的面板里已经能改**全部**字段 + (标题/截止/负责人/协作人/分类/备注 + 状态/看板/子任务/评论/附件), + 再给一颗「编辑」只会让用户以为"是不是还有别的东西要进另一个页面改"。 + ⚠️ **不能无条件藏**:观影 / 旅程 / 日程 / 纪念日 那些来源没有 panel, + 它们仍然只有「详情 + 编辑」,那颗按钮必须留着。 + ⚠️ `data.del`(删除)不动 —— 用户没要求改删除,保持原样。 */ + if (ed) { ed.hidden = !(isEvent && data.edit) || !!data.panel; } var de = byId('fbEntryDelete'); if (de) { de.hidden = !(isEvent && data.del); } @@ -226,6 +235,19 @@ if (typeof window.fbBootAntd === 'function') { try { window.fbBootAntd(); } catch (e) { /* 挂不上就留在原生下拉,不阻断面板 */ } } + /* 🔴 #78(用户 2026-11-05):「选人的时候不能按列表,因为人太多了。 + 你参考一下观影模式里面选人的方式。」 + ⇒ 面板里的负责人/协作人改用 `_person_picker.php`(弹窗 + 拼音联想), + 那个组件靠 `fbInitPickers(容器的)` 接管点击。 + `loadEdit()` 里一直有这一句(第 315 行),**`loadPanel()` 漏了** —— + 所以面板里的选人按钮点了没反应。 + ⚠️ 组件自带 `data-pp-init` 幂等标记,重复调用不会重复绑。 */ + if (typeof window.fbLoadPicker === 'function') { + try { window.fbLoadPicker(); } catch (e) { /* 忽略 */ } + } + if (typeof window.fbInitPickers === 'function') { + try { window.fbInitPickers(bodyEl()); } catch (e) { /* 忽略 */ } + } }) .catch(function () { if (!bodyEl()) { return; } diff --git a/assets/js/task_panel.js b/assets/js/task_panel.js index 7f2133c..5fd686e 100644 --- a/assets/js/task_panel.js +++ b/assets/js/task_panel.js @@ -92,6 +92,11 @@ if (typeof window.fbBootAntd === 'function') { try { window.fbBootAntd(); } catch (err) { /* 挂不上就留在原生下拉,不阻断面板 */ } } + /* #78:重画后选人组件是新节点 ⇒ 要重新接管(自带 data-pp-init 幂等标记)。 + 不补这一步的话,做过任何动作之后「更换负责人 / + 添加人」就点不动了。 */ + if (typeof window.fbInitPickers === 'function') { + try { window.fbInitPickers(b); } catch (err) { /* 忽略 */ } + } return true; } @@ -154,7 +159,13 @@ * @param {FormData} fd 已经带好 `_csrf` / `ajax_panel` 的请求体 * @param {boolean} keepDraft 换面板时要不要保住用户正在打的字(传附件时为 true) */ - function send(url, fd, keepDraft) { + /** + * 发请求并刷新面板。 + * + * @param keepDraft #78:重画后保住用户没发出去的评论草稿(传附件时用) + * @param closeOnOk #78:成功后**关掉浮层**(「保存」用;其它动作不关) + */ + function send(url, fd, keepDraft, closeOnOk) { if (!url) { setTip('这一项暂时不能在首页改。', true); return; } if (!window.fetch || !window.FormData) { window.location.reload(); return; } fetch(url, { @@ -166,6 +177,20 @@ return r.json().catch(function () { return null; }); }).then(function (d) { if (!d) { window.location.reload(); return; } // 会话过期 / 500:整页重载最诚实 + /* 🔴 #78(用户 2026-11-05):「点击保存之后,按道理说应该直接把这个窗口关闭才对呀。」 + ⇒ 保存成功就关窗,不再重画面板(用户已经不在看了)。 + ⚠️ 关窗前**先更新背后那张首页卡片**:否则用户关掉浮层看到的还是旧标题/旧状态, + "改了没生效"的错觉比不关窗更糟。 + ⚠️ 失败(d.ok === false)时**不关** —— 让用户看到错误提示、就地改。 */ + if (closeOnOk && d.ok !== false) { + syncRow(d); + if (typeof window.fbEntryClose === 'function') { + window.fbEntryClose(); + } else if (typeof window.fbCloseEntry === 'function') { + window.fbCloseEntry(); + } + return; + } paint(d, keepDraft); syncRow(d); setTip(d.msg || '', !d.ok); @@ -173,13 +198,23 @@ } /** 把「id + 几个附加字段」包成一个动作请求 */ - function act(url, extra) { + /** + * 发一个面板动作。 + * + * @param url 目标地址 + * @param extra 额外字段 + * @param closeOnOk #78:成功后**关掉浮层**(只有「保存」用)。 + * 用户 2026-11-05:「点击保存之后,按道理说应该直接把这个窗口关闭才对呀。」 + * ⚠️ 只给「保存」用:改状态 / 挪看板 / 加子任务 / 发评论 / 传附件 + * 都还要继续在这个面板里操作,关窗会把用户赶走。 + */ + function act(url, extra, closeOnOk) { 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); + send(url, fd, false, closeOnOk); } /* ---- ① 表单提交(加子任务 / 发评论 / 删附件)------------------------------ @@ -377,11 +412,32 @@ setTimeout(function () { var extra = {}; extra['due_at'] = readDue(dbox); - /* ⚠️ 只有控件真的在 DOM 里才提交该项 —— 没有成员时负责人下拉不渲染, - 那时不该把它当成 0 去清掉原值。 */ + /* ⚠️ 只有控件真的在 DOM 里才提交该项 —— 控件不渲染时不该把原值清掉。 */ if (asg) { extra['assignee_id'] = asg.value; } if (cat) { extra['category_id'] = cat.value; } - act(urlOf('save'), extra); + + /* #78:标题 / 备注 —— 删掉「编辑」按钮后,它们只能在这里改 */ + var ti = bd.querySelector('#tkp-title'); + if (ti) { extra['title'] = ti.value; } + var nt = bd.querySelector('#tkp-note'); + if (nt) { extra['note'] = nt.value; } + + /* #78:协作人(`_person_picker.php` 的 multi 选人把结果存在 + 隐藏 checkbox 里,name="shares[]")。逐个收集。 + ⚠️ 必须带 `shares_submitted=1` 这个哨兵: + 服务端靠它区分"表单里根本没有协作人控件"(不动)与 + "用户把它们全删了"(清空)—— 只靠 `shares[]` 是否存在, + 空数组时 PHP 收不到任何键,无法区分。 */ + var shareBox = bd.querySelector('input[name="shares[]"]'); + if (shareBox) { + extra['shares_submitted'] = '1'; + var picked = bd.querySelectorAll('input[name="shares[]"]:checked'); + for (var i = 0; i < picked.length; i++) { + extra['shares[' + i + ']'] = picked[i].value; + } + } + + act(urlOf('save'), extra, true); }, 60); /* 面板会被整块换掉(act → send → paint),按钮随之重建,不用手动恢复 */ setTimeout(function () { btn.disabled = false; }, 4000); diff --git a/index.php b/index.php index be80d15..4cd0dcb 100644 --- a/index.php +++ b/index.php @@ -7355,7 +7355,7 @@ $want = array_keys($want); redirect('tasks'); } /* 只把**表单真的提交了**的字段放进 $sd: - 面板里负责人下拉在"没有成员"等极端情况下可能不渲染, + 面板里负责人控件在"没有成员"等极端情况下可能不渲染, 那时就不该把 assignee_id 当成 0 去清掉原值。 */ $sd = []; if (array_key_exists('due_at', $_POST)) { @@ -7367,7 +7367,54 @@ $want = array_keys($want); if (array_key_exists('category_id', $_POST)) { $sd['category_id'] = (int)$_POST['category_id']; } + /* 🔴 #78(用户 2026-11-05):「现在在首页上点一个任务,直接就是编辑页面啊。 + 所以不需要再加一个编辑按钮了。」 + ⇒ 既然要删掉「编辑」按钮,标题和备注必须能在这里改。 + ⚠️ 标题不允许被改成空 —— 与 tasks/update 同一口径(那里空标题会被打回)。 + 空了就**不更新标题**(而不是把标题清掉)。 */ + if (array_key_exists('title', $_POST)) { + $nt = trim((string)$_POST['title']); + if ($nt !== '') { + $sd['title'] = $nt; + } + } + if (array_key_exists('note', $_POST)) { + $sd['note'] = trim((string)$_POST['note']); + } Task::setAttrs($familyId, $id, $sd); + + /* 协作人(共享给)—— #78。 + 🔴 用户:「我记得任务其实可以共享给别人,也就是协作人。 + 但现在怎么感觉编辑这里面没有这个选项呢?」 + ⇒ 数据层早就有 `task_shares` + Task::share/unshare,只是没入口。 + + ⚠️ 语义是"**以提交的列表为准**":表单没带 `shares[]` 时(比如只改截止时间 + 的旧面板/其它调用方)**完全不动**协作人,避免把已有共享清空。 + 表单带了(哪怕是空数组 `shares=[]` 表示"清空全部")就按它同步。 */ + if (array_key_exists('shares', $_POST) || array_key_exists('shares_submitted', $_POST)) { + $wantIds = []; + foreach ((array)($_POST['shares'] ?? []) as $sid) { + $sid = (int)$sid; + /* 不能共享给自己(空操作);也不能共享给不存在的人 */ + if ($sid > 0 && $sid !== $me) { + $wantIds[$sid] = true; + } + } + $haveIds = []; + foreach (Task::shares($id) as $sh) { + $haveIds[(int)$sh['person_id']] = true; + } + foreach (array_keys($wantIds) as $sid) { + if (!isset($haveIds[$sid])) { + Task::setShare($id, $sid, 'read'); + } + } + foreach (array_keys($haveIds) as $sid) { + if (!isset($wantIds[$sid])) { + Task::dropShare($id, $sid); + } + } + } $msg = '已保存。'; if (task_panel_wanted()) { task_panel_json($id, $msg); @@ -7391,7 +7438,29 @@ $want = array_keys($want); flash('err', '找不到这项任务。'); redirect('tasks'); } - $cid = Task::addComment($id, $me, (string)($_POST['content'] ?? '')); + /* 🔴 #78(用户 2026-11-05):「评论那个地方也需要可以添加附件。」 + ⇒ 允许「只发附件、不写字」—— 这是很常见的一次性用法 + (拍张照片甩过来,不需要配文字)。 + + ⚠️ 但不能两边都空:那是误点。这时返回 0 走原来的"留言是空的"分支。 + ⚠️ 判"有没有附件"要看 `$_FILES`,不能看 content: + `attach_save_files` 内部会过滤空文件槽(未选择的 file input + 也会出现在 $_FILES 里,name 是空串)。 + 这里用一个轻量判断:只要有任一非空 tmp_name 就算有附件。 */ + $rawContent = trim((string)($_POST['content'] ?? '')); + $hasFiles = false; + foreach ((array)($_FILES['attach'] ?? []) as $k => $v) { + if ($k === 'name' && is_array($v)) { + foreach ($v as $nm) { + if (trim((string)$nm) !== '') { $hasFiles = true; break 2; } + } + } + } + if ($rawContent === '' && $hasFiles) { + /* 占位文案:让列表里能看懂"这条是什么"(纯空白没人知道是图还是文件) */ + $rawContent = '(附件)'; + } + $cid = Task::addComment($id, $me, $rawContent); if ($cid > 0) { attach_save_files($familyId, 'task_comment', $cid, 'attach', $me); if (task_panel_wanted()) { diff --git a/templates/tasks/_panel.php b/templates/tasks/_panel.php index 024b719..35f55e7 100644 --- a/templates/tasks/_panel.php +++ b/templates/tasks/_panel.php @@ -68,6 +68,12 @@ foreach ($tpCats as $tpCat) { } } $tpDue = (string)($tpT['due_at'] ?? ''); +/* #78:协作人(共享给)。Task::find 已经带回 `shares`,直接用。 */ +$tpShares = (array)($tpT['shares'] ?? []); +$tpShareIds = []; +foreach ($tpShares as $tpS) { + $tpShareIds[] = (int)($tpS['person_id'] ?? 0); +} $tpDueWord = \App\Task::dueWord($tpDue !== '' ? $tpDue : null); $tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpDue); ?> @@ -149,6 +155,22 @@ $tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpD
还没有留言。写 @名字 可以点名。
-