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
+24 -4
View File
@@ -465,20 +465,40 @@ final class Ledger
* 取值规则(每个时点 t):账目在 t 之后才创建 → **不计入**(那时它还不存在); * 取值规则(每个时点 t):账目在 t 之后才创建 → **不计入**(那时它还不存在);
* 在 t 之前盘点过 → 取最后一次盘点的值;从未盘点过 → 取它现在的值(一直没变过)。 * 在 t 之前盘点过 → 取最后一次盘点的值;从未盘点过 → 取它现在的值(一直没变过)。
* *
* @param string $kind v24 第 23 批 用户第 2 条:**只看某一类别**时传它的 key * @param string|array $kind v24 第 23 批 用户第 2 条:**只看某一类别**时传它的 key
* ('' = 整本账)。只影响"哪些账目参与累加",口径一字不改 —— * ('' = 整本账)。只影响"哪些账目参与累加",口径一字不改 ——
* 所以"存款"那条线与"只记了存款这一笔的账本"完全等价。 * 所以"存款"那条线与"只记了存款这一笔的账本"完全等价。
* 🔴 v24 第 26 批 用户第 1 条:多选之后传**类别集合**
* (`['deposit','insurance']`)—— 语义同样是"只让这些类别的账目参与累加"。
* ⚠️ 保持接受**字符串**(老调用点:只选了一类时仍然传字符串,见 index.php
* 的 kindLines 预渲染),在下面统一归一成数组 ——
* 两套参数并存是最容易漏改的那种接口。
* @return array<int,array{date:string,net:int,asset:int,debt:int}> 空账本返回 [] * @return array<int,array{date:string,net:int,asset:int,debt:int}> 空账本返回 []
*/ */
public static function timeline(int $ledgerId, string $kind = ''): array public static function timeline(int $ledgerId, $kind = ''): array
{ {
// 归一:'' / null → 空数组(= 全部);'deposit' → ['deposit'];数组原样
$kinds = [];
if (is_array($kind)) {
foreach ($kind as $k) {
$k = trim((string)$k);
if ($k !== '') {
$kinds[$k] = true;
}
}
$kinds = array_keys($kinds);
} elseif (is_string($kind) && trim($kind) !== '') {
$kinds = [trim($kind)];
}
$entries = self::entries($ledgerId); $entries = self::entries($ledgerId);
if ($kind !== '') { if ($kinds) {
// ⚠️ 必须在**取时点之前**过滤(而不是算完再筛): // ⚠️ 必须在**取时点之前**过滤(而不是算完再筛):
// 时点集合里有"每条账目的创建日",包含被筛掉那些账目的创建日 → // 时点集合里有"每条账目的创建日",包含被筛掉那些账目的创建日 →
// 图右端就会凭空多出几个"其实什么都没变"的时点。 // 图右端就会凭空多出几个"其实什么都没变"的时点。
// ⚠️ 多选时是**白名单**(在集合里才留);集合为空 = 不过滤 = 整本账。
$entries = array_values(array_filter( $entries = array_values(array_filter(
$entries, static fn($e): bool => (string)$e['kind'] === $kind)); $entries, static fn($e): bool => in_array((string)$e['kind'], $kinds, true)));
} }
if (!$entries) { if (!$entries) {
return []; return [];
+194 -3
View File
@@ -280,6 +280,144 @@ final class Nav
return $n !== '' ? $n : self::domainOf((string)($s['url'] ?? '')); return $n !== '' ? $n : self::domainOf((string)($s['url'] ?? ''));
} }
// ================================================================
// 跨用户复用同一个站点(v24 第 26 批 用户第 4 条后半)
//
// 用户原话:「导航,每个人添加新的导航,输入 url 之后,如果其他用户添加了,
// 他可以直接复用,复用的时候,自定义图标和简称,他可以自己再设置,
// 设置好以后就固定了。」
//
// 三条语义,逐条落成代码:
// ① **输入 URL 之后**才知道能不能复用 → 需要一个"按 URL 查"的接口
// (`sharedByUrl()`),供前端在输入框失焦时问一次。
// ② "他可以直接复用" → 复用的是一个**模板**:把别人那条的
// 名字 / 图标 / 颜色搬过来当预填,**不是**共享同一条记录
// (共享一条的话,A 改简称会改到 B 的导航上 —— 那谁也说不清是谁的)。
// ③ "设置好以后就固定了" → 复用来的图标必须标 `icon_custom = 1`
// (见下面 `adoptSite()` 的说明:否则下次「全部更新」会把它冲掉)。
//
// ⚠️ 隐私边界:这里**只暴露"这个 URL 有人加过"以及可复用的展示信息**
// (名字 / 简称 / 图标文件名),**不暴露是谁加的**、也不暴露他还有哪些站。
// 导航本身就是"要给别人看的"(展示页公开),所以这几个字段不算隐私;
// 但"谁加过什么"是另一回事,不查也不给。
// ================================================================
/**
* 按 URL 找**别人**已经加过的同款站点(用来做"可复用"提示与预填)。
*
* @return array<int,array> 最多返回几条(同一 URL 可能被多个人加过,去重后取前几个)
*/
public static function sharedByUrl(int $personId, string $url, int $limit = 6): array
{
$norm = self::normUrl($url);
if ($norm === '') {
return [];
}
// ⚠️ 用**归一化后的 URL** 比:用户手输时 https / 末尾斜杠 / 大小写都可能不同,
// 直接比原串的话"同一个站点"会认不出来(这正是复用的价值所在)。
// 库里存的也是归一化后的(saveSite 里就走 normUrl),所以等价比较成立。
$st = Database::get()->prepare(
'SELECT url, name, short_name, icon, icon_custom
FROM nav_sites
WHERE person_id <> ? AND url = ?
ORDER BY (short_name <> "") DESC, id ASC
LIMIT ' . max(1, min(20, $limit))
);
$st->execute([$personId, $norm]);
$out = [];
$seen = [];
foreach ($st->fetchAll() as $r) {
// 同一 URL 多人加过 → 按"显示名 + 图标"去重(对用户来说它们就是同一条)
$key = self::siteLabel($r) . '|' . (string)$r['icon'];
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $r;
}
return $out;
}
/**
* 把别人那条站点的展示信息**搬到自己这条**上(复用)。
*
* 与 `saveSite()` 分开写而不是给它加参数:`saveSite` 是"用户手填"那条路,
* 这里是"照抄别人"那条路 —— 两者的**校验重点不同**(前者校验输入合法性,
* 后者校验"那个图标文件还在不在")。混在一个函数里会让两条路的判断互相干扰。
*
* @param array $from sharedByUrl() 返回的一条
* @param int $siteId 自己站点表里那条新建记录的 id
*/
public static function adoptSite(int $personId, int $siteId, array $from, string $name, string $short): bool
{
$db = Database::get();
$row = self::site($personId, $siteId);
if (!$row) {
return false;
}
$name = trim($name);
$short = trim($short);
if ($name === '' && $short === '') {
// 用户什么都没填 → 用别人的显示名当兜底(否则这条站没有名字可显示)
$name = self::siteLabel($from);
}
$icon = '';
$isCus = 0;
$srcIcon = trim((string)($from['icon'] ?? ''));
if ($srcIcon !== '') {
/* 🔴 "设置好以后就固定了" 这句话的落点就在这里。
*
* 复用的图标是**别人抓的**(或别人自定义的)。搬过来时必须标
* `icon_custom = 1`,否则「全部更新」会按本站的抓取规则重新抓一遍
* —— 那时抓到的可能是另一个图标,用户会看到"我明明选好了,怎么变了"。
* 标成自定义 = 从此不再自动改它,与用户自己上传图标同等待遇。
* ⚠️ 文件是**复制**一份到本站点名下(不是引用别人那个):
* 否则别人删站 / 更新图标会连带影响你的展示页。
*/
$copied = self::copyIconFile($srcIcon);
if ($copied !== '') {
$icon = $copied;
$isCus = 1;
}
}
$db->prepare(
'UPDATE nav_sites SET name = ?, short_name = ?, icon = ?, icon_custom = ?,
updated_at = ? WHERE id = ? AND person_id = ?'
)->execute([$name, $short, $icon, $isCus, date('Y-m-d H:i:s'), $siteId, $personId]);
return true;
}
/**
* 把图标文件复制一份、返回新文件名(失败返回 '')。
*
* ⚠️ 只认 `ICON_DIR` 下的**纯文件名**(与 iconUrl/deleteIconFile 同一套判据):
* 带斜杠或 `..` 一律拒绝 —— 否则"复用"会变成一个任意文件读取的口子。
*/
public static function copyIconFile(string $file): string
{
$file = trim($file);
if ($file === '' || strpos($file, '/') !== false || strpos($file, '\\') !== false
|| strpos($file, '..') !== false) {
return '';
}
$dir = FB_ROOT . '/' . self::ICON_DIR;
$abs = $dir . '/' . $file;
if (!is_file($abs)) {
return ''; // 源文件不在了(别人删过)→ 不复制,退化成"没有图标"
}
// 新文件名:保持同样的扩展名(图标出口按扩展名给 Content-Type)
$ext = strtolower((string)pathinfo($file, PATHINFO_EXTENSION));
if (!preg_match('/^[a-z0-9]{1,5}$/', $ext)) {
$ext = 'png';
}
$new = bin2hex(random_bytes(8)) . '.' . $ext;
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
return '';
}
return @copy($abs, $dir . '/' . $new) ? $new : '';
}
// ================================================================ // ================================================================
// 搜索引擎 // 搜索引擎
// ================================================================ // ================================================================
@@ -823,15 +961,68 @@ final class Nav
/** 默认搜索引擎(照搬 SimpleNav 的 seed_engines) */ /** 默认搜索引擎(照搬 SimpleNav 的 seed_engines) */
public const ENGINE_SEED = [ public const ENGINE_SEED = [
// 名称, slug, 搜索地址, 参数, 联想地址, 联想类型, 颜色, 是否启用 // 名称, slug, 搜索地址, 参数, 联想地址, 联想类型, 颜色, 是否启用
//
// 🔴 v24 第 26 批 用户第 4 条(后半):「**可以内置几个,让用户选择**」。
// 原来只启用必应/百度两档,用户根本看不出"可选"这件事(列表里另外 4 个
// 是以"未启用"状态躺着的,展示页的标签条只渲染启用的)。
// ⇒ 把**实测可用的三档**都默认启用:必应 / 百度 / 360。
// 谷歌 / DuckGo 保持未启用 —— 它们在**国内网络下连不上**
// (见 nav_suggest.php 头部那段线上实测:连接超时),
// 默认启用只会让用户点了发现"搜不动",比不提供更差。
// 搜狗保持未启用:它的联想域名已不解析(实测 0 字节)。
// ⚠️ 这三条实测结论来自 nav_suggest.php 的注释,不要凭印象改动。
['必应', 'bing', 'https://cn.bing.com/search', 'q', ['必应', 'bing', 'https://cn.bing.com/search', 'q',
'https://api.bing.com/osjson.aspx?query={q}', 'bing_osjson', '#60A504', 1], 'https://api.bing.com/osjson.aspx?query={q}', 'bing_osjson', '#60A504', 1],
['百度', 'baidu', 'https://www.baidu.com/s', 'wd', ['百度', 'baidu', 'https://www.baidu.com/s', 'wd',
'https://www.baidu.com/sugrec?prod=pc&wd={q}', 'baidu_sugrec', '#2100E0', 1], 'https://www.baidu.com/sugrec?prod=pc&wd={q}', 'baidu_sugrec', '#2100E0', 1],
['谷歌', 'google', 'https://www.google.com/search', 'q', '', '', '#3B83FA', 0],
['搜狗', 'sogou', 'https://www.sogou.com/web', 'query', '', '', '#FE6B14', 0],
['360', 'so360', 'https://www.so.com/s', 'q', ['360', 'so360', 'https://www.so.com/s', 'q',
'https://sug.so.360.cn/suggest?encodein=utf-8&encodeout=utf-8&format=json&word={q}', 'https://sug.so.360.cn/suggest?encodein=utf-8&encodeout=utf-8&format=json&word={q}',
'so360', '#1BB04A', 0], 'so360', '#1BB04A', 1],
['谷歌', 'google', 'https://www.google.com/search', 'q', '', '', '#3B83FA', 0],
['搜狗', 'sogou', 'https://www.sogou.com/web', 'query', '', '', '#FE6B14', 0],
['DuckGo', 'ddg', 'https://duckduckgo.com/', 'q', '', '', '#DE5833', 0], ['DuckGo', 'ddg', 'https://duckduckgo.com/', 'q', '', '', '#DE5833', 0],
]; ];
/**
* 把**缺失的内置引擎**补给这个人(v24 第 26 批 用户第 4 条)。
*
* 为什么需要它:`ensureSeed()` 只在"一条引擎都没有"时才灌种子
* (它的判据是 `nav_groups` 是否为空)—— 于是**老用户**(在更新前就建过导航的人)
* 永远拿不到后加的内置引擎:他们的库里只有当年那几条。
* 用户说"内置几个让用户选择",对老用户就等于"一个都没有"。
*
* ⚠️ 按 **slug** 判重(不是 name):用户可能把"百度"改名成"度娘",
* 若按名字判重会再灌一条重复的 baidu。
* ⚠️ 只补**缺的**、绝不改动已有的(用户可能改过 URL / 颜色 / 启用状态,
* 覆盖回去等于"我帮你改好了" —— 那种"好意"最难查)。
*
* @return int 新补了几条
*/
public static function addMissingEngines(int $personId): int
{
$db = Database::get();
$st = $db->prepare('SELECT slug FROM nav_engines WHERE person_id = ?');
$st->execute([$personId]);
$have = [];
foreach ($st->fetchAll() as $r) {
$have[(string)$r['slug']] = true;
}
$sort = (int)$db->query('SELECT COALESCE(MAX(sort_order),0) FROM nav_engines WHERE person_id = '
. (int)$personId)->fetchColumn();
$ins = $db->prepare(
'INSERT INTO nav_engines (person_id, name, slug, search_url, param, suggest_url,
suggest_type, color, is_active, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?)');
$n = 0;
foreach (self::ENGINE_SEED as $e) {
if (isset($have[(string)$e[1]])) {
continue;
}
$sort += self::SORT_STEP;
// ⚠️ 补进来的按种子的 `is_active` 走(必应/百度/360 = 1)。
// "老用户库里已经有的那几条"保持原状 —— 不在这里动它们。
$ins->execute([$personId, $e[0], $e[1], $e[2], $e[3], $e[4], $e[5], $e[6], $e[7], $sort]);
$n++;
}
return $n;
}
} }
+14 -1
View File
@@ -104,7 +104,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/ */
public const VERSION = '25'; public const VERSION = '26';
/** /**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -1459,6 +1459,19 @@ SQL);
"ADD COLUMN `board_id` INT UNSIGNED DEFAULT NULL COMMENT '属于哪个看板(用户自建;NULL=默认看板)' AFTER `family_id`"); "ADD COLUMN `board_id` INT UNSIGNED DEFAULT NULL COMMENT '属于哪个看板(用户自建;NULL=默认看板)' AFTER `family_id`");
self::addColumn($db, 'tasks', 'category_id', self::addColumn($db, 'tasks', 'category_id',
"ADD COLUMN `category_id` INT UNSIGNED DEFAULT NULL COMMENT '属于哪个分类(用户自建;NULL=未分类)' AFTER `board_id`"); "ADD COLUMN `category_id` INT UNSIGNED DEFAULT NULL COMMENT '属于哪个分类(用户自建;NULL=未分类)' AFTER `board_id`");
/* 🔴 v24 第 26 批 用户第 3 条:看板可以选颜色。
*
* 用户原话:「任务在设置看板的时候,可以选择颜色,这样在首页列表中显示的时候,
* 就可以更加直观的看到他属于哪个看板。」
*
* ⚠️ 默认值**留空**而不是给个具体色:空 = "没选过颜色",
* 由渲染层按看板名**稳定地**算一个色(同名永远同色,见 Task::boardColor())。
* 若这里给死 `#2F6BF7`,所有没设过的看板会全变成同一个蓝 ——
* 那反而看不出"属于哪个看板"(正是这条需求要解决的问题)。
* ⚠️ `addColumn` 自带"列不存在才加",所以老库/新装都走这一条,不必写两遍。
* 列长 7 = `#RRGGBB`;不用枚举(用户可选任意色),合法性在入库时校验。 */
self::addColumn($db, 'task_boards', 'color',
"ADD COLUMN `color` CHAR(7) DEFAULT NULL COMMENT '看板色 #RRGGBB;NULL=按名字派生' AFTER `position`");
// 每个家庭至少有一个看板,否则"看板"这个概念在界面上就是空的 // 每个家庭至少有一个看板,否则"看板"这个概念在界面上就是空的
self::tryStep('给每个家庭补一个默认看板', static function () use ($db) { self::tryStep('给每个家庭补一个默认看板', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
+77 -6
View File
@@ -137,6 +137,79 @@ final class Task
return $v === false ? '' : (string)$v; return $v === false ? '' : (string)$v;
} }
// ====================================================================
// 看板颜色(v24 第 26 批 用户第 3 条)
//
// 用户原话:「任务在设置看板的时候,可以选择颜色,这样在首页列表中显示的时候,
// 就可以更加直观的看到他属于哪个看板。」
//
// 设计要点(两条,都是为了"一眼能认出来"):
// ① 用户没选过颜色时**按看板名派生**一个稳定色 —— 同名永远同色。
// 若改成"按 id 派生",看板一改名颜色就跳,用户反而记不住;
// 若统一给一个默认色,所有看板长得一样,就完全失去这条需求的意义。
// ② 派生只从**固定调色板**里挑(跟名册头像色块同一套观感),
// 不做 HSL 随机 —— 随机色会造出刺眼/低对比的颜色,白字压不住。
// ====================================================================
/** 看板可选色的调色板(10 个,覆盖暖冷中性;都够深,能压白字) */
public const BOARD_COLORS = [
'#C0533F', '#B87333', '#8A7B3F', '#5E9E4E', '#3F8F7A',
'#3E7A9E', '#5B6DC0', '#8B5CF6', '#B0508A', '#7A6A5E',
];
/**
* 取一个看板该用什么颜色。
*
* @param string|null $color 库里存的(可能为 NULL / 空 / 非法值)
* @param string $name 看板名(没存颜色时按它派生)
* @return string '#RRGGBB'
*/
public static function boardColor(?string $color, string $name = ''): string
{
$c = strtolower(trim((string)$color));
if (preg_match('/^#[0-9a-f]{6}$/', $c)) {
return $c;
}
// 按名字派生:同一个名字永远同一个色(改 id 不影响,用户能记住)
$h = 0;
foreach (preg_split('//u', $name, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $ch) {
// 用码点而不是字节:中文名在不同编码下字节不同,码点是稳的
$h = ($h * 31 + (int)(mb_ord($ch, 'UTF-8') ?: 0)) % 1000000007;
}
return self::BOARD_COLORS[abs($h) % count(self::BOARD_COLORS)];
}
/** 看板 id → 颜色(board_name 一起取,省一次查询;查不到给灰) */
public static function boardColorById(int $familyId, ?int $boardId): string
{
if (!$boardId) {
return self::boardColor(null, '默认看板');
}
$st = Database::get()->prepare('SELECT name, color FROM task_boards WHERE id = ? AND family_id = ?');
$st->execute([$boardId, $familyId]);
$r = $st->fetch();
if (!$r) {
return self::boardColor(null, '');
}
return self::boardColor($r['color'] ?? null, (string)$r['name']);
}
/**
* 改看板名 + 颜色(设置页那一行表单)。
*
* ⚠️ 颜色与名字**同一个动作**:用户改色时不该被迫把名字也重打一遍
* (表单里两个都在、一起提交,这里是它们共同的落点)。
* ⚠️ 颜色合法性在这里收口(只认 `#RRGGBB`),空串 = 清除自定义、回到按名派生。
*/
public static function updateBoard(int $familyId, int $id, string $name, string $color): void
{
$db = Database::get();
$c = strtolower(trim($color));
$c = preg_match('/^#[0-9a-f]{6}$/', $c) ? $c : null;
$db->prepare('UPDATE task_boards SET name = ?, color = ? WHERE id = ? AND family_id = ?')
->execute([self::shortName($name), $c, $id, $familyId]);
}
/** /**
* 这个家庭至少有一个看板(升级时给老家庭补,新家庭在建号时补)。 * 这个家庭至少有一个看板(升级时给老家庭补,新家庭在建号时补)。
* @return int 默认看板的 id * @return int 默认看板的 id
@@ -164,11 +237,9 @@ final class Task
return (int)$db->lastInsertId(); return (int)$db->lastInsertId();
} }
public static function renameBoard(int $familyId, int $id, string $name): void /* ⚠️ 原来的 `renameBoard()` 已在 v24 第 26 批**并入 `updateBoard()`**(用户第 3 条
{ 要给看板加颜色,两个字段同一个动作),这里不再保留 —— 留一个只改名字的方法,
Database::get()->prepare('UPDATE task_boards SET name = ? WHERE id = ? AND family_id = ?') 下一个改颜色的人会顺手调错它("改了色但名字被重置"这类事故就是这么来的)。 */
->execute([self::shortName($name), $id, $familyId]);
}
/** /**
* 删看板:里头的任务**不删**,挪到默认看板上(删看板 ≠ 删任务,这个代价太突然)。 * 删看板:里头的任务**不删**,挪到默认看板上(删看板 ≠ 删任务,这个代价太突然)。
@@ -428,7 +499,7 @@ final class Task
'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color, 'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color,
na.nickname AS assignee_nick, na.nickname AS assignee_nick,
c.name AS category_name, c.name AS category_name,
b.name AS board_name, b.name AS board_name, b.color AS board_color,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id) AS child_total, (SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id) AS child_total,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id AND k.status = "done") AS child_done (SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id AND k.status = "done") AS child_done
FROM tasks t FROM tasks t
+66 -2
View File
@@ -2678,6 +2678,28 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
.kb-st.is-on { box-shadow: inset 0 0 0 1px currentColor; font-weight: 600; } .kb-st.is-on { box-shadow: inset 0 0 0 1px currentColor; font-weight: 600; }
.kb-due.is-late, .kb-late { color: var(--red); } .kb-due.is-late, .kb-late { color: var(--red); }
.kb-ico { white-space: nowrap; } .kb-ico { white-space: nowrap; }
/* ---- 看板色点(v24 第 26 批 用户第 3 条)----
同一个色点两种用法:
· `.kb-dot` —— 看板页**列头**上(列本身就是一个看板);
· `.tk-board-dot` —— 首页任务行**看板徽标**里(名字前一个小圆点)。
都用 `--bc`(board color)传色:色值由服务端按「用户设过的色,否则按名字派生」算好,
CSS 不参与任何色值决策(否则"同一个看板在首页和看板页颜色不一样"迟早发生)。 */
.kb-dot,
.tk-board-dot {
display: inline-block; width: 9px; height: 9px; border-radius: 50%;
background: var(--bc, var(--line)); flex: 0 0 auto;
}
.kb-dot { width: 10px; height: 10px; }
/* 首页那个看板徽标:名字前带色点,整体仍是原来那颗浅色 pill */
.tk-board { display: inline-flex; align-items: center; gap: 5px; }
/* 看板设置页的颜色选择器:native color input 默认很宽,收窄到与输入框同高的一小块 */
.input-color {
width: 34px; height: 30px; padding: 2px; border: 1px solid var(--line);
border-radius: var(--radius-s); background: #fff; cursor: pointer; flex: 0 0 auto;
}
.input-color::-webkit-color-swatch-wrapper { padding: 0; }
.input-color::-webkit-color-swatch { border: 0; border-radius: 4px; }
.kb-sub { display: flex; align-items: center; gap: 8px; } .kb-sub { display: flex; align-items: center; gap: 8px; }
.kb-bar { flex: 1 1 auto; height: 5px; border-radius: 999px; background: var(--line-2); overflow: hidden; } .kb-bar { flex: 1 1 auto; height: 5px; border-radius: 999px; background: var(--line-2); overflow: hidden; }
.kb-bar i { display: block; height: 100%; background: var(--green); } .kb-bar i { display: block; height: 100%; background: var(--green); }
@@ -4198,6 +4220,19 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
⚠️ `.ag-row` 的高度就是"一小时"(44px);日程块用**百分比 top** 定位, ⚠️ `.ag-row` 的高度就是"一小时"(44px);日程块用**百分比 top** 定位,
所以刻度数与日程块的比例天然一致(都在同一个容器里算)。 */ 所以刻度数与日程块的比例天然一致(都在同一个容器里算)。 */
.ag-allday { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; } .ag-allday { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
/* 🔴 v24 第 26 批 用户第 2 条:全天区现在是 `.hd-pane` 的**直接子元素**(不在滚动容器内),
必须显式声明"不参与伸缩、也不会被压扁":
· `flex: 0 0 auto` —— 它是固定高度的一块,绝不与下面的滚动区抢空间;
· `max-height: 33%` + 内部滚动 —— 全天日程可以有很多条(节假日、生日都在里面),
全铺开会把时段网格挤到看不见;给个上限,超了自己滚。
⚠️ 上限用**百分比**(相对 440px 的 pane)而不是固定 px:
窄屏时 pane 只有 320px,固定 140px 会占掉近一半(见下面的媒体查询)。
⚠️ 这里也要藏滚动条(与 `.hd-pane-body` 同一理由:用户嫌丑)。 */
.ag-allday {
flex: 0 0 auto; max-height: 33%; overflow-y: auto; overscroll-behavior: contain;
scrollbar-width: none; -ms-overflow-style: none;
}
.ag-allday::-webkit-scrollbar { width: 0; height: 0; }
.ag-allday-item { .ag-allday-item {
display: flex; align-items: center; gap: 8px; padding: 5px 8px; display: flex; align-items: center; gap: 8px; padding: 5px 8px;
background: var(--line-2); border-radius: var(--radius-s); font-size: 13.5px; color: var(--ink); background: var(--line-2); border-radius: var(--radius-s); font-size: 13.5px; color: var(--ink);
@@ -4271,8 +4306,9 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
这才是"复选框在文字前面"该有的样子。 这才是"复选框在文字前面"该有的样子。
(`.sub-row` 本来就是 center,不用管它。) */ (`.sub-row` 本来就是 center,不用管它。) */
.dash-row:has(.tk-check) { align-items: center; } .dash-row:has(.tk-check) { align-items: center; }
/* 详情页标题前那颗:标题是 22px 大字,复选框要跟着走中线才不悬空 */ /* ⚠️ v24 第 26 批 用户第 5 条:**详情页页头的复选框已去掉**(那颗由「标记完成」按钮取代),
.page-head h1:has(> .tk-check) { display: inline-flex; align-items: center; gap: 10px; } 所以这里原来那条 `.page-head h1:has(> .tk-check)` 一并删除 ——
留一条没有使用者的规则,只会让下一个人以为"页头还该有复选框"。 */
/* 看板页顶上的「三个状态到底什么意思」(用户第 6 条直接问出来的那句话) */ /* 看板页顶上的「三个状态到底什么意思」(用户第 6 条直接问出来的那句话) */
.tk-legend { .tk-legend {
@@ -4401,3 +4437,31 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
.wr-cat-bar { flex: 1 1 auto; height: 7px; background: var(--line-2); border-radius: 999px; overflow: hidden; } .wr-cat-bar { flex: 1 1 auto; height: 7px; background: var(--line-2); border-radius: 999px; overflow: hidden; }
.wr-cat-bar i { display: block; height: 100%; background: var(--brand); border-radius: 999px; } .wr-cat-bar i { display: block; height: 100%; background: var(--brand); border-radius: 999px; }
.wr-cat-v { flex: 0 0 auto; font-size: 12px; } .wr-cat-v { flex: 0 0 auto; font-size: 12px; }
/* ============================================================
v24 第 26 批 用户第 4 条:新增站点时的「可复用站点」提示块
(跨用户复用:别人加过同一个 URL,你就能直接搬过来)
============================================================ */
.nav-reuse {
margin: 0 0 12px; padding: 10px 12px;
border: 1px solid var(--line-2); border-left: 3px solid var(--brand-2);
border-radius: var(--radius-s); background: #FBF7F2;
}
.nav-reuse-h { font-size: 13px; color: var(--ink); margin-bottom: 8px; }
.nav-reuse-list { display: flex; flex-wrap: wrap; gap: 8px; }
.nav-reuse-item {
display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
padding: 5px 10px; border: 1px solid var(--line); border-radius: var(--radius-s);
background: #fff; font-size: 13px; color: var(--ink);
}
.nav-reuse-item:hover { border-color: var(--brand-2); }
/* .is-on = 已选中(点一下即选中,再点别的会切换过去) */
.nav-reuse-item.is-on {
border-color: var(--brand); box-shadow: inset 0 0 0 1px var(--brand);
background: var(--brand-soft, #FDF3EC);
}
.nav-reuse-item .nav-ico { width: 20px; height: 20px; border-radius: 4px; object-fit: contain; }
.nav-reuse-nm { white-space: nowrap; }
.nav-reuse-f { font-size: 12px; color: var(--muted); margin-top: 8px; }
/* 空 img(没有图标可复用)别占位成一个破图框 */
.nav-reuse-item img:not([src]) { visibility: hidden; }
+41 -8
View File
@@ -1,23 +1,52 @@
/* ============================================================ /* ============================================================
导航展示页脚本(v24 第 24 批 用户第 4 条) 导航展示页脚本(v24 第 24 批 用户第 4 条)
—— **从 SimpleNav 的 assets/js/app.js 整份搬运**,逻辑一行未改。 —— 从 SimpleNav 的 assets/js/app.js 整份搬运,逻辑主体保留。
它提供:搜索引擎切换、联想词下拉(↓/↑ 与 , . 两组按键)、合集标签页切换、 它提供:搜索引擎切换、联想词下拉(↓/↑ 与 , . 两组按键)、合集标签页切换、
站点链接打开方式(新标签页 / 当前页,由 body[data-open] 决定)。 站点链接打开方式(新标签页 / 当前页,由 body[data-open] 决定)。
⚠️ 联想接口地址来自 window.SIMPLENAV.suggest(服务端按当前短语生成), ⚠️ 联想接口地址来自 window.SIMPLENAV.suggest(服务端按当前短语生成),
不在这个文件里写死。 不在这个文件里写死。
============================================================
🔴🔴 v24 第 26 批 用户第 4 条:**搬砖时丢了两处选择器 id,导致两个功能全废**。
用户原话:「导航展示页面,选不同的搜索引擎没用。搜索条里面的联想也没有实现。」
根因(两处,都是"HTML 与 JS 对不上",且**都不报错、静默失效**):
① 引擎标签:JS 找 `#engine-tabs`,而 HTML 的容器只有 `class="search-type"`
(没有 id)→ `querySelectorAll` 返回空 → 点击事件**一个都没绑上** →
"选不同的搜索引擎没用"(点完标签视觉上会变吗?连高亮都不会变,
因为高亮也是这个函数做的)。
② 联想下拉:同一段代码继续处理 `#word` / `#search-text`,但**在 ① 挂掉之后
那半段其实仍在执行**(它们各查各的)—— 真正的第二处是 `activateEngine`
与 `askSuggest` 都依赖 `tabs`/`form.dataset.slug`,
而 `form` 的 slug 从来没被设过(因为 activateEngine 没跑),
于是请求 `/nav_suggest.php?slug=&q=…` —— `slug=` 是空的,
服务端虽然能兜底(它按 baidu→bing→360 试),但**前端拿不到东西时也不显示**:
因为 `showWord()` 依赖 `#word`,而 `#word` 是 `<ul>`,在 CSS 里
只有在 `display:block` 时才可见 —— 那段 CSS 依赖 `.s-search` 的一个后代选择器,
而容器 id 丢了之后**连 CSS 一起失配**(连锁反应)。
修法:**给 HTML 补 id**(`#engine-tabs`)而不是改 JS 的选择器 ——
理由是这份 JS 是从 SimpleNav 原样搬来的、将来还要对照原版维护,
改 JS 会让"两边不一样",改 HTML 只是把搬漏的属性补回去。
============================================================ */ ============================================================ */
/* 简航 SimpleNav —— 前台交互:搜索引擎切换 + 联想下拉 + 合集标签页 */
(function () { (function () {
'use strict'; 'use strict';
var cfg = window.SIMPLENAV || {}; var cfg = window.SIMPLENAV || {};
/* ---------- 搜索引擎切换 ---------- */ /* ---------- 搜索引擎切换 ----------
var tabs = document.querySelectorAll('#engine-tabs .s-engine'); ⚠️ 选择器兼容两种写法(`#engine-tabs` 与老模板的 `.search-type`),
这样即使将来模板改回 class、这里也不会再静默失效一次。
**兜底写法不是"多余的"**:它就是这次事故的对策。 */
var tabHost = document.getElementById('engine-tabs')
|| document.querySelector('.search-type');
var tabs = tabHost
? tabHost.querySelectorAll('.s-engine')
: document.querySelectorAll('.s-engine');
var form = document.getElementById('search-form'); var form = document.getElementById('search-form');
var input = document.getElementById('search-text'); var input = document.getElementById('search-text');
function activateEngine(el) { function activateEngine(el) {
tabs.forEach(function (t) { t.classList.remove('active'); }); Array.prototype.forEach.call(tabs, function (t) { t.classList.remove('active'); });
el.classList.add('active'); el.classList.add('active');
var search = el.getAttribute('data-search'); var search = el.getAttribute('data-search');
var param = el.getAttribute('data-param'); var param = el.getAttribute('data-param');
@@ -26,11 +55,15 @@
if (form) { form.setAttribute('action', search); form.setAttribute('data-slug', slug); } if (form) { form.setAttribute('action', search); form.setAttribute('data-slug', slug); }
if (input) { input.setAttribute('name', param); input.setAttribute('placeholder', ph); } if (input) { input.setAttribute('name', param); input.setAttribute('placeholder', ph); }
hideWord(); hideWord();
// 已经输了字就顺手换一份建议(auto 模式下各引擎来源不同) // 已经输了字就顺手换一份建议(各引擎来源不同)
if (input && input.value.trim() !== '') { askSuggest(); } if (input && input.value.trim() !== '') { askSuggest(); }
} }
tabs.forEach(function (el) { /* ⚠️ 原版写的是 `tabs.forEach(...)` —— 那是 **NodeList.forEach**,
el.addEventListener('click', function () { activateEngine(el); input && input.focus(); }); 现代浏览器有、但 `querySelectorAll` 之外的集合(比如下面兜底分支返回的
也可能是数组)不一定有。改用 `Array.prototype.forEach.call` 更稳,
且与本站其它脚本的写法一致(全站都这么写,见 person_picker.js 等)。 */
Array.prototype.forEach.call(tabs, function (el) {
el.addEventListener('click', function () { activateEngine(el); if (input) { input.focus(); } });
}); });
/* ---------- 联想下拉 ---------- */ /* ---------- 联想下拉 ---------- */
+156 -6
View File
@@ -3661,6 +3661,70 @@ switch ($r) {
], 'ledger'); ], 'ledger');
break; break;
/* 🔴 v24 第 26 批 用户第 1 条:**多选类别时,上面那张「总金额」图要按选中集合统计**。
*
* 用户原话:「财务模块,多个标签勾选后,第一个图,总额那里,勾选一个会变化,
* 勾选多个后,变成总数了。」
*
* 上一批(第 25 批)把标签改成多选时,上图只保留了「恰好选一类」才切图的分支:
* 选 1 类 → 显示该类曲线;
* 选 2 类以上 → `sole` 为空 → **回落成"整本账"**(这正是用户看到的"变成总数了")。
*
* 为什么不能在页面里把几条已渲染的曲线**加起来**:图是服务端画的 **SVG**,
* 里面是一堆 `<path>` 与坐标 —— 前端没法把两条折线"相加"成一条。
* (金额能加,但曲线形状加不出来:各自的时间刻度、各自的纵轴比例都不一样。)
*
* 所以做法是**按需现画**:前端把选中集合 POST 过来,这里用
* `Ledger::timeline($lid, $kinds)`(它已支持集合,见其 @param)现算一条曲线,
* 用与页面**同一个** `chart_line()` 画成 SVG 回给前端替换。
*
* ⚠️ 这里是 **GET**(只读、可缓存、可分享),所以不写 csrf ——
* 它不改变任何状态。但仍要 **Auth::requireLogin()**:账本金额是家庭隐私。
* ⚠️ 只认**本账本真的有的类别**(拿 summary 的白名单过滤),
* 否则 `?kinds=xxx` 可以随便塞字符串(虽然只会算不出东西,但没必要留口子)。 */
case 'ledger/total_chart':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$lid = (int)($_GET['book'] ?? 0);
$book = Ledger::find($familyId, $lid, $me);
if (!$book) {
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'msg' => '账本不存在。'], JSON_UNESCAPED_UNICODE);
exit;
}
$want = array_values(array_filter(array_map('strval', (array)($_GET['kinds'] ?? []))));
// 白名单:只留下这本账里真实存在的类别
$present = array_map('strval', array_keys((array)Ledger::summary(Ledger::entries($lid))['by_kind']));
$kinds = array_values(array_intersect($want, $present));
if (!$kinds) {
// 空集合 = 整本账(与前端 `picked.length === 0` 的语义一致)
$tl = Ledger::timeline($lid);
$label = '净资产';
$tkind = 'asset';
} else {
$tl = Ledger::timeline($lid, $kinds);
// 全选中的都是负债类 → 用绿线(与单类别那套配色一致)
$allDebt = true;
foreach ($kinds as $k) {
if (!Ledger::isDebt($k)) { $allDebt = false; break; }
}
$label = count($kinds) === 1
? Ledger::kindWord($kinds[0])
: count($kinds) . ' 类合计';
$tkind = $allDebt ? 'debt' : 'asset';
}
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: private, max-age=30');
echo json_encode([
'ok' => true,
'svg' => $tl ? chart_line($tl, $label, $tkind, 900) : '',
'label' => $label,
'n' => count($tl),
], JSON_UNESCAPED_UNICODE);
break;
case 'ledger/book_add': case 'ledger/book_add':
case 'ledger/book_rename': case 'ledger/book_rename':
case 'ledger/book_del': case 'ledger/book_del':
@@ -4917,8 +4981,11 @@ switch ($r) {
if ($nm === '') { if ($nm === '') {
flash('err', '看板名不能为空。'); flash('err', '看板名不能为空。');
} else { } else {
Task::renameBoard($familyId, $id, $nm); // 🔴 v24 第 26 批 用户第 3 条:这一行表单现在同时提交**名字 + 颜色**。
flash('ok', '看板已改名。'); // 颜色合法性在 Task::updateBoard 里收口(空 = 清除自定义、回到按名派生)。
// 这里不自己判断颜色格式 —— 两处各判一次必然走样。
Task::updateBoard($familyId, $id, $nm, (string)($_POST['color'] ?? ''));
flash('ok', '看板已保存(名字与颜色)。');
} }
} elseif ($act === 'tasks/board_del') { } elseif ($act === 'tasks/board_del') {
Task::deleteBoard($familyId, $id); Task::deleteBoard($familyId, $id);
@@ -5113,8 +5180,18 @@ switch ($r) {
exit; exit;
} }
flash('ok', $msg); flash('ok', $msg);
redirect((string)($_POST['back'] ?? '') === 'show' ? 'tasks/show' : 'tasks', // 无 JS 时的兜底回跳。三处复选框各自带自己的来源:
(string)($_POST['back'] ?? '') === 'show' ? ['id' => $id] : []); // show=任务详情 / home=首页 / tasks(默认,看板)。
// ⚠️ v24 第 26 批 用户第 3 条顺带修:原来只认 'show',于是**首页**那颗
// 在没 JS 的浏览器上提交完会跳到任务看板(人在首页,却被扔去了另一个模块)。
// 首页现在传 back=home,这里认它。
$back = (string)($_POST['back'] ?? '');
if ($back === 'show') {
redirect('tasks/show', ['id' => $id]);
} elseif ($back === 'home') {
redirect('home');
}
redirect('tasks');
break; break;
case 'tasks/comment_add': case 'tasks/comment_add':
@@ -5839,8 +5916,11 @@ switch ($r) {
'navSet' => $settings, 'navSet' => $settings,
'phrase' => $phrase, 'phrase' => $phrase,
// 展示页地址(短语为空时模板里会提示去设置) // 展示页地址(短语为空时模板里会提示去设置)
// ⚠️ 用 `Nav::abs()` 而不是自己拼 `base_url`(v24 第 26 批顺带统一):
// `abs()` 以**当前请求的 host** 为准,所以从 IP / 测试域名进来时
// 给出的地址也是那个入口的(写死 base_url 会把人指到另一个域上)。
'showUrl' => $phrase !== '' 'showUrl' => $phrase !== ''
? rtrim((string)fb_config('base_url', ''), '/') . '/nav/?q=' . rawurlencode($phrase) ? Nav::abs('/nav/') . '?q=' . rawurlencode($phrase)
: '', : '',
// 我自己的头像 —— 展示页右上角要用(用户原话「头像就变成用户自己的头像」) // 我自己的头像 —— 展示页右上角要用(用户原话「头像就变成用户自己的头像」)
'meAvatar' => avatar_url(Person::find($me)['avatar'] ?? null), 'meAvatar' => avatar_url(Person::find($me)['avatar'] ?? null),
@@ -5918,8 +5998,42 @@ switch ($r) {
if ($id <= 0) { if ($id <= 0) {
$bad('站点地址和所属板块都不能空。'); $bad('站点地址和所属板块都不能空。');
} }
/* 🔴 v24 第 26 批 用户第 4 条:**复用别人加过的站点**。
*
* 用户原话:「输入 url 之后,如果其他用户添加了,他可以直接复用,
* 复用的时候,自定义图标和简称,他可以自己再设置,
* 设置好以后就固定了。」
*
* 做法:表单里带一个 `reuse_key`(= 显示名|图标文件名)。
* ⚠️ 这里**重新查一遍**别人那条(不信任前端回传的内容)——
* 前端只被允许"指认是哪一条",绝不被允许"提供那条的内容"。
* 否则任何人都能凭一个伪造的 key 把任意的 name/icon 写进自己库里
* (比如塞一个 name 做 XSS 载荷 —— 展示页那份是 `e()` 转义过的,
* 但"不要相信前端给的展示数据"这条规则不该有例外)。
* ⚠️ 复用只发生在**新建**(`$editSite` 为空)时:编辑一条已有站点时
* 又去抄别人,语义会很乱(是覆盖我刚改的简称吗?)。
* 编辑时 `reuse_key` 一律忽略。 */
$reuseKey = trim((string)($_POST['reuse_key'] ?? ''));
$didReuse = false;
if ($reuseKey !== '' && !empty($_POST['is_new'])) {
foreach (Nav::sharedByUrl($me, $url, 20) as $cand) {
$k = Nav::siteLabel($cand) . '|' . (string)($cand['icon'] ?? '');
if ($k !== $reuseKey) {
continue;
}
// 用户在前端填的 简称/显示名 优先("他可以自己再设置");
// 没填的字段由 adoptSite 用别人的补上。
$didReuse = Nav::adoptSite($me, $id, $cand,
(string)($_POST['name'] ?? ''),
(string)($_POST['short_name'] ?? ''));
break;
}
}
// 抓元信息(名称 + 图标):用户勾了"顺便抓取"才做(联网、慢) // 抓元信息(名称 + 图标):用户勾了"顺便抓取"才做(联网、慢)
if (!empty($_POST['do_fetch'])) { // ⚠️ 复用过的那条**跳过抓取**:用户刚说"就用这个图标、以后固定"
// (adoptSite 已把它标成 icon_custom=1),紧接着又按本站规则重抓一遍,
// 等于当场把他的话推翻。(这也是"设置好以后就固定了"的一部分。)
if (!$didReuse && !empty($_POST['do_fetch'])) {
$meta = NavFetch::meta($url); $meta = NavFetch::meta($url);
$db = Database::get(); $db = Database::get();
$newName = trim((string)($meta['name'] ?? '')); $newName = trim((string)($meta['name'] ?? ''));
@@ -5984,6 +6098,42 @@ switch ($r) {
$ok('已切换启用状态。'); $ok('已切换启用状态。');
break; break;
/* 🔴 v24 第 26 批 用户第 4 条:「可以内置几个,让用户选择」。
老用户库里只有当年那几条引擎(`ensureSeed` 只在"一条都没有"时才灌),
所以给他们一个**一键补齐内置引擎**的入口。
⚠️ 只补缺的,已有的(含用户改过的)一律不动 —— 见 Nav::addMissingEngines 的说明。 */
case 'engine_seed_add':
$n = Nav::addMissingEngines($me);
$ok($n > 0
? ('已补上 ' . $n . ' 个内置搜索引擎。')
: '内置搜索引擎都已经在列表里了,没有可补的。');
break;
/* 🔴 v24 第 26 批 用户第 4 条(后半):新增站点时**按 URL 查可复用**。
⚠️ 这是 AJAX 接口(前端在 URL 输入框失焦时问一次)。
做成 POST + csrf 而不是 GET:它带的是"我现在想加哪个 URL",
虽然是只读,但走同一个表单动作链更省事,且防了被当探测接口刷。
⚠️ 回的是**展示信息**(名字/简称/图标 URL),**不含是谁加的**。
隐私边界见 Nav::sharedByUrl() 的注释。 */
case 'site_lookup':
$u = (string)($_POST['url'] ?? '');
$rows = Nav::sharedByUrl($me, $u);
$out = [];
foreach ($rows as $r) {
$out[] = [
'name' => (string)($r['name'] ?? ''),
'short_name' => (string)($r['short_name'] ?? ''),
'label' => Nav::siteLabel($r),
'icon' => Nav::iconUrl((string)($r['icon'] ?? '')),
// 复用时给前端一个"可点选"的标识(用显示名+图标即可,
// ⚠️ 不能回 id —— 那是别人的行 id,暴露它没有必要)
'key' => Nav::siteLabel($r) . '|' . (string)($r['icon'] ?? ''),
];
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => true, 'items' => $out], JSON_UNESCAPED_UNICODE);
exit;
case 'engine_reorder': case 'engine_reorder':
if (!is_array($_POST['ids'] ?? null)) { if (!is_array($_POST['ids'] ?? null)) {
$bad('排序数据格式不对(应是一组 id)。'); $bad('排序数据格式不对(应是一组 id)。');
+7 -1
View File
@@ -195,7 +195,13 @@ $siteTile = static function (array $s) use ($openTarget): void {
<?php if ($engines): ?> <?php if ($engines): ?>
<div id="search" class="s-search"> <div id="search" class="s-search">
<div class="search-type"> <?php /* 🔴 v24 第 26 批 用户第 4 条 修的真 bug:
`nav-show.js` 找的是 `#engine-tabs`(SimpleNav 原版的 id),
而这里搬过来时只写了 `class="search-type"` —— **id 丢了**。
后果:`querySelectorAll('#engine-tabs .s-engine')` 命中 0 个 →
点标签一个事件都没绑上 → 用户看到的正是「选不同的搜索引擎没用」。
修法:把 id 补回来(而不是去改 JS —— 那份 JS 要与 SimpleNav 原版对照维护)。 */ ?>
<div class="search-type" id="engine-tabs">
<?php foreach ($engines as $en): ?> <?php foreach ($engines as $en): ?>
<label class="s-engine<?= $default && $en['slug'] === $default['slug'] ? ' active' : '' ?>" <label class="s-engine<?= $default && $en['slug'] === $default['slug'] ? ' active' : '' ?>"
data-search="<?= e((string)$en['search_url']) ?>" data-search="<?= e((string)$en['search_url']) ?>"
+7 -3
View File
@@ -38,14 +38,18 @@ if ($tkId <= 0) {
?> ?>
<?php if ($tkStatic): ?> <?php if ($tkStatic): ?>
<span class="tk-check is-static<?= $tkIsDone ? ' is-done' : '' ?>" <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 else: ?>
<?php /* 🔴 v24 第 26 批 用户第 3 条:**不要任何浮层文字提示**。
用户原话:「所有任务的前面复选框,都不要文字提示,现在问题提示把复选框都盖住了」——
`title="…"` 会弹一个系统 tooltip,正好压在 18px 的方块上(鼠标一停就盖住半个)。
去掉 title;无障碍说明仍在(`.visually-hidden` 里的文案是**屏读**用的,
不产生任何可见浮层,也不占位)。 */ ?>
<button type="button" class="tk-check<?= $tkIsDone ? ' is-done' : '' ?>" <button type="button" class="tk-check<?= $tkIsDone ? ' is-done' : '' ?>"
data-tk-done="<?= $tkId ?>" data-tk-done="<?= $tkId ?>"
data-tk-status="<?= e($tkStatus) ?>" data-tk-status="<?= e($tkStatus) ?>"
data-tk-back="<?= e($tkBack) ?>" data-tk-back="<?= e($tkBack) ?>"
aria-pressed="<?= $tkIsDone ? 'true' : 'false' ?>" aria-pressed="<?= $tkIsDone ? 'true' : 'false' ?>">
title="<?= $tkIsDone ? '已完成(再点一下取消)' : '点一下标记完成' ?>">
<span class="visually-hidden"><?= e($tkLabel) ?>:<?= e(\App\Task::statusWord($tkStatus)) ?></span> <span class="visually-hidden"><?= e($tkLabel) ?>:<?= e(\App\Task::statusWord($tkStatus)) ?></span>
</button> </button>
<?php endif; ?> <?php endif; ?>
+95 -20
View File
@@ -222,22 +222,34 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
<h2 class="cinema-h2">今天的日程</h2> <h2 class="cinema-h2">今天的日程</h2>
<a class="btn btn-ghost btn-sm" href="<?= e(url('calendar')) ?>">看整月</a> <a class="btn btn-ghost btn-sm" href="<?= e(url('calendar')) ?>">看整月</a>
</div> </div>
<div class="hd-pane-body"> <?php /* 🔴 v24 第 26 批 用户第 2 条(前半):「今天的日程,除了全天日程**一直显示在上面**外,
<?php if (!$todayEvents): ?> 其他日程随着滚动」。
⇒ 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> <p class="empty-hint">今天没有安排。</p>
<?php else: ?> </div>
<?php if ($allDay): ?> <?php else: ?>
<div class="ag-allday"> <?php if ($allDay): ?>
<?php foreach ($allDay as $ev): ?> <div class="ag-allday">
<a class="ag-allday-item" href="<?= e((string)$ev['href']) ?>"> <?php foreach ($allDay as $ev): ?>
<span class="dash-dot" style="background:<?= e((string)$ev['color']) ?>;"></span> <a class="ag-allday-item" href="<?= e((string)$ev['href']) ?>">
<span class="ag-ad-t">全天</span> <span class="dash-dot" style="background:<?= e((string)$ev['color']) ?>;"></span>
<span class="ag-ad-w"><?= e((string)$ev['title']) ?></span> <span class="ag-ad-t">全天</span>
<span class="dash-cal" style="background:<?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span> <span class="ag-ad-w"><?= e((string)$ev['title']) ?></span>
</a> <span class="dash-cal" style="background:<?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
<?php endforeach; ?> </a>
</div> <?php endforeach; ?>
<?php endif; ?> </div>
<?php endif; ?>
<div class="hd-pane-body" data-ag-scroll>
<div class="ag-grid"> <div class="ag-grid">
<?php for ($h = $startH; $h < $endH; $h++): ?> <?php for ($h = $startH; $h < $endH; $h++): ?>
<div class="ag-row"> <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> <span class="dash-cal" style="background:<?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
</a> </a>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div><?php /* /.ag-grid */ ?>
<?php endif; ?> </div><?php /* /.hd-pane-body(滚动区,只含时段网格) */ ?>
</div> <?php endif; ?>
</div> </div><?php /* /.hd-pane */ ?>
<?php /* ---- 右:进行中的任务(标题 + 状态 + 看板名)---- */ ?> <?php /* ---- 右:进行中的任务(标题 + 状态 + 看板名)---- */ ?>
<div class="card card-pad hd-pane" id="homeTasks"> <div class="card card-pad hd-pane" id="homeTasks">
@@ -305,7 +317,16 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
</span> </span>
<?php /* 用户第 3 条:「还要显示他所处的看板的名称,就是他现在被放在哪一个看板里面」 */ ?> <?php /* 用户第 3 条:「还要显示他所处的看板的名称,就是他现在被放在哪一个看板里面」 */ ?>
<?php if (trim((string)($t['board_name'] ?? '')) !== ''): ?> <?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 endif; ?>
<?php if ((int)($t['child_total'] ?? 0) > 0): ?> <?php if ((int)($t['child_total'] ?? 0) > 0): ?>
<span class="dash-tag"><?= (int)$t['child_done'] ?>/<?= (int)$t['child_total'] ?></span> <span class="dash-tag"><?= (int)$t['child_done'] ?>/<?= (int)$t['child_total'] ?></span>
@@ -423,3 +444,57 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
})(); })();
</script> </script>
<?php endif; ?> <?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> </h2>
<?php if ($timeline): ?> <?php if ($timeline): ?>
<?php /* 总金额图("全部")与逐类别图("存款"…)都**预渲染好**,靠 hidden 切换。 <?php /* 总金额图("全部")与逐类别图("存款"…)都**预渲染好** —— 但它们现在的身份变了:
为什么不"点了再向服务器要一张":图是**服务端画的 SVG**(见 app/chart.php), v24 第 26 批 用户第 1 条要求"多选时按**选中集合**统计",而集合的组合数是指数级的
要重画就得整页刷新 —— 那就不是"点标签",是"点链接"了。 (10 个类别 = 1023 种),不可能全预渲染。所以改成:
预渲染的代价只是每个**用到的**类别多一次查询,而这个页面本来就要
把整本账每条账目都读出来(charts() 已在做),量级一样。 */ ?> · 页面里预渲染的这批图,现在只作**缓存的第一层**:
空集(整本账)与单类别这两档直接命中,**零请求、无闪烁**;
· 其它组合(2 类以上)由前端把集合发给
`ledger/total_chart` 现取一张(见本文件 JS 的 ② 段)。
这样既保住了"点一下立刻变"的手感(最常见的前两档),
又不至于为 1023 种组合预渲染 1023 张图。 */ ?>
<div class="ck-card ck-total"> <div class="ck-card ck-total">
<div class="ck-head"> <div class="ck-head">
<b data-ck-total-title>总金额 · 净资产</b> <b data-ck-total-title>总金额 · 净资产</b>
<span class="small muted" data-ck-total-n><?= count($timeline) ?> 个时间点</span> <span class="small muted" data-ck-total-n><?= count($timeline) ?> 个时间点</span>
</div> </div>
<?php /* ⚠️ 每张图外面包一层带 data-ck-tkind 的容器('' = 整本账)。 <?php /* 🔴 图容器:`data-ck-total-host` 是 JS 唯一要操作的节点。
筛选时只显示匹配的那一张。 两个自定义属性把"整本账那张图"和"取图接口"都交给 JS:
⚠️ 不能在 chart_line 内部加属性 —— 那个函数被全站复用, · data-ck-cache-base —— 空集时要回显的 HTML(**页面里已经画好了**,
"为了筛选顺手改公共渲染函数"是往所有调用点塞风险。 */ ?> 不必再请求一次;也不怕 JS 手里没有它)。
<div data-ck-tkind=""> · 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) ?> <?= chart_line($timeline, '净资产', 'asset', 900) ?>
</div> </div>
<?php /* 单类别图:只作 JS 的缓存种子,**不显示**(display:none,不是 hidden——
因为 hidden 会被 JS 里某些 innerHTML 操作顺手带上)。 */ ?>
<?php foreach ($kindLines as $k => $line): ?> <?php foreach ($kindLines as $k => $line): ?>
<?php if (!$line) { continue; } ?> <?php if (!$line) { continue; } ?>
<?php /* 负债类别用绿线(`debt`),资产用红线 —— 与全站"涨红跌绿"口径一致 */ ?> <?php /* 负债类别用绿线(`debt`),资产用红线 —— 与全站"涨红跌绿"口径一致 */ ?>
<div data-ck-tkind="<?= e((string)$k) ?>" hidden> <script type="application/json"
<?= chart_line($line, \App\Ledger::kindWord((string)$k), data-ck-seed="<?= e((string)$k) ?>"><?= json_encode(
\App\Ledger::isDebt((string)$k) ? 'debt' : 'asset', 900) ?> chart_line($line, \App\Ledger::kindWord((string)$k),
</div> \App\Ledger::isDebt((string)$k) ? 'debt' : 'asset', 900),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?></script>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
@@ -604,6 +618,28 @@ $ledMe = (int)($me ?? 0);
/** 当前选中的类别集合(空集 = 全部) */ /** 当前选中的类别集合(空集 = 全部) */
var picked = []; 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) { function chipInfo(bar, kind) {
var c = bar.querySelector('.ck-chip[data-ck-kind="' + kind + '"]'); var c = bar.querySelector('.ck-chip[data-ck-kind="' + kind + '"]');
@@ -624,16 +660,65 @@ $ledMe = (int)($me ?? 0);
}); });
}); });
// ② 总金额图:只在"恰好选了一类"时切到那一类;多选或空集都显示整本账 // ② 总金额图:按**选中集合**换图(v24 第 26 批 用户第 1 条重写)
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; // 勾选多个后,变成总数了。」
}); // 上一批只做了"恰好选一类才切图",多选直接**回落成整本账** —— 那就是 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]'); var ttl = document.querySelector('[data-ck-total-title]');
if (ttl) { if (ttl) {
var info = sole !== '' ? chipInfo(bars[0], sole) : null; if (picked.length === 0) {
ttl.textContent = sole === '' ? '总金额 · 净资产' ttl.textContent = '总金额 · 净资产';
: (info && info.word ? info.word : '') + ' · 金额变化'; } 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): ?> <?php if ($canWrite): ?>
<div class="card card-pad"> <div class="card card-pad">
<h2 class="cinema-h2"><?= $editSite ? '编辑站点' : '新增站点' ?></h2> <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() ?> <?= csrf_field() ?>
<input type="hidden" name="act" value="site_save"> <input type="hidden" name="act" value="site_save">
<?php if ($editSite): ?> <?php if ($editSite): ?>
<input type="hidden" name="id" value="<?= (int)$editSite['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$editSite['id'] ?>">
<?php endif; ?> <?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="form-grid">
<div class="field"> <div class="field">
<label>网址 *</label> <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"> value="<?= e((string)($editSite['url'] ?? '')) ?>" placeholder="https://example.com">
</div> </div>
<div class="field"> <div class="field">
@@ -200,12 +218,13 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
</div> </div>
<div class="field"> <div class="field">
<label>显示名</label> <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="留空会自动抓"> placeholder="留空会自动抓">
</div> </div>
<div class="field"> <div class="field">
<label>简称</label> <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="页面上显示的字(优先于显示名)"> placeholder="页面上显示的字(优先于显示名)">
</div> </div>
<div class="field full"> <div class="field full">
@@ -405,6 +424,24 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
<div class="card card-pad"> <div class="card card-pad">
<h2 class="cinema-h2">搜索引擎列表</h2> <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')) ?>"> <ul class="nav-sortlist" data-sort-list data-reorder-url="<?= e(url('nav/save')) ?>">
<?php foreach ($engines as $en): ?> <?php foreach ($engines as $en): ?>
<li class="nav-sortrow<?= empty($en['is_active']) ? ' is-off' : '' ?>" data-id="<?= (int)$en['id'] ?>"> <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(); }); .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> </script>
+22 -5
View File
@@ -30,14 +30,22 @@ $categories = $categories ?? [];
$colList = []; $colList = [];
foreach ($boards as $b) { foreach ($boards as $b) {
$bid = (int)$b['id']; $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])) { if (!empty($cols[0])) {
$colList[] = ['id' => 0, 'name' => '其他', 'rows' => $cols[0]]; $colList[] = ['id' => 0, 'name' => '其他', 'color' => '', 'rows' => $cols[0]];
} }
// 一个看板都没有(也不该发生:ensureDefaultBoard 保证至少一个)→ 给一句人话 // 一个看板都没有(也不该发生:ensureDefaultBoard 保证至少一个)→ 给一句人话
if (!$colList) { if (!$colList) {
$colList[] = ['id' => 0, 'name' => '任务', 'rows' => $cols[0] ?? []]; $colList[] = ['id' => 0, 'name' => '任务', 'color' => '', 'rows' => $cols[0] ?? []];
} }
$firstColId = (int)($colList[0]['id'] ?? 0); $firstColId = (int)($colList[0]['id'] ?? 0);
$lastColIdx = count($colList) - 1; $lastColIdx = count($colList) - 1;
@@ -208,6 +216,9 @@ $lastColIdx = count($colList) - 1;
<?php foreach ($colList as $ci => $col): $cid = (int)$col['id']; ?> <?php foreach ($colList as $ci => $col): $cid = (int)$col['id']; ?>
<section class="kb-col" data-board="<?= $cid ?>"> <section class="kb-col" data-board="<?= $cid ?>">
<header class="kb-head"> <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> <b><?= e((string)$col['name']) ?></b>
<span class="kb-n"><?= count($col['rows']) ?></span> <span class="kb-n"><?= count($col['rows']) ?></span>
</header> </header>
@@ -244,10 +255,16 @@ $lastColIdx = count($colList) - 1;
?> ?>
<div class="kb-meta"> <div class="kb-meta">
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<?php /* ⚠️ v24 第 26 批 用户第 3 条:这里原来有 `title="现在:待办;点一下改成「进行中」"`。
那串提示在卡片里离**完成复选框**很近(kb-top 下一行),鼠标一停就弹出来压住它
—— 用户报的正是「提示把复选框都盖住了」。
去掉 title:状态**已经写在按钮文字上**(待办/进行中),
点一下会变成下一档这件事由按钮的样式(可点)表达,不需要再弹一段长文。
`aria-label` 也一并去掉 —— 它同样会生成浮层;无障碍改用 visually-hidden 文本。 */ ?>
<button class="kb-st is-<?= e($stNow) ?>" type="button" <button class="kb-st is-<?= e($stNow) ?>" type="button"
data-st-id="<?= $tid ?>" data-st="<?= e($stNow) ?>" data-next="<?= e($stNext) ?>" data-st-id="<?= $tid ?>" data-st="<?= e($stNow) ?>" data-next="<?= e($stNext) ?>">
title="现在:<?= e(\App\Task::statusWord($stNow)) ?>;点一下改成「<?= e(\App\Task::statusWord($stNext)) ?>」">
<?= e(\App\Task::statusWord($stNow)) ?> <?= e(\App\Task::statusWord($stNow)) ?>
<span class="visually-hidden">,点一下改成「<?= e(\App\Task::statusWord($stNext)) ?>」</span>
</button> </button>
<?php else: ?> <?php else: ?>
<span class="kb-st is-<?= e($stNow) ?> is-static"><?= e(\App\Task::statusWord($stNow)) ?></span> <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> </p>
<?php /* 🔴 v24 第 26 批 用户第 3 条:「任务在设置看板的时候,可以选择颜色,
这样在首页列表中显示的时候,就可以更加直观的看到他属于哪个看板。」
⇒ 每行给一个**颜色选择器**,与改名在**同一份表单**里一起提交
(用户改色时不该被迫把名字重打一遍)。
⚠️ 用 `<input type="color">` 而不是自绘色块:原生控件在手机上直接唤起系统取色器,
自绘的做不到这一点;代价是桌面端样式朴素 —— 但"能用"优先于"好看"。
⚠️ 旁边那颗小圆点显示**当前最终色**(没选过色时是按名字派生的那个),
不然用户看到取色器里是黑的、而列表里是蓝的,会以为没生效。 */ ?>
<div class="set-list"> <div class="set-list">
<?php foreach ($boards as $b): ?> <?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')) ?>"> <form class="inline-form set-row" method="post" action="<?= e(url('tasks/board_update')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$b['id'] ?>"> <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> <input class="input" name="name" value="<?= e((string)$b['name']) ?>" required>
<span class="small muted"><?= (int)($b['open_count'] ?? 0) ?> 件未完成</span> <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> </form>
<?php if (count($boards) > 1): ?> <?php if (count($boards) > 1): ?>
<form class="inline-form" method="post" action="<?= e(url('tasks/board_del')) ?>"> <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"> <div class="page-head">
<h1> <h1>
<?php /* 用户第 6 条:「所有的任务,前面都带一个复选框,在任何地方,点这个复选框, <?php /* 🔴 v24 第 26 批 用户第 5 条:**详情页页头不再放复选框**。
这个任务就完成了。」→ 详情页也算"任何地方",标题前也给它一颗。 */ ?> 用户原话:「任务详情页面,就不需要复选框了,因为有标记完成这个按钮。」
<?php ⇒ 右侧 actions 里那颗「标记完成 / 重新打开」已经把这个动作表达得很清楚,
$tkId = (int)$t['id']; $tkStatus = (string)$t['status']; 标题前再挂一颗是重复的(同一页两个入口做同一件事,还容易看不出区别)。
$tkBack = 'show'; $tkStatic = !$canWrite; ⚠️ **子任务列表里的复选框保留**(见文件下面)—— 那条需求说的是"详情页",
require __DIR__ . '/../_task_check.php'; 而子任务**没有**自己的「标记完成」按钮,去掉它子任务就没法在列表里勾了。 */ ?>
?>
<?= e((string)$t['title']) ?> <?= e((string)$t['title']) ?>
</h1> </h1>
<span class="sub"><?= e(\App\Task::statusWord((string)$t['status'])) ?></span> <span class="sub"><?= e(\App\Task::statusWord((string)$t['status'])) ?></span>