Files
FamilyBook/templates/tasks/show.php
T
laoyang 190dc56301 v55:笔记详情不重复标题 / 任务状态选完即改(去按钮)/ 编辑任务弹框 / 子任务截止时间换最新控件
用户 2026-10-17 四条。

① 笔记详情:正文里不再重复显示标题
   用户原话:「笔记详情里面的标题已经变成这个日笔记的题目了,那**下面那个里面
   就不用再显示这个题目**了。」
   背景:v52 已把**页头**改成笔记自己的标题(并参与 <title>),
   而**正文区里又写了一遍**同一个标题 ⇒ 同一页同一个名字出现两次。
   ⇒ 删正文里那个 `<h1 class="note-read-title">`;**页头那份保留**。
   连带删掉已无使用者的 `.note-read-title` CSS 规则
   (留一条没有使用者的规则,只会让下一个人以为"正文里还该有个标题")。

② 任务详情:状态**选一下就改**(去掉「改」按钮)
   用户原话:「任务详情里边,状态后面**不用加按钮**了。不是说选了之后再点一下才改,
   而是**只要一选就让它改**:选一下,状态就发生改变就行了。」
   ⇒ `<select>` 的 `change` 直接 fetch 提交到**既有的** `tasks/status`
      (它本来就认 `X-Requested-With` 并回 JSON —— 不新开路由、不加新契约)。
   成功后**就地**同步三处(不刷页、不丢滚动位置):
     · 下拉本身;
     · 页头副标题的状态词;
     · 页头那颗「标记完成 ↔ 重新打开」按钮的文案与 hidden 值
       (改了状态而按钮还写"标记完成"就自相矛盾)。
   反馈:一条**浮在顶部**的轻提示 `.tk-flash`(`position:fixed` 不占文档流 ⇒
   改状态不会让页面跳动)。循环任务那句"下一次也排上了"(`d.msg`)照旧显示 ——
   不能因为改交互把 v24 定下的行为丢掉。
   ⚠️ **无 JS 兜底**:`<noscript>` 里那颗「改」按钮
      (规范:脚本开启时 noscript **整块不渲染**)。不要用 `<button hidden>`+JS
      去"显示"它(会闪、脚本报错时永远不出现)。
   🔴🔴 **踩坑(本轮最值钱)**:第一版写成"先 `sel.disabled = true`、再
      `new FormData(form)`" ⇒ **HTML 规范:禁用的控件不参与表单数据构造** ⇒
      字段丢了 ⇒ 服务端收到空 status ⇒ 落回 `todo`。
      症状是"请求成功、回包 ok、页面还给了提示,唯独值不对" —— 极具误导性。
      ⇒ **先构建、后禁用**。

③ 编辑任务 = 弹框
   用户原话:「**编辑任务,弹框处理**。」
   原来详情页是个 `<details>` 就地展开表单(展开会把下面的子任务/附件/评论整段往下推);
   现在点「编辑这项任务」**浮起弹框**(`.cal-modal`,与「新建任务 / 新建日程 /
   编辑观影记录」同一套类名)。
   ⚠️ **表单一个字没改** —— 仍 require 同一份 `_edit_form.php`
      (条目浮层 `tasks/edit?embed=1` 也用它,两处永远同一张)。
      这次只加了个 `$tkEditModal` 开关:true 时套 `.cal-modal` 壳 +
      「保存」挪到底栏(与新建任务一致:主要动作在右下角)。
   ⚠️ 打开那一刻才 `fbBootAntd()` / `fbUpgradeRange()` 升级里面的日期控件
      (hidden 时也能挂上,但 antd 弹层定位需要元素有实际尺寸)。
   ⚠️ 事件委托挂 `document`(与观影详情页同一理由:校验失败若重画这块,委托仍有效)。

