用户 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 端 / 设备管理改动,
一字未碰、也未提交。
566 lines
29 KiB
PHP
566 lines
29 KiB
PHP
<?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; ?>
|