v26 第 26 批(5 条):财务多选按集合取图 / 首页全天置顶与红线定位 / 任务复选框去提示与看板颜色 / 导航搜索引擎切换与站点复用 / 详情页去复选框

- 财务:勾选多个标签时上面那张「总金额」图按**选中集合**统计(新增
  ledger/total_chart 路由 + 单一容器 data-ck-total-host,按集合现取并缓存;
  Ledger::timeline 第二参数放宽为 string|array)
- 首页日程:全天日程移出滚动容器固定置顶;进场把当前时刻红线居中
  (scrollTop 计算,不用 scrollIntoView),触发条件不绑 $showNow
- 任务:复选框去除 title 提示;看板可设颜色(Task::boardColor/updateBoard),
  首页任务行用该颜色显示所属看板
- 导航:展示页补回 id=engine-tabs(切换搜索引擎无效的根因);补内置引擎;
  新增站点输 URL 可复用他人已加过的站点(sharedByUrl/copyIconFile/adoptSite)
- 任务详情页页头去掉复选框(已有「标记完成」按钮)
This commit is contained in:
2026-10-07 15:31:26 +08:00
parent a5e559b2b3
commit c8839fa728
15 changed files with 964 additions and 93 deletions
+7 -3
View File
@@ -38,14 +38,18 @@ if ($tkId <= 0) {
?>
<?php if ($tkStatic): ?>
<span class="tk-check is-static<?= $tkIsDone ? ' is-done' : '' ?>"
aria-hidden="true" title="<?= e(\App\Task::statusWord($tkStatus)) ?>"></span>
aria-hidden="true"></span>
<?php else: ?>
<?php /* 🔴 v24 第 26 批 用户第 3 条:**不要任何浮层文字提示**。
用户原话:「所有任务的前面复选框,都不要文字提示,现在问题提示把复选框都盖住了」——
`title="…"` 会弹一个系统 tooltip,正好压在 18px 的方块上(鼠标一停就盖住半个)。
去掉 title;无障碍说明仍在(`.visually-hidden` 里的文案是**屏读**用的,
不产生任何可见浮层,也不占位)。 */ ?>
<button type="button" class="tk-check<?= $tkIsDone ? ' is-done' : '' ?>"
data-tk-done="<?= $tkId ?>"
data-tk-status="<?= e($tkStatus) ?>"
data-tk-back="<?= e($tkBack) ?>"
aria-pressed="<?= $tkIsDone ? 'true' : 'false' ?>"
title="<?= $tkIsDone ? '已完成(再点一下取消)' : '点一下标记完成' ?>">
aria-pressed="<?= $tkIsDone ? 'true' : 'false' ?>">
<span class="visually-hidden"><?= e($tkLabel) ?>:<?= e(\App\Task::statusWord($tkStatus)) ?></span>
</button>
<?php endif; ?>
+95 -20
View File
@@ -222,22 +222,34 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
<h2 class="cinema-h2">今天的日程</h2>
<a class="btn btn-ghost btn-sm" href="<?= e(url('calendar')) ?>">看整月</a>
</div>
<div class="hd-pane-body">
<?php if (!$todayEvents): ?>
<?php /* 🔴 v24 第 26 批 用户第 2 条(前半):「今天的日程,除了全天日程**一直显示在上面**外,
其他日程随着滚动」。
⇒ DOM 结构必须分成两层:
① `.ag-allday` **留在 pane 里、不在滚动容器内** —— 它永远钉在标题下方;
② `.hd-pane-body`(滚动容器)**只包** `.ag-grid` —— 只有时段网格随滚动。
为什么不能在同一个滚动容器里给全天区 `position: sticky`:
它在滚动区**内部**,sticky 只能粘在滚动区顶端,而滚动区顶端在标题下面 ——
滚起来时全天区会被"标题 + 滚动区"两层挤着,看起来仍然在动;
而且 sticky 需要不透明底色遮住底下滚过去的日程,与现在的浅色卡片叠色不好看。
直接把它移出滚动容器,是**零副作用**的做法。 */ ?>
<?php if (!$todayEvents): ?>
<div class="hd-pane-body">
<p class="empty-hint">今天没有安排。</p>
<?php else: ?>
<?php if ($allDay): ?>
<div class="ag-allday">
<?php foreach ($allDay as $ev): ?>
<a class="ag-allday-item" href="<?= e((string)$ev['href']) ?>">
<span class="dash-dot" style="background:<?= e((string)$ev['color']) ?>;"></span>
<span class="ag-ad-t">全天</span>
<span class="ag-ad-w"><?= e((string)$ev['title']) ?></span>
<span class="dash-cal" style="background:<?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php else: ?>
<?php if ($allDay): ?>
<div class="ag-allday">
<?php foreach ($allDay as $ev): ?>
<a class="ag-allday-item" href="<?= e((string)$ev['href']) ?>">
<span class="dash-dot" style="background:<?= e((string)$ev['color']) ?>;"></span>
<span class="ag-ad-t">全天</span>
<span class="ag-ad-w"><?= e((string)$ev['title']) ?></span>
<span class="dash-cal" style="background:<?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="hd-pane-body" data-ag-scroll>
<div class="ag-grid">
<?php for ($h = $startH; $h < $endH; $h++): ?>
<div class="ag-row">
@@ -266,10 +278,10 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
<span class="dash-cal" style="background:<?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div><?php /* /.ag-grid */ ?>
</div><?php /* /.hd-pane-body(滚动区,只含时段网格) */ ?>
<?php endif; ?>
</div><?php /* /.hd-pane */ ?>
<?php /* ---- 右:进行中的任务(标题 + 状态 + 看板名)---- */ ?>
<div class="card card-pad hd-pane" id="homeTasks">
@@ -305,7 +317,16 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
</span>
<?php /* 用户第 3 条:「还要显示他所处的看板的名称,就是他现在被放在哪一个看板里面」 */ ?>
<?php if (trim((string)($t['board_name'] ?? '')) !== ''): ?>
<span class="tk-board"><?= e((string)$t['board_name']) ?></span>
<?php /* 🔴 v24 第 26 批 用户第 3 条(后半):「任务在设置看板的时候,可以选择颜色,
这样在首页列表中显示的时候,就可以更加直观的看到他属于哪个看板。」
⇒ 徽标**同时带颜色**:左边一个小圆点(看板色)+ 看板名。
为什么不用"整块背景染色":首页这一行已经有状态徽标、看板徽标、逾期等几个
色块,整块染色会互相打架、反而看不出哪个色对应哪个语义。
一个小圆点信息量足够、又不抢戏。 */
$bColor = \App\Task::boardColor($t['board_color'] ?? null, (string)$t['board_name']); ?>
<span class="tk-board" style="--bc: <?= e($bColor) ?>;">
<i class="tk-board-dot" aria-hidden="true"></i><?= e((string)$t['board_name']) ?>
</span>
<?php endif; ?>
<?php if ((int)($t['child_total'] ?? 0) > 0): ?>
<span class="dash-tag"><?= (int)$t['child_done'] ?>/<?= (int)$t['child_total'] ?></span>
@@ -422,4 +443,58 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
});
})();
</script>
<?php endif; ?>
<?php /* ==================== 日程滚动区:进场定位到当前时刻(v24 第 26 批 用户第 2 条)======
用户原话:「每次进来,都要定位到当前时刻,就是红线出现的位置,让红线在中间。」
⇒ 打开首页时把**滚动容器**滚到一个位置,使"当前时刻"落在可视区**正中**。
⚠️ 触发条件只看**网格在不在**(`$timed` 非空才有网格),**不要**再加 `$showNow`。
第一版写了 `if (!empty($timed) && $showNow)`,后果是:
· 红线落在区间外(现在 23:40、日程只到 18 点 → 页面不画红线)时**整段脚本不渲染**
→ "每次进来都要定位"这条需求在最常见的场景(晚上开首页)直接失效;
· 更糟的是**判据依赖了被优化掉的东西**:探针那次库里没有分时段日程,
脚本根本没输出,页面看着"没报错",实测 `scrollTop=0`(本轮实测抓到的)。
⇒ 现在:**有网格就定位**。红线在就对齐红线;红线不在(在区间外)就用
`$nowPos` 同一个比例算出"此刻"在网格里的位置来对齐(数值同源,不会两套口径)。
⚠️ 用 `scrollTop = 目标偏移 - 容器高/2`,**不要**用 `scrollIntoView({block:'center'})`:
它会**连带滚动外层页面**(把整个浏览器窗口也滚下去),
用户一进首页就被拽到"页面中部" —— 那比不定位更糟。
直接给容器赋 `scrollTop` 只动这一个盒子,外层纹丝不动。
⚠️ 容器"没得滚"时(内容比容器矮)赋任何值都是 0,天然安全,不必特判。
⚠️ 目标位置**由服务端算好**传进来(`$nowPos`),不在 JS 里再算一遍时间 ——
两处各算一次迟早不一致(服务端用的是站点时区,JS 用的是浏览器时区)。 */ ?>
<?php if (!$todayEvents || empty($timed)): ?>
<?php /* 没有分时段日程 → 没有网格可滚,什么都不做 */ ?>
<?php else: ?>
<script>
(function () {
var box = document.querySelector('[data-ag-scroll]');
if (!box) { return; }
// 红线(在的话)优先;不在就用服务端算好的 nowPos 现算一个位置。
var line = box.querySelector('.ag-now');
var RATIO = <?= json_encode(round($nowPos, 6)) ?>; // 0~1(可能落在区间外)
function targetOffset() {
if (line) { return line.offsetTop; }
var grid = box.querySelector('.ag-grid');
if (!grid) { return 0; }
// ⚠️ 用**排掉全天区之后**的内容高度算:全天区现在不在滚动容器里,
// 所以 grid 的高度就是"整条时间轴"的高度(与服务端算 nowPos 的口径一致)。
return grid.offsetHeight * RATIO;
}
function centerOnNow() {
// offsetTop 相对**定位祖先**(.ag-grid,position:relative),即"内容坐标"
box.scrollTop = Math.max(0, targetOffset() - (box.clientHeight / 2));
}
centerOnNow();
/* ⚠️ 再补两拍:字体加载完 / 浏览器恢复滚动位置(back-forward cache)之后,
内容高度会变,第一次算出来的 scrollTop 可能偏。
用 rAF 而不是 setTimeout:rAF 在排版完成后跑,时序更准;
两拍是为了覆盖"布局在渲染后才最终确定"的那一帧。 */
if (window.requestAnimationFrame) {
requestAnimationFrame(function () { requestAnimationFrame(centerOnNow); });
}
})();
</script>
<?php endif; ?>
+107 -22
View File
@@ -209,30 +209,44 @@ $ledMe = (int)($me ?? 0);
</h2>
<?php if ($timeline): ?>
<?php /* 总金额图("全部")与逐类别图("存款"…)都**预渲染好**,靠 hidden 切换。
为什么不"点了再向服务器要一张":图是**服务端画的 SVG**(见 app/chart.php),
要重画就得整页刷新 —— 那就不是"点标签",是"点链接"了。
预渲染的代价只是每个**用到的**类别多一次查询,而这个页面本来就要
把整本账每条账目都读出来(charts() 已在做),量级一样。 */ ?>
<?php /* 总金额图("全部")与逐类别图("存款"…)都**预渲染好** —— 但它们现在的身份变了:
v24 第 26 批 用户第 1 条要求"多选时按**选中集合**统计",而集合的组合数是指数级的
(10 个类别 = 1023 种),不可能全预渲染。所以改成:
· 页面里预渲染的这批图,现在只作**缓存的第一层**:
空集(整本账)与单类别这两档直接命中,**零请求、无闪烁**;
· 其它组合(2 类以上)由前端把集合发给
`ledger/total_chart` 现取一张(见本文件 JS 的 ② 段)。
这样既保住了"点一下立刻变"的手感(最常见的前两档),
又不至于为 1023 种组合预渲染 1023 张图。 */ ?>
<div class="ck-card ck-total">
<div class="ck-head">
<b data-ck-total-title>总金额 · 净资产</b>
<span class="small muted" data-ck-total-n><?= count($timeline) ?> 个时间点</span>
</div>
<?php /* ⚠️ 每张图外面包一层带 data-ck-tkind 的容器('' = 整本账)。
筛选时只显示匹配的那一张。
⚠️ 不能在 chart_line 内部加属性 —— 那个函数被全站复用,
"为了筛选顺手改公共渲染函数"是往所有调用点塞风险。 */ ?>
<div data-ck-tkind="">
<?php /* 🔴 图容器:`data-ck-total-host` 是 JS 唯一要操作的节点。
两个自定义属性把"整本账那张图"和"取图接口"都交给 JS:
· data-ck-cache-base —— 空集时要回显的 HTML(**页面里已经画好了**,
不必再请求一次;也不怕 JS 手里没有它)。
· data-ck-url —— 现取接口(集合作为 &kinds=a,b 追加)。
⚠️ 单类别那几张也**照样渲染在页面里**(`data-ck-seed` 标记),
由 JS 启动时读进缓存 —— 它们不再靠 hidden 切换显示,
但"预渲染一次、JS 直接取用"这个好处要留住(选一类时零延迟)。 */ ?>
<div data-ck-total-host
data-ck-cache-base="<?= e(chart_line($timeline, '净资产', 'asset', 900)) ?>"
data-ck-url="<?= e(url('ledger/total_chart', ['book' => (int)$cur['id']])) ?>">
<?= chart_line($timeline, '净资产', 'asset', 900) ?>
</div>
<?php /* 单类别图:只作 JS 的缓存种子,**不显示**(display:none,不是 hidden——
因为 hidden 会被 JS 里某些 innerHTML 操作顺手带上)。 */ ?>
<?php foreach ($kindLines as $k => $line): ?>
<?php if (!$line) { continue; } ?>
<?php /* 负债类别用绿线(`debt`),资产用红线 —— 与全站"涨红跌绿"口径一致 */ ?>
<div data-ck-tkind="<?= e((string)$k) ?>" hidden>
<?= chart_line($line, \App\Ledger::kindWord((string)$k),
\App\Ledger::isDebt((string)$k) ? 'debt' : 'asset', 900) ?>
</div>
<script type="application/json"
data-ck-seed="<?= e((string)$k) ?>"><?= json_encode(
chart_line($line, \App\Ledger::kindWord((string)$k),
\App\Ledger::isDebt((string)$k) ? 'debt' : 'asset', 900),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?></script>
<?php endforeach; ?>
</div>
<?php endif; ?>
@@ -604,6 +618,28 @@ $ledMe = (int)($me ?? 0);
/** 当前选中的类别集合(空集 = 全部) */
var picked = [];
/* v24 第 26 批 用户第 1 条:总金额图的**按集合缓存**。
key = 排序后的类别串('' = 整本账 → 不进缓存表,直接回显预渲染那张)
value = 该组合对应的 SVG 字符串
⚠️ 缓存的是 **SVG 文本**而不是 DOM 节点:innerHTML 每次要复制一份,
节点被移动来移动去会出各种"元素凭空消失"的怪事。 */
var totalCache = {};
/** 正在请求中的组合(防止连点同一组合发多次) */
var totalFetching = {};
/* 把页面预渲染的单类别图读进缓存(选一类时零请求、零延迟)。
⚠️ 用 `<script type="application/json">` 承载 SVG 而不是塞进 data-*——
属性里的引号会被 HTML 实体化,读出来还得反转义一次;
JSON 块原样读、`JSON.parse` 即可(服务端已 json_encode)。
⚠️ 这里**必须 try/catch**:种子坏了只是"少一层缓存",
绝不能让整个筛选条(连同第 ③ ④ 步)一起挂掉。 */
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-seed]'), function (el) {
try {
var k = el.getAttribute('data-ck-seed') || '';
if (k !== '') { totalCache[k] = JSON.parse(el.textContent || '""'); }
} catch (e) { /* 忽略:少一层缓存而已 */ }
});
/** 读一遍某颗标签上带的说明数据(合计 / 笔数 / 类别名) */
function chipInfo(bar, kind) {
var c = bar.querySelector('.ck-chip[data-ck-kind="' + kind + '"]');
@@ -624,16 +660,65 @@ $ledMe = (int)($me ?? 0);
});
});
// ② 总金额图:只在"恰好选了一类"时切到那一类;多选或空集都显示整本账
var sole = picked.length === 1 ? picked[0] : '';
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-tkind]'), function (w) {
w.hidden = (w.getAttribute('data-ck-tkind') || '') !== sole;
});
// ② 总金额图:按**选中集合**换图(v24 第 26 批 用户第 1 条重写)
//
// 🔴 用户原话:「财务模块,多个标签勾选后,第一个图,总额那里,勾选一个会变化,
// 勾选多个后,变成总数了。」
// 上一批只做了"恰好选一类才切图",多选直接**回落成整本账** —— 那就是 bug。
//
// 做法:集合为空 → 用页面里预渲染的「整本账」那张;
// 否则 → 找一张**已经画好该组合**的缓存图;没有就 AJAX 现取一张。
// 为什么要缓存:用户连点几下标签(选中→取消→再选中)时,
// 每一下都打服务器是浪费(同一组合的画法完全确定)。
// key 用**排序后的类别串**,所以 `[存款,保险]` 与 `[保险,存款]` 命中同一张。
//
// ⚠️ 用 `totalHost.innerHTML = svg` 替换而不是 append:
// 否则连点几次会堆一摞图在容器里(每次只藏旧的,越藏越多)。
var totalHost = document.querySelector('[data-ck-total-host]');
var totalKey = picked.slice().sort().join(',');
if (totalHost) {
if (totalKey === '') {
// 整本账:直接回显页面里预渲染的那张(不请求、无闪烁)
totalHost.innerHTML = totalHost.getAttribute('data-ck-cache-base') || '';
} else {
var cached = totalCache[totalKey];
if (cached !== undefined) {
totalHost.innerHTML = cached;
} else if (!totalFetching[totalKey]) {
totalFetching[totalKey] = true;
var url = totalHost.getAttribute('data-ck-url') || '';
if (url) {
fetch(url + '&kinds=' + encodeURIComponent(totalKey), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin'
}).then(function (r) { return r.json().catch(function () { return null; }); })
.then(function (d) {
totalFetching[totalKey] = false;
if (!d || !d.ok) { return; }
totalCache[totalKey] = d.svg || '';
// ⚠️ 只有**当前选中的还是这个组合**时才贴上去:
// 用户可能已经又点了几下,慢响应回来时不该覆盖新结果。
if (picked.slice().sort().join(',') === totalKey) {
totalHost.innerHTML = totalCache[totalKey];
var t2 = document.querySelector('[data-ck-total-title]');
if (t2 && d.label) { t2.textContent = d.label + ' · 金额变化'; }
}
}).catch(function () { totalFetching[totalKey] = false; });
}
}
}
}
var ttl = document.querySelector('[data-ck-total-title]');
if (ttl) {
var info = sole !== '' ? chipInfo(bars[0], sole) : null;
ttl.textContent = sole === '' ? '总金额 · 净资产'
: (info && info.word ? info.word : '') + ' · 金额变化';
if (picked.length === 0) {
ttl.textContent = '总金额 · 净资产';
} else if (picked.length === 1) {
var info = chipInfo(bars[0], picked[0]);
ttl.textContent = (info && info.word ? info.word : '') + ' · 金额变化';
} else {
var ws2 = [];
picked.forEach(function (k) { ws2.push(chipInfo(bars[0], k).word); });
ttl.textContent = picked.length + ' 类合计 · ' + ws2.join(' + ');
}
}
// ③ 多线图:按集合显隐线条(几何不动,只切可见性)
+135 -4
View File
@@ -175,16 +175,34 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
<?php if ($canWrite): ?>
<div class="card card-pad">
<h2 class="cinema-h2"><?= $editSite ? '编辑站点' : '新增站点' ?></h2>
<form method="post" action="<?= e(url('nav/save')) ?>">
<?php /* 🔴 v24 第 26 批 用户第 4 条:「输入 url 之后,如果其他用户添加了,
他可以直接复用」。做法是**输入框失焦时问一次**服务端,
把别人加过的同款站点列出来让用户挑一个 —— 挑中就带上 reuse_key 提交。
⚠️ 复用只在**新增**时给(编辑已有站点时再抄别人语义很乱):
所以这段只在 `!$editSite` 时输出,并带 `is_new=1` 让服务端也认这个前提。 */ ?>
<?php if (!$editSite): ?>
<div class="nav-reuse" id="navReuse" hidden>
<div class="nav-reuse-h">有人加过这个站点,可以直接复用它的图标与名称:</div>
<div class="nav-reuse-list" id="navReuseList"></div>
<div class="nav-reuse-f" id="navReuseFoot"></div>
</div>
<?php endif; ?>
<form method="post" action="<?= e(url('nav/save')) ?>" id="navSiteForm">
<?= csrf_field() ?>
<input type="hidden" name="act" value="site_save">
<?php if ($editSite): ?>
<input type="hidden" name="id" value="<?= (int)$editSite['id'] ?>">
<?php endif; ?>
<?php if (!$editSite): ?>
<?php /* 服务端凭这个标记判断"这是新建、可以复用"(见 index.php 的 site_save 段) */ ?>
<input type="hidden" name="is_new" value="1">
<?php endif; ?>
<?php /* 用户选定的可复用条目(点一下列表里的卡片就填进来) */ ?>
<input type="hidden" name="reuse_key" id="navReuseKey" value="">
<div class="form-grid">
<div class="field">
<label>网址 *</label>
<input class="input mono" name="url" required
<input class="input mono" name="url" id="navSiteUrl" required
value="<?= e((string)($editSite['url'] ?? '')) ?>" placeholder="https://example.com">
</div>
<div class="field">
@@ -200,12 +218,13 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
</div>
<div class="field">
<label>显示名</label>
<input class="input" name="name" value="<?= e((string)($editSite['name'] ?? '')) ?>"
<input class="input" name="name" id="navSiteName" value="<?= e((string)($editSite['name'] ?? '')) ?>"
placeholder="留空会自动抓">
</div>
<div class="field">
<label>简称</label>
<input class="input" name="short_name" value="<?= e((string)($editSite['short_name'] ?? '')) ?>"
<input class="input" name="short_name" id="navSiteShort"
value="<?= e((string)($editSite['short_name'] ?? '')) ?>"
placeholder="页面上显示的字(优先于显示名)">
</div>
<div class="field full">
@@ -405,6 +424,24 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
<div class="card card-pad">
<h2 class="cinema-h2">搜索引擎列表</h2>
<?php /* 🔴 v24 第 26 批 用户第 4 条:「可以内置几个,让用户选择」。
默认已经内置 6 个(必应/百度/360 启用,谷歌/搜狗/DuckGo 未启用 —— 后三个
在国内网络下不可用,见 nav_suggest.php 头部的线上实测)。
⚠️ 但**老用户**库里只有当年那几条(ensureSeed 只在"一条都没有"时才灌),
所以要给一个"补齐内置"的入口,否则"内置几个"对他们等于 0 个。
⚠️ 用独立表单(不是 data-nav-* 那种 AJAX 按钮):它是一次性动作、
执行完要整页刷新看结果,走普通 POST 最省事也最不容易出错。 */ ?>
<p class="small muted" style="margin-top:0">
内置了必应 / 百度 / 360 三档(国内可用)。谷歌 / 搜狗 / DuckGo 也列在下面,
但它们的联想或搜索在国内网络下不可用,所以默认<strong>未启用</b>。
展示页顶部那排标签,就是这里<strong>已启用</strong>的引擎。
</p>
<form method="post" action="<?= e(url('nav/save')) ?>" class="inline-form" style="margin:0 0 10px">
<?= csrf_field() ?>
<input type="hidden" name="act" value="engine_seed_add">
<button class="btn btn-sm" type="submit">补齐内置搜索引擎</button>
<span class="small muted">已有的不会被改动(你改过的地址、颜色都保留)。</span>
</form>
<ul class="nav-sortlist" data-sort-list data-reorder-url="<?= e(url('nav/save')) ?>">
<?php foreach ($engines as $en): ?>
<li class="nav-sortrow<?= empty($en['is_active']) ? ' is-off' : '' ?>" data-id="<?= (int)$en['id'] ?>">
@@ -541,5 +578,99 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
.then(function () { window.location.reload(); });
}
});
/* ==================================================================
跨用户复用站点(v24 第 26 批 用户第 4 条)
用户原话:「输入 url 之后,如果其他用户添加了,他可以直接复用,
复用的时候,自定义图标和简称,他可以自己再设置,
设置好以后就固定了。」
交互设计(三拍,对应他这句话的三段):
① **输入 URL 之后** → 输入框失焦(或粘贴后 600ms)自动问一次服务端;
② 有人加过 → 列出可复用的卡片(图标 + 显示名),**点一下即选中**;
③ 选中后 → 把它的 名字/图标 展示出来,并把 reuse_key 填进隐藏域;
用户可以在上面的「显示名 / 简称」里改成自己喜欢的("自己再设置"),
提交时服务端会把图标**复制一份并标成自定义**("设置好以后就固定了")。
⚠️ 输入框再变 → 立刻清空已选(URL 换了,之前选的复用目标就不成立了)。
这一条最容易漏:不清的话用户改了网址、却还带着上一个站点的图标提交。
⚠️ 全程不阻塞:查询失败(离线/超时)就当"没人加过",不弹错、不挡提交。
================================================================== */
var urlIn = document.getElementById('navSiteUrl');
var reuse = document.getElementById('navReuse');
var reuseL = document.getElementById('navReuseList');
var reuseF = document.getElementById('navReuseFoot');
var reuseK = document.getElementById('navReuseKey');
var nameIn = document.getElementById('navSiteName');
var shrtIn = document.getElementById('navSiteShort');
if (urlIn && reuse && reuseL && reuseK) {
var lookupTimer = null;
var lastLookup = '';
function clearReuse() {
reuseK.value = '';
reuse.hidden = true;
reuseL.innerHTML = '';
if (reuseF) { reuseF.textContent = ''; }
}
function doLookup() {
var u = (urlIn.value || '').trim();
if (u === '' || !/^https?:\/\//i.test(u)) { clearReuse(); return; }
if (u === lastLookup) { return; } // 同一个地址不重复问
lastLookup = u;
post({ act: 'site_lookup', url: u }).then(function (d) {
if (!d || !d.items || !d.items.length) { clearReuse(); return; }
reuseL.innerHTML = '';
d.items.forEach(function (it) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'nav-reuse-item';
b.setAttribute('data-key', it.key);
b.setAttribute('data-name', it.name || '');
b.setAttribute('data-short', it.short_name || '');
var img = document.createElement('img');
img.className = 'nav-ico';
img.alt = '';
if (it.icon) { img.src = it.icon; }
var sp = document.createElement('span');
sp.className = 'nav-reuse-nm';
sp.textContent = it.label || '';
b.appendChild(img);
b.appendChild(sp);
b.addEventListener('click', function () {
Array.prototype.forEach.call(reuseL.querySelectorAll('.nav-reuse-item'),
function (x) { x.classList.remove('is-on'); });
b.classList.add('is-on');
reuseK.value = it.key;
/* "他可以自己再设置":把别人的名字/简称**预填**进输入框,
用户想改就直接改(不填也不会错 —— 服务端会用别人的补上)。
⚠️ 只填**空的**字段:用户已经手打过名字时不要覆盖他(那是抢用户的输入)。 */
if (nameIn && nameIn.value.trim() === '' && it.name) { nameIn.value = it.name; }
if (shrtIn && shrtIn.value.trim() === '' && it.short_name) { shrtIn.value = it.short_name; }
if (reuseF) {
reuseF.textContent = '已选「' + (it.label || '') + '」:'
+ '图标会复制一份到你名下并固定(以后「全部更新」不会改它)。'
+ '上面两项可自行修改。';
}
});
reuseL.appendChild(b);
});
reuse.hidden = false;
}).catch(function () { clearReuse(); });
}
// 失焦后问(不用 input 事件:边打边问会把半截域名也拿去查)
urlIn.addEventListener('blur', function () { setTimeout(doLookup, 120); });
// 粘贴之后通常不会失焦,补一拍
urlIn.addEventListener('paste', function () {
clearTimeout(lookupTimer);
lookupTimer = setTimeout(doLookup, 600);
});
// URL 一变 → 之前选的复用目标失效,必须清掉
urlIn.addEventListener('input', function () {
if (reuseK.value !== '') { clearReuse(); }
lastLookup = '';
});
}
})();
</script>
+22 -5
View File
@@ -30,14 +30,22 @@ $categories = $categories ?? [];
$colList = [];
foreach ($boards as $b) {
$bid = (int)$b['id'];
$colList[] = ['id' => $bid, 'name' => (string)$b['name'], 'rows' => $cols[$bid] ?? []];
/* 🔴 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' => '其他', 'rows' => $cols[0]];
$colList[] = ['id' => 0, 'name' => '其他', 'color' => '', 'rows' => $cols[0]];
}
// 一个看板都没有(也不该发生:ensureDefaultBoard 保证至少一个)→ 给一句人话
if (!$colList) {
$colList[] = ['id' => 0, 'name' => '任务', 'rows' => $cols[0] ?? []];
$colList[] = ['id' => 0, 'name' => '任务', 'color' => '', 'rows' => $cols[0] ?? []];
}
$firstColId = (int)($colList[0]['id'] ?? 0);
$lastColIdx = count($colList) - 1;
@@ -208,6 +216,9 @@ $lastColIdx = count($colList) - 1;
<?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>
@@ -244,10 +255,16 @@ $lastColIdx = count($colList) - 1;
?>
<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) ?>"
title="现在:<?= e(\App\Task::statusWord($stNow)) ?>;点一下改成「<?= e(\App\Task::statusWord($stNext)) ?>」">
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>
+13 -1
View File
@@ -32,14 +32,26 @@ $canWrite = \App\Auth::canWrite();
看板是「分开看几摊事」用的(家务 / 装修 / 旅行准备 …)。任务看板顶上可以切换。
删掉一个看板时,里面的任务不会被删,会挪到默认看板。
</p>
<?php /* 🔴 v24 第 26 批 用户第 3 条:「任务在设置看板的时候,可以选择颜色,
这样在首页列表中显示的时候,就可以更加直观的看到他属于哪个看板。」
⇒ 每行给一个**颜色选择器**,与改名在**同一份表单**里一起提交
(用户改色时不该被迫把名字重打一遍)。
⚠️ 用 `<input type="color">` 而不是自绘色块:原生控件在手机上直接唤起系统取色器,
自绘的做不到这一点;代价是桌面端样式朴素 —— 但"能用"优先于"好看"。
⚠️ 旁边那颗小圆点显示**当前最终色**(没选过色时是按名字派生的那个),
不然用户看到取色器里是黑的、而列表里是蓝的,会以为没生效。 */ ?>
<div class="set-list">
<?php foreach ($boards as $b): ?>
<?php $bColor = \App\Task::boardColor($b['color'] ?? null, (string)$b['name']); ?>
<form class="inline-form set-row" method="post" action="<?= e(url('tasks/board_update')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$b['id'] ?>">
<span class="kb-dot" style="background: <?= e($bColor) ?>;" aria-hidden="true"></span>
<input type="color" class="input-color" name="color" value="<?= e($bColor) ?>"
aria-label="看板颜色">
<input class="input" name="name" value="<?= e((string)$b['name']) ?>" required>
<span class="small muted"><?= (int)($b['open_count'] ?? 0) ?> 件未完成</span>
<button class="btn btn-sm" type="submit">改名</button>
<button class="btn btn-sm" type="submit">保存</button>
</form>
<?php if (count($boards) > 1): ?>
<form class="inline-form" method="post" action="<?= e(url('tasks/board_del')) ?>">
+6 -7
View File
@@ -24,13 +24,12 @@ $isChild = !empty($t['parent_id']);
?>
<div class="page-head">
<h1>
<?php /* 用户第 6 条:「所有的任务,前面都带一个复选框,在任何地方,点这个复选框,
这个任务就完成了。」→ 详情页也算"任何地方",标题前也给它一颗。 */ ?>
<?php
$tkId = (int)$t['id']; $tkStatus = (string)$t['status'];
$tkBack = 'show'; $tkStatic = !$canWrite;
require __DIR__ . '/../_task_check.php';
?>
<?php /* 🔴 v24 第 26 批 用户第 5 条:**详情页页头不再放复选框**。
用户原话:「任务详情页面,就不需要复选框了,因为有标记完成这个按钮。」
⇒ 右侧 actions 里那颗「标记完成 / 重新打开」已经把这个动作表达得很清楚,
标题前再挂一颗是重复的(同一页两个入口做同一件事,还容易看不出区别)。
⚠️ **子任务列表里的复选框保留**(见文件下面)—— 那条需求说的是"详情页",
而子任务**没有**自己的「标记完成」按钮,去掉它子任务就没法在列表里勾了。 */ ?>
<?= e((string)$t['title']) ?>
</h1>
<span class="sub"><?= e(\App\Task::statusWord((string)$t['status'])) ?></span>