Files
FamilyBook/templates/tasks/index.php
T
laoyang 40fd6c501c v78 服务端:首页任务改成「就地处理」面板 + 新建任务搬到首页
用户原话:
「网页端,首页点击一个任务后,除了展示信息,应该有相应的功能,可以修改状态:
  待办、进行中,可以挪动分组,可以拖动上传附件,可以添加评论,添加子任务等。
  其目地,是为了让用户不离开首页,就能对任务进行简单的处理。」
「另外,在全部任务旁边,放一个新建任务的功能,其实就是把任务中的新建和编辑功能,
  搬过来,用弹出框来处理。」

一、首页任务浮层由「只读信息」改成「可操作面板」
- 新增 templates/tasks/_panel.php:状态三段 / 看板 chips(带看板色)/
  子任务(复用 _task_check.php)/ 附件拖放框(复用 _attachments.php)/ 讨论
- 新增路由 tasks/panel:只吐一块 HTML(找不到 404)
- 既有七个动作路由各加一条 ajax_panel 回包分支(状态 / 挪分组 / 新建子任务 /
  评论增删 / 附件增删),没有新开写接口
- helpers.php 新增 task_panel_wanted() / task_panel_json();
  fb_entry_json() 的 payload 增加 panel 字段(只有任务会给)
- 每次动作回**整块新面板**:一次动作会牵动子任务计数、附件张数、看板选中态,
  逐处同步 DOM 必然漏一处
- 拖进来的附件**立刻上传**(任务已存在,不该再让用户点一次保存),
  不复用 _stage_drop.php 那套「暂存 + 认领」

二、新建任务搬到首页
- 从 templates/tasks/index.php 抽出公共片段 templates/tasks/_add_modal.php
  (字段与原来一字未改),首页与任务页共用同一份
- 新增 assets/js/task_add.js 管开关(两页共用,幂等绑定);
  打开那一刻才 fbUpgradeRange() + fbBootAntd()(hidden 时宽高为 0,antd 会定位错乱)
- App\HomeCards 登记表里加 add => 新建任务,首页卡头与「全部任务」并排
- tasks/create 支持 back=home(白名单),新建完回首页

三、其它
- assets/js/task_panel.js:事件委托;动作后整块换面板;
  顺手同步首页那张卡的状态徽标与复选框(关掉浮层不能还是旧状态);
  传附件时保住用户写了一半的留言与子任务标题;取不到面板时退回只读信息
- app.css 追加面板样式(含 color-mix 的 @supports 兜底)与卡片窄到宽 2 时允许折行

验证:本地 e2e 1558/0、几何 428/0 + 660/0、v78 反向验证 11/0、
片段作用域体检 35/0、php -l 与 node --check 全过
2026-10-22 12:40:14 +08:00

