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:
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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')) ?>">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user