④ 子任务截止时间:换最新控件
   用户原话:「**子任务的截止时间,使用最新的时间控件**。」
   **真根因**:`tasks/show.php` 子任务那块 require `_datetime.php` 时
   **漏传 `$dtPlugin`** ⇒ 空值时走**旧的三下拉(年/月/日)**
   ⇒ 与「新建任务」「任务编辑」里的 antd 控件**不一样**。
   ⇒ 补 `$dtPlugin = 'antd-single'`(截止时间是**单个时刻**,不是范围)。
   ⚠️ 全仓这一族的调用点**逐处核对过**:只有子任务这一处漏了;其余
      (日程起止 / 观影起止 / 任务新建 / 任务编辑 / 行程起止 / 修正起止)
      都已有 `'antd'` 或 `'antd-single'`。
   ⚠️ `people/form.php`(生日)**有意不传** —— 生日是"日期",年份要能下拉到 1900,
      与"截止时间"不是一回事,别顺手改。

验证(新增 2 套件 + 更新 deploy 断言):
 · _fb_v55_ui.py(**30 条**真机断言)
 · _b55_falsify.py(**8 注入 / 8 全抓**,基线 30/0)
 · e2e **1518/0**;familybook_deploy.sh 加「第55批形态」段(**13 条**)
 · 部署 **0 FAIL**;线上 4 文件 md5 逐字节一致;
   线上剥注释核实:笔记模板/`app.css` 里已无 `note-read-title`(反向)、
   任务模板有 `data-st-select` / `fd=FormData` / `noscript` 兜底 / `antd-single`、
   编辑表单有 `tkEditCard`/`tkEditSubmit`、`app.css` 有 `.tk-flash`;
   诊断页已删复验 404

⚠️ 本轮踩坑(已写进 pitfalls.md):
 · 🔴🔴 **先 `disabled=true` 再 `new FormData(form)` ⇒ 字段会丢**(表单数据不含禁用控件);
 · 连带印证:**用裸 `strpos`/`grep` 做反向断言会命中注释**(我的线上诊断脚本
   就因为这个连报两次"还有",剥注释后才对)—— 反向断言一律走"剥注释"。

⚠️ 另:工作树里 `app/ApiHandler.php` / `app/PersonQuery.php` / `app/Home.php` /
   `app/Api.php` / `app/ApiToken.php` / `app/Schema.php` / `index.php` /
   `templates/settings/me.php` 是**另一个会话**正在做的 App 端 / 设备管理改动,
   一字未碰、也未提交。
2026-10-17 23:10:49 +08:00

