diff --git a/app/HomeCards.php b/app/HomeCards.php index ef4d7d0..64e8af9 100644 --- a/app/HomeCards.php +++ b/app/HomeCards.php @@ -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):「通知消息这个,也是首页的卡片,可以调整顺序和大小。」 ⇒ 它从"网格外的写死区块"变成**正规卡片**:可拖顺序、可调宽高、可关掉。 diff --git a/app/helpers.php b/app/helpers.php index 4a19671..96617b9 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -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; +} diff --git a/assets/css/app.css b/assets/css/app.css index 909312b..9b7e728 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -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; } diff --git a/assets/js/entry_popup.js b/assets/js/entry_popup.js index bd2fe14..8201666 100644 --- a/assets/js/entry_popup.js +++ b/assets/js/entry_popup.js @@ -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 = '
' + rowsHtml(data.rows) + '
'; } - 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 = '

正在载入…

'; } + 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 = '
' + rowsHtml(data && data.rows) + '
'; + if (panelEl()) { panelEl().classList.remove('is-wide'); } + setTip('这条任务的快捷面板没载入,先看基本信息。', true); + }); } function restoreDetail() { diff --git a/assets/js/task_add.js b/assets/js/task_add.js new file mode 100644 index 0000000..af78768 --- /dev/null +++ b/assets/js/task_add.js @@ -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); + }); +})(); diff --git a/assets/js/task_panel.js b/assets/js/task_panel.js new file mode 100644 index 0000000..63cb1a3 --- /dev/null +++ b/assets/js/task_panel.js @@ -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); + } + + /* ---- ① 表单提交(加子任务 / 发评论 / 删附件)------------------------------ + 面板里**只有需要打字或选文件的地方**才用真正的
(见 _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(); + } +})(); diff --git a/index.php b/index.php index c561bb1..f15df3d 100644 --- a/index.php +++ b/index.php @@ -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; diff --git a/templates/home.php b/templates/home.php index 383b3ae..aed111c 100644 --- a/templates/home.php +++ b/templates/home.php @@ -120,6 +120,17 @@ > + + + + @@ -198,3 +209,24 @@ })(); + + + + + diff --git a/templates/home/_card_tasks.php b/templates/home/_card_tasks.php index d81e96f..89b9b8e 100644 --- a/templates/home/_card_tasks.php +++ b/templates/home/_card_tasks.php @@ -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` 那份只读信息 —— 不会白屏。 */ ?>
@@ -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) : ''], diff --git a/templates/layout.php b/templates/layout.php index ad90166..00f6b61 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -251,6 +251,13 @@ require __DIR__ . '/_entry_popup.php'; ?> 三处入口分散在首页/日程页,按需加载的判据会在这些页面之间来回漏; 它只有几 KB,不值得为省这点流量赌"某个页面漏加载"。 */ ?> + + + + + + + + diff --git a/templates/tasks/_panel.php b/templates/tasks/_panel.php new file mode 100644 index 0000000..9645155 --- /dev/null +++ b/templates/tasks/_panel.php @@ -0,0 +1,281 @@ + 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); +?> +
+ + + + +
+ 状态 +
+ $tpSv): ?> + + + + + + +
+
+ + + +
+ 看板 +
+ + + + + + + + + + +
+
+ + + +
+ +
截止
+
+
+ + +
负责人
+
$tpT['assignee_name'], 'my_nickname' => $tpT['assignee_nick'] ?? ''])) ?>
+
+ + +
分类
+ + +
循环
+ + +
备注
+ +
+ + + +
+
+ 子任务 + / +
+ +
    + +
  • + :<状态词>」—— + 所以这里只给标题,别把"子任务"三个字再拼一遍 + (否则读屏会念成「子任务子任务甲:待办」)。 */ + $tkLabel = (string)$tpKidRow['title']; + require __DIR__ . '/../_task_check.php'; + ?> + + + + + + +
  • + +
+ +

还没有子任务。

+ + +
+ + + + + + +
+ +
+ + + +
+
附件
+ + '1']; + require __DIR__ . '/../_attachments.php'; + ?> + +

还没有附件。

+ + + +
+
+ + 点击或拖动进行上传 +
+ + + +
+ +
+ + +
+
讨论
+ +
    + +
  • +
    + + + + 会带上浏览器的灰底与内边距。 */ ?> + + +
    +
    +
  • + +
+ +

还没有留言。写 @名字 可以点名。

+ + +
+ + + + +
+
+ +
+
+ diff --git a/templates/tasks/index.php b/templates/tasks/index.php index 6b95448..30c81ef 100644 --- a/templates/tasks/index.php +++ b/templates/tasks/index.php @@ -57,7 +57,7 @@ $lastColIdx = count($colList) - 1;
- + 只读账号 @@ -65,158 +65,17 @@ $lastColIdx = count($colList) - 1;
- - - - + @@ -375,30 +234,9 @@ $lastColIdx = count($colList) - 1;