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 之前盘点过 → 取最后一次盘点的值;从未盘点过 → 取它现在的值(一直没变过)。
*
* @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}> 空账本返回 []
*/
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 [];
+194 -3
View File
@@ -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<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) */
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;
}
}
+14 -1
View File
@@ -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) {
+77 -6
View File
@@ -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