566 lines
29 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 任务详情(v12):正文 + 子任务 + 评论(@人)+ 附件 + 共享。
*
* @人:评论里写「@老杨」会高亮(Task::commentHtml),**不会**真的发通知 ——
* 家庭场景下"点名"主要是让人一眼看到在说谁,做通知要先解决推送渠道,先不做。
*
* @param array $t 任务(带 children / comments / attachs / shares)
* @param array $people 名册(负责人 / 共享 / @人 提示)
* @param array $parents 可选的父任务候选(把当前任务挂到别的父任务下)
*/
$canWrite = \App\Auth::canWrite() && \App\Task::canWrite($t, (int)\App\Auth::personId());
$meId = (int)\App\Auth::personId();
$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)));
$isChild = !empty($t['parent_id']);
?>
<div class="page-head">
<h1>
<?php /* 🔴 v24 第 26 批 用户第 5 条:**详情页页头不再放复选框**。
用户原话:「任务详情页面,就不需要复选框了,因为有标记完成这个按钮。」
⇒ 右侧 actions 里那颗「标记完成 / 重新打开」已经把这个动作表达得很清楚,
标题前再挂一颗是重复的(同一页两个入口做同一件事,还容易看不出区别)。
⚠️ **子任务列表里的复选框保留**(见文件下面)—— 那条需求说的是"详情页",
而子任务**没有**自己的「标记完成」按钮,去掉它子任务就没法在列表里勾了。 */ ?>
<?= e((string)$t['title']) ?>
</h1>
<span class="sub"><?= e(\App\Task::statusWord((string)$t['status'])) ?></span>
<div class="actions">
<?php if ($canWrite): ?>
<?php /* 🔴 v55(第 2 条):给它一个 id —— 状态下拉改完要**就地**同步这颗按钮的
文案与语义(选了"已完成"它还写"标记完成"就自相矛盾了)。
⚠️ 只加 id(`tkMarkBtn`,与任务模块其余 id 的 `tk-` 前缀一致),
表单 action / 字段名一字未改。 */ ?>
<form method="post" action="<?= e(url('tasks/move')) ?>" class="res-inline" id="tkMarkBtn">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<?php if ($t['status'] !== 'done'): ?>
<input type="hidden" name="status" value="done">
<button class="btn" type="submit">标记完成</button>
<?php else: ?>
<input type="hidden" name="status" value="doing">
<button class="btn btn-ghost" type="submit">重新打开</button>
<?php endif; ?>
</form>
<?php endif; ?>
<a class="btn btn-ghost" href="<?= e(url('tasks')) ?>">返回任务</a>
</div>
</div>
<div class="card card-pad">
<dl class="kv">
<?php /* v24 第 14 批 用户第 2 条:状态要**能选**(不只是"标记完成/重新打开"两档)。
这里给完整的三档下拉,与看板卡片上那个徽标走同一个路由。
🔴 v55(用户 2026-10-17 第 2 条):「任务详情里边,状态后面**不用加按钮**了。
不是说选了之后再点一下才改,而是**只要一选就让它改**:
选一下,状态就发生改变就行了。」
⇒ 去掉那颗「改」按钮;`<select>` 的 `change` 直接提交。
实现走**既有的 `tasks/status` + `X-Requested-With`**(它本来就回 JSON,
见 index.php 那个分支)—— 不新开路由、不加新契约。
⚠️ **无 JS 时仍要能用**:`<noscript>` 里给一颗提交按钮。
这是本仓一贯的兜底口径(弹框/就地改都保留原生路径)。
⚠️ 表单里**不放** `back` 的 submit 按钮时,服务端拿到的 `back` 仍是 'show'
(下面那个 hidden 一直在),所以无 JS 提交也回本页。 */ ?>
<div class="kv-row">
<dt>状态</dt>
<dd>
<?php if ($canWrite): ?>
<?php /* v55:`data-st-select` 是 JS 的挂钩;`name=status` 仍是提交契约(一字未改)。 */ ?>
<form method="post" action="<?= e(url('tasks/status')) ?>" class="res-inline st-form" id="tkStForm">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<input type="hidden" name="back" value="show">
<select class="select" name="status" aria-label="任务状态" data-st-select
data-st-id="<?= (int)$t['id'] ?>">
<?php foreach (\App\Task::STATUS as $sk => $sv): ?>
<option value="<?= e($sk) ?>" <?= (string)$t['status'] === $sk ? 'selected' : '' ?>>
<?= e($sv) ?>
</option>
<?php endforeach; ?>
</select>
<?php /* ⚠️ **无 JS 时的兜底**:`<noscript>` 在"脚本可用"时**整块不渲染**
(HTML5 规范:body 里的 noscript,脚本开启则其内容不渲染),
所以有 JS 时选完即改、页面上看不到任何按钮 —— 正是用户要去掉的那颗;
没有 JS 时它才出现,让下拉仍能提交。
⚠️ **不要**用 `<button hidden>` + JS 去显示它:那需要等脚本跑起来才"显示",
中间会闪一下,且脚本一旦报错按钮就永远不出现(=点了没反应)。
⚠️ 这里**不写** `class="btn"` 之外的东西:与全站按钮同一套样式。 */ ?>
<noscript><button class="btn btn-sm" type="submit">改</button></noscript>
</form>
<?php else: ?>
<span class="kb-st is-<?= e((string)$t['status']) ?> is-static">
<?= e(\App\Task::statusWord((string)$t['status'])) ?>
</span>
<?php endif; ?>
</dd>
</div>
<?php if (($t['due_at'] ?? '') !== ''): ?>
<div class="kv-row"><dt>截止</dt>
<dd class="<?= \App\Task::isOverdue($t['due_at'] ?? null) && $t['status'] !== 'done' ? 'kb-late' : '' ?>">
<?= e((string)$t['due_at']) ?><?= \App\Task::dueWord($t['due_at'] ?? null) !== '' ? '(' . e(\App\Task::dueWord($t['due_at'] ?? null)) . ')' : '' ?>
</dd>
</div>
<?php endif; ?>
<?php if (!empty($t['repeat_rule'])): ?>
<div class="kv-row"><dt>循环</dt><dd><?= e(\App\Task::repeatWord($t['repeat_rule'])) ?></dd></div>
<?php endif; ?>
<?php if (!empty($t['assignee_name'])): ?>
<div class="kv-row"><dt>负责人</dt>
<dd><?= e(person_name(['name' => $t['assignee_name'], 'my_nickname' => $t['assignee_nick'] ?? ''])) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($t['creator_name'])): ?>
<div class="kv-row"><dt>谁建的</dt>
<dd><?= e(person_name(['name' => $t['creator_name'], 'my_nickname' => $t['creator_nick'] ?? ''])) ?></dd>
</div>
<?php endif; ?>
<?php if ($isChild && !empty($t['parent_title'])): ?>
<div class="kv-row"><dt>属于</dt>
<dd><a href="<?= e(url('tasks/show', ['id' => (int)$t['parent_id']])) ?>"><?= e((string)$t['parent_title']) ?></a></dd>
</div>
<?php endif; ?>
<?php if (!empty($t['note'])): ?>
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e((string)$t['note'])) ?></dd></div>
<?php endif; ?>
</dl>
<?php if ($canWrite): ?>
<?php /* 🔴 v55(用户 2026-10-17 第 3 条):「**编辑任务,弹框处理**。」
原来这里是一个 `<details>`:点一下**就地展开**一张表单。
⇒ 改成**弹框**(与「新建任务」「新建日程」「编辑观影记录」同一形态)。
⚠️ 表单本身**一个字没改** —— 仍 require 同一份 `_edit_form.php`
(条目浮层 `tasks/edit?embed=1` 也用它,两处永远同一张)。
这次只是给它套一层 `.cal-modal` 壳(由 `$tkEditModal` 开关)。
⚠️ 为什么"就地展开"不够:展开会把下面的**子任务 / 附件 / 评论**整段往下推,
而用户只是想改几个字段;弹框则原地浮起、关掉就回来。
⚠️ `$tkEditEmbed` 仍为 false —— 那是"要不要自己的保存键",
弹框形态下按钮在**弹框底栏**(与新建任务一致),所以这里仍传 false,
由 `$tkEditModal` 把"保存"挪到底栏。 */ ?>
<button class="btn btn-sm" type="button" id="tkEditBtn"
aria-haspopup="dialog" aria-expanded="false">编辑这项任务</button>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<?php /* ---- 编辑任务的弹框(内嵌同一份 _edit_form.php;初始关闭)---- */ ?>
<?php
$tkEditEmbed = true; // 浮层形态:不输出自己的「保存」,由弹框底栏给
$tkEditModal = true; // 🔴 v55:套 `.cal-modal` 壳
$tkEditOpen = false; // 初始关闭(点「编辑这项任务」才开)
require __DIR__ . '/_edit_form.php';
unset($tkEditEmbed, $tkEditModal, $tkEditOpen);
?>
<?php endif; ?>
<?php /* ---- 子任务 ---- */ ?>
<?php if (!$isChild): ?>
<div class="card card-pad">
<h2 class="cinema-h2">子任务 <span class="group-n"><?= count($t['children']) ?> 项</span></h2>
<?php if (!$t['children']): ?>
<p class="empty-hint">还没有子任务。把一件大事拆成几件小事,进度才看得见。</p>
<?php else: ?>
<ul class="sub-list">
<?php foreach ($t['children'] as $c): ?>
<li class="sub-row<?= $c['status'] === 'done' ? ' is-done' : '' ?>">
<?php /* v24 第 25 批 用户第 6 条:子任务的复选框**改用全站那一颗**
—— 原来是"每行一个 form + ☐/☑ 字符":
· 每行一个 form = 子任务一多就是几十个 form(名册页那个坑);
· ☐/☑ 是普通字符,跨字体基线会歪,且与看板上的复选框长得不一样。
现在卡片上只放 data-tk-done,表单全页一份(见页尾)。 */ ?>
<?php
$tkId = (int)$c['id']; $tkStatus = (string)$c['status'];
$tkBack = 'show'; $tkStatic = !$canWrite;
$tkLabel = '子任务' . (string)$c['title'];
require __DIR__ . '/../_task_check.php';
?>
<a class="sub-title" href="<?= e(url('tasks/show', ['id' => (int)$c['id']])) ?>">
<?= e((string)$c['title']) ?>
</a>
<?php if (($c['due_at'] ?? '') !== ''): ?>
<span class="node-time<?= \App\Task::isOverdue($c['due_at'] ?? null) && $c['status'] !== 'done' ? ' kb-late' : '' ?>">
<?= e(\App\Task::dueWord($c['due_at'] ?? null)) ?>
</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($canWrite): ?>
<details class="node-add">
<summary class="link-quiet">+ 加一项子任务</summary>
<form method="post" action="<?= e(url('tasks/create')) ?>" class="inline-form">
<?= csrf_field() ?>
<input type="hidden" name="parent_id" value="<?= (int)$t['id'] ?>">
<input class="input" name="title" required placeholder="如:擦玻璃">
<?php /* 🔴 v55(用户 2026-10-17 第 4 条):「**子任务的截止时间,使用最新的时间控件**。」
真根因:这一处 require `_datetime.php` 时**漏传了 `$dtPlugin`** ——
而 `$dtPlugin` 为空时控件走**旧的三下拉(年/月/日)**,
与「新建任务」弹框、任务编辑表单里的 antd 控件**不一样**
(用户看到的正是"这里怎么还是老样子")。
⇒ 补 `$dtPlugin = 'antd-single'`(截止时间是**单个时刻**,不是范围)。
⚠️ 全仓这一族的调用点已逐处核对:只有子任务这一处漏了,
其余(日程起止 / 观影起止 / 任务新建 / 任务编辑 / 行程起止 / 修正起止)
都已有 `'antd'` 或 `'antd-single'`。
⚠️ `people/form.php`(生日)**有意不传** —— 生日是"日期"不含时刻,
且年份要能下拉到 1900,与"截止时间"不是一回事,别顺手改。 */ ?>
<?php $dtName = 'due_at'; $dtValue = null; $dtRequired = false;
$dtPlugin = 'antd-single';
require __DIR__ . '/../_datetime.php'; ?>
<button class="btn btn-sm" type="submit">加上</button>
</form>
</details>
<?php endif; ?>
</div>
<?php endif; ?>
<?php /* ---- 附件 ---- */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">附件</h2>
<?php if (!$t['attachs']): ?>
<p class="empty-hint">还没有附件。</p>
<?php else: ?>
<?php
$attRows = $t['attachs'];
$attCanEdit = $canWrite;
$attDelUrl = 'tasks/attach_del';
$attDelField = 'id';
$attExtra = [];
require __DIR__ . '/../_attachments.php';
?>
<?php endif; ?>
<?php if ($canWrite): ?>
<form class="inline-form" method="post" enctype="multipart/form-data" action="<?= e(url('tasks/attach_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<?php /* v24 第 21 批 第 7 条:不再放原生文件框 + 按钮,改成一个「点击或拖动」的框。
⚠️ 这个表单**唯一**的用途就是传附件,所以提交键仍然要有 ——
"点击/拖动"只是选文件的方式,最后还得点一下才能提交。 */
$sdName = 'attach[]'; $sdSlot = 'photo';
require __DIR__ . '/../_stage_drop.php'; ?>
<div class="btn-row" style="margin-top:12px">
<button class="btn btn-sm" type="submit">上传附件</button>
</div>
</form>
<?php endif; ?>
</div>
<?php /* ---- 共享 ---- */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">共享</h2>
<?php if (!$t['shares']): ?>
<p class="empty-hint">还没共享给别人。共享之后,任何一个人完成都算完成。</p>
<?php else: ?>
<div class="res-shares">
<?php foreach ($t['shares'] as $s): ?>
<?= person_chip($s) ?>
<span class="node-badge"><?= ($s['perm'] ?? '') === 'write' ? '可写' : '只读' ?></span>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('tasks/unshare')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<input type="hidden" name="person_id" value="<?= (int)$s['person_id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">取消</button>
</form>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($canWrite): ?>
<form class="inline-form" method="post" action="<?= e(url('tasks/share')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<?php
$ppName = 'person_id';
$ppPeople = array_values(array_filter($people, fn($p) => (int)$p['id'] !== $meId));
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择要共享给谁';
$ppTitle = '共享给谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
<select class="select" name="perm">
<option value="write">可写</option>
<option value="read">只读</option>
</select>
<button class="btn btn-sm" type="submit">共享</button>
</form>
<?php endif; ?>
</div>
<?php /* ---- 评论 ---- */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">讨论 <span class="group-n"><?= count($t['comments']) ?> 条</span></h2>
<?php if (!$t['comments']): ?>
<p class="empty-hint">还没有留言。写 @名字 可以点名。</p>
<?php else: ?>
<ul class="cmt-list">
<?php foreach ($t['comments'] as $c): ?>
<li class="cmt-row">
<div class="cmt-who">
<?= person_chip($c) ?>
<span class="cmt-time"><?= e((string)$c['created_at']) ?></span>
</div>
<div class="cmt-body"><?= \App\Task::commentHtml((string)$c['content'], $names) ?></div>
<?php if ((int)$c['person_id'] === $meId): ?>
<form method="post" action="<?= e(url('tasks/comment_del')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<input type="hidden" name="task_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit" onclick="return confirm('删掉这条留言?')">删除</button>
</form>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('tasks/comment_add')) ?>" class="cmt-form">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<textarea class="input" name="content" rows="2" required
placeholder="说点什么… 写 @名字 可以点名"><?= e(old('content', '')) ?></textarea>
<div class="btn-row"><button class="btn btn-sm" type="submit">发表</button></div>
</form>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<div class="task-danger">
<form method="post" action="<?= e(url('tasks/destroy')) ?>"
onsubmit="return confirm('删除「<?= e((string)$t['title']) ?>」?子任务、附件与留言会一起删掉。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-danger btn-sm" type="submit">删除这项任务</button>
</form>
</div>
<?php endif; ?>
<?php /* 完成复选框的**全页一份**表单(用户第 6 条)—— 本页可能出现多颗
(任务自己 + 每条子任务),所以更要"卡片上只放数据、表单只此一份"。 */ ?>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('tasks/status')) ?>" id="taskDoneForm" class="hidden-form">
<?= csrf_field() ?>
<input type="hidden" name="id" id="doneId">
<input type="hidden" name="status" id="doneStatus">
<input type="hidden" name="back" id="doneBack" value="show">
</form>
<?php /* ⚠️ 本页的复选框走**原生提交**(不 AJAX):
详情页是一次性动作,改完最常见的是"看一眼就返回";
就地改反而要在页面上同步三处(标题旁的状态、kv 里的状态、子任务进度)。
原生提交 + back=show → 服务端 302 回本页,状态永远与服务端一致。
代价只是刷一次页 —— 详情页本来就"打开看某一条",没有滚动位置要保住
(那是看板/首页才有的诉求)。
⚠️ 但**不能只渲染 form 不绑事件**:那样点了按钮毫无反应
("按钮坏了"比"刷一下"糟得多)。这里只做一件最小的事:
把"点的是哪一条 / 改成什么"灌进表单再提交。 */ ?>
<script>
(function () {
var form = document.getElementById('taskDoneForm');
if (!form) { return; }
Array.prototype.forEach.call(document.querySelectorAll('.tk-check[data-tk-done]'), function (b) {
b.addEventListener('click', function (ev) {
ev.preventDefault();
var now = b.getAttribute('data-tk-status') || 'todo';
document.getElementById('doneId').value = b.getAttribute('data-tk-done');
document.getElementById('doneStatus').value = (now === 'done') ? 'todo' : 'done';
document.getElementById('doneBack').value = b.getAttribute('data-tk-back') || 'show';
form.submit();
});
});
})();
</script>
<?php endif; ?>
<?php /* ==================== 🔴 v55(用户 2026-10-17 第 3 条):编辑任务的弹框 ====================
用户原话:「编辑任务,弹框处理。」
原来详情页是一个 `<details>` 就地展开表单;现在点「编辑这项任务」浮起弹框。
⚠️ **`_edit_form.php` 里的日期控件要在"打开弹框的那一刻"才升级成 antd** ——
弹框初始是 `hidden` 的,`app.js` 的挂载器按"页面里有没有 `[data-dt-antd]`"来找,
而 `hidden` 的元素**在 DOM 里是存在的**,所以挂载本身会成功;
但 antd 的弹出面板依赖元素有**实际尺寸**(hidden 时宽高为 0),
拿到 0 尺寸会让面板定位错乱。⇒ 打开时显式再 boot 一次(幂等,见 movies/show.php 同一手法)。
⚠️ 事件**委托**(挂 document)而不是逐个 addEventListener:
与观影详情页同一理由 —— 校验失败若重画了这块,委托仍然有效,不用重绑。
⚠️ 幂等闸门 `__fbTkEditBound`:本页脚本只跑一次,但加上它以后若有人复用这段
(比如某天详情页也做"替换正文再重跑脚本")不会被绑第二套监听。 */ ?>
<?php if ($canWrite): ?>
<script>
(function () {
if (window.__fbTkEditBound) { return; }
window.__fbTkEditBound = true;
function modal() { return document.getElementById('tkEditCard'); }
function setOpen(on) {
var m = modal();
if (!m) { return; }
m.hidden = !on;
var b = document.getElementById('tkEditBtn');
if (b) { b.setAttribute('aria-expanded', on ? 'true' : 'false'); }
if (on) {
/* 打开这一刻才升级日期控件(见上面的说明)。两步都要:
先绑自建下拉(兜底),再挂 antd(正主)。两个函数都会自判幂等。 */
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
var t = document.getElementById('tk-title');
if (t) { t.focus(); }
}
}
function antdPanelOpen() {
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('#tkEditBtn')) { e.preventDefault(); setOpen(true); return; }
if (t.closest('#tkEditX') || t.closest('#tkEditCancel')) { setOpen(false); return; }
var m = modal();
if (m && t === m) { setOpen(false); } // 点遮罩关;点面板内部不关
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') { return; }
var m = modal();
if (!m || m.hidden) { return; }
if (antdPanelOpen()) { return; } // 让 antd 先收它自己的面板
setOpen(false);
});
})();
</script>
<?php endif; ?>
<?php /* ==================== 🔴 v55(用户 2026-10-17 第 2 条):状态**选完即改** ====================
用户原话:「任务详情里边,状态后面**不用加按钮**了。不是说选了之后再点一下才改,
而是**只要一选就让它改**:选一下,状态就发生改变就行了。」
做法:`<select>` 的 `change` → fetch 提交到**既有的** `tasks/status`
(它本来就认 `X-Requested-With` 并回 `{ok,id,status,word,msg}`,见 index.php)。
成功后**就地**更新页面上的三处显示,不刷页:
· 这个下拉自己(保持选中项);
· 页头副标题里的状态词(`page-head .sub`);
· 页头那颗「标记完成 / 重新打开」按钮的文案与语义(它跟着状态走)。
失败/拿不到 JSON 时**退回原生提交**(下拉还在那个 form 里,`form.submit()` 即可)——
与全站"没有 JS 照样能用"的口径一致。
⚠️ 为什么不用「表单自动提交」(`this.form.submit()` 就完事):
那会整页刷新、滚动位置丢掉,而用户在详情页往下翻着看子任务/评论——
改个状态就跳回顶部是"打一次断",用户要的是"选一下就改了"。
⚠️ 循环任务(`repeat_rule` + 有截止日):服务端会顺带排下一次,
它的提示语在 `d.msg` 里 —— 所以这里要把 msg 显示出来,
否则用户不知道"下一次也排上了"(那是 v24 就定下的行为,不能因为改交互而丢掉)。
提示落在页面顶部一条轻提示里(`#tkStFlash`),2.5 秒后自淡出。
⚠️ **幂等**:本段脚本在页面里只跑一次(详情页没有"替换正文再重跑"那套),
但为了以后若有人复用这段,加了 `window.__fbTkStBound` 闸门。 */ ?>
<?php if ($canWrite): ?>
<div class="tk-flash" id="tkStFlash" hidden role="status" aria-live="polite"></div>
<script>
(function () {
if (window.__fbTkStBound) { return; }
window.__fbTkStBound = true;
var sel = document.querySelector('select[data-st-select]');
if (!sel) { return; }
var form = document.getElementById('tkStForm');
if (!form) { return; }
function flash(msg, ok) {
var el = document.getElementById('tkStFlash');
if (!el) { return; }
el.textContent = msg;
el.hidden = false;
el.className = 'tk-flash' + (ok ? ' is-ok' : ' is-err');
clearTimeout(el.__fbTimer);
el.__fbTimer = setTimeout(function () { el.hidden = true; }, 2500);
}
/* 就地同步页面上的状态显示(不只是这个下拉)。
⚠️ 页头那颗「标记完成 / 重新打开」按钮**也跟状态走** ——
选了"已完成"之后它还写着"标记完成"就是自相矛盾。
它是个 form(hidden status + submit),所以这里同时改它的
hidden 值与按钮文字,与服务端渲染逻辑保持一致。 */
function apply(status, word) {
var head = document.querySelector('.page-head .sub');
if (head) { head.textContent = word; }
var mark = document.getElementById('tkMarkBtn');
if (mark) {
var st = mark.querySelector('input[name=status]');
var btn = mark.querySelector('button[type=submit]');
if (st && btn) {
if (status === 'done') { st.value = 'doing'; btn.textContent = '重新打开'; }
else { st.value = 'done'; btn.textContent = '标记完成'; }
}
}
/* 复选框(如果这一页有)也跟着走,免得与下拉说的不一致 */
var chk = document.querySelector('.tk-check[data-tk-done="' + sel.getAttribute('data-st-id') + '"]');
if (chk) {
chk.setAttribute('data-tk-status', status);
chk.classList.toggle('is-done', status === 'done');
chk.setAttribute('aria-pressed', status === 'done' ? 'true' : 'false');
}
}
var busy = false;
sel.addEventListener('change', function () {
if (busy) { return; }
var want = sel.value;
if (!window.fetch || !window.FormData) { form.submit(); return; } // 老浏览器:原生提交
busy = true;
/* 🔴🔴 **必须先构造 FormData、再禁用控件!**
实测踩坑:`sel.disabled = true` 之后 `new FormData(form)` **不会带上这个字段**
(HTML 规范:禁用的表单控件不参与表单数据构造)⇒ 服务端收到空 status
⇒ `normalizeStatus('')` 落回 'todo' ⇒ 看起来"选了没反应、还提示已标成待办"。
⚠️ 症状极具误导性:网络请求成功、JSON 也 ok、页面还给了"已标成待办"的提示,
唯一的反常是"我明明选了进行中"。**先构建、后禁用** 就好。 */
var fd = new FormData(form);
sel.disabled = true;
fetch(form.getAttribute('action'), {
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) {
busy = false;
sel.disabled = false;
if (d && d.ok) {
var st = String(d.status || want);
var word = String(d.word || '');
sel.value = st;
apply(st, word);
flash(String(d.msg || ('已改成「' + word + '」。')), true);
return;
}
if (d && d.msg) {
/* 服务端明确说不让改(权限/找不到)⇒ 回退到原值并提示,不要假装成功 */
sel.value = sel.getAttribute('data-st-was') || sel.value;
flash(String(d.msg), false);
return;
}
// 拿不到 JSON(会话过期 / 500)→ 退回原生提交,让服务端说话
form.submit();
}).catch(function () {
busy = false;
sel.disabled = false;
form.submit();
});
});
/* 记下"改之前是什么",失败时好回退 */
sel.setAttribute('data-st-was', sel.value);
})();
</script>
<?php endif; ?>