用户 2026-11-05:「点开之后,就可以修改任务的所有信息,包括:
1. 上传附件;2. 添加评论;3. 添加子任务;4. 修改截止时间
(已有值要带进来);5. 改状态、挪看板。这些操作全部都在这一个页面完成。」
盘点发现:附件 / 评论 / 子任务 / 状态 / 看板 **本来就有**(_panel.php 里
早就实现了),只有「截止时间」是只读的一行 dd。所以只补这一项。
四处改动:
① Task::setDue()(新增)
不复用 update():那是全字段覆盖(title/note/status/assignee/repeat/
parent/board/category 一起写)。表单只交 due_at 的话,其余字段会被
默认值抹成空 ——「只想改个日期,结果标题和备注没了」,不可恢复。
⇒ 单独一条只写 due_at 的 UPDATE。
② tasks/due 路由(新增,紧跟 tasks/status)
与 status/move 同一套约定:ajax_panel=1 回整块新面板,否则 flash+302。
空串 = 清掉截止时间。
③ _panel.php:只读「截止」行 → AntD 单点控件
原来:
<?php if ($tpDue !== ): ?>…<dd>截止 …</dd>…<?php endif; ?>
**没有截止时间时整行都不显示** —— 用户根本不知道该去哪填。
现在不管有没有值都画控件,$dtValue = $tpDue 自动带出已有值。
有值且在逾期时,右侧补一行「还剩 N 天」小字(.tk-due-word)。
④ task_panel.js
· paint() 后调 fbBootAntd():面板是 AJAX 换进来的,bootAntd 首屏
扫过一次 [data-dt-antd-single],那时面板还不存在 ⇒ 必须补挂。
· bindDue():监听 .dt-input 的 change 立即保存。
不用按钮式 data-tk-act(面板里其它动作都是"点一下/选完就生效",
再让用户点一次保存会与其它处不一致)。
监听隐藏字段的 change 而不是 AntD 回调 ⇒ 覆盖 AntD 与原生下拉两种形态。
data-fb-due-bound 幂等标记防止重复绑监听(否则改一次发好几条请求)。
⑤ CSS:.tk-sec-inline > .dt-input 加 flex/min-width/max-width ——
.dt-input 是 flex-wrap,放进也是 flex-wrap 的 .tk-sec-inline 里
会被当弹性项按内容撑,窄屏上可能把标签挤走或控件溢出。
实测:
setDue 设值 => [2026-12-25 18:30:00] ✅
标题仍为原值 ✅ 没被清空(证明没用 update())
setDue 清空 => NULL ✅
面板渲染:data-tk-url-due / data-dt-antd-single / due_at_date 全在,
带出的日期值 = [2026-10-22] ✅
状态/附件/子任务/评论 均仍在
409 lines
18 KiB
JavaScript
409 lines
18 KiB
JavaScript
/* ============================================================================
|
||
首页任务面板 —— 就地改状态 / 挪看板 / 加子任务 / 传附件 / 发评论(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; }
|
||
}
|
||
/* 🔴 #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)。
|
||
*
|
||
* 为什么要有这一步:面板是浮层,背后那张任务是"带着旧状态"站在那儿的。
|
||
* 用户改完关掉浮层,如果卡片上还写着「待办」,他会怀疑"到底改上了没有"。
|
||
* 只改状态徽标与复选框 —— 看板徽标/逾期色这些要重算的东西留给下次刷新,
|
||
* 不在这里猜(猜错比不猜更糟)。
|
||
*
|
||
* 🔴🔴 工单 #20(用户 2026-10-28,第二次提同一条需求):
|
||
* 「首页,近期任务,如果勾选了完成,应该刷新一下这个列表,把完成的删掉。」
|
||
* 第一版只改了行首那颗复选框(home.php 里的脚本),**漏了这里**——
|
||
* 从面板点「已完成」时,行留在原地只把徽标改成「已完成」。
|
||
* 同一个用户动作(都是"标成完成")两条路径两种结果,用户复现的就是这条。
|
||
*
|
||
* ⇒ 变更后是 done 时,也把这一行从首页卡片里摘掉。
|
||
* 真正干活的在 app.js 的 `window.fbHomeTaskDrop()`:它自己判断
|
||
* "行在不在 `[data-tk-home-host]` 里",**不在就完全不碰**
|
||
* (任务看板页的行也在 `[data-task-row]` 上,那里必须保留该行)。
|
||
*
|
||
* ⚠️ `d.status` 是**父任务**的状态(`task_panel_json()` 用 `panel_of` 找的父任务,
|
||
* 见 app/helpers.php:506)。面板里勾的是**子任务**时,请求带 `panel_of`,
|
||
* 回来的 status 仍是父任务的 ⇒ 这里不会因为"子任务做完了"就删掉父任务那一行。
|
||
*
|
||
* ⚠️ 删行前先把 `wasStatus` 取出来:下面几行会改写 `data-tk-status`,
|
||
* 改完再读就只能读到 done,概述卡不知道该减「在做」还是「待办」。
|
||
*/
|
||
function syncRow(d) {
|
||
if (!d || !d.status) { return; }
|
||
var row = document.querySelector('[data-task-row="' + taskId() + '"]');
|
||
if (!row) { return; }
|
||
|
||
/* 🔴 必须在改写 `data-tk-status` **之前**读 —— 见上面最后一条 ⚠️。 */
|
||
var chk0 = row.querySelector('.tk-check[data-tk-done]');
|
||
var was = chk0 ? (chk0.getAttribute('data-tk-status') || 'todo') : '';
|
||
|
||
var badge = row.querySelector('.kb-st');
|
||
if (badge) {
|
||
badge.className = 'kb-st is-' + d.status + ' is-static';
|
||
if (d.word) { badge.textContent = d.word; }
|
||
}
|
||
if (chk0) {
|
||
chk0.setAttribute('data-tk-status', d.status);
|
||
chk0.classList.toggle('is-done', d.status === 'done');
|
||
chk0.setAttribute('aria-pressed', d.status === 'done' ? 'true' : 'false');
|
||
}
|
||
|
||
/* 标成完成 ⇒ 从首页「近期任务」里摘掉(工单 #20)。
|
||
⚠️ 用服务端回的 `d.status`,不是"我点之前猜的 next"。 */
|
||
if (d.status === 'done' && typeof window.fbHomeTaskDrop === 'function') {
|
||
window.fbHomeTaskDrop(row, was);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发一个动作。
|
||
* @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);
|
||
}
|
||
|
||
/* ---- ① 表单提交(加子任务 / 发评论 / 删附件)------------------------------
|
||
面板里**只有需要打字或选文件的地方**才用真正的 <form>(见 _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();
|
||
}
|
||
})();
|