diff --git a/app/Ledger.php b/app/Ledger.php index ed76020..94005e0 100644 --- a/app/Ledger.php +++ b/app/Ledger.php @@ -465,20 +465,40 @@ final class Ledger * 取值规则(每个时点 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 空账本返回 [] */ - 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); - if ($kind !== '') { + if ($kinds) { // ⚠️ 必须在**取时点之前**过滤(而不是算完再筛): // 时点集合里有"每条账目的创建日",包含被筛掉那些账目的创建日 → // 图右端就会凭空多出几个"其实什么都没变"的时点。 + // ⚠️ 多选时是**白名单**(在集合里才留);集合为空 = 不过滤 = 整本账。 $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) { return []; diff --git a/app/Nav.php b/app/Nav.php index 53f1708..61ea491 100644 --- a/app/Nav.php +++ b/app/Nav.php @@ -280,6 +280,144 @@ final class Nav 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 最多返回几条(同一 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) */ public const ENGINE_SEED = [ // 名称, slug, 搜索地址, 参数, 联想地址, 联想类型, 颜色, 是否启用 + // + // 🔴 v24 第 26 批 用户第 4 条(后半):「**可以内置几个,让用户选择**」。 + // 原来只启用必应/百度两档,用户根本看不出"可选"这件事(列表里另外 4 个 + // 是以"未启用"状态躺着的,展示页的标签条只渲染启用的)。 + // ⇒ 把**实测可用的三档**都默认启用:必应 / 百度 / 360。 + // 谷歌 / DuckGo 保持未启用 —— 它们在**国内网络下连不上** + // (见 nav_suggest.php 头部那段线上实测:连接超时), + // 默认启用只会让用户点了发现"搜不动",比不提供更差。 + // 搜狗保持未启用:它的联想域名已不解析(实测 0 字节)。 + // ⚠️ 这三条实测结论来自 nav_suggest.php 的注释,不要凭印象改动。 ['必应', 'bing', 'https://cn.bing.com/search', 'q', 'https://api.bing.com/osjson.aspx?query={q}', 'bing_osjson', '#60A504', 1], ['百度', 'baidu', 'https://www.baidu.com/s', 'wd', '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', '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], ]; + + /** + * 把**缺失的内置引擎**补给这个人(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; + } } \ No newline at end of file diff --git a/app/Schema.php b/app/Schema.php index 963a9a4..c9ad4bc 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -104,7 +104,7 @@ final class Schema * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 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`"); self::addColumn($db, 'tasks', 'category_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) { foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { diff --git a/app/Task.php b/app/Task.php index 02bc8ef..4224893 100644 --- a/app/Task.php +++ b/app/Task.php @@ -137,6 +137,79 @@ final class Task 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 @@ -164,11 +237,9 @@ final class Task return (int)$db->lastInsertId(); } - public static function renameBoard(int $familyId, int $id, string $name): void - { - Database::get()->prepare('UPDATE task_boards SET name = ? WHERE id = ? AND family_id = ?') - ->execute([self::shortName($name), $id, $familyId]); - } + /* ⚠️ 原来的 `renameBoard()` 已在 v24 第 26 批**并入 `updateBoard()`**(用户第 3 条 + 要给看板加颜色,两个字段同一个动作),这里不再保留 —— 留一个只改名字的方法, + 下一个改颜色的人会顺手调错它("改了色但名字被重置"这类事故就是这么来的)。 */ /** * 删看板:里头的任务**不删**,挪到默认看板上(删看板 ≠ 删任务,这个代价太突然)。 @@ -428,7 +499,7 @@ final class Task 'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color, na.nickname AS assignee_nick, 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 AND k.status = "done") AS child_done FROM tasks t diff --git a/assets/css/app.css b/assets/css/app.css index 20c07c8..d4abf1f 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -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-due.is-late, .kb-late { color: var(--red); } .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-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); } @@ -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-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 { 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); @@ -4271,8 +4306,9 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b 这才是"复选框在文字前面"该有的样子。 (`.sub-row` 本来就是 center,不用管它。) */ .dash-row:has(.tk-check) { align-items: center; } -/* 详情页标题前那颗:标题是 22px 大字,复选框要跟着走中线才不悬空 */ -.page-head h1:has(> .tk-check) { display: inline-flex; align-items: center; gap: 10px; } +/* ⚠️ v24 第 26 批 用户第 5 条:**详情页页头的复选框已去掉**(那颗由「标记完成」按钮取代), + 所以这里原来那条 `.page-head h1:has(> .tk-check)` 一并删除 —— + 留一条没有使用者的规则,只会让下一个人以为"页头还该有复选框"。 */ /* 看板页顶上的「三个状态到底什么意思」(用户第 6 条直接问出来的那句话) */ .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 i { display: block; height: 100%; background: var(--brand); border-radius: 999px; } .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; } diff --git a/assets/js/nav-show.js b/assets/js/nav-show.js index 815d14e..6e5ebd4 100644 --- a/assets/js/nav-show.js +++ b/assets/js/nav-show.js @@ -1,23 +1,52 @@ /* ============================================================ 导航展示页脚本(v24 第 24 批 用户第 4 条) - —— **从 SimpleNav 的 assets/js/app.js 整份搬运**,逻辑一行未改。 + —— 从 SimpleNav 的 assets/js/app.js 整份搬运,逻辑主体保留。 它提供:搜索引擎切换、联想词下拉(↓/↑ 与 , . 两组按键)、合集标签页切换、 站点链接打开方式(新标签页 / 当前页,由 body[data-open] 决定)。 ⚠️ 联想接口地址来自 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` 是 `