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:
+24
-4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(); } });
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ---------- 联想下拉 ---------- */
|
/* ---------- 联想下拉 ---------- */
|
||||||
|
|||||||
@@ -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
@@ -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']) ?>"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')) ?>">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user