436 lines
22 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;v24 用户 2026-10-03 第 7 条重做列的含义)。
*
* ⚠️ 第 7 条原话:「任务模块跟我想的不同,每个人自定义的看板,不是显示在现在的看板上面,
* 而是**直接代替现在的待办和进行中**,是看板,而不是看板的分类。」
*
* 于是这一页的样子变了:
* · 以前:顶上一条「看板切换」,下面是固定两列「待办 / 进行中」;
* · 现在:**一列 = 一个看板**(你自己建的那些),全都在同一屏上,
* 拖动/左右箭头 = **把卡搬到另一个看板**。
*
* 三条保留的纪律:
* · **已完成的任务不在看板上**(用户第 10 条:直接消失)→ 去「任务设置 → 已完成」看;
* · 拖动是渐进增强:每张卡下面还有 ← → 按钮,没有 JS / 手机上照样能搬;
* · 看板里只有父任务:子任务挂在父任务下面(点卡片进详情看/勾)。
*
* @param array $cols 看板 id => 该列的任务(键 0 = 没有看板归属的兜底列)
* @param array $boards 看板清单(顺序即列顺序)
* @param array $people 名册(选负责人 / 共享给别人)
*/
$canWrite = \App\Auth::canWrite();
$boards = $boards ?? [];
$cols = $cols ?? [];
$categories = $categories ?? [];
/* 列清单:先按 boards 的顺序,最后补上兜底列(键 0,且只在真有任务时才画)。
⚠️ 兜底列的标题不能叫「未分类」——「分类」在任务模块里是另一回事(task_categories),
叫「未分类」会让人以为是按分类分的列。这里它表示"还没归到任何看板",叫「其他」。 */
$colList = [];
foreach ($boards as $b) {
$bid = (int)$b['id'];
/* 🔴 v24 第 26 批 用户第 3 条:列头带上**看板色**。
用户要的是"一眼看出属于哪个看板" —— 列头本身就是一个看板,
给它一个色点,比只在卡片上标色更直观(卡片散落各处,列头是它们的归属地)。 */
$colList[] = [
'id' => $bid,
'name' => (string)$b['name'],
'color' => \App\Task::boardColor($b['color'] ?? null, (string)$b['name']),
'rows' => $cols[$bid] ?? [],
];
}
if (!empty($cols[0])) {
$colList[] = ['id' => 0, 'name' => '其他', 'color' => '', 'rows' => $cols[0]];
}
// 一个看板都没有(也不该发生:ensureDefaultBoard 保证至少一个)→ 给一句人话
if (!$colList) {
$colList[] = ['id' => 0, 'name' => '任务', 'color' => '', 'rows' => $cols[0] ?? []];
}
$firstColId = (int)($colList[0]['id'] ?? 0);
$lastColIdx = count($colList) - 1;
?>
<div class="page-head">
<h1>任务</h1>
<span class="sub">
<?= (int)$total ?> 项在做<?= count($boards) > 1 ? ',' . count($boards) . ' 个看板' : '' ?>
</span>
<div class="actions">
<?php if ($canWrite): ?>
<button class="btn" type="button" data-tk-add-open>新建任务</button>
<?php else: ?>
<span class="link-quiet">只读账号</span>
<?php endif; ?>
<?php $setCurrent = 'board'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<?php /* 🔴 v78(用户 2026-10-22):新建任务弹框与**首页**共用同一份
`templates/tasks/_add_modal.php`(不许各写一张 —— 字段迟早不同步)。
⚠️ 打开/关闭的脚本也搬到了 `assets/js/task_add.js`(两页共用一份)。
⚠️ 这里把本页已有的 `$boards` / `$categories` / `$people` 原样传进去;
新建完回看板(`back=tasks`)。 */
$tkAddBack = 'tasks';
$tkAddBoards = $boards;
$tkAddCats = $categories;
$tkAddPeople = $people;
require __DIR__ . '/_add_modal.php';
unset($tkAddBack, $tkAddBoards, $tkAddCats, $tkAddPeople); ?>
<?php /* 拖动的提交目标:JS 把 id / 目标**看板** / 参照顺序填进来再 submit。
放在看板**外面**,避免被任何一张卡片的 form 套住。 */ ?>
<form method="post" action="<?= e(url('tasks/move')) ?>" id="taskMoveForm" class="hidden-form">
<?= csrf_field() ?>
<input type="hidden" name="id" id="mvId">
<input type="hidden" name="board" id="mvBoard">
<input type="hidden" name="before" id="mvBefore">
</form>
<?php /* v24 第 25 批 用户第 6 条:**每张卡前面一颗「完成」复选框**。
用户原话:「任务相关的模块,所有的任务,前面都带一个复选框,在任何地方,
点这个复选框,这个任务就完成了。」
⚠️ 卡片上只放 `data-tk-done`(见 _task_check.php),真正的表单**全页一份**,
放在这下面 —— 与搬卡/改状态那两份同一个思路:
几十张卡就是几十份 form,那是名册页被密码管理器拖垮的同一个坑。 */ ?>
<?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="tasks">
</form>
<?php endif; ?>
<?php /* 🔴 v44(用户 2026-10-12 第 4 条):「任务页面,任务下面有一行字:
"待办记下来了,还没动手,排队等着进行"之类的。这个提示不要了,去掉。」
⇒ 整块「状态说明」legend **删掉**(连那三个状态徽标也一起去掉)——
用户要的是"任务下面不要再挂一行字",留着徽标仍然是一行字。
⚠️ `Task::STATUS_HINT` / `statusHint()` **保留在类里**(它是这三个状态语义的
唯一书面定义,后端/文案用得上),只是**界面不再渲染**。
⚠️ `.tk-legend` 那条 CSS 留着不动:删 CSS 会牵连缩进,而它只是空规则,
真正的约束是"模板里不再输出 TK-LEGEND 这一块"。 */ ?>
<?php /* v24 第 14 批 用户第 2 条:**每张卡上都能看到并改状态**。
⚠️ 与上面搬卡那份同理:表单全页只有一份,卡片上只放 `data-st-*`,
不然几十张卡就是几十份 form —— 名册就是这么被密码管理器拖垮的。 */ ?>
<form method="post" action="<?= e(url('tasks/status')) ?>" id="taskStatusForm" class="hidden-form">
<?= csrf_field() ?>
<input type="hidden" name="id" id="stId">
<input type="hidden" name="status" id="stStatus">
<input type="hidden" name="back" value="tasks">
</form>
<div class="kanban" data-cols="<?= count($colList) ?>">
<?php foreach ($colList as $ci => $col): $cid = (int)$col['id']; ?>
<section class="kb-col" data-board="<?= $cid ?>">
<header class="kb-head">
<?php if ((string)($col['color'] ?? '') !== ''): ?>
<i class="kb-dot" style="--bc: <?= e((string)$col['color']) ?>;" aria-hidden="true"></i>
<?php endif; ?>
<b><?= e((string)$col['name']) ?></b>
<span class="kb-n"><?= count($col['rows']) ?></span>
</header>
<div class="kb-body" data-board="<?= $cid ?>">
<?php foreach ($col['rows'] as $t): ?>
<?php
$tid = (int)$t['id'];
$kids = (int)($t['child_total'] ?? 0);
$doneN = (int)($t['child_done'] ?? 0);
$overdue = \App\Task::isOverdue($t['due_at'] ?? null);
?>
<div class="kb-card<?= $overdue ? ' is-overdue' : '' ?>" data-id="<?= $tid ?>"
draggable="<?= $canWrite ? 'true' : 'false' ?>">
<?php /* 用户第 6 条:**任务前面带复选框**,点它就是"完成"。
⚠️ 复选框与标题必须包在 `.kb-top` 里**横排** ——
卡片是 flex-column,不包的话复选框独占一行、标题被挤下去。 */ ?>
<div class="kb-top">
<?php
$tkId = $tid; $tkStatus = (string)($t['status'] ?? 'todo');
$tkBack = 'tasks'; $tkStatic = !$canWrite;
require __DIR__ . '/../_task_check.php';
?>
<a class="kb-title" href="<?= e(url('tasks/show', ['id' => $tid])) ?>">
<?= e((string)$t['title']) ?>
</a>
</div>
<?php /* 状态徽标:每一张卡都有(用户第 2 条「每个任务都应该有不同的状态」)。
点一下推进到下一个状态:待办 → 进行中 → 已完成 → 回到待办
(最后一档做成可回头,是"点错了能改回来",完成本身另有详情页的按钮)。
⚠️ 只读账号给的是**静态**徽标(span),点了没反应还容易误会。 */ ?>
<?php
$stNow = (string)($t['status'] ?? 'todo');
$stNext = ['todo' => 'doing', 'doing' => 'done', 'done' => 'todo'][$stNow] ?? 'doing';
?>
<div class="kb-meta">
<?php if ($canWrite): ?>
<?php /* ⚠️ v24 第 26 批 用户第 3 条:这里原来有 `title="现在:待办;点一下改成「进行中」"`。
那串提示在卡片里离**完成复选框**很近(kb-top 下一行),鼠标一停就弹出来压住它
—— 用户报的正是「提示把复选框都盖住了」。
去掉 title:状态**已经写在按钮文字上**(待办/进行中),
点一下会变成下一档这件事由按钮的样式(可点)表达,不需要再弹一段长文。
`aria-label` 也一并去掉 —— 它同样会生成浮层;无障碍改用 visually-hidden 文本。 */ ?>
<button class="kb-st is-<?= e($stNow) ?>" type="button"
data-st-id="<?= $tid ?>" data-st="<?= e($stNow) ?>" data-next="<?= e($stNext) ?>">
<?= e(\App\Task::statusWord($stNow)) ?>
<span class="visually-hidden">,点一下改成「<?= e(\App\Task::statusWord($stNext)) ?>」</span>
</button>
<?php else: ?>
<span class="kb-st is-<?= e($stNow) ?> is-static"><?= e(\App\Task::statusWord($stNow)) ?></span>
<?php endif; ?>
<?php if (($t['due_at'] ?? '') !== ''): ?>
<span class="kb-due<?= $overdue ? ' is-late' : '' ?>">
<?= e(\App\Task::dueWord($t['due_at'] ?? null)) ?>
</span>
<?php endif; ?>
<?php if (!empty($t['repeat_rule'])): ?>
<span class="node-badge"><?= e(\App\Task::repeatWord($t['repeat_rule'])) ?></span>
<?php endif; ?>
<?php if ((int)($t['comment_count'] ?? 0) > 0): ?>
<span class="kb-ico" title="评论">💬 <?= (int)$t['comment_count'] ?></span>
<?php endif; ?>
<?php if ((int)($t['attach_count'] ?? 0) > 0): ?>
<span class="kb-ico" title="附件">📎 <?= (int)$t['attach_count'] ?></span>
<?php endif; ?>
<?php if (($t['category_name'] ?? '') !== ''): ?>
<span class="node-badge" title="分类"><?= e((string)$t['category_name']) ?></span>
<?php endif; ?>
</div>
<?php if ($kids > 0): ?>
<div class="kb-sub">
<span class="kb-bar"><i style="width: <?= (int)round($doneN * 100 / max(1, $kids)) ?>%"></i></span>
<span class="kb-sub-n"><?= $doneN ?>/<?= $kids ?></span>
</div>
<?php endif; ?>
<div class="kb-foot">
<?php if (!empty($t['assignee_name'])): ?>
<?= person_chip([
'id' => (int)$t['assignee_id'], 'name' => (string)$t['assignee_name'],
'my_nickname' => (string)($t['assignee_nick'] ?? ''),
'avatar' => $t['avatar'] ?? null, 'avatar_color' => $t['avatar_color'] ?? null,
]) ?>
<?php endif; ?>
<?php if ($canWrite): ?>
<span class="kb-move">
<?php if ($ci > 0): ?>
<button class="btn btn-ghost btn-sm" type="button"
data-move="left" data-id="<?= $tid ?>">←</button>
<?php endif; ?>
<?php if ($ci < $lastColIdx): ?>
<button class="btn btn-ghost btn-sm" type="button"
data-move="right" data-id="<?= $tid ?>">→</button>
<?php endif; ?>
</span>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php if (!$col['rows']): ?>
<p class="empty-hint kb-empty">这一列是空的</p>
<?php endif; ?>
</div>
</section>
<?php endforeach; ?>
</div>
<script>
(function () {
/* 🔴 v78:「新建任务」弹框的开关搬到了 `assets/js/task_add.js`
—— 首页那张卡也要开同一张弹框,两处各写一份必然有一处忘了 `fbBootAntd`。
本文件从这一行往下只剩**看板**自己的交互(搬卡 / 状态 / 拖动)。 */
/* ---- 左右搬(不需要拖也能用)----
⚠️ 列的集合**从 DOM 现取**,不要写死数组:看板是用户自己建的,
"待办/进行中/已完成"那套常量在这儿已经不成立了。 */
var form = document.getElementById('taskMoveForm');
if (!form) return;
var colIds = Array.prototype.map.call(
document.querySelectorAll('.kb-body[data-board]'),
function (b) { return b.getAttribute('data-board'); }
);
function submitMove(id, board, before) {
document.getElementById('mvId').value = id;
document.getElementById('mvBoard').value = board;
document.getElementById('mvBefore').value = before || '';
form.submit();
}
/* ---- 状态徽标:点一下推进到下一个状态(用户第 2 条)----
走的是**全页一份**的 #taskStatusForm(卡片上只有 data-st-*),
与搬卡那份表单同一思路 —— 不这么做的话,几十张卡就是几十份 form。
⚠️ v24 第 25 批 用户第 6 条:改成 **AJAX 就地改**(原来是整页提交)。
理由是"复选框在任何地方点一下就完成"这件事,如果每次都刷整页,
在首页点完会跳回首页顶部、滚动位置全丢 —— 那不是"点一下",是"打一次断"。
失败仍退回原生提交(见 catch / 无 JSON 分支),所以没有 JS 照样能用。 */
var stForm = document.getElementById('taskStatusForm');
if (stForm) {
Array.prototype.forEach.call(document.querySelectorAll('.kb-st[data-st-id]'), function (b) {
b.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
submitStatus(b.getAttribute('data-st-id'), b.getAttribute('data-next'), b);
});
});
}
/* ---- 完成复选框(用户第 6 条):点一下 = 完成 ---- */
var doneForm = document.getElementById('taskDoneForm');
function submitStatus(id, status, btn) {
if (!doneForm) { return; }
document.getElementById('doneId').value = id;
document.getElementById('doneStatus').value = status;
document.getElementById('doneBack').value = (btn && btn.getAttribute('data-tk-back')) || 'tasks';
// 没有 fetch(极老浏览器)→ 直接原生提交,不假装成功
if (!window.fetch || !window.FormData) {
doneForm.submit();
return;
}
if (btn) { btn.disabled = true; }
fetch(doneForm.getAttribute('action'), {
method: 'POST',
body: new FormData(doneForm),
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) {
return r.json().catch(function () { return null; });
}).then(function (d) {
if (btn) { btn.disabled = false; }
if (d && d.ok) {
applyStatus(String(d.id), String(d.status), String(d.word || ''));
return;
}
if (d && d.msg) { say(d.msg, false); return; }
// 拿不到 JSON(会话过期 / 500)→ 退回原生提交,让服务端说话
doneForm.submit();
}).catch(function () {
if (btn) { btn.disabled = false; }
doneForm.submit();
});
}
/** 把一张卡的状态**就地**改掉:复选框、状态徽标、卡片样式一起动。
* ⚠️ 完成之后看板上还有没有这张卡,交给用户刷新时由服务端决定 ——
* 不在这里偷偷摘卡片:任务是"刚做完",正想看一眼自己刚完成什么,
* 立刻消失会让人怀疑"是不是点到删除了"。 */
function applyStatus(id, status, word) {
var card = document.querySelector('.kb-card[data-id="' + id + '"]');
if (!card) { return; }
var chk = card.querySelector('.tk-check[data-tk-done]');
if (chk) {
chk.setAttribute('data-tk-status', status);
chk.classList.toggle('is-done', status === 'done');
chk.setAttribute('aria-pressed', status === 'done' ? 'true' : 'false');
}
var badge = card.querySelector('.kb-st[data-st-id]');
if (badge) {
var next = status === 'todo' ? 'doing' : (status === 'doing' ? 'done' : 'todo');
badge.className = 'kb-st is-' + status;
badge.setAttribute('data-st', status);
badge.setAttribute('data-next', next);
badge.textContent = word;
/* 🔴🔴 v28 用户第 4 条:「任务完成复选框的提示文字还在,需要删除。」
根因就在这一行 —— 它给**状态徽标**动态挂了一个 `title`。
v24 第 26 批只把**静态渲染**里那颗徽标的 title 去掉了(服务端模板),
却漏了这一处 JS:用户只要点过一次复选框,徽标就又被安上 tooltip,
而它离复选框最近,鼠标一停就把复选框盖住 —— 与当初报的是同一个现象。
⚠️ 上一批的静态断言(查渲染出的 HTML 里有没有 `title=`)**永远抓不到**:
它只在点击之后才出现。这类"JS 复活的旧写法"必须靠真机点击验证。
⇒ 直接删掉,不再设 title;无障碍说明靠 aria-label(不产生可见浮层)。 */
badge.setAttribute('aria-label', word);
}
card.classList.toggle('is-done', status === 'done');
say('已标成「' + word + '」。', true);
}
/** 页内提示(用页面自己的 flash 样式,不弹 alert) */
function say(msg, ok) {
var host = document.querySelector('.flash');
if (!host) {
host = document.createElement('div');
host.className = 'flash';
var main = document.querySelector('main') || document.body;
main.insertBefore(host, main.firstChild);
}
var el = document.createElement('div');
el.className = 'flash-item' + (ok ? '' : ' err');
el.setAttribute('role', 'status');
el.textContent = msg;
host.insertBefore(el, host.firstChild);
setTimeout(function () { if (el.parentNode) { el.parentNode.removeChild(el); } }, 3200);
}
Array.prototype.forEach.call(document.querySelectorAll('.tk-check[data-tk-done]'), function (b) {
b.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
var now = b.getAttribute('data-tk-status') || 'todo';
// 勾上 = 完成;已完成的再点一下 = 取消(回到「待办」)——
// 用户可能点错了,必须能退回来(这与"没有任何撤销"的删除是两回事)。
submitStatus(b.getAttribute('data-tk-done'), now === 'done' ? 'todo' : 'done', b);
});
});
Array.prototype.forEach.call(document.querySelectorAll('[data-move]'), function (btn) {
btn.addEventListener('click', function () {
var card = btn.closest('.kb-card');
var from = card.closest('.kb-body').getAttribute('data-board');
var i = colIds.indexOf(from) + (btn.getAttribute('data-move') === 'right' ? 1 : -1);
if (i < 0 || i >= colIds.length) { return; }
submitMove(card.getAttribute('data-id'), colIds[i], '');
});
});
/* ---- 拖动(渐进增强:拖不动还有上面的左右按钮)---- */
/* ⚠️ v24(用户 2026-10-02 第 8 条):拖完一张卡,松手那一下浏览器还会
补一个 click 给卡片里的标题链接 —— 于是"刚拖到位就立刻跳进详情页",
看板直接没法用了。
修法:拖结束就立个牌子 dragEnded,标题链接的 click 看到牌子就吞掉。
牌子在**下一次 mousedown** 时摘掉(真想点进去总得再按一次鼠标),
所以不会误伤正常点击 —— 比用 setTimeout 猜时间准得多。 */
var dragging = null;
var dragEnded = false;
document.addEventListener('mousedown', function () { dragEnded = false; }, true);
Array.prototype.forEach.call(document.querySelectorAll('.kb-card[draggable="true"]'), function (card) {
card.addEventListener('dragstart', function (ev) {
dragging = card;
dragEnded = false;
card.classList.add('is-dragging');
if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; }
});
card.addEventListener('dragend', function () {
card.classList.remove('is-dragging');
dragging = null;
dragEnded = true;
Array.prototype.forEach.call(document.querySelectorAll('.kb-body.is-over'), function (el) {
el.classList.remove('is-over');
});
});
});
Array.prototype.forEach.call(document.querySelectorAll('.kb-card .kb-title'), function (a) {
a.addEventListener('click', function (ev) {
if (dragEnded) { ev.preventDefault(); ev.stopPropagation(); }
});
});
Array.prototype.forEach.call(document.querySelectorAll('.kb-body'), function (body) {
body.addEventListener('dragover', function (ev) {
if (!dragging) return;
ev.preventDefault();
body.classList.add('is-over');
});
body.addEventListener('dragleave', function () { body.classList.remove('is-over'); });
body.addEventListener('drop', function (ev) {
if (!dragging) return;
ev.preventDefault();
body.classList.remove('is-over');
/* 放在谁前面:找松手位置下方最近的那张卡(排除自己),没有就排到末尾 */
var after = null;
var kids = Array.prototype.slice.call(body.querySelectorAll('.kb-card'));
for (var i = 0; i < kids.length; i++) {
if (kids[i] === dragging) continue;
var box = kids[i].getBoundingClientRect();
if (ev.clientY < box.top + box.height / 2) { after = kids[i]; break; }
}
submitMove(dragging.getAttribute('data-id'), body.getAttribute('data-board'),
after ? after.getAttribute('data-id') : '');
});
});
})();
</script>