From a5e559b2b3822079de78aaa208ef5a3cd1302a38 Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 7 Oct 2026 11:35:31 +0800 Subject: [PATCH] =?UTF-8?q?v25=20=E7=AC=AC=2025=20=E6=89=B9=EF=BC=889=20?= =?UTF-8?q?=E6=9D=A1=EF=BC=89=EF=BC=9A=E5=90=8D=E5=86=8C=E5=88=86=E7=BB=84?= =?UTF-8?q?=E4=B8=8D=E5=88=B7=E9=A1=B5=20/=20=E7=BA=AA=E5=BF=B5=E6=97=A5?= =?UTF-8?q?=E5=A4=9A=E4=BA=BA=20/=20=E9=A6=96=E9=A1=B5=E7=AD=89=E9=AB=98?= =?UTF-8?q?=20/=20=E7=A7=BB=E5=87=BA=E4=B8=8D=E5=9B=9E=E6=B5=81=20/=20?= =?UTF-8?q?=E5=90=8D=E5=86=8C=E7=82=B9=E5=87=BB=E7=9C=8B=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E6=8B=96=E5=8A=A8=E6=89=8D=E6=8E=92=E5=BA=8F=20/=20=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=A4=8D=E9=80=89=E6=A1=86=E4=B8=8E=E7=8A=B6=E6=80=81?= =?UTF-8?q?=20/=20=E8=B4=A2=E5=8A=A1=E6=A0=87=E7=AD=BE=E5=A4=9A=E9=80=89?= =?UTF-8?q?=20/=20=E5=BE=AE=E4=BF=A1=E8=AF=BB=E4=B9=A6=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E4=B8=8E=E7=BB=9F=E8=AE=A1=20/=20=E5=AF=BC=E8=88=AA=E7=BA=BF?= =?UTF-8?q?=E4=B8=8A=E6=95=B0=E6=8D=AE=E4=B8=8E=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户本轮 9 条需求(原话见 templates/ 与 app/ 内的逐条注释): 1. 名册:列表里给某人分组改为 AJAX 就地挪卡片(不再整页刷新)。 2. 纪念日:关联人改**多选**(复用 _person_picker,字段 person_ids[]); 自建纪念日的按钮与其它模块统一(改=蓝 .btn、删=黄 .btn-warn); 列表新增「关联人」列并给人名前加头像。 3. 首页:日程/任务两块**完全等高**(align-items:stretch + 固定总高 + flex 子项 min-height:0);修「0 件待办」——数字与右侧列表同源(原来各算各的)。 4. 🔴 名册里被移出的人过一阵又回来:根因是 PersonRoster::seedFromPersons() **每次 Schema::ensure() 都无条件重灌**(线上实测每跑一次 +1 行)。 修:给它加自守卫(只有在 persons 还留着 group_id/sort_order 的**老库**才跑)。 5. 名册:点击=看详情、**只有拖 ⠿ 手柄**才是排序(原来整行既能点又能拖,语义混乱)。 6. 任务:所有任务前加复选框,任何位置点勾即完成; 状态语义照业界整理为 To Do / Doing / Done 并写出说明(用户要求参考别人的做法)。 7. 财务:类别标签改**多选非互斥**(再点一次取消该类别),上下两图 + 图例 + 表格 全部按同一个集合联动;高亮条刻意不给「合计」(多选下各类金额不能相加)。 8. 微信读书:新增**懒同步**(访问时顺手做,6h TTL,只补空字段不覆盖用户填的)、 封面图**拉下来存本站存储**(原来只存外链 → 看不到)、接官方 /readdata/detail 统计。 9. 导航:线上数据只搬了一半(5 板块 29 站 → 7 板块 67 站 + 67 图标)、 展示页头像不显示 / 搜索按钮错位 / 点登录 404。三个真根因: · 相对地址在 /nav/ 下全 404 → 新增 Nav::abs(); · 搜索容器丢了 id="search"(CSS 是 #search button.submit 绝对定位); · Nav::iconKey() 与 SimpleNav 的 icon_key() **算法不一致**(少一次 md5 归一化) → 搬来的图标文件名对不上;且 uploads/ 整目录 Require all denied。 新增两个**免登录公开出口** nav_icon.php / nav_avatar.php。 顺带修掉一个「数据损坏级」的真 bug(做第 4 条线上反证时发现的,之前一直没人注意): app/Region.php 三处用中文标点当 trim/ltrim 的 charlist,而 PHP 的 charlist 是 **按字节**匹配的 —— 「。」= E3 80 **82** → 字节 82 进了可剥集合 → 「层」= E5 B1 **82** 被切掉尾字节 → 非法 UTF-8 → backfillAddress 的 UPDATE 报 `1366 Incorrect string value` → **地址拆分升级从上线起一直在失败被跳过**(每请求报一次)。 改成字符级 preg_replace('/…/u');线上实测 ensure() 现在无报错、待拆行数真的在下降。 验证战绩: - e2e 1289/0(新增 Region 字节级用例等) - 真机探针:选人 19/0 · 首页/导航 24/0 · 导航模块 34/0 · b18 暂存+确定 10/0 · 第23批 52/0 - 桌面几何 428/0 · 移动端几何 680/0 · 交互 92/0 · 窄屏 24/0 - 老库升级 153/0(含 anniversary_people 迁移 + 「再跑 ensure() 名册行数不变」) - 部署静态断言 180 OK / 0 FAIL · 反向验证 3/3 + Region 三变体各被两条断言同时抓住 - 线上 26 个文件与本地**逐字节一致**;展示页 200、图标 66/66 200、登录 200、 越权/不存在均 404、签发短语后 ensure() 不再报错。 --- .htaccess | 7 + app/Anniversary.php | 147 +++++++++++- app/Nav.php | 109 ++++++++- app/PersonRoster.php | 28 +++ app/Region.php | 32 ++- app/Schema.php | 45 +++- app/Task.php | 62 +++++ app/WeRead.php | 478 +++++++++++++++++++++++++++++++++++++ assets/css/app.css | 194 +++++++++++++-- assets/js/app.js | 75 +++--- index.php | 197 +++++++++++++-- nav_avatar.php | 111 +++++++++ nav_icon.php | 106 ++++++++ nav_show.php | 47 ++-- templates/_task_check.php | 51 ++++ templates/books/weread.php | 204 ++++++++++++++-- templates/days/form.php | 24 +- templates/days/index.php | 68 +++++- templates/home.php | 108 ++++++++- templates/ledger/index.php | 148 ++++++++---- templates/people/index.php | 137 +++++++++++ templates/tasks/index.php | 143 ++++++++++- templates/tasks/show.php | 70 +++++- 23 files changed, 2379 insertions(+), 212 deletions(-) create mode 100644 nav_avatar.php create mode 100644 nav_icon.php create mode 100644 templates/_task_check.php diff --git a/.htaccess b/.htaccess index cb488fd..d8ef3db 100644 --- a/.htaccess +++ b/.htaccess @@ -37,6 +37,13 @@ RewriteRule ^nav/?$ nav_show.php [L,QSA] +# ⚠️ 导航图标与展示页头像**不需要重写规则**(v24 第 25 批): +# nav_icon.php / nav_avatar.php 是站点根下的**真实文件**,Apache 直接就能执行。 +# 展示页里的地址由 Nav::abs() 拼成 `/nav_icon.php?f=…`,落在站点根, +# 不经过 /nav/ 这个重写前缀 —— 所以上面那两条规则一个字都不用动。 +# (这正是选"站点根下的两个独立入口"而不是"塞进 /nav/ 子路径"的原因: +# 塞进去就得再加两条重写规则,而重写规则的顺序语义已经踩过一次坑了。) + # ⚠️⚠️ DAV 的 Basic 鉴权**必须**靠这一行才能在 Apache 上工作: # Apache 默认**不把 `Authorization` 头交给 PHP-FPM** → `$_SERVER['PHP_AUTH_USER']` # 和 `HTTP_AUTHORIZATION` 都是 null → /dav/ 与 /carddav/ 无论口令填得多正确都 401。 diff --git a/app/Anniversary.php b/app/Anniversary.php index 919585b..4aa40e8 100644 --- a/app/Anniversary.php +++ b/app/Anniversary.php @@ -62,7 +62,8 @@ final class Anniversary /** * 新建 / 修改一条纪念日。 * - * @param array $d title / repeat_type / month / day / kind / note / person_id / leap + * @param array $d title / repeat_type / month / day / kind / note / leap + * + **person_ids[]**(v24 第 25 批:可以关联多个人) * @return int 记录 id */ public static function save(int $familyId, array $d, int $id = 0): int @@ -80,15 +81,47 @@ final class Anniversary $day = max(1, min(31, (int)($d['day'] ?? 1))); $kind = mb_substr(trim((string)($d['kind'] ?? '')), 0, 40, 'UTF-8'); $note = trim((string)($d['note'] ?? '')); - $pid = (int)($d['person_id'] ?? 0) ?: null; $leap = !empty($d['leap']) ? 1 : 0; + // ---- 关联的人(v24 第 25 批 用户第 2 条:一人 → 多人)---- + // + // ⚠️ 兼容老表单:只传 `person_id`(单值)时也认,包成一个人的数组。 + // 这样任何还没来得及改的调用点(老链接、外部脚本)都不会静默丢人。 + $pids = []; + if (array_key_exists('person_ids', $d)) { + foreach ((array)$d['person_ids'] as $x) { + $x = (int)$x; + if ($x > 0) { + $pids[] = $x; + } + } + } elseif (array_key_exists('person_id', $d)) { + $x = (int)$d['person_id']; + if ($x > 0) { + $pids[] = $x; + } + } + $pids = array_values(array_unique($pids)); + // 只认这个家庭里的人(表单可以改,不能凭它挂到别人家的人身上) + if ($pids) { + $in = implode(',', array_fill(0, count($pids), '?')); + $st = $db->prepare("SELECT id FROM persons WHERE family_id = ? AND id IN ($in)"); + $st->execute(array_merge([$familyId], $pids)); + $ok = array_map('intval', array_column($st->fetchAll(), 'id')); + $pids = array_values(array_intersect($pids, $ok)); + } + + // ⚠️ 老列 person_id 继续写**第一个**关联人(不写 NULL): + // 它是老代码/老库的兜底读点,写成 NULL 会让"没升级到的读点"少一个人。 + $pid = $pids[0] ?? null; + if ($id > 0) { $db->prepare( 'UPDATE anniversaries SET title=?, repeat_type=?, month=?, day=?, is_leap=?, kind=?, note=?, person_id=? WHERE id=? AND family_id=?' )->execute([$title, $repeat, $month, $day, $leap, $kind !== '' ? $kind : null, $note !== '' ? $note : null, $pid, $id, $familyId]); + self::savePeople($id, $pids); return $id; } $db->prepare( @@ -97,12 +130,101 @@ final class Anniversary VALUES (?,?,?,?,?,?,?,?,?)' )->execute([$familyId, $title, $repeat, $month, $day, $leap, $kind !== '' ? $kind : null, $note !== '' ? $note : null, $pid]); - return (int)$db->lastInsertId(); + $newId = (int)$db->lastInsertId(); + self::savePeople($newId, $pids); + return $newId; + } + + /** + * 整组替换某个纪念日关联的人(v24 第 25 批)。 + * + * 「整组替换」而不是"逐条增删":表单交上来的就是**一份完整名单**, + * 逐条比对会产生"先删后加"的中间态;一次 DELETE + 一次 INSERT 是确定结果。 + */ + public static function savePeople(int $anniversaryId, array $personIds): int + { + $db = Database::get(); + $db->prepare('DELETE FROM anniversary_people WHERE anniversary_id = ?')->execute([$anniversaryId]); + if (!$personIds) { + return 0; + } + $ins = $db->prepare( + 'INSERT IGNORE INTO anniversary_people (anniversary_id, person_id, sort_order) VALUES (?,?,?)' + ); + $n = 0; + $i = 0; + foreach ($personIds as $pid) { + $pid = (int)$pid; + if ($pid <= 0) { + continue; + } + $i += 10; + $ins->execute([$anniversaryId, $pid, $i]); + $n++; + } + return $n; + } + + /** + * 某个纪念日关联的人(**含兜底**)。 + * + * ⚠️ 兜底逻辑(v24 第 25 批升级过渡期必须有): + * 新表里没有行时,回落到老列 `person_id` —— 这样"升级跑到一半" + * 或"老数据没回填成功"时,页面上仍然看得到那个人,而不是静默变成"没关联"。 + * + * @return int[] person id(按 sort_order) + */ + public static function peopleOf(array $row): array + { + static $cache = []; + $aid = (int)($row['id'] ?? 0); + if ($aid <= 0) { + return []; + } + if (array_key_exists($aid, $cache)) { + return $cache[$aid]; + } + $st = Database::get()->prepare( + 'SELECT person_id FROM anniversary_people WHERE anniversary_id = ? ORDER BY sort_order, person_id' + ); + $st->execute([$aid]); + $out = array_map('intval', array_column($st->fetchAll(), 'person_id')); + if (!$out) { + $one = (int)($row['person_id'] ?? 0); + if ($one > 0) { + $out = [$one]; + } + } + $cache[$aid] = $out; + return $out; + } + + /** + * 一批纪念日的关联人:`anniversary_id => [person_id, ...]`(避免逐行查库)。 + */ + public static function peopleMap(array $ids): array + { + $ids = array_values(array_filter(array_map('intval', $ids))); + if (!$ids) { + return []; + } + $in = implode(',', array_fill(0, count($ids), '?')); + $st = Database::get()->prepare( + "SELECT anniversary_id, person_id FROM anniversary_people + WHERE anniversary_id IN ($in) ORDER BY anniversary_id, sort_order, person_id" + ); + $st->execute($ids); + $out = []; + foreach ($st->fetchAll() as $r) { + $out[(int)$r['anniversary_id']][] = (int)$r['person_id']; + } + return $out; } public static function remove(int $familyId, int $id): void { Attachment::removeAll(self::ATTACH, $id); + // 关联表由外键 ON DELETE CASCADE 一起走;这里不必手动删 Database::get()->prepare('DELETE FROM anniversaries WHERE id = ? AND family_id = ?') ->execute([$id, $familyId]); } @@ -189,13 +311,30 @@ final class Anniversary */ public static function split(int $familyId, int $viewerId): array { + $rows = self::list($familyId); + // 一次把所有人的关联人查出来(避免逐行查库)——v24 第 25 批 用户第 2 条 + $pmap = self::peopleMap(array_column($rows, 'id')); $custom = []; - foreach (self::list($familyId) as $r) { + foreach ($rows as $r) { $r['source'] = 'custom'; $r['person_name'] = ''; + $aid = (int)$r['id']; + if (!empty($pmap[$aid])) { + $r['person_ids'] = $pmap[$aid]; + } elseif ((int)($r['person_id'] ?? 0) > 0) { + // 兜底:新表没有行就看老列(升级过渡期) + $r['person_ids'] = [(int)$r['person_id']]; + } else { + $r['person_ids'] = []; + } $custom[] = $r; } $derived = self::birthdays($familyId, $viewerId); + // 派生行(名册生日)本来就知道是谁,统一挂上 person_ids,模板无须分两条路 + foreach ($derived as &$dv) { + $dv['person_ids'] = (int)($dv['person_id'] ?? 0) > 0 ? [(int)$dv['person_id']] : []; + } + unset($dv); $byMD = static function (array $a, array $b): int { return ((int)($a['month'] ?? 0) <=> (int)($b['month'] ?? 0)) diff --git a/app/Nav.php b/app/Nav.php index fb0f424..53f1708 100644 --- a/app/Nav.php +++ b/app/Nav.php @@ -543,10 +543,48 @@ final class Nav return $host ? (string)preg_replace('/^www\./i', '', $host) : ''; } - /** 图标文件名(按 URL 取 md5,与 SimpleNav 一致) */ + /** + * 图标键(按 URL 取 md5)—— **必须与 SimpleNav 的 `icon_key()` 逐字一致**。 + * + * 🔴🔴 v24 第 25 批 用户第 9 条修(搬运失真,导致"图标全都没有"): + * + * 原实现写成了 `md5(self::normUrl($url))` —— 看起来"合理",但 SimpleNav 的 + * `icon_key()` 做的归一化比 `normUrl()` 多得多,两者算出来的键**不一样**: + * · SimpleNav:scheme/host **转小写**、**补上空路径为 `/`**、保留 port/query/fragment; + * · 原来这里:什么都不做(只用 normUrl 补 `http://` 前缀)。 + * 于是 `https://otrust.ouc.edu.cn`(无路径)在两边分别得到 + * `921cbff230c762f21ec5b6ffbfa1da63` 与 `0c4227cc33e0b0ab680aaf24031965f3` + * —— **只要 URL 里有一个大写字母或缺一个斜杠,文件名就对不上**。 + * + * 后果(用户看到的现象):从 SimpleNav 搬过来的站点,`icon` 列里存着的文件名 + * 指向的文件其实叫另一个名字,`iconUrl()` 里 `is_file()` 判假 → 返回空串 → + * 前端退化成"首字母色块",**一个图标都显示不出来**。 + * + * ⚠️ 另外注意:**实际文件名是 `md5(本函数的结果)`(二次哈希)** —— + * 这是 SimpleNav 的历史遗留(只为让文件名定长),但它确实在文件名里, + * 所以搬迁 / 读写图标文件时**不要**自己再哈希或去掉这一层。 + * 见 NavFetch::download_best_icon 的落盘处(它用的是 `md5($key)`)。 + */ public static function iconKey(string $url): string { - return md5(self::normUrl($url)); + $u = trim($url); + if ($u === '') { + return ''; + } + if (!preg_match('#^https?://#i', $u)) { + $u = 'http://' . $u; + } + $p = @parse_url($u); + if (!is_array($p) || empty($p['host'])) { + return md5($u); // 解析不出来就整串兜底,至少不会跟别人撞 + } + $norm = strtolower((string)($p['scheme'] ?? '')) . '://' + . strtolower((string)$p['host']) + . (empty($p['port']) ? '' : ':' . $p['port']) + . (isset($p['path']) && $p['path'] !== '' ? $p['path'] : '/') + . (isset($p['query']) ? '?' . $p['query'] : '') + . (isset($p['fragment']) ? '#' . $p['fragment'] : ''); + return md5($norm); } /** 名称首字配色(无图标时的回退底色)—— 照搬 SimpleNav 的色板 */ @@ -587,7 +625,72 @@ final class Nav if (!is_file($abs)) { return ''; } - return url('/' . self::ICON_URL . '/' . rawurlencode($file)) . '?v=' . filemtime($abs); + // 🔴 走 **nav_icon.php** 这个免登录公开出口,**不能**直接给 `uploads/…` 的地址: + // uploads/ 整个目录是 `Require all denied`(线上实测直接取会 403), + // 而展示页是免登录的、拿不到 media.php 需要的登录态。 + // 详见 nav_icon.php 的文件头(两个约束为什么会撞在一起)。 + // ⚠️ 管理页(登录态)也用同一条:一个图标地址两套出口, + // "管理页看得见、展示页看不见"这种半生效是最难查的。 + return self::abs('/nav_icon.php') . '?f=' . rawurlencode($file) . '&v=' . filemtime($abs); + } + + /** + * 把站内相对地址变成**绝对地址**(v24 第 25 批 用户第 9 条修)。 + * + * 🔴🔴 为什么必须有这个函数(这是本批最要紧的一个根因): + * + * 家书的 `url()` 产出的是 `index.php?r=xxx`、`avatar_url()` 产出 `media.php?k=…` + * —— 全是**相对**地址(只有 `asset()` 是 `/assets/…` 绝对路径)。 + * 它们在站点根(`https://host/index.php?...`)上没问题, + * 但**导航展示页在 `/nav/` 这个子目录下**:浏览器会把相对地址拼在**当前目录**上, + * 于是变成 `/nav/index.php?r=…`、`/nav/media.php?k=…` —— **线上实测全 404**。 + * + * 症状正是用户报的那三条一起出现(同一个根因): + * · 展示页头像不显示(`/nav/media.php?k=avatar.jpg` → 404) + * · 点「登录」404(`/nav/index.php?r=auth/login` → 404) + * · 搜索联想请求打到 `/nav/…`(同样 404,下拉永远空) + * + * 修法:**凡是展示页要输出的自引用地址,一律经这里拼上 base_url**。 + * ⚠️ 只在**免登录展示页**用得到(它是唯一不在站点根下的页面)—— + * 不要拿它去替换全站的 `url()`:那会把所有站内链接都变成绝对地址, + * `?r=` 那套路由本来就不需要域名,改了反而多一处配置依赖。 + * + * @param string $path 以 `/` 开头的站内路径,或 `index.php?r=…` 这种相对串 + */ + public static function abs(string $path): string + { + $path = '/' . ltrim($path, '/'); + + /* 🔴 优先用**当前请求的 Host**,不要写死配置里的 base_url。 + * + * 为什么(2026-10-07 第 25 批实测):本地真机探针起的是**随机端口**的 + * `php -S 127.0.0.1:`,而 `config.php` 的 `base_url` 写的是 + * `http://127.0.0.1:8791`。展示页把 CSS 拼成 `…:8791/assets/nav-show.css` + * → 与探针那个端口对不上 → **样式整份取不到** → + * 症状是"登录按钮落到左边、头像判据也跟着偏",看起来像**布局坏了**, + * 其实是"资源地址指到了另一个端口"(最难查的一类:页面 200、HTML 全对)。 + * ⇒ 绝对化要以"用户实际访问的那个 host"为准 —— 这也是**唯一正确**的口径: + * 线上有多个入口(域名 / IP / 宝塔测试域名)时,写死 base_url 会把 + * 另外那些入口的静态资源全指到 base_url 那个域上去。 + * + * ⚠️ 但 Host 头是**客户端可控**的(Host 注入)—— 所以只接受**形状合法**的 + * host:字母数字点横线、可带端口。不合法就退回 base_url。 + */ + $host = (string)($_SERVER['HTTP_HOST'] ?? ''); + if ($host !== '' && preg_match('/^[A-Za-z0-9.\-]+(:\d+)?$/', $host)) { + $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || (string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https' + || (int)($_SERVER['SERVER_PORT'] ?? 0) === 443; + return ($https ? 'https://' : 'http://') . $host . $path; + } + + $base = rtrim((string)fb_config('base_url', ''), '/'); + if ($base === '') { + // 没配 base_url(极少数自建环境)→ 退化成"从站点根算"的绝对路径。 + // 仍然比相对地址安全:相对地址在 /nav/ 下必错,而 /index.php 至少指对了。 + return $path; + } + return $base . $path; } public static function deleteIconFile(string $file): void diff --git a/app/PersonRoster.php b/app/PersonRoster.php index 5ab6123..9c6589b 100644 --- a/app/PersonRoster.php +++ b/app/PersonRoster.php @@ -325,12 +325,40 @@ final class PersonRoster * 老库里的 group_id / sort_order 也一并搬——分类语义从"人的属性"变成"我和他的关系", * 但**落在每个人名册里的值不变**,所以看起来完全一样。 * + * 🔴🔴 **自守卫:只在"还没迁移过"的库上跑**(v24 第 25 批 用户第 4 条修的真 bug)。 + * + * 用户原话:「名册中,有一些人,我从名册中移出去了,结果过一段时间就又回来了, + * 不知道什么原因,需要查一下。」 + * + * 根因就在这里:本方法原先是**无条件跑**的,而它在**每一次** `Schema::ensure()` 里 + * 都被调用一次 —— `ensure()` 的触发判据是「Schema.php 源码指纹变了」(见 + * Schema::fingerprint 的说明),也就是说**任何一次改 Schema 的部署之后, + * 所有已迁移完成的老库都会再被重灌一遍全家的成员**。 + * 于是用户「从我的名册移走」的那个人,在一段时间后(下一次部署 / 下一次改结构) + * **自己回来了** —— 而且因为 INSERT IGNORE 只看唯一键,回来的那条 source 还是 + * 'seeded',看不出是刚被重灌的。 + * + * ⚠️ 线上实测证据(2026-10-07):线上运行一次本方法的**前后对比**是 + * `total 1621 → 1622`(真的又插回来一条),而此前 members=811、 + * 名册总行数 1621 > 811×2 —— 差值正是历次重灌留下的。 + * + * 修法:**判据用"persons 上那两个旧列还在不在"** —— + * `group_id` / `sort_order` 是 **v5 迁移的输入**,迁移一跑完它们就被 + * `dropColumnIfExists` 删掉了(见 Schema::ensure 里紧跟着的两行)。 + * 所以「两列都不在」= 这个库早就迁移过了 = 本方法无事可做。 + * 这样它天然只在**真正需要它的那一次**(老库首次升级)生效, + * 不需要任何哨兵键,也不会因为"下次改 Schema"再跑一遍。 + * * 幂等:靠 uk_roster_owner_person 唯一键 + INSERT IGNORE,重复跑不会插重。 */ public static function seedFromPersons(\PDO $db): void { $hasGroup = self::hasColumn($db, 'persons', 'group_id'); $hasSort = self::hasColumn($db, 'persons', 'sort_order'); + // 🔴 自守卫:旧列都没了 = 已经迁移过 = 绝不能重灌(用户移走的人会回来) + if (!$hasGroup && !$hasSort) { + return; + } $oldGroup = $hasGroup ? 'p.group_id' : 'NULL'; $oldSort = $hasSort ? 'p.sort_order' : 'p.id'; diff --git a/app/Region.php b/app/Region.php index a9e1b90..6f1d86f 100644 --- a/app/Region.php +++ b/app/Region.php @@ -186,7 +186,12 @@ final class Region foreach (['中华人民共和国', '中国'] as $c) { if (mb_strpos($text, $c) === 0) { $out['country'] = '中国'; - $text = ltrim(mb_substr($text, mb_strlen($c)), ' ,,'); + // ⚠️ 同下面 `parse()` 末尾那条注释:`ltrim(…, ' ,,')` 的 charlist + // **按字节**匹配,「,」= EF BC 8C → 字节 EF/BC/8C 也进了可剥集合, + // 会把以这些字节开头的汉字**剥掉首字节** → 非法 UTF-8。 + // 改成字符级(`/u`)去空白与中文逗号。 + $text = (string)preg_replace( + '/^[\s,,]+/u', '', mb_substr($text, mb_strlen($c))); break; } } @@ -228,7 +233,27 @@ final class Region } } - $rest = trim($rest, " ,,。;;\t\n\r\0\x0B"); + /* 🔴🔴 这里**绝不能**写成 `trim($rest, " ,,。;;\t\n\r\0\x0B")` + * (v24 第 25 批 2026-10-07 在线上实测抓到的真 bug,且是**数据损坏级**的)。 + * + * 根因:PHP 的 `trim()` 第二参数(charlist)是**按字节**匹配的,不是按字符。 + * 上面那串里带三个**多字节**中文标点: + * 「,」= E3 80 8C、「。」= E3 80 82、「;」= EF BC 9B + * 于是字节集合里混进了 **80 / 82 / 8C / 9B / BC …** —— + * 任何一个**尾字节**恰好等于这些值的汉字,都会被\"剥掉最后一字节\": + * 「层」= E5 B1 82 → 被剥成 E5 B1(**非法 UTF-8**) + * 「,」类标点本身也会被剥掉前两字节,只剩 E3。 + * 症状链条(线上实测):`backfillAddress` 拿这个坏 detail 去 UPDATE + * → `SQLSTATE[HY000] 1366 Incorrect string value: '\xE5\xB1' for column 'address'` + * → 升级步骤被 tryStep 捕获跳过 → **这条升级从上线起就没成功过**, + * 而且 request 每来一次就报一次错(日志一直刷,没人注意到)。 + * ⚠️ 更阴的地方:它**只在特定数据上发作**(地址里带「层」「,」(半角在 + * 中间不影响)等尾字节命中集合的字),本地夹具没有这种地址 → 本地全绿。 + * + * 正确做法:先把中文标点(多字节)**按字符**去掉,剩下的 ASCII 空白再交给 trim。 + * 顺序不能反 —— 先 trim 会把中文标点剥成半个字节,再去字符级就没救了。 + */ + $rest = (string)preg_replace('/^[\s,,。;;、]+|[\s,,。;;、]+$/u', '', $rest); if ($rest !== '') { $out['detail'] = $rest; } @@ -286,7 +311,8 @@ final class Region continue; // 太短会误吃(如「东区」) } if (mb_strpos($text, $cand) === 0) { - return ltrim(mb_substr($text, mb_strlen($cand)), ' ,,'); + return (string)preg_replace( + '/^[\s,,]+/u', '', mb_substr($text, mb_strlen($cand))); } } return null; diff --git a/app/Schema.php b/app/Schema.php index 8cf7748..963a9a4 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -104,7 +104,7 @@ final class Schema * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 */ - public const VERSION = '24'; + public const VERSION = '25'; /** * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 @@ -1699,6 +1699,49 @@ SQL); } }); + // ---- 纪念日关联**多个人**(版本 25,用户 2026-10-07 第 2 条)---- + // + // 用户原话:「纪念日模块,每个纪念日现在只能关联一个人,应该可以关联多个人。 + // 和选择观影人一样,多选模式。」 + // + // ⚠️ 建表放在 ensure() 里(而不是 statements() 的 return 数组): + // 它有两个外键指向 `anniversaries` 与 `persons` —— 那两张表由 + // statements() 先建出来,这里紧随其后才安全;而且这一段本来就在 + // "改结构"的位置上(statements() 只管最终形态的建表)。 + // ⚠️ **不改 anniversaries.person_id 那一列**(保留它、继续写第一个关联人): + // 它是老库/老代码的读点,删掉会让"没跟上升级"的地方静默少一个人。 + // 改成"以新表为准、老列只做兜底"—— 见 Anniversary::peopleOf()。 + $db->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `anniversary_people` ( + `anniversary_id` int(10) unsigned NOT NULL, + `person_id` int(10) unsigned NOT NULL, + `sort_order` INT NOT NULL DEFAULT 10 COMMENT '列表里的先后(关联多个人时按它排)', + PRIMARY KEY (`anniversary_id`, `person_id`), + KEY `idx_annp_person` (`person_id`), + CONSTRAINT `fk_annp_ann` FOREIGN KEY (`anniversary_id`) REFERENCES `anniversaries` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_annp_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='纪念日关联的人(v24 第 25 批 用户第 2 条:一个纪念日可以关联多个人)' +SQL); + + // 回填:把老库里 `person_id` 非空的那一条搬进新表。 + // ⚠️ 必须**只在还没有任何新表行时**搬(用 NOT EXISTS 判),否则 + // 用户在新界面上改过关联的人之后,下一次请求又会被老列覆盖回去 + // (与第 4 条"移出的人又回来"同一个类别的坑:无条件回填 = 删除被撤销)。 + self::tryStep('纪念日关联人搬到新表(一人 → 多人)', static function () use ($db) { + if (!self::hasTable($db, 'anniversary_people')) { + return; + } + $db->exec( + "INSERT IGNORE INTO `anniversary_people` (anniversary_id, person_id, sort_order) + SELECT a.id, a.person_id, 10 + FROM `anniversaries` a + WHERE a.person_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `anniversary_people` p + WHERE p.anniversary_id = a.id)" + ); + }); + // 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 —— // 不然「升级没跑完」这件事只活在日志里,跟没发生一样。 if (self::$stepErrors) { diff --git a/app/Task.php b/app/Task.php index 5f26bb6..02bc8ef 100644 --- a/app/Task.php +++ b/app/Task.php @@ -5,6 +5,35 @@ * 看板三列直接落在 `status` 上(todo / doing / done),不另存"列"字段 —— * "列"和"状态"本来就是一件事,存两份必然对不上(拖到 done 了状态还是 doing 之类)。 * + * ──────────────────────────────────────────────────────────────── + * 「待办」与「进行中」到底差什么(v24 第 25 批 用户第 6 条要求说清楚) + * + * 用户原话:「任务的状态也不太对,待办和进行中,有什么区别?去网上查查, + * 别人都是怎么设置状态的。」 + * + * 查证结果(这个答得干脆,业界早就收敛了): + * 主流做法就是 Kanban 的**三态**,而且**列名与状态名是同一件事**: + * · **To Do / 待办** —— 已记下来、**还没动手**。它是一份"排队"。 + * · **In Progress / 进行中** —— **已经动手了**(哪怕只做了两分钟)。 + * · **Done / 已完成** —— 做完并验收。 + * 出处:Todoist 的 Kanban 指南、Wrike 的 Kanban 词条("What Are The Kanban + * To Do, Doing, Done Lists?")都把这三列定义为**工作流阶段**; + * ONES 的中文词条更直白:「待处理=已创建但尚未开始」「进行中=正在执行过程中, + * 让其他人知道**已有人在处理**,避免重复工作」。 + * + * ⇒ 所以分界线是**"有没有开始"**,不是"重不重要"、也不是"谁在管": + * 待办 = 队列里排着(我还没碰它) + * 进行中 = 手上有活(**别人不该再认领**) + * 两条纪律(Kanban 的 WIP 限制): + * ① 「进行中」**不要堆太多** —— 那是"同时开了 N 摊",做不完; + * ② 首页只显示「进行中」(+ 已逾期的待办),因为"我手头在做什么"才是要看的。 + * 这正是 v24 第 14 批 用户第 2 条那句「按状态取,不看板取」的来由。 + * + * ⚠️ **不做**「待确认 / 待审核 / 已暂停」这类扩展状态: + * 那是团队协作的产物(要有人审、有人批)。家庭场景三个人做家务, + * 多一个状态就多一次"该选哪个"的犹豫。三态够用,且与业界口径一致。 + * 真需要更细,用**看板**分("属于哪摊事")而不是加压状态 —— 两个维度本来就分开。 + * * 两级:只做父任务 / 子任务两层(parent_id)。**不做无限层级** —— * 家庭任务("周末大扫除"下面挂"擦玻璃""洗车")两层足够, * 无限层级要递归查询 + 缩进渲染,代价远大于收益。 @@ -24,6 +53,39 @@ final class Task { public const STATUS = ['todo' => '待办', 'doing' => '进行中', 'done' => '已完成']; + /** + * 每个状态「到底是什么意思」——**给界面上的人看**(v24 第 25 批 用户第 6 条)。 + * + * 用户原话:「待办和进行中,有什么区别?」——他不知道区别,就是这一处没写清楚。 + * 所以这句话要出现在**他看到状态的地方**:卡片上的 title、状态说明、 + * 以及任务页头那句总说明(见 templates/tasks/index.php)。 + * + * ⚠️ 文案刻意用"我/手头"这种第一人称口语:这是家庭场景, + * 写成"尚未开始执行"那种公文腔他自己也不会去读。 + */ + public const STATUS_HINT = [ + 'todo' => '记下来了,还没动手 —— 排队等着', + 'doing' => '已经动手了(哪怕刚开始)—— 别人就别再认领了', + 'done' => '做完了', + ]; + + /** 状态的一句话解释(界面上用它做 title / 说明) */ + public static function statusHint(string $k): string + { + return self::STATUS_HINT[$k] ?? ''; + } + + /** + * 界面上「点一下就能完成」的那颗复选框要提交的目标状态。 + * + * 用户原话:「任务相关的模块,所有的任务,前面都带一个复选框,在任何地方, + * 点这个复选框,这个任务就完成了。」 + * ⇒ 所以勾上 = `done`(**不是**"推进到下一个状态")。 + * ⚠️ 与卡片上那颗状态徽标(点一下轮转 todo→doing→done)刻意不同: + * 复选框只有一个意思「完成」,徽标是"改状态"。两个控件、两种语义。 + */ + public const DONE_STATUS = 'done'; + public const REPEAT = [ '' => '不循环', 'day' => '每天', diff --git a/app/WeRead.php b/app/WeRead.php index c5a6119..bc15147 100644 --- a/app/WeRead.php +++ b/app/WeRead.php @@ -50,6 +50,21 @@ final class WeRead /** 用户设置里存加密 Key 的键名 */ public const SETTING_KEY = 'weread_api_key'; + /** 上次自动同步的时间戳(存秒;不是字符串,别再解析一次) */ + public const SYNC_AT_KEY = 'weread_sync_at'; + + /** 自动同步总开关('1' 开 / '0' 关) */ + public const AUTO_KEY = 'weread_auto_sync'; + + /** + * 自动同步的最小间隔(秒)——**默认 6 小时**。 + * + * ⚠️ 为什么是 6 小时而不是"每天一次": + * 读书是随时发生的。用户下午读完一本,晚上打开家书希望"它已经是读完的"。 + * 而 6 小时也足够克制 —— 一天最多 4 次 API 调用,不会触发频率限制。 + */ + public const SYNC_TTL = 21600; + // ================================================================ // Key 的存取(加密) // ================================================================ @@ -325,6 +340,239 @@ final class WeRead return ['ok' => true, 'items' => $items, 'error' => '']; } + /** + * 取一本书的**基本信息**(v24 第 25 批 用户第 8 条:封面看不到)。 + * + * 用户原话:「微信读书导入,需要能够做到定期完成,自动同步。现在可以导入了, + * 不过**封面图看不到**,另外也缺少一些对微信读书的统计功能,需要加强。」 + * + * 根因:导入时只建了 title + author + status,**从来没写过 cover_key** —— + * 而官方 ` /book/info ` 明明给了 `cover`(封面 URL)、`isbn`、`publisher`、 + * `category`、`intro`、`newRating`、`wordCount`。 + * 所以书架里那一排全是「📖 空封面」。这里把该拿的一次拿齐。 + * + * @return array{ok:bool,book:array,error:string} + */ + public static function bookInfo(int $personId, string $bookId): array + { + $r = self::call($personId, '/book/info', ['bookId' => $bookId]); + if (!$r['ok']) { + return ['ok' => false, 'book' => [], 'error' => $r['error']]; + } + $d = (array)$r['data']; + return ['ok' => true, 'error' => '', 'book' => [ + 'title' => (string)($d['title'] ?? ''), + 'author' => (string)($d['author'] ?? ''), + 'translator' => (string)($d['translator'] ?? ''), + 'cover' => (string)($d['cover'] ?? ''), + 'intro' => (string)($d['intro'] ?? ''), + 'category' => (string)($d['category'] ?? ''), + 'publisher' => (string)($d['publisher'] ?? ''), + 'published' => (string)($d['publishTime'] ?? ''), + 'isbn' => (string)($d['isbn'] ?? ''), + 'wordCount' => (int)($d['wordCount'] ?? 0), + 'rating' => (int)round((int)($d['newRating'] ?? 0) / 10), // 官方是百分制 + ]]; + } + + /** + * 阅读进度(`/book/getprogress`)—— 用来判断"这本书读完了没"。 + * + * ⚠️ 官方文档专门强调:`progress` 是 **0~100 的整数**,而且 + * **只有 100 才代表读完**(1 表示 1%,不是 100%)。 + * 这一条极易写错(把 1 当"已读"),所以在这里就把判据封装好。 + * + * @return array{ok:bool,progress:int,finished:bool,done_at:string,seconds:int,error:string} + */ + public static function progress(int $personId, string $bookId): array + { + $r = self::call($personId, '/book/getprogress', ['bookId' => $bookId]); + if (!$r['ok']) { + return ['ok' => false, 'progress' => 0, 'finished' => false, + 'done_at' => '', 'seconds' => 0, 'error' => $r['error']]; + } + $b = (array)($r['data']['book'] ?? []); + $p = max(0, min(100, (int)($b['progress'] ?? 0))); + $fin = $p >= 100; + return [ + 'ok' => true, + 'error' => '', + 'progress' => $p, + 'finished' => $fin, + 'done_at' => $fin ? self::ts((int)($b['finishTime'] ?? 0)) : '', + 'seconds' => (int)($b['recordReadingTime'] ?? 0), + ]; + } + + /** + * 阅读统计(`/readdata/detail`)—— 用户第 8 条要的"统计功能加强"。 + * + * 官方给的东西比本项目原来的统计丰富得多,这里只挑**能直接用**的几样: + * · `totalReadTime` —— 该周期总时长(**秒**,官方文档反复强调别当分钟) + * · `readDays` —— 有效阅读天数(官方口径:单日满 1 分钟) + * · `readStat` —— 「读过 N 本 / 读完 N 本 / 阅读 N 天 / 笔记 N 条」 + * · `readLongest` —— 读得最多的书(含封面) + * · `preferCategory` —— 偏好分类(带时长) + * · `preferAuthor` —— 偏好作者 + * + * ⚠️ 官方只支持**固定自然周期**(weekly/monthly/annually/overall), + * 不支持任意起止日期。默认给 `overall`("一共读了多久"最常被问)。 + * + * @param string $mode weekly / monthly / annually / overall + * @return array{ok:bool,data:array,error:string} + */ + public static function readStats(int $personId, string $mode = 'overall', int $baseTime = 0): array + { + if (!in_array($mode, ['weekly', 'monthly', 'annually', 'overall'], true)) { + $mode = 'overall'; + } + $params = ['mode' => $mode]; + if ($baseTime > 0) { + $params['baseTime'] = $baseTime; + } + $r = self::call($personId, '/readdata/detail', $params); + if (!$r['ok']) { + return ['ok' => false, 'data' => [], 'error' => $r['error']]; + } + $d = (array)$r['data']; + + // 读得最多的书(最多 10 条,官方已按 readTime 降序、且过滤掉不足 5 分钟的) + $top = []; + foreach ((array)($d['readLongest'] ?? []) as $it) { + $bk = (array)($it['book'] ?? []); + $top[] = [ + 'title' => (string)($bk['title'] ?? ''), + 'author' => (string)($bk['author'] ?? ''), + 'cover' => (string)($bk['cover'] ?? ''), + 'bookId' => (string)($bk['bookId'] ?? ''), + 'seconds' => (int)($it['readTime'] ?? 0), + 'tags' => array_map('strval', (array)($it['tags'] ?? [])), + ]; + } + // 偏好分类(带时长与本数) + $cats = []; + foreach ((array)($d['preferCategory'] ?? []) as $c) { + $cats[] = [ + 'name' => (string)($c['categoryTitle'] ?? ''), + 'seconds' => (int)($c['readingTime'] ?? 0), + 'books' => (int)($c['readingCount'] ?? 0), + 'val' => (int)($c['val'] ?? 0), + ]; + } + // 偏好作者(注意:官方的 readTime 这里是**格式化字符串**,不是秒) + $authors = []; + foreach ((array)($d['preferAuthor'] ?? []) as $a) { + $authors[] = [ + 'name' => (string)($a['name'] ?? ''), + 'books' => (int)($a['count'] ?? 0), + 'timeWord' => (string)($a['readTime'] ?? ''), + ]; + } + // 摘要那几行(读过/读完/阅读/笔记) + $stat = []; + foreach ((array)($d['readStat'] ?? []) as $s) { + $stat[] = ['name' => (string)($s['stat'] ?? ''), 'count' => (string)($s['counts'] ?? '')]; + } + return ['ok' => true, 'error' => '', 'data' => [ + 'mode' => $mode, + 'seconds' => (int)($d['totalReadTime'] ?? 0), + 'days' => (int)($d['readDays'] ?? 0), + 'avgSec' => (int)($d['dayAverageReadTime'] ?? 0), + 'compare' => $d['compare'] ?? null, + 'stat' => $stat, + 'topBooks' => $top, + 'categories'=> $cats, + 'authors' => $authors, + 'preferWord'=> (string)($d['preferCategoryWord'] ?? ''), + 'preferTime'=> (string)($d['preferTimeWord'] ?? ''), + 'yearReport'=> (array)($d['yearReport'] ?? []), + ]]; + } + + /** + * 把一张封面图**拉下来存进本站存储**(用户第 8 条:封面看不到)。 + * + * ⚠️ 为什么不直接把官方的 `cover` URL 存进 `books.cover_key`: + * ① 那个 URL 是**第三方 CDN**(微信读书的),可能带防盗链/时效; + * 直接把外链塞进 cover_key,展示时要么不显示、要么哪天忽然全变破图; + * ② 本站所有附件都走 `media.php` 代理(登录态 + 存储驱动), + * 混一个外链进去就破了"所有媒体一个出口"这条规矩; + * ③ 用户可能清理缓存/换账号,外链失效后书架就一排空封面。 + * ⇒ 拉下来走**与用户手传封面完全同一条路**(`Upload::image`, + * slot = `bookcover`),这样它不仅存好了,还顺带做了 2:3 居中裁剪 —— + * 与用户自己传的封面在页面上长得**一模一样**(否则两种来源两种比例, + * 书架格子会被撑得参差不齐)。 + * + * @return string 存好的 cover_key(失败返回 '') + */ + public static function fetchCover(int $familyId, string $url, string $title): string + { + $url = trim($url); + if ($url === '' || !preg_match('#^https?://#i', $url)) { + return ''; + } + $binary = self::httpBinary($url, 4 * 1024 * 1024, 12); + if ($binary === null || strlen($binary) < 64) { + return ''; + } + $tmp = tempnam(sys_get_temp_dir(), 'fbcv'); + if ($tmp === false) { + return ''; + } + if (@file_put_contents($tmp, $binary) === false) { + @unlink($tmp); + return ''; + } + // 伪装成 $_FILES 的形态交给 Upload::image(它只认 tmp_name/error/name/type) + $fake = [ + 'name' => self::safeBase($title) . '.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => $tmp, + 'error' => UPLOAD_ERR_OK, + 'size' => strlen($binary), + ]; + $up = \App\Upload::image($familyId, $fake, 'bookcover', ''); + @unlink($tmp); + return !empty($up['ok']) ? (string)$up['file'] : ''; + } + + /** 标题 → 可做文件名的安全基名(去掉路径符号,限长) */ + private static function safeBase(string $s): string + { + $s = preg_replace('#[\\\\/:*?"<>|\x00-\x1f]#u', '', $s) ?? ''; + $s = trim(preg_replace('/\s+/u', '_', $s) ?? ''); + return $s === '' ? 'cover' : mb_substr($s, 0, 60, 'UTF-8'); + } + + /** 抓一段二进制(3 秒超时不够,封面有时几 MB;失败返回 null,绝不抛) */ + private static function httpBinary(string $url, int $maxBytes = 3145728, int $timeout = 10): ?string + { + if (!function_exists('curl_init')) { + $ctx = stream_context_create(['http' => ['timeout' => $timeout, 'ignore_errors' => true]]); + $r = @file_get_contents($url, false, $ctx, 0, $maxBytes); + return $r === false ? null : (string)$r; + } + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_USERAGENT => 'FamilyBook/1.0', + // ⚠️ 微信读书的 CDN 可能挑 Referer —— 给一个自己站点的 Referer + CURLOPT_HTTPHEADER => ['Referer: https://weread.qq.com/'], + ]); + $r = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + if ($r === false || $code < 200 || $code >= 300) { + return null; + } + return strlen((string)$r) > $maxBytes ? substr((string)$r, 0, $maxBytes) : (string)$r; + } + /** Unix 时间戳 → 'Y-m-d H:i:s'(0 或非法时给当前时间,别让库里出现 1970) */ private static function ts(int $t): string { @@ -409,4 +657,234 @@ final class WeRead $st->execute([$familyId, $t]); return (int)($st->fetchColumn() ?: 0); } + + // ================================================================ + // 定期自动同步(v24 第 25 批 用户第 8 条) + // ================================================================ + + /** + * 用户原话:「微信读书导入,需要能够做到定期完成,自动同步。」 + * + * ──────────────────────────────────────────────────────────────── + * **为什么不用系统 cron**(这是一个刻意的取舍,写清楚免得下一个人来加 crontab) + * + * 本项目其它"定期"的事都是**访问时顺手做**(见 StagedUpload::sweep 的注释: + * "在 business 路由里顺手调一次,不必挂定时任务")。理由: + * ① 这是**家用**系统,可能几周没人访问。挂个 cron 每天凌晨去调微信读书 API, + * 在没人用的时候白烧请求、还可能触发官方的频率限制; + * ② cron 要配服务器(宝塔面板 + 路径 + PHP 版本),用户自己维护不了, + * 而"配错了"是静默的(任务不跑,谁也不知道); + * ③ 访问时同步的**体验完全等价**:用户打开读书页 → 数据是新的。 + * 他不在的时候,同步不同步没有任何人能察觉。 + * + * ⚠️ 所以判据是「**距上次同步过了多久**」而不是"今天跑过没": + * 间隔到期才真的去打接口(默认 6 小时,见 SYNC_TTL), + * 否则只读一个时间戳。这样"每天开十次书页"也只会同步两次。 + * + * ⚠️ 失败**不重试、不阻塞**:网络不通/Key 失效时静默记一下时间(下次再试), + * 绝不让"同步失败"把读书页也打不开 —— 外部依赖不能拖死主流程。 + * + * @param bool $force true = 忽略间隔,立刻同步(用户点「立即同步」时用) + * @return array{ok:bool,ran:bool,error:string,books:int,notes:int,covers:int,at:string} + */ + public static function maybeSync(int $familyId, int $personId, bool $force = false): array + { + $empty = ['ok' => true, 'ran' => false, 'error' => '', 'books' => 0, + 'notes' => 0, 'covers' => 0, 'at' => self::lastSync($personId)]; + if (!self::hasKey($personId) || $familyId <= 0 || $personId <= 0) { + return $empty; + } + $last = (int)UserSetting::get($personId, self::SYNC_AT_KEY, '0'); + if (!$force && $last > 0 && (time() - $last) < self::SYNC_TTL) { + return $empty; + } + // ⚠️ 先记时间再干活:万一下面 20 秒里超时/抛错,也不会在同一个页面 + // 反复重试("每次打开读书页都卡 20 秒"是最糟的症状)。 + UserSetting::set($personId, self::SYNC_AT_KEY, (string)time()); + + try { + $res = self::syncNow($familyId, $personId); + } catch (\Throwable $e) { + return ['ok' => false, 'ran' => true, 'error' => $e->getMessage(), + 'books' => 0, 'notes' => 0, 'covers' => 0, 'at' => date('Y-m-d H:i:s')]; + } + return [ + 'ok' => true, 'ran' => true, 'error' => (string)($res['error'] ?? ''), + 'books' => (int)($res['books'] ?? 0), 'notes' => (int)($res['notes'] ?? 0), + 'covers' => (int)($res['covers'] ?? 0), 'at' => date('Y-m-d H:i:s'), + ]; + } + + /** + * 真的同步一遍:把有笔记的书补进书架、补封面、补进度、补笔记。 + * + * 「补」而不是"重建":已经在书架里的书**不覆盖用户改过的东西** + * (用户可能改了分类、打了分、改了状态)—— 只补**空着的**那几个字段。 + * ⚠️ 这是"自动同步"能不能长期开着的关键:只要有一次把用户手动填的 + * 评分/分类冲掉,他就再也不敢让它自动跑了。 + * + * @return array{ok:bool,error:string,books:int,notes:int,covers:int,scanned:int} + */ + public static function syncNow(int $familyId, int $personId, int $maxBooks = 60): array + { + $nb = self::notebooks($personId); + if (!$nb['ok']) { + return ['ok' => false, 'error' => (string)$nb['error'], 'books' => 0, + 'notes' => 0, 'covers' => 0, 'scanned' => 0]; + } + $newBooks = $newNotes = $newCovers = 0; + $scanned = 0; + foreach ($nb['books'] as $b) { + if ($scanned >= $maxBooks) { + break; + } + $title = trim((string)$b['title']); + if ($title === '') { + continue; + } + $scanned++; + $bid = self::matchLocalBook($familyId, $title); + + // ---- ① 书不在书架 → 建一本(顺便把该拿的元数据一次拿齐)---- + if ($bid <= 0) { + $info = self::bookInfo($personId, (string)$b['bookId']); + $meta = $info['ok'] ? $info['book'] : []; + $coverKey = ''; + if (!empty($meta['cover'])) { + $coverKey = self::fetchCover($familyId, (string)$meta["cover"], $title); + if ($coverKey !== '') { $newCovers++; } + } + $bid = Book::create($familyId, $personId, [ + 'title' => $title, + 'author' => (string)($meta['author'] ?? $b['author']), + 'translator' => (string)($meta['translator'] ?? ''), + 'publisher' => (string)($meta['publisher'] ?? ''), + 'published' => (string)($meta['published'] ?? ''), + 'isbn' => (string)($meta['isbn'] ?? ''), + 'category' => (string)($meta['category'] ?? ''), + 'summary' => (string)($meta['intro'] ?? ''), + 'words' => (int)($meta['wordCount'] ?? 0), + 'rating' => (int)($meta['rating'] ?? 0) ?: null, + 'cover_key' => $coverKey, + 'status' => 'reading', + ]); + if ($bid > 0) { + $newBooks++; + } + } else { + // ---- ② 已在书架 → **只补空的**(绝不覆盖用户填过的)---- + $row = Book::find($familyId, $bid); + if ($row) { + $patch = []; + // 封面空着就补(用户第 8 条:封面看不到) + if (trim((string)($row['cover_key'] ?? '')) === '') { + $info = self::bookInfo($personId, (string)$b['bookId']); + if ($info['ok'] && !empty($info['book']['cover'])) { + $ck = self::fetchCover($familyId, + (string)$info['book']['cover'], $title); + if ($ck !== '') { $patch['cover_key'] = $ck; $newCovers++; } + } + } + // 简介/ISBN/出版社/字数空着就补 + $needMore = trim((string)($row['summary'] ?? '')) === '' + || trim((string)($row['publisher'] ?? '')) === '' + || empty($row['words']); + if ($needMore) { + $info = self::bookInfo($personId, (string)$b['bookId']); + if ($info['ok']) { + $m = $info['book']; + if (trim((string)($row['summary'] ?? '')) === '' && $m['intro'] !== '') { + $patch['summary'] = (string)$m['intro']; + } + if (trim((string)($row['publisher'] ?? '')) === '' && $m['publisher'] !== '') { + $patch['publisher'] = (string)$m['publisher']; + } + if (trim((string)($row['isbn'] ?? '')) === '' && $m['isbn'] !== '') { + $patch['isbn'] = (string)$m['isbn']; + } + if (empty($row['words']) && (int)$m['wordCount'] > 0) { + $patch['words'] = (int)$m['wordCount']; + } + } + } + if ($patch) { + self::patchBook($familyId, $bid, $patch); + } + } + } + if ($bid <= 0) { + continue; + } + + // ---- ③ 进度:读完了就把本地状态也标成读完(用户第 8 条"统计"要它)---- + $pg = self::progress($personId, (string)$b['bookId']); + if ($pg['ok'] && $pg['finished']) { + $cur = Book::find($familyId, $bid); + if ($cur && (string)$cur['status'] !== 'done') { + Book::setStatus($familyId, $bid, 'done'); + } + } + + // ---- ④ 笔记(已有的会自动跳过,见 store 的去重判据)---- + $nt = self::notes($personId, (string)$b['bookId']); + if ($nt['ok']) { + $r = self::store($bid, $personId, (array)$nt['items']); + $newNotes += (int)$r['added']; + } + } + return ['ok' => true, 'error' => '', 'books' => $newBooks, 'notes' => $newNotes, + 'covers' => $newCovers, 'scanned' => $scanned]; + } + + /** 只更新**指定几列**(自动同步补空专用;不走 Book::update —— 那会把其它列一起写) */ + public static function patchBook(int $familyId, int $bookId, array $fields): void + { + $allow = ['cover_key', 'summary', 'publisher', 'isbn', 'words', 'translator', + 'published', 'category', 'rating', 'author']; + $sets = []; + $args = []; + foreach ($fields as $k => $v) { + if (!in_array($k, $allow, true)) { + continue; + } + $sets[] = "`{$k}` = ?"; + $args[] = $v === '' ? null : $v; + } + if (!$sets) { + return; + } + $args[] = $bookId; + $args[] = $familyId; + Database::get()->prepare( + 'UPDATE books SET ' . implode(', ', $sets) . ' WHERE id = ? AND family_id = ?' + )->execute($args); + } + + /** 上次自动同步的时间('Y-m-d H:i:s';从没同步过返回空串) */ + public static function lastSync(int $personId): string + { + $t = (int)UserSetting::get($personId, self::SYNC_AT_KEY, '0'); + return $t > 0 ? date('Y-m-d H:i:s', $t) : ''; + } + + /** 下次该自动同步的时间('Y-m-d H:i:s';没配 Key 返回空串) */ + public static function nextSync(int $personId): string + { + if (!self::hasKey($personId)) { + return ''; + } + $t = (int)UserSetting::get($personId, self::SYNC_AT_KEY, '0'); + return date('Y-m-d H:i:s', ($t > 0 ? $t : time()) + self::SYNC_TTL); + } + + /** 是否打开了自动同步(默认开;用户可在导入页关掉) */ + public static function autoSyncOn(int $personId): bool + { + return (string)UserSetting::get($personId, self::AUTO_KEY, '1') === '1'; + } + + public static function setAutoSync(int $personId, bool $on): void + { + UserSetting::set($personId, self::AUTO_KEY, $on ? '1' : '0'); + } } \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css index 73b05a0..20c07c8 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -225,6 +225,21 @@ a:hover { text-decoration: underline; } /* ⚠️ 上面**只写 .card + .card**:.section 自己有 margin-top:28px, 若在这里再写 `.card + .section { margin-top:18px }`,特异性 0,2,0 会压过 `.section` 的 0,1,0,反而把原本 28px 的间距改小 —— 修 A 不能顺手弄坏 B。 */ +/* 🔴🔴 v24 第 25 批 用户第 3 条:**并排的两块不该再吃这条"相邻间距"**。 + 用户原话:「日程和任务看板,两个模块,现在有点错位,一个高,一个低, + 应该完全一样才对。」 + + 根因(实测,不是猜):`.home-duo` 里左右两块**都是 `.card`**、且在 DOM 里互为 + 紧邻兄弟 → `.card + .card` 命中**第二块** → 给它加了 `margin-top:18px`。 + grid 的 `align-items: stretch` 让两块**高度**相等(那个数是对的), + 但第二块被整体推下去 18px —— 于是"一样高、却一高一低"。 + (我上一版只调了 `.home-duo` 的 align-items 与高度,没动这条, + 真机探针才把 18px 这个偏移量量出来 —— 光看代码是看不出来的。) + + ⇒ 并排容器里的 `.card` 一律不吃相邻间距(间距由 `gap` 统一给)。 + 用 `:first-child` 之外的兄弟选择器把它们点掉,且**特异性要比 0,2,0 高**。 */ +.home-duo > .card { margin-top: 0; } +.home-duo > .card + .card { margin-top: 0; } /* 首页头图 */ .hero { @@ -1243,14 +1258,21 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, border-radius: 6px; font-size: 14px; color: var(--muted); background: rgba(255, 255, 255, .82); border: 1px solid var(--line-2); - /* ⚠️ 手柄现在是**能抓的**(不再 pointer-events:none): - 鼠标可以拖整行,但**触摸只能抓这个手柄** —— 否则手指在行上一划 - 就被浏览器当成滚页面,永远拖不起来。touch-action:none 就是这句话。 */ + /* 🔴 v24 第 25 批 用户第 5 条:手柄是**拖动名册的唯一入口**。 + 用户原话:「点击列表中的人,是直接查看这个人的信息,拖动的时候才是调整数序。」 + → 整行不再参与拖动,**只有按在这个 ⠿ 上才算**(鼠标与触摸同一判据)。 + 所以它必须一眼看得出来"这里是能抓的": + · `cursor: grab`(抓着时 JS 加 .is-dragged 的卡片会变 grabbing,见下) + · `touch-action: none` —— 不写它,手指在柄上一划会被浏览器当成滚页面,永远拖不起来 + · 悬停加一点底色,别让它看着像一个装饰性的字符 */ touch-action: none; cursor: grab; } +.drag-handle:hover { background: var(--brand-soft, rgba(168,74,40,.10)); color: var(--brand); } .drag-handle-inline { color: var(--muted); } /* 列表里每行要留出左侧拖柄的位置(否则⠿会压在头像上) */ .roster.is-droppable .person { padding-left: 18px; } +/* 正被拖的那张卡:手柄变抓握态(与 cursor:grab 配成一对) */ +.person.is-dragged .drag-handle { cursor: grabbing; } /* 空栏平时收起来(七个预置分组可能只用到三个,摊开会显得名册破破烂烂)—— 但**拖动时必须露出来**,否则"想拖到一个现在没人的分组里"就没地方放。 */ @@ -1904,6 +1926,24 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, } .ann-date { flex: 0 0 auto; color: var(--muted); font-variant-numeric: tabular-nums; } .ann-title { flex: 1 1 auto; color: var(--ink); min-width: 0; } +/* ---- 纪念日的「关联人」列(v24 第 25 批 用户第 2 条)---- + 用户原话:「再加一列,关联人。列表中,人的前面,都加头像。」 + + ⚠️ 列里画的是全站统一的 person_chip(`.viewer-tag`,自带头像 + 底色), + 所以这里只需要处理**排布**,不要另造一套人名样式(会与其它模块不一致)。 + ⚠️ 关联多个人时要能换行 —— `.viewer-tag` 是 `inline-flex`, + 不写 flex-wrap 的话 5 个人会把表格撑宽(手机上直接横向溢出)。 + ⚠️ 「改/删」两个按钮要排成一行:`.res-inline` 那个 form 是 inline 的, + 但两个按钮之间得靠 gap 分开,否则会贴在一起、看着像一个双色按钮。 */ +.ann-people { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; max-width: 260px; } +.ann-acts { display: flex; gap: 6px; align-items: center; } +.ann-acts .btn { flex: 0 0 auto; } +@media (max-width: 767px) { + /* 窄屏:关联人那一列不再限宽(表格本来就会横向滚动), + 但两个按钮要保持不换行 —— 换行后「删除」会掉到「修改」下面, + 在表格行里看着像两张卡片。 */ + .ann-people { max-width: none; } +} .chk-inline { display: flex; align-items: center; gap: 6px; font-size: 14px; } /* ---- 任务设置(v24 第 9、10 条):看板/分类的增删改列表 ---- */ .kb-boards { margin-bottom: 12px; } @@ -2609,6 +2649,13 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, } .kb-card.is-dragging { opacity: .45; } .kb-card.is-overdue { border-color: rgba(192, 83, 63, .4); } +/* 🔴 v24 第 25 批 用户第 6 条:卡片加了「完成」复选框之后,它必须与标题**同一行**。 + 作者样式是 `flex-direction: column` —— 不处理的话复选框会独占一行、 + 标题被挤到第二行,一张卡凭空高一截(用户要的是"任务前面带一个复选框", + 不是"任务上面带一个")。做法:把标题包成一行(.kb-top), + 复选框 + 标题一起横排,剩下的 meta/foot 仍然竖着叠。 */ +.kb-top { display: flex; align-items: flex-start; gap: 8px; } +.kb-top .kb-title { flex: 1 1 auto; min-width: 0; } .kb-title { font-size: 14px; font-weight: 500; color: var(--ink); } .kb-title:hover { color: var(--brand); } .kb-meta { display: flex; flex-wrap: wrap; gap: 4px 8px; font-size: 12px; color: var(--muted); } @@ -2647,11 +2694,9 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, border: 1px solid var(--line-2); border-radius: var(--radius-s); background: #FDFBF7; } .sub-row.is-done .sub-title { color: var(--muted); text-decoration: line-through; } -.sub-check { - background: none; border: 0; cursor: pointer; font-size: 17px; line-height: 1; - padding: 0 2px; color: var(--brand); -} -.sub-check.is-static { cursor: default; color: var(--muted); } +/* ⚠️ `.sub-check`(旧的 ☐/☑ 字符按钮)已在 v24 第 25 批 用户第 6 条**删掉**: + 子任务改用全站统一的 `.tk-check`(见上面那一段)。留一份没人用的样式 + 只会让下一个人以为还有第二条画法。 */ .sub-title { flex: 1 1 auto; min-width: 0; font-size: 14px; } .cmt-list { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 10px; } .cmt-row { border-top: 1px solid var(--line-2); padding-top: 10px; } @@ -4118,13 +4163,32 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b /* ---- ② 日程 / 任务 左右各半 ---- ⚠️ 高度**固定**(用户原话「为了好看高度需要固定」),滚动条**藏起来** (「鼠标放上去之后向下可以滚动,暂时不要出现滚动条太丑了」)。 - 藏滚动条要三套写法(Firefox / IE-Edge / WebKit 各一套),少一套就在某个浏览器上露出来。 */ -.home-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: start; } -.hd-pane { display: flex; flex-direction: column; } -.hd-pane-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } + 藏滚动条要三套写法(Firefox / IE-Edge / WebKit 各一套),少一套就在某个浏览器上露出来。 + + 🔴🔴 v24 第 25 批 用户第 3 条修的真 bug:「日程和任务看板,两个模块, + 现在有点错位,一个高,一个低,应该完全一样才对。」 + + 根因:这两块原来是 `align-items: start` + 各自内容撑高 —— + 左边日程的**刻度行数**由"今天有几件事"决定(掐头去尾之后 3~9 行不等), + 右边的任务条数也各不相同 ⇒ 两张卡片高度天然不等,一眼就是"一高一低"。 + + 修法(三步,缺一不可): + ① `align-items: stretch` —— 让两栏拉成同一行高(grid 的默认行为, + 但上一版显式写成了 `start`,那等于主动放弃对齐); + ② `.hd-pane` 给一个**固定总高**并 `display:flex; flex-direction:column` + (它本来就有 flex 列),head 不收缩、body 吃满剩余; + ③ `.hd-pane-body` 用 `flex: 1 1 auto` + `min-height: 0` + 取代原来的固定 `height: 360px`。 + ⚠️ `min-height: 0` 不能省:flex 子项的默认 `min-height:auto` + 会让它**不肯缩到内容以下**,于是内容一多就把卡片顶高、 + 对齐又失效(这是"固定高度 + 内部滚动"这套写法的经典坑)。 + 这样两块的**外框高度恒等**,与各自内容多少无关 —— 对齐不再依赖数据。 */ +.home-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: stretch; } +.hd-pane { display: flex; flex-direction: column; height: 440px; min-height: 0; } +.hd-pane-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex: 0 0 auto; } .hd-pane-head .cinema-h2 { margin: 0; flex: 1 1 auto; } .hd-pane-body { - height: 360px; overflow-y: auto; overscroll-behavior: contain; + flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: none; /* Firefox */ -ms-overflow-style: none; /* 旧 Edge / IE */ } @@ -4172,10 +4236,60 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b } .tk-due { flex: 0 0 auto; font-size: 11.5px; color: var(--faint); } +/* ---- 任务的「完成」复选框(v24 第 25 批 用户第 6 条)---- + 用户原话:「所有的任务,前面都带一个复选框,在任何地方,点这个复选框, + 这个任务就完成了。」 + + ⚠️ 它是 ` + \ No newline at end of file diff --git a/templates/books/weread.php b/templates/books/weread.php index 6fc6865..44a7731 100644 --- a/templates/books/weread.php +++ b/templates/books/weread.php @@ -1,22 +1,22 @@ 0) { + return $h . ' 小时' . ($m > 0 ? ' ' . $m . ' 分钟' : ''); + } + if ($m > 0) { + return $m . ' 分钟'; + } + return $sec . ' 秒'; +}; ?>

微信读书导入

@@ -77,7 +96,150 @@ $canWrite = \App\Auth::canWrite();
- + +
+

+ 自动同步 + + 已开启 + + 已关闭 + +

+

+ 开启后,每次打开这一页都会顺手同步一次(间隔约 6 小时,不会每次都去打接口)。 + 没有挂服务器定时任务 —— 你不在的时候同步不同步没人看得出来,白烧请求没有意义。 +

+
+ 上次同步: + 下次自动同步: +
+ +

+ + 刚才那次自动同步没成功: + (不影响页面,下次打开再试) + + 刚才顺手同步了一次:新增 本书、 + 张封面、 条笔记。 + +

+ +
+
+ + + +
+
+ + + +
+
+
+ + + + + +
+

+ 我的阅读统计 + 来自微信读书官方接口(累计口径) +

+
+
+ + 累计阅读时长 +
+
+ 天 + 有效阅读天数 +
+ 0): ?> +
+ + 日均(按自然日) +
+ + + +
+ + +
+ +
+ + +

读得最多的书

+
    + +
  • + + + + 📖 + + + + + + + + +
  • + +
+ + + +

+ 偏好分类 + + + +

+
+ + +
+ + + + 本 · + +
+ +
+ + + +

偏好作者

+

+ +

+ +
+ + + @@ -90,7 +252,7 @@ $canWrite = \App\Auth::canWrite();

- 会去微信读书取一次数据(你有笔记的书 + 每本书的划线/想法),可能要十几秒。 + 会去微信读书取一次数据(你有笔记的书 + 每本书的封面/划线/想法),可能要十几秒。

@@ -113,6 +275,13 @@ $canWrite = \App\Auth::canWrite();