From 7138314dc7d9cd328123397ba04dcea4684e6d38 Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 5 Oct 2026 10:58:17 +0800 Subject: [PATCH] =?UTF-8?q?v24=20=E7=AC=AC=2015=20=E6=89=B9=EF=BC=9A?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=20/=20=E6=97=85=E7=A8=8B=E4=B8=A4=E7=BA=A7?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=EF=BC=88=E6=9C=AF=E8=AF=AD=20=C2=B7=20?= =?UTF-8?q?=E6=B4=BE=E7=94=9F=E6=97=B6=E9=97=B4=E5=9F=8E=E5=B8=82=20=C2=B7?= =?UTF-8?q?=20=E8=A1=A8=E5=8D=95=E4=B8=89=E9=A1=B9=20=C2=B7=20=E9=A2=98?= =?UTF-8?q?=E5=9B=BE=E5=8F=AF=E7=82=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户原话四条,逐条落地: 1. 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」 → 顶层一律改称**活动**:按钮「记一次活动」、页标题「活动」、`N 次` / `N 段旅程`; 活动详情三组改叫「计划的旅程 / 走过的旅程 / 没成行的」; 节点表单「这段旅程叫什么」「阶段」;flash 文案 17 处同步。 (导航条仍叫「旅程」——用户就是这么称呼这个模块,列表页加副标题说清两级关系。) 2. 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间,和最晚的 旅程结束时间,计算出来的。目的地也不需要…去的城市,也是根据旅程,得到的。」 → 活动**不再存**这三样,全部**读时算出来**: · Trip::derived() 最早开始 / 最晚结束 / 城市去重 · Trip::cities() 按最早出现时间排(先后顺序本身是信息,不是字母序) · Trip::attachDerived() 列表页批量补(避免 N+1) · Trip::activitySpan() 没有旅程时说「还没有旅程」,不用「日期待补」 create/update 不再写 events 的起止与地点(老值保留不动,页面不读)。 列表排序/搜索、首页「最近去了」改走子查询。 3. 「题图那里,没有办法上传图片,点击哪里都没反应。」——**真 bug** 「点图 = 开文件框」那句 JS 只写在 people/form.php 的内页脚本里(头像专用); 第 14 批加的旅程题图与图书封面照抄了同一套 DOM 却没有对应的 JS, 于是图块有 cursor:pointer 与 hover 高亮(看着能点),点下去毫无反应。 → 做成声明式通用绑定:图上写 data-pick-input="#fileInputId", app.js 统一接管(一个页面多个也各绑各的),并删掉旧的那份专用实现。 三处(头像 / 题图 / 图书封面)全挂上。 4. 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」 → 主区就这三项,顺序照原话;时间/城市不做输入项(只留一句说明它们从哪来, 不说清用户会满页找"怎么没地方填时间")。 备注 / 公开 / 同行的人收进「更多设置」折叠区——功能一个没丢。 顺带修掉两处会炸的实现问题: · ORDER BY 引用聚合别名(first_at)在 MariaDB/MySQL 报 1247,且只在运行时炸、 php -l 看不出 → 改成 ORDER BY MIN(starts_at),否则整个旅程模块 Fatal。 · e2e 里"详情页显示了派生时间"原本写成整页查找,下面每段旅程自己也渲染日期, 于是把派生值塞成 null 也照样绿(借光假绿)→ 判据收进页头那一段。 验证:e2e 845/0(+15 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 移动几何 688/0 · 交互 92/0 · 题图真机点击 4/0 · 读书窄屏 15/0 · 家谱 34/0 + 10/0 + 反向 3/3 · 观影按钮 60/0 + 反向 3/3 · 本批反向验证 4/4(题图绑定抽掉→红、派生值不挂→红、 城市退回读 location→红、时间字段加回表单→红)· 部署静态断言新增两组(含"活动不许 再写起止时间/目的地"与"ORDER BY 不许引用聚合别名"的反退化防线)。 部署后服务器自检 19/19,五个关键文件 md5 与线上逐一核对一致。 --- app/Book.php | 638 +++++++++++++++++++++++++++++ app/BookNoteImport.php | 252 ++++++++++++ app/BookParser.php | 545 ++++++++++++++++++++++++ app/Home.php | 47 ++- app/HomeMods.php | 2 +- app/ImageSlot.php | 25 +- app/Module.php | 5 +- app/Schema.php | 86 +++- app/Task.php | 49 +++ app/Trip.php | 310 +++++++++++++- assets/css/app.css | 261 +++++++++++- assets/js/app.js | 28 ++ index.php | 527 ++++++++++++++++++++++-- templates/books/_settings_menu.php | 43 ++ templates/books/form.php | 211 ++++++++++ templates/books/index.php | 164 ++++++++ templates/books/notes_import.php | 138 +++++++ templates/books/read.php | 107 +++++ templates/books/show.php | 220 ++++++++++ templates/books/stats.php | 140 +++++++ templates/calendar/index.php | 171 ++++++-- templates/home.php | 6 + templates/people/form.php | 19 +- templates/tasks/index.php | 56 +++ templates/tasks/show.php | 27 +- templates/trips/_node_form.php | 30 +- templates/trips/_revise_form.php | 6 +- templates/trips/form.php | 238 ++++++----- templates/trips/index.php | 36 +- templates/trips/show.php | 80 ++-- 30 files changed, 4177 insertions(+), 290 deletions(-) create mode 100644 app/Book.php create mode 100644 app/BookNoteImport.php create mode 100644 app/BookParser.php create mode 100644 templates/books/_settings_menu.php create mode 100644 templates/books/form.php create mode 100644 templates/books/index.php create mode 100644 templates/books/notes_import.php create mode 100644 templates/books/read.php create mode 100644 templates/books/show.php create mode 100644 templates/books/stats.php diff --git a/app/Book.php b/app/Book.php new file mode 100644 index 0000000..222cdf8 --- /dev/null +++ b/app/Book.php @@ -0,0 +1,638 @@ + '想读', + 'reading' => '在读', + 'done' => '读完', + 'paused' => '搁置', + ]; + + /** 笔记种类 */ + public const NOTE_KINDS = [ + 'quote' => '划线', + 'note' => '笔记', + 'review' => '书评', + ]; + + /** 笔记来源 */ + public const NOTE_SOURCES = [ + 'manual' => '本站', + 'weread' => '微信读书', + 'douban' => '豆瓣', + 'other' => '其它', + ]; + + /** 上传图书允许的格式(解析能力见 BookParser) */ + public const FILE_EXT = ['epub', 'txt', 'pdf', 'mobi', 'azw3', 'azw', 'doc', 'docx']; + + // ---------------- 读 ---------------- + + /** + * 书架。 + * + * @param array $f 过滤:status / category / q(书名作者标签)/ owner(谁的书) + */ + public static function all(int $familyId, array $f = []): array + { + $sql = 'SELECT b.*, p.name AS owner_name, p.avatar AS owner_avatar, + p.avatar_color AS owner_color, + (SELECT COUNT(*) FROM book_notes n WHERE n.book_id = b.id) AS note_count + FROM books b + LEFT JOIN persons p ON p.id = b.owner_id + WHERE b.family_id = ?'; + $args = [$familyId]; + $st = (string)($f['status'] ?? ''); + if ($st !== '' && isset(self::STATUS[$st])) { + $sql .= ' AND b.status = ?'; + $args[] = $st; + } + $cat = trim((string)($f['category'] ?? '')); + if ($cat !== '') { + $sql .= ' AND b.category = ?'; + $args[] = $cat; + } + $owner = (int)($f['owner'] ?? 0); + if ($owner > 0) { + $sql .= ' AND b.owner_id = ?'; + $args[] = $owner; + } + $q = trim((string)($f['q'] ?? '')); + if ($q !== '') { + $like = '%' . $q . '%'; + $sql .= ' AND (b.title LIKE ? OR b.author LIKE ? OR b.subtitle LIKE ? + OR b.publisher LIKE ? OR b.tags LIKE ? OR b.isbn LIKE ?)'; + array_push($args, $like, $like, $like, $like, $like, $like); + } + // 在读的排最前(那是"手头正在看什么"),其次是最近更新过的 + $sql .= ' ORDER BY FIELD(b.status, "reading", "wish", "paused", "done"), b.updated_at DESC, b.id DESC'; + $st2 = Database::get()->prepare($sql); + $st2->execute($args); + return $st2->fetchAll(); + } + + /** + * 各状态的数量(书架页的筛选条用;'_all' 是总数)。 + * + * ⚠️ 与筛选条件**无关**:这里要的是"这个状态一共有几本", + * 不是"当前筛选下有几本"(否则点进"在读"后,其它状态的数字全变 0, + * 筛选条就成了自我实现的死路 —— 再也点不回去)。 + */ + public static function statusCounts(int $familyId): array + { + $out = ['_all' => 0]; + foreach (self::STATUS as $k => $v) { + $out[$k] = 0; + } + $st = Database::get()->prepare( + 'SELECT status, COUNT(*) AS n FROM books WHERE family_id = ? GROUP BY status' + ); + $st->execute([$familyId]); + foreach ($st->fetchAll() as $r) { + $out['_all'] += (int)$r['n']; + $k = (string)$r['status']; + if (isset($out[$k])) { + $out[$k] = (int)$r['n']; + } + } + return $out; + } + + /** 用过的分类(筛选用) */ + public static function categories(int $familyId): array + { + $st = Database::get()->prepare( + 'SELECT category AS name, COUNT(*) AS n FROM books + WHERE family_id = ? AND category IS NOT NULL AND category <> "" + GROUP BY category ORDER BY n DESC, category' + ); + $st->execute([$familyId]); + return $st->fetchAll(); + } + + public static function find(int $familyId, int $id): ?array + { + $st = Database::get()->prepare( + 'SELECT b.*, p.name AS owner_name, p.avatar AS owner_avatar, p.avatar_color AS owner_color + FROM books b LEFT JOIN persons p ON p.id = b.owner_id + WHERE b.family_id = ? AND b.id = ?' + ); + $st->execute([$familyId, $id]); + $r = $st->fetch(); + return $r ?: null; + } + + /** + * 章节目录(**不取正文** —— 一本书的正文有几 MB,列表页取它就是自杀)。 + */ + public static function chapters(int $bookId): array + { + $st = Database::get()->prepare( + 'SELECT id, seq, title, words FROM book_chapters WHERE book_id = ? ORDER BY seq' + ); + $st->execute([$bookId]); + return $st->fetchAll(); + } + + /** 一章(含正文) */ + public static function chapter(int $bookId, int $seq): ?array + { + $st = Database::get()->prepare( + 'SELECT * FROM book_chapters WHERE book_id = ? AND seq = ?' + ); + $st->execute([$bookId, $seq]); + $r = $st->fetch(); + return $r ?: null; + } + + /** 整本正文(导出/统计字数用;量很大,只在必要时调) */ + public static function chapterCount(int $bookId): int + { + $st = Database::get()->prepare('SELECT COUNT(*) FROM book_chapters WHERE book_id = ?'); + $st->execute([$bookId]); + return (int)$st->fetchColumn(); + } + + // ---------------- 写 ---------------- + + /** @return int 新书 id */ + public static function create(int $familyId, ?int $creatorId, array $d): int + { + $db = Database::get(); + $db->prepare( + 'INSERT INTO books (family_id, owner_id, creator_id, title, subtitle, author, translator, + publisher, published, isbn, language, category, tags, summary, + cover_key, file_key, file_name, file_format, file_size, + pages, words, status, rating, started_at, finished_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' + )->execute([ + $familyId, + self::ownerId($familyId, (int)($d['owner_id'] ?? 0)), + $creatorId ?: null, + self::title($d['title'] ?? ''), + self::txt($d['subtitle'] ?? ''), + self::txt($d['author'] ?? ''), + self::txt($d['translator'] ?? ''), + self::txt($d['publisher'] ?? ''), + self::txt($d['published'] ?? ''), + self::txt($d['isbn'] ?? ''), + self::txt($d['language'] ?? ''), + self::txt($d['category'] ?? ''), + self::txt($d['tags'] ?? ''), + self::longText($d['summary'] ?? ''), + self::key($d['cover_key'] ?? ''), + self::key($d['file_key'] ?? ''), + self::txt($d['file_name'] ?? ''), + self::fmt($d['file_format'] ?? ''), + self::intOrNull($d['file_size'] ?? 0), + self::intOrNull($d['pages'] ?? 0), + self::intOrNull($d['words'] ?? 0), + self::status($d['status'] ?? ''), + self::rating($d['rating'] ?? null), + self::dateOrNull($d['started_at'] ?? ''), + self::dateOrNull($d['finished_at'] ?? ''), + ]); + return (int)$db->lastInsertId(); + } + + public static function update(int $familyId, int $id, array $d): void + { + $old = self::find($familyId, $id); + if (!$old) { + return; + } + $status = self::status($d['status'] ?? ''); + // ⚠️ 状态与"读完那天"必须一起维护: + // · 变成 done 且还没记过 → 记今天; + // · 从 done 退回来 → **清空**(留着会让"今年读完几本"一直虚高)。 + $fin = self::dateOrNull($d['finished_at'] ?? '') ?? ((string)$old['finished_at'] ?: null); + if ($status === 'done' && $fin === null) { + $fin = date('Y-m-d'); + } elseif ($status !== 'done') { + $fin = null; + } + $db = Database::get(); + $db->prepare( + 'UPDATE books SET owner_id=?, title=?, subtitle=?, author=?, translator=?, publisher=?, + published=?, isbn=?, language=?, category=?, tags=?, summary=?, cover_key=?, + file_key=?, file_name=?, file_format=?, file_size=?, pages=?, words=?, + status=?, rating=?, started_at=?, finished_at=? + WHERE id=? AND family_id=?' + )->execute([ + self::ownerId($familyId, (int)($d['owner_id'] ?? 0)), + self::title($d['title'] ?? ''), + self::txt($d['subtitle'] ?? ''), + self::txt($d['author'] ?? ''), + self::txt($d['translator'] ?? ''), + self::txt($d['publisher'] ?? ''), + self::txt($d['published'] ?? ''), + self::txt($d['isbn'] ?? ''), + self::txt($d['language'] ?? ''), + self::txt($d['category'] ?? ''), + self::txt($d['tags'] ?? ''), + self::longText($d['summary'] ?? ''), + self::key($d['cover_key'] ?? ''), + self::key($d['file_key'] ?? ''), + self::txt($d['file_name'] ?? ''), + self::fmt($d['file_format'] ?? ''), + self::intOrNull($d['file_size'] ?? 0), + self::intOrNull($d['pages'] ?? 0), + self::intOrNull($d['words'] ?? 0), + $status, + self::rating($d['rating'] ?? null), + self::dateOrNull($d['started_at'] ?? ''), + $fin, + $id, $familyId, + ]); + } + + /** + * 改状态(列表上的一键:想读 → 在读 → 读完)。 + * + * @return string 改完之后的状态 + */ + public static function setStatus(int $familyId, int $id, string $status): string + { + $old = self::find($familyId, $id); + if (!$old) { + return ''; + } + $status = self::status($status); + $db = Database::get(); + if ($status === 'done') { + $db->prepare( + 'UPDATE books SET status=?, finished_at=COALESCE(finished_at, ?), + started_at=COALESCE(started_at, ?) WHERE id=? AND family_id=?' + )->execute([$status, date('Y-m-d'), date('Y-m-d'), $id, $familyId]); + } elseif ($status === 'reading') { + $db->prepare( + 'UPDATE books SET status=?, finished_at=NULL, started_at=COALESCE(started_at, ?) + WHERE id=? AND family_id=?' + )->execute([$status, date('Y-m-d'), $id, $familyId]); + } else { + $db->prepare('UPDATE books SET status=?, finished_at=NULL WHERE id=? AND family_id=?') + ->execute([$status, $id, $familyId]); + } + return $status; + } + + /** 读到第几章(阅读器翻页时记) */ + public static function setProgress(int $familyId, int $id, int $seq): void + { + Database::get()->prepare( + 'UPDATE books SET progress = ? WHERE id = ? AND family_id = ?' + )->execute([max(0, $seq), $id, $familyId]); + } + + public static function destroy(int $familyId, int $id): void + { + $b = self::find($familyId, $id); + if (!$b) { + return; + } + // 文件与封面是自己存的,数据库级联删不掉存储对象 + if (!empty($b['file_key'])) { + \App\Storage\Storage::delete($familyId, (string)$b['file_key']); + } + if (!empty($b['cover_key'])) { + \App\Storage\Storage::delete($familyId, (string)$b['cover_key']); + } + Database::get()->prepare('DELETE FROM books WHERE id = ? AND family_id = ?')->execute([$id, $familyId]); + } + + /** 整组替换章节(重新解析 / 重新上传文件时用) */ + public static function replaceChapters(int $bookId, array $chapters): int + { + $db = Database::get(); + $db->prepare('DELETE FROM book_chapters WHERE book_id = ?')->execute([$bookId]); + $st = $db->prepare( + 'INSERT INTO book_chapters (book_id, seq, title, content, words) VALUES (?,?,?,?,?)' + ); + $seq = 0; + $words = 0; + foreach ($chapters as $c) { + $seq++; + $body = (string)($c['content'] ?? ''); + $w = self::countWords($body); + $words += $w; + $st->execute([$bookId, $seq, self::txt($c['title'] ?? ''), $body, $w]); + } + $db->prepare('UPDATE books SET chapter_count = ?, words = ? WHERE id = ?') + ->execute([$seq, $words > 0 ? $words : null, $bookId]); + return $seq; + } + + // ---------------- 笔记 ---------------- + + /** + * 一本书的笔记(按时间正序,**全部人**的都给)。 + * + * ⚠️ 昵称必须按**看的人**($viewerId)取,不能写成 `nk.viewer_id = n.person_id` + * —— 那是"这人对自己的称呼",页面上会冒出一个跟谁都不对应的名字。 + * (nicknames 是 (viewer_id, target_id) 二元组,缺了 viewer 就取错人。) + * + * 笔记不按人过滤:笔记是"读的人留下的字",家庭里没必要互相藏。 + * + * @param int $viewerId 谁在看(决定"我怎么称呼这条笔记的作者") + */ + public static function notes(int $bookId, int $viewerId = 0): array + { + $st = Database::get()->prepare( + 'SELECT n.*, p.name AS person_name, p.avatar, p.avatar_color, + nk.nickname AS my_nickname + FROM book_notes n + LEFT JOIN persons p ON p.id = n.person_id + LEFT JOIN nicknames nk ON nk.target_id = n.person_id AND nk.viewer_id = ? + WHERE n.book_id = ? ORDER BY n.created_at, n.id' + ); + $st->execute([$viewerId, $bookId]); + return $st->fetchAll(); + } + + /** @return int 笔记 id */ + public static function addNote(int $familyId, int $bookId, ?int $personId, array $d): int + { + $content = trim((string)($d['content'] ?? '')); + if ($content === '') { + return 0; + } + $db = Database::get(); + $db->prepare( + 'INSERT INTO book_notes (book_id, person_id, kind, chapter, loc, content, source) + VALUES (?,?,?,?,?,?,?)' + )->execute([ + $bookId, + $personId > 0 ? $personId : null, + isset(self::NOTE_KINDS[(string)($d['kind'] ?? '')]) ? (string)$d['kind'] : 'note', + self::txt($d['chapter'] ?? ''), + self::txt($d['loc'] ?? ''), + mb_substr($content, 0, 8000, 'UTF-8'), + self::source($d['source'] ?? ''), + ]); + return (int)$db->lastInsertId(); + } + + public static function deleteNote(int $familyId, int $noteId, int $personId): void + { + // 只能删自己写的(导入的无主笔记谁都能删 —— 不然它永远留在那儿) + $st = Database::get()->prepare( + 'SELECT n.id FROM book_notes n JOIN books b ON b.id = n.book_id + WHERE n.id = ? AND b.family_id = ? AND (n.person_id IS NULL OR n.person_id = ?)' + ); + $st->execute([$noteId, $familyId, $personId]); + if ($st->fetchColumn() === false) { + return; + } + Database::get()->prepare('DELETE FROM book_notes WHERE id = ?')->execute([$noteId]); + } + + // ---------------- 统计 ---------------- + + /** + * 书架的整体数字。 + * + * ⚠️ 读完的书按 **finished_at 的年份**分桶(不是 created_at): + * "今年读完了几本"问的是读完的时间,加进书架的时间没有意义 + * (很多人是年底一次性把看过的书补进去的)。 + */ + public static function stats(int $familyId): array + { + $db = Database::get(); + $byStatus = []; + foreach (self::STATUS as $k => $v) { + $byStatus[$k] = 0; + } + $st = $db->prepare('SELECT status, COUNT(*) AS n FROM books WHERE family_id = ? GROUP BY status'); + $st->execute([$familyId]); + foreach ($st->fetchAll() as $r) { + $byStatus[(string)$r['status']] = (int)$r['n']; + } + $one = static function (string $sql) use ($db, $familyId): int { + $s = $db->prepare($sql); + $s->execute([$familyId]); + return (int)$s->fetchColumn(); + }; + $out = [ + 'total' => array_sum($byStatus), + 'status' => $byStatus, + 'pages' => $one('SELECT COALESCE(SUM(pages),0) FROM books WHERE family_id = ? AND status = "done"'), + 'words' => $one('SELECT COALESCE(SUM(words),0) FROM books WHERE family_id = ? AND status = "done"'), + 'rated' => $one('SELECT COUNT(*) FROM books WHERE family_id = ? AND rating IS NOT NULL'), + 'avgRate' => 0.0, + 'notes' => $one('SELECT COUNT(*) FROM book_notes n JOIN books b ON b.id = n.book_id + WHERE b.family_id = ?'), + ]; + $sum = $one('SELECT COALESCE(SUM(rating),0) FROM books WHERE family_id = ? AND rating IS NOT NULL'); + $out['avgRate'] = $out['rated'] > 0 ? round($sum / $out['rated'], 1) : 0.0; + + // 按年读完(近 8 年) + $st = $db->prepare( + 'SELECT YEAR(finished_at) AS y, COUNT(*) AS n FROM books + WHERE family_id = ? AND status = "done" AND finished_at IS NOT NULL + GROUP BY YEAR(finished_at) ORDER BY y' + ); + $st->execute([$familyId]); + $out['byYear'] = $st->fetchAll(); + + // 评分分布(0-10) + $dist = array_fill(0, 11, 0); + $st = $db->prepare( + 'SELECT rating, COUNT(*) AS n FROM books + WHERE family_id = ? AND rating IS NOT NULL GROUP BY rating' + ); + $st->execute([$familyId]); + foreach ($st->fetchAll() as $r) { + $dist[max(0, min(10, (int)$r['rating']))] = (int)$r['n']; + } + $out['ratingDist'] = $dist; + + // 读得最多的作者 / 分类 + $st = $db->prepare( + 'SELECT author AS name, COUNT(*) AS n FROM books + WHERE family_id = ? AND author IS NOT NULL AND author <> "" + GROUP BY author ORDER BY n DESC, author LIMIT 8' + ); + $st->execute([$familyId]); + $out['topAuthors'] = $st->fetchAll(); + + $st = $db->prepare( + 'SELECT category AS name, COUNT(*) AS n FROM books + WHERE family_id = ? AND category IS NOT NULL AND category <> "" + GROUP BY category ORDER BY n DESC, category LIMIT 8' + ); + $st->execute([$familyId]); + $out['topCategories'] = $st->fetchAll(); + + return $out; + } + + // ---------------- 展示 ---------------- + + public static function statusWord(string $k): string + { + return self::STATUS[$k] ?? '想读'; + } + + public static function normalizeStatus(string $v): string + { + return self::status($v); + } + + public static function kindWord(string $k): string + { + return self::NOTE_KINDS[$k] ?? '笔记'; + } + + public static function sourceWord(string $k): string + { + return self::NOTE_SOURCES[$k] ?? (string)$k; + } + + /** 文件大小的"人话" */ + public static function sizeWord($bytes): string + { + $b = (int)$bytes; + if ($b <= 0) { + return ''; + } + if ($b < 1024) { + return $b . ' B'; + } + if ($b < 1024 * 1024) { + return round($b / 1024, 1) . ' KB'; + } + return round($b / 1024 / 1024, 1) . ' MB'; + } + + /** 字数的"人话"(按中文习惯说"万字") */ + public static function wordsWord($words): string + { + $w = (int)$words; + if ($w <= 0) { + return ''; + } + if ($w < 10000) { + return number_format($w) . ' 字'; + } + return round($w / 10000, 1) . ' 万字'; + } + + /** 中英混排的字数(中文按字、西文按词)—— 与 BookParser 同一口径 */ + public static function countWords(string $s): int + { + $s = trim($s); + if ($s === '') { + return 0; + } + $cn = preg_match_all('/[\x{4e00}-\x{9fff}]/u', $s); + $noCn = preg_replace('/[\x{4e00}-\x{9fff}]/u', ' ', $s); + $en = preg_match_all('/[A-Za-z0-9]+/u', $noCn); + return (int)$cn + (int)$en; + } + + // ---------------- 内部 ---------------- + + private static function ownerId(int $familyId, int $id): ?int + { + if ($id <= 0) { + return null; + } + $st = Database::get()->prepare('SELECT id FROM persons WHERE id = ? AND family_id = ?'); + $st->execute([$id, $familyId]); + return $st->fetchColumn() === false ? null : $id; + } + + private static function status(string $v): string + { + return isset(self::STATUS[$v]) ? $v : 'wish'; + } + + private static function source(string $v): string + { + $v = trim($v); + return $v !== '' ? mb_substr($v, 0, 40, 'UTF-8') : 'manual'; + } + + private static function title(string $v): string + { + return mb_substr(trim($v), 0, 300, 'UTF-8'); + } + + private static function txt(string $v): ?string + { + $v = trim($v); + return $v !== '' ? $v : null; + } + + private static function longText(string $v): ?string + { + $v = trim($v); + return $v !== '' ? mb_substr($v, 0, 20000, 'UTF-8') : null; + } + + private static function key(string $v): ?string + { + $v = ltrim(trim($v), '/'); + if ($v === '' || strpos($v, '..') !== false || strpos($v, "\0") !== false) { + return null; + } + return $v; + } + + private static function fmt(string $v): ?string + { + $v = strtolower(trim($v)); + $v = preg_replace('/[^a-z0-9]/', '', $v) ?? ''; + return $v !== '' ? mb_substr($v, 0, 20, 'UTF-8') : null; + } + + private static function rating($v): ?int + { + if ($v === null || $v === '') { + return null; + } + $n = (int)$v; + return ($n >= 0 && $n <= 10) ? $n : null; + } + + private static function intOrNull($v): ?int + { + $n = (int)$v; + return $n > 0 ? $n : null; + } + + private static function dateOrNull(string $v): ?string + { + $v = trim($v); + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : null; + } +} diff --git a/app/BookNoteImport.php b/app/BookNoteImport.php new file mode 100644 index 0000000..7e85777 --- /dev/null +++ b/app/BookNoteImport.php @@ -0,0 +1,252 @@ + 原文`、`### 划线` 之后的裸行 + * · 想法 —— `>> 我的想法`、`### 笔记` / `### 想法` 之后的裸行 + * + * ⚠️ 只做**纯文本**:不解析 HTML(微信读书导出还有 HTML 版,但那种各家模板不同, + * 而且要防注入 —— 用户贴进来的东西一律当纯文本存,页面展示时转义)。 + */ + +declare(strict_types=1); + +namespace App; + +final class BookNoteImport +{ + /** 解析一大段文本 → 若干本书 + 各自的笔记 */ + public static function parse(string $raw): array + { + $raw = str_replace(["\r\n", "\r"], "\n", trim($raw)); + $lines = explode("\n", $raw); + $books = []; + $cur = -1; // 当前书在 $books 里的下标 + $mode = 'quote'; // 当前归类:quote(划线)/ note(想法) + $chapter = ''; + + foreach ($lines as $ln) { + $t = trim($ln); + if ($t === '') { + continue; + } + + // ---- 书名:Markdown 标题 / 《》 / 单独一行且很短 ---- + $heading = self::headingText($t); + $isSection = $heading !== null && self::isSectionWord($heading); + if ($heading !== null && !$isSection) { + [$title, $author] = self::splitTitleAuthor($heading); + if ($title !== '') { + $books[] = ['title' => $title, 'author' => $author, 'notes' => []]; + $cur = count($books) - 1; + $mode = 'quote'; + $chapter = ''; + continue; + } + } + if (preg_match('/^《(.+?)》/u', $t, $m)) { + [$title, $author] = self::splitTitleAuthor(trim($m[1]) . mb_substr($t, mb_strlen($m[0], 'UTF-8'), null, 'UTF-8')); + $books[] = ['title' => $title, 'author' => $author, 'notes' => []]; + $cur = count($books) - 1; + $mode = 'quote'; + $chapter = ''; + continue; + } + // ---- 分节标记:划线 / 笔记 / 想法 ---- + if ($isSection) { + $mode = self::modeOfWord($heading); + continue; + } + // ---- 作者行 ---- + if ($cur >= 0 && preg_match('/^(作者|作者名)\s*[::]\s*(.+)$/u', $t, $m)) { + if ((string)$books[$cur]['author'] === '') { + $books[$cur]['author'] = trim($m[2]); + } + continue; + } + // ---- 章节:◆ / ● 开头 ---- + if (preg_match('/^[◆●]\s*(.+)$/u', $t, $m)) { + $chapter = mb_substr(trim($m[1]), 0, 200, 'UTF-8'); + continue; + } + // ---- 时间行(2023-05-12 / 2023年5月12日)→ 丢掉,它不是笔记内容 ---- + if (preg_match('/^\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}/u', $t) + && mb_strlen($t, 'UTF-8') <= 30) { + continue; + } + // ---- 想法:`>>` 开头 ---- + if (strpos($t, '>>') === 0) { + $c = trim(mb_substr($t, 2, null, 'UTF-8')); + if ($c !== '') { + self::push($books, $cur, 'note', $chapter, $c); + } + continue; + } + // ---- 划线:`>` 开头 ---- + if (strpos($t, '>') === 0) { + $c = trim(mb_substr($t, 1, null, 'UTF-8')); + if ($c !== '') { + self::push($books, $cur, 'quote', $chapter, $c); + } + continue; + } + // ---- 裸行:按当前归类 ---- + self::push($books, $cur, $mode, $chapter, $t); + } + + // 一条笔记都没归到任何书(用户贴了一段没有书名的话)→ 归到"未命名的一本" + return array_values(array_filter($books, static function (array $b): bool { + return count($b['notes']) > 0 || $b['title'] !== ''; + })); + } + + /** 把解析结果落库:能匹配到已有书就挂上去,匹配不到就新建一本 */ + public static function import(int $familyId, int $personId, array $books, string $source): array + { + $created = 0; + $added = 0; + $skipped = 0; + $names = []; + foreach ($books as $b) { + $title = trim((string)($b['title'] ?? '')); + if ($title === '') { + continue; + } + $notes = $b['notes'] ?? []; + if (!$notes) { + $skipped++; + continue; + } + $bookId = self::matchBook($familyId, $title); + if ($bookId === 0) { + $bookId = Book::create($familyId, $personId, [ + 'title' => $title, + 'author' => (string)($b['author'] ?? ''), + 'status' => 'done', // 有笔记 = 至少读过;标成"读完"比"想读"贴近事实 + 'owner_id' => $personId, + ]); + $created++; + } + foreach ($notes as $n) { + $id = Book::addNote($familyId, $bookId, $personId, [ + 'kind' => (string)($n['kind'] ?? 'note'), + 'chapter' => (string)($n['chapter'] ?? ''), + 'content' => (string)($n['content'] ?? ''), + 'source' => $source, + ]); + if ($id > 0) { + $added++; + } + } + $names[] = $title; + } + return ['created' => $created, 'added' => $added, 'skipped' => $skipped, 'titles' => $names]; + } + + /** 按书名找已有的一本(去空格、去大小写;找不到返回 0) */ + public static function matchBook(int $familyId, string $title): int + { + $key = self::key($title); + if ($key === '') { + return 0; + } + $st = Database::get()->prepare('SELECT id, title FROM books WHERE family_id = ?'); + $st->execute([$familyId]); + foreach ($st->fetchAll() as $r) { + if (self::key((string)$r['title']) === $key) { + return (int)$r['id']; + } + } + return 0; + } + + public static function sourceOf(?string $raw): string + { + $s = strtolower((string)$raw); + if (strpos($s, 'weread') !== false || strpos($s, '微信读书') !== false) { + return 'weread'; + } + if (strpos($s, 'douban') !== false || strpos($s, '豆瓣') !== false) { + return 'douban'; + } + return 'other'; + } + + // ---------------- 内部 ---------------- + + /** + * @param int $cur **按引用传**:第一次遇到"没有书名就先来了一段笔记"时, + * 这里会建一本「未命名的一本」,而外面的 $cur 必须跟着指向它 —— + * 否则后面每一段都会各自新建一本(实测:一段话变出 7 本书)。 + */ + private static function push(array &$books, int &$cur, string $kind, string $chapter, string $content): void + { + if ($cur < 0) { + // 第一段文字之前没有书名 → 先建一本"未命名",用户导入后可以改 + $books[] = ['title' => '未命名的一本', 'author' => '', 'notes' => []]; + $cur = count($books) - 1; + } + $books[$cur]['notes'][] = [ + 'kind' => $kind, + 'chapter' => $chapter, + 'content' => mb_substr($content, 0, 8000, 'UTF-8'), + ]; + } + + /** `# xxx` / `## xxx` → xxx;不是标题返回 null */ + private static function headingText(string $t): ?string + { + if (!preg_match('/^#{1,6}\s*(.+)$/u', $t, $m)) { + return null; + } + return trim($m[1]); + } + + /** 这个标题是"划线/笔记/想法"这类**分节**标记,还是书名 */ + private static function isSectionWord(?string $h): bool + { + $h = (string)$h; + return (bool)preg_match('/^(划线|笔记|想法|感想|书评|摘录|原文|批注)/u', $h); + } + + private static function modeOfWord(?string $h): string + { + $h = (string)$h; + if (preg_match('/^(笔记|想法|感想|书评|批注)/u', $h)) { + return 'note'; + } + return 'quote'; + } + + /** 「活着(余华)」/「活着 - 刘慈欣」→ [书名, 作者] */ + private static function splitTitleAuthor(string $s): array + { + $s = trim($s); + $author = ''; + if (preg_match('/^(.*?)[((【]([^))】]+)[))】]\s*$/u', $s, $m)) { + $s = trim($m[1]); + $author = trim($m[2]); + } elseif (preg_match('/^(.*?)\s+-\s+(.+)$/u', $s, $m)) { + $s = trim($m[1]); + $author = trim($m[2]); + } + $s = trim(preg_replace('/^[《](.+?)[》]/u', '$1', $s) ?? $s); + return [mb_substr($s, 0, 300, 'UTF-8'), mb_substr($author, 0, 200, 'UTF-8')]; + } + + private static function key(string $s): string + { + $s = preg_replace('/\s+/u', '', $s) ?? $s; + return mb_strtolower(trim($s), 'UTF-8'); + } +} diff --git a/app/BookParser.php b/app/BookParser.php new file mode 100644 index 0000000..91860f6 --- /dev/null +++ b/app/BookParser.php @@ -0,0 +1,545 @@ +临时文件路径, 'mime'=>…](有封面才有;调用方负责上传与删临时文件) + */ + +declare(strict_types=1); + +namespace App; + +final class BookParser +{ + /** 单章上限(字符):超过就截断,防止一本巨书把库撑爆 */ + private const CHAPTER_MAX = 200000; + /** 一本书最多解析多少章 */ + private const CHAPTER_LIMIT = 600; + + /** + * @param string $path 临时文件路径 + * @param string $ext 扩展名(小写) + * @param string $name 原始文件名(用来兜底猜书名/作者) + */ + public static function parse(string $path, string $ext, string $name = ''): array + { + if (!is_file($path)) { + return ['ok' => false, 'error' => '文件读不到', 'meta' => [], 'chapters' => []]; + } + $ext = strtolower($ext); + if ($ext === '' && $name !== '') { + $ext = strtolower((string)pathinfo($name, PATHINFO_EXTENSION)); + } + switch ($ext) { + case 'epub': + return self::parseEpub($path); + case 'txt': + case 'text': + return self::parseTxt($path, $name); + case 'pdf': + return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name), + 'chapters' => [], 'note' => 'PDF 只存文件(服务端不提取正文),阅读时用浏览器自带的 PDF 阅读器看。']; + default: + return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name), + 'chapters' => [], 'note' => self::formatWord($ext) . ' 正文解析不了,文件已经存好了,元数据请手填。']; + } + } + + // ---------------- EPUB ---------------- + + /** + * EPUB = 一个 zip 包:META-INF/container.xml 指路 → .opf 里放元数据与清单 → + * spine 决定章节顺序 → 每一章是一个 XHTML 文件。 + * + * ⚠️ 路径要**相对 opf 所在目录**拼(很多书的 OEBPS/ 与 opf 不在一起), + * 直接用 opf 的路径当基准去拼会 404(ZipArchive::getFromName 返回 false)。 + */ + private static function parseEpub(string $path): array + { + if (!class_exists('ZipArchive')) { + return ['ok' => true, 'error' => '', 'meta' => [], 'chapters' => [], + 'note' => '服务器没有 zip 扩展,EPUB 的正文解析不了;文件已保存,元数据请手填。']; + } + $zip = new \ZipArchive(); + $rc = $zip->open($path); + if ($rc !== true) { + return ['ok' => false, 'error' => '这个 EPUB 打不开(错误码 ' . (int)$rc . ')', + 'meta' => [], 'chapters' => []]; + } + + // ① 找 OPF(书的"目录清单") + $opfPath = ''; + $container = $zip->getFromName('META-INF/container.xml'); + if ($container !== false && $container !== '') { + if (preg_match('/full-path\s*=\s*["\']([^"\']+)["\']/i', $container, $m)) { + $opfPath = trim($m[1]); + } + } + if ($opfPath === '') { + // 有些书没写 container.xml(不合规范但确实存在)→ 自己找第一个 .opf + for ($i = 0; $i < $zip->numFiles; $i++) { + $n = (string)$zip->getNameIndex($i); + if (substr(strtolower($n), -4) === '.opf') { + $opfPath = $n; + break; + } + } + } + if ($opfPath === '') { + $zip->close(); + return ['ok' => false, 'error' => '这个 EPUB 里找不到目录文件(.opf),可能已损坏', + 'meta' => [], 'chapters' => []]; + } + $opfDir = self::dirOf($opfPath); + $opf = (string)$zip->getFromName($opfPath); + $zip->close(); + if ($opf === '') { + return ['ok' => false, 'error' => '这个 EPUB 的目录文件是空的', 'meta' => [], 'chapters' => []]; + } + + $meta = self::opfMeta($opf); + // ② 清单:id → 文件 + $manifest = self::opfManifest($opf); + // ③ 顺序:spine 里的 idref + $spine = self::opfSpine($opf, $manifest); + + // ④ 封面:metadata 里的 ,或 manifest 里 properties 带 cover-image + $cover = self::epubCover($path, $opf, $opfDir, $manifest); + + $zip2 = new \ZipArchive(); + if ($zip2->open($path) !== true) { + return ['ok' => true, 'meta' => $meta, 'chapters' => [], 'cover' => $cover, + 'note' => '元数据读到了,正文没读出来(文件打不开第二次)。']; + } + $chapters = []; + $seq = 0; + foreach ($spine as $href) { + if (count($chapters) >= self::CHAPTER_LIMIT) { + break; + } + $full = self::joinPath($opfDir, $href); + $raw = $zip2->getFromName($full); + // ⚠️ 有些书的 spine 里写了 HTML 实体或大小写不一致 → 退一步按名字再试 + if ($raw === false) { + $raw = $zip2->getFromName(ltrim($href, '/')); + } + if ($raw === false || $raw === '') { + continue; + } + [$title, $body] = self::xhtmlToText($raw); + if (trim($body) === '') { + continue; + } + $seq++; + $chapters[] = [ + 'seq' => $seq, + 'title' => $title !== '' ? $title : ('第 ' . $seq . ' 章'), + 'content' => mb_substr($body, 0, self::CHAPTER_MAX, 'UTF-8'), + ]; + } + $zip2->close(); + + return ['ok' => true, 'error' => '', 'meta' => $meta, 'chapters' => $chapters, 'cover' => $cover]; + } + + /** OPF 的 metadata:书名 / 作者 / 出版社 / 语言 / ISBN / 简介 */ + private static function opfMeta(string $opf): array + { + $get = static function (string $tag) use ($opf): string { + if (preg_match_all('#]*>(.*?)#is', $opf, $m)) { + foreach ($m[1] as $v) { + $v = trim(html_entity_decode(strip_tags($v), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + if ($v !== '') { + return $v; + } + } + } + return ''; + }; + $isbn = ''; + if (preg_match_all('#]*>(.*?)#is', $opf, $mm)) { + foreach ($mm[0] as $i => $full) { + $v = trim(strip_tags($mm[1][$i])); + // opf:scheme="ISBN" 或 urn:isbn:xxxx + if (stripos($full, 'ISBN') !== false || stripos($v, 'isbn') === 0) { + $isbn = preg_replace('/^urn:isbn:/i', '', $v) ?? ''; + break; + } + if ($isbn === '' && preg_match('/^(97[89])?\d{9}[\dXx]$/', $v)) { + $isbn = $v; + } + } + } + $date = $get('date'); + return [ + 'title' => $get('title'), + 'subtitle' => '', + 'author' => $get('creator'), + 'publisher' => $get('publisher'), + 'language' => $get('language'), + 'isbn' => $isbn, + 'published' => $date !== '' ? substr($date, 0, 10) : '', + 'summary' => $get('description'), + ]; + } + + /** @return array id => 项 */ + private static function opfManifest(string $opf): array + { + $out = []; + if (!preg_match_all('#]*?)/?>#is', $opf, $m)) { + return $out; + } + foreach ($m[1] as $attr) { + $a = self::attrs($attr); + $id = (string)($a['id'] ?? ''); + if ($id === '') { + continue; + } + $out[$id] = [ + 'href' => html_entity_decode((string)($a['href'] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'), + 'type' => strtolower((string)($a['media-type'] ?? '')), + 'props' => strtolower((string)($a['properties'] ?? '')), + ]; + } + return $out; + } + + /** @return string[] 按阅读顺序排的正文文件(相对 opf 目录) */ + private static function opfSpine(string $opf, array $manifest): array + { + $ids = []; + if (preg_match('#]*>(.*?)#is', $opf, $m)) { + if (preg_match_all('#]*?)/?>#is', $m[1], $mm)) { + foreach ($mm[1] as $attr) { + $a = self::attrs($attr); + $id = (string)($a['idref'] ?? ''); + if ($id !== '') { + $ids[] = $id; + } + } + } + } + $out = []; + foreach ($ids as $id) { + $it = $manifest[$id] ?? null; + if (!$it) { + continue; + } + $t = (string)$it['type']; + // 只收正文档(xhtml / html / xml);图片、css、ncx 跳过 + if ($t !== '' && strpos($t, 'html') === false && strpos($t, 'xml') === false) { + continue; + } + $out[] = (string)$it['href']; + } + // spine 为空(不合规范)→ 退一步:按 manifest 里所有 html 收 + if (!$out) { + foreach ($manifest as $it) { + $t = (string)$it['type']; + if (strpos($t, 'html') !== false) { + $out[] = (string)$it['href']; + } + } + } + return $out; + } + + /** 封面:找出图并存到临时文件(调用方再交给 Upload) */ + private static function epubCover(string $path, string $opf, string $opfDir, array $manifest): ?array + { + $href = ''; + if (preg_match('#]*name\s*=\s*["\']cover["\'][^>]*content\s*=\s*["\']([^"\']+)["\']#is', $opf, $m)) { + $id = trim($m[1]); + $href = (string)($manifest[$id]['href'] ?? ''); + } + if ($href === '') { + if (preg_match('#]*content\s*=\s*["\']([^"\']+)["\'][^>]*name\s*=\s*["\']cover["\']#is', $opf, $m2)) { + $id = trim($m2[1]); + $href = (string)($manifest[$id]['href'] ?? ''); + } + } + if ($href === '') { + foreach ($manifest as $it) { + if (strpos((string)$it['props'], 'cover-image') !== false) { + $href = (string)$it['href']; + break; + } + } + } + if ($href === '') { + foreach ($manifest as $it) { + if (strpos((string)$it['type'], 'image/') === 0 + && stripos((string)$it['href'], 'cover') !== false) { + $href = (string)$it['href']; + break; + } + } + } + if ($href === '') { + return null; + } + if (!class_exists('ZipArchive')) { + return null; + } + $zip = new \ZipArchive(); + if ($zip->open($path) !== true) { + return null; + } + $full = self::joinPath($opfDir, $href); + $bin = $zip->getFromName($full); + if ($bin === false) { + $bin = $zip->getFromName(ltrim($href, '/')); + } + $zip->close(); + if ($bin === false || $bin === '') { + return null; + } + $tmp = tempnam(sys_get_temp_dir(), 'fbcov'); + if ($tmp === false || @file_put_contents($tmp, $bin) === false) { + return null; + } + $fino = new \finfo(FILEINFO_MIME_TYPE); + $mime = (string)$fino->file($tmp); + if (strpos($mime, 'image/') !== 0) { + @unlink($tmp); + return null; + } + return ['tmp' => $tmp, 'mime' => $mime]; + } + + /** + * XHTML → 纯文本(保留段落换行)。 + * + * ⚠️ 不是"去标签"就完事: + * ① 先砍掉 script / style / head(不然 CSS 与 JS 会混进正文); + * ② 块级标签换成换行(


),否则整章粘成一团; + * ③ 实体还原(  & …)之后再 strip_tags; + * ④ 连续空行压成一个 —— 电子书的源文件里空行多到能占一半篇幅。 + */ + private static function xhtmlToText(string $html): array + { + $title = ''; + if (preg_match('#]*>(.*?)#is', $html, $m)) { + $title = trim(html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } + $body = $html; + if (preg_match('#]*>(.*)#is', $body, $m2)) { + $body = $m2[1]; + if ($title === '' && preg_match('#]*>(.*?)#is', $body, $m3)) { + $title = trim(html_entity_decode(strip_tags($m3[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } + } + $body = preg_replace('#<(script|style|noscript)\b[^>]*>.*?#is', '', $body) ?? $body; + $body = preg_replace('##i', "\n", $body) ?? $body; + $body = preg_replace('##i', "\n", $body) ?? $body; + $body = html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $body = str_replace(["\r\n", "\r", "\xC2\xA0"], ["\n", "\n", ' '], $body); + $body = preg_replace('/[ \t]+\n/', "\n", $body) ?? $body; + $body = preg_replace('/\n{3,}/', "\n\n", $body) ?? $body; + $body = preg_replace('/^\n+|\n+$/', '', $body) ?? $body; + return [mb_substr($title, 0, 300, 'UTF-8'), $body]; + } + + // ---------------- TXT ---------------- + + private static function parseTxt(string $path, string $name): array + { + $raw = (string)@file_get_contents($path); + if ($raw === '') { + return ['ok' => false, 'error' => '这个文本文件是空的', 'meta' => [], 'chapters' => []]; + } + $text = self::toUtf8($raw); + $chapters = self::splitChapters($text); + return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name), 'chapters' => $chapters]; + } + + /** + * 编码兜底:UTF-8 → GB18030(中文电子书最常见的老编码)→ BIG5 → Latin1。 + * ⚠️ 不认编码就硬读,中文会整页乱码 —— 那是"看起来解析了、其实全是问号"。 + */ + private static function toUtf8(string $raw): string + { + if (substr($raw, 0, 3) === "\xEF\xBB\xBF") { + return substr($raw, 3); + } + if (mb_check_encoding($raw, 'UTF-8')) { + return $raw; + } + foreach (['GB18030', 'BIG-5', 'ISO-8859-1'] as $enc) { + $s = @iconv($enc, 'UTF-8//IGNORE', $raw); + if ($s !== false && $s !== '') { + return $s; + } + } + return $raw; + } + + /** + * 切章:认"第X章/第X节/序/前言/后记/Chapter N"这类行; + * 认不出来就按 5000 字一段(不然一本长篇会变成一整页,浏览器直接卡死)。 + */ + private static function splitChapters(string $text): array + { + $text = str_replace(["\r\n", "\r"], "\n", $text); + $lines = explode("\n", $text); + $re = '/^\s*(第\s*[0-9零一二三四五六七八九十百千万]+\s*[章节回部篇卷集]\s*' + . '|chapter\s+[0-9ivx]+\b' + . '|序章|序言|前言|自序|后记|附录|尾声|楔子)/iu'; + $chapters = []; + $curTitle = ''; + $buf = ''; + $flush = static function () use (&$chapters, &$curTitle, &$buf): void { + $body = trim($buf); + if ($body !== '') { + $chapters[] = ['title' => $curTitle, 'content' => mb_substr($body, 0, self::CHAPTER_MAX, 'UTF-8')]; + } + $buf = ''; + }; + foreach ($lines as $ln) { + $t = trim($ln); + if ($t !== '' && mb_strlen($t, 'UTF-8') <= 60 && preg_match($re, $t)) { + $flush(); + $curTitle = mb_substr($t, 0, 200, 'UTF-8'); + continue; + } + $buf .= $ln . "\n"; + } + $flush(); + + if (count($chapters) <= 1) { + // 没切出来 → 按字数均分 + $chapters = []; + $n = mb_strlen($text, 'UTF-8'); + $size = 5000; + $parts = (int)max(1, ceil($n / $size)); + for ($i = 0; $i < $parts; $i++) { + $seg = mb_substr($text, $i * $size, $size, 'UTF-8'); + if (trim($seg) === '') { + continue; + } + $chapters[] = ['title' => '第 ' . ($i + 1) . ' 段', 'content' => $seg]; + } + } + $out = []; + $seq = 0; + foreach ($chapters as $c) { + if (count($out) >= self::CHAPTER_LIMIT) { + break; + } + $seq++; + $out[] = ['seq' => $seq, 'title' => (string)$c['title'], 'content' => (string)$c['content']]; + } + return $out; + } + + // ---------------- 文件名猜书名 / 作者 ---------------- + + /** + * 从 "三体(全三册)- 刘慈欣.epub" 这类文件名里猜。 + * ⚠️ 只是**兜底**:猜错了用户一眼能改,比空着强(空着要一个字一个字敲)。 + */ + private static function guessFromName(string $name): array + { + $base = pathinfo((string)$name, PATHINFO_FILENAME); + $base = trim(preg_replace('/[\[\((【].*?[\]\))】]/u', '', $base) ?? $base); + $author = ''; + $title = $base; + foreach ([' - ', '—', '-', '_'] as $sep) { + if (strpos($base, $sep) !== false) { + $parts = explode($sep, $base); + $title = trim((string)array_pop($parts)); + $author = trim(implode(' ', $parts)); + break; + } + } + // "作者 书名" 反过来也常见:名字很短的那一段更像作者 + if ($author === '' && preg_match('/^(.+?)\s{1}(.+)$/u', $base, $m)) { + $author = trim($m[1]); + $title = trim($m[2]); + } + $title = trim(preg_replace('/\.(epub|txt|pdf|mobi|azw3?)$/i', '', $title) ?? $title); + return [ + 'title' => mb_substr($title, 0, 300, 'UTF-8'), + 'subtitle' => '', + 'author' => mb_substr($author, 0, 200, 'UTF-8'), + 'publisher' => '', + 'language' => '', + 'isbn' => '', + 'published' => '', + 'summary' => '', + ]; + } + + public static function formatWord(string $ext): string + { + $map = ['epub' => 'EPUB', 'txt' => 'TXT', 'pdf' => 'PDF', 'mobi' => 'MOBI', + 'azw' => 'AZW', 'azw3' => 'AZW3', 'doc' => 'DOC', 'docx' => 'DOCX']; + return $map[strtolower($ext)] ?? strtoupper($ext); + } + + /** 这个文件能不能直接放进浏览器内嵌阅读(PDF 就是) */ + public static function inlinable(?string $fmt): bool + { + return strtolower((string)$fmt) === 'pdf'; + } + + // ---------------- 小工具 ---------------- + + /** @return array */ + private static function attrs(string $s): array + { + $out = []; + if (preg_match_all('/([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(["\'])(.*?)\2/s', $s, $m, PREG_SET_ORDER)) { + foreach ($m as $x) { + $out[strtolower($x[1])] = $x[3]; + } + } + return $out; + } + + private static function dirOf(string $p): string + { + $p = str_replace('\\', '/', $p); + $i = strrpos($p, '/'); + return $i === false ? '' : substr($p, 0, $i + 1); + } + + /** 把 opf 目录与 href 拼成 zip 内的完整路径(顺手去掉 ../ 与锚点) */ + private static function joinPath(string $dir, string $href): string + { + $href = preg_replace('/#.*$/', '', $href) ?? $href; + $href = str_replace('\\', '/', trim(urldecode($href))); + $full = $dir . $href; + // 消掉 a/b/../c + $parts = []; + foreach (explode('/', str_replace('\\', '/', $full)) as $seg) { + if ($seg === '' || $seg === '.') { + continue; + } + if ($seg === '..') { + array_pop($parts); + continue; + } + $parts[] = $seg; + } + return implode('/', $parts); + } +} diff --git a/app/Home.php b/app/Home.php index 9805a09..b0e6ac8 100644 --- a/app/Home.php +++ b/app/Home.php @@ -327,11 +327,18 @@ final class Home ); $st->execute([$familyId]); $tripN = (int)$st->fetchColumn(); + // ⚠️ 用户第 15 批 第 2 条:活动自己没有时间了 —— "最近去过哪"要按 + // **各段旅程里最晚的那个时间**排,不能再看 e.starts_at(那列已经不用了, + // 恒为 NULL,等于每次取到的都是"最后插入的那条",看起来像随机)。 $st = Database::get()->prepare( "SELECT e.title FROM events e LEFT JOIN trip_details d ON d.event_id = e.id WHERE e.family_id = ? AND e.kind = 'trip' AND COALESCE(d.is_public,1) = 1 - ORDER BY e.starts_at DESC, e.id DESC LIMIT 1" + ORDER BY GREATEST( + COALESCE((SELECT MAX(n.starts_at) FROM trip_nodes n + WHERE n.event_id = e.id AND n.stage IN ('plan','actual')), '1000-01-01'), + '1000-01-01') DESC, + e.id DESC LIMIT 1" ); $st->execute([$familyId]); $lastTrip = (string)($st->fetchColumn() ?: ''); @@ -340,8 +347,8 @@ final class Home 'icon' => self::icon('trips'), 'color' => Module::color('trips', $familyId), 'href' => url('trips'), - 'big' => $tripN . ' 趟', - 'lines' => $lastTrip !== '' ? ['最近去了' . $lastTrip] : ['还没有旅程记录'], + 'big' => $tripN . ' 次', + 'lines' => $lastTrip !== '' ? ['最近去了' . $lastTrip] : ['还没有活动记录'], ]; // ---- 日程 ---- @@ -372,15 +379,16 @@ final class Home ]; // ---- 任务 ---- - $board = Task::board($familyId, $me); - $doing = count($board['doing']); - $todo = count($board['todo']); + // ⚠️ v24 第 14 批 用户第 2 条:**按状态算,不看板算**。 + // 看板是"这件事属于哪摊事",状态是"做到哪一步";首页关心的是后者, + // 所以这里用 Task::byStatus()(跨看板)—— 用 board() 只会数到默认看板里的。 + $doing = count(Task::byStatus($familyId, $me, 'doing')); + $todo = count(Task::byStatus($familyId, $me, 'todo')); $late = 0; - foreach ([$board['todo'], $board['doing']] as $col) { - foreach ($col as $t) { - if (Task::isOverdue($t['due_at'] ?? null)) { - $late++; - } + foreach (array_merge(Task::byStatus($familyId, $me, 'doing'), + Task::byStatus($familyId, $me, 'todo')) as $t) { + if (Task::isOverdue($t['due_at'] ?? null)) { + $late++; } } $cards['tasks'] = [ @@ -421,6 +429,21 @@ final class Home 'lines' => [$hasBook ? count($books) . ' 本账(净资产)' : '先建一本家庭账'], ]; + // ---- 读书(v24 第 14 批 用户第 5 条)---- + $bs = Book::stats($familyId); + $reading = (int)($bs['status']['reading'] ?? 0); + $doneN = (int)($bs['status']['done'] ?? 0); + $cards['books'] = [ + 'name' => '读书', + 'icon' => self::icon('books'), + 'color' => Module::color('books', $familyId), + 'href' => url('books'), + 'big' => $bs['total'] > 0 ? ($reading > 0 ? $reading . ' 本在读' : $doneN . ' 本读过') : '空书架', + 'lines' => [$bs['total'] > 0 + ? ($doneN . ' 本读完 · ' . (int)$bs['notes'] . ' 条笔记') + : '传一本进来,或先记下想读的书'], + ]; + return $cards; } @@ -437,6 +460,8 @@ final class Home 'tasks' => '', 'days' => '', 'ledger' => '', + // 读书:一本摊开的书(两页 + 书脊) + 'books' => '', ]; $d = $paths[$key] ?? ''; if ($d === '') { diff --git a/app/HomeMods.php b/app/HomeMods.php index 8f294a0..1f48661 100644 --- a/app/HomeMods.php +++ b/app/HomeMods.php @@ -29,7 +29,7 @@ final class HomeMods * 比默认导航条短一些:首页是"概览",把九个模块都铺上去就成了一面墙。 * 选的是"每天想看的那几个"。 */ - public const DEFAULT_HOME = ['tasks', 'calendar', 'days', 'movies', 'trips', 'ledger']; + public const DEFAULT_HOME = ['tasks', 'calendar', 'days', 'movies', 'trips', 'ledger', 'books']; /** 这个人首页上要显示哪些模块(按顺序) */ public static function forPerson(int $personId): array diff --git a/app/ImageSlot.php b/app/ImageSlot.php index 19b2ac0..280cf98 100644 --- a/app/ImageSlot.php +++ b/app/ImageSlot.php @@ -68,9 +68,28 @@ final class ImageSlot 'dir' => 'photos', 'hint' => '现场照片不裁剪,等比缩到最长边 1600。', ], - // 题图按 16:9 —— 宽屏横幅。需要时把它启用即可: - // 'cover' => ['label' => '题图', 'ratio' => '16:9', 'w' => 1920, 'h' => 1080, - // 'dir' => 'covers', 'hint' => '宽屏横幅,显示在详情页顶部。'], + // v24 第 14 批(用户第 5 条):**书的封面**。与海报一样是竖版(常见 2:3), + // 单独一个槽位而不是复用 poster —— 以后想给书封面换个尺寸/目录, + // 改这一行就行,不会连观影海报一起改了。 + 'bookcover' => [ + 'label' => '封面', + 'ratio' => '2:3', + 'w' => 600, + 'h' => 900, + 'dir' => 'bookcovers', + 'hint' => '书的封面是竖版,按 2:3 裁。拖动图片挪位置,滚轮缩放。', + ], + // v24 第 14 批(用户第 3 条):旅程活动的**题图**,16:9 宽屏横幅,可裁剪。 + // ⚠️ 与头像同一套机制:比例写在这一处,前端裁剪框与服务端最终裁切都读它, + // 前端**不许**再自己写一个数字('16:9' 用 parseFloat 会得到 16 → 裁成 16:1)。 + 'cover' => [ + 'label' => '题图', + 'ratio' => '16:9', + 'w' => 1600, + 'h' => 900, + 'dir' => 'covers', + 'hint' => '宽屏横幅,按 16:9 裁。拖动图片挪位置,滚轮缩放。', + ], ]; public static function exists(string $key): bool diff --git a/app/Module.php b/app/Module.php index f57d022..fa35ba3 100644 --- a/app/Module.php +++ b/app/Module.php @@ -45,6 +45,8 @@ final class Module 'days' => ['纪念日', '#B4633C', 'days'], // v24 第 15 条:财务(账本 + 账目 + 共享) 'ledger' => ['财务', '#5E7A3C', 'ledger'], + // v24 第 14 批:读书(书架 + 上传解析 + 在线读 + 笔记 + 统计) + 'books' => ['读书', '#6B5B95', 'books'], ]; /** @@ -54,7 +56,8 @@ final class Module * "毕竟,不需要每天都查看家谱"。家谱模块本身还在(个人设置里能勾回来), * 只是不再占顶栏的位置。 */ - public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources', 'tasks', 'days', 'ledger']; + public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources', + 'tasks', 'days', 'ledger', 'books']; /** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */ private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24'; diff --git a/app/Schema.php b/app/Schema.php index d1eaeff..95fe25b 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -80,7 +80,7 @@ final class Schema * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 */ - public const VERSION = '19'; + public const VERSION = '20'; /** * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 @@ -646,6 +646,90 @@ CREATE TABLE IF NOT EXISTS `regions` ( PRIMARY KEY (`code`), KEY `idx_region_parent` (`parent_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='全国行政区划(省/市/区县三级):地址结构化与导入时对齐用' +SQL, + + // ---- 读书(版本 20,用户 2026-10-05 第 14 批第 5 条)---- + // 像 calibre 那样管自己的书:书架 + 上传/导入 + 在线读 + 读完 + 笔记 + 统计。 + // + // 三张表,各管一件事,**不要**合并: + // · books —— 书本身(元数据 + 文件 + 读到哪 + 读完没读完) + // · book_chapters—— 解析出来的正文(一本书几十上百段,单独一张表, + // 否则列表页 SELECT * 会把整本书一起捞出来) + // · book_notes —— 笔记 / 划线 / 感想,**按人**记(同一个家里两个人读 + // 同一本书,各自的笔记不能混) + <<<'SQL' +CREATE TABLE IF NOT EXISTS `books` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `owner_id` INT UNSIGNED DEFAULT NULL COMMENT '谁在读(NULL = 全家共用一本)', + `creator_id` INT UNSIGNED DEFAULT NULL COMMENT '谁把它加进书架的', + `title` VARCHAR(300) NOT NULL, + `subtitle` VARCHAR(300) DEFAULT NULL, + `author` VARCHAR(200) DEFAULT NULL, + `translator` VARCHAR(200) DEFAULT NULL, + `publisher` VARCHAR(120) DEFAULT NULL, + `published` VARCHAR(40) DEFAULT NULL COMMENT '出版时间(自由文本:2012 / 2012-05 / 2012年5月)', + `isbn` VARCHAR(40) DEFAULT NULL, + `language` VARCHAR(40) DEFAULT NULL, + `category` VARCHAR(80) DEFAULT NULL COMMENT '分类(自由文本,家庭自己约定叫法)', + `tags` VARCHAR(300) DEFAULT NULL COMMENT '标签,逗号分隔', + `summary` TEXT DEFAULT NULL COMMENT '内容简介', + `cover_key` VARCHAR(255) DEFAULT NULL COMMENT '封面(走 media.php 代理,与头像同一套)', + `file_key` VARCHAR(255) DEFAULT NULL COMMENT '原书文件(books 目录下的相对键,如 books/xxx.epub)', + `file_name` VARCHAR(255) DEFAULT NULL COMMENT '上传时的文件名(下载时用)', + `file_format` VARCHAR(20) DEFAULT NULL COMMENT 'epub / txt / pdf / mobi / azw3 …', + `file_size` INT UNSIGNED DEFAULT NULL, + `pages` INT UNSIGNED DEFAULT NULL COMMENT '页数(有就填,用于统计)', + `words` INT UNSIGNED DEFAULT NULL COMMENT '字数(解析时估算)', + `chapter_count` INT NOT NULL DEFAULT 0 COMMENT '解析出多少章(0 = 没解析过)', + `status` ENUM('wish','reading','done','paused') NOT NULL DEFAULT 'wish' + COMMENT '想读 / 在读 / 读完 / 搁置', + `rating` TINYINT DEFAULT NULL COMMENT '评分 0-10(NULL = 还没打分)', + `progress` INT NOT NULL DEFAULT 0 COMMENT '读到第几章(0 = 还没开始)', + `started_at` DATE DEFAULT NULL, + `finished_at` DATE DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_book_family` (`family_id`, `status`, `updated_at`), + KEY `idx_book_owner` (`family_id`, `owner_id`), + CONSTRAINT `fk_book_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_book_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_book_creator` FOREIGN KEY (`creator_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='书架:一本书一行(元数据 + 文件 + 读到哪 + 读完没读完)' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `book_chapters` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `book_id` INT UNSIGNED NOT NULL, + `seq` INT NOT NULL DEFAULT 1 COMMENT '第几章(从 1 开始)', + `title` VARCHAR(300) DEFAULT NULL, + `content` MEDIUMTEXT DEFAULT NULL COMMENT '正文(已去标签的纯文本,段落之间换行)', + `words` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_bc_book` (`book_id`, `seq`), + CONSTRAINT `fk_bc_book` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='书的正文(解析出来的章节)' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `book_notes` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `book_id` INT UNSIGNED NOT NULL, + `person_id` INT UNSIGNED DEFAULT NULL COMMENT '谁的笔记(导入时可能不知道是谁 → NULL)', + `kind` ENUM('quote','note','review') NOT NULL DEFAULT 'note' + COMMENT 'quote=划线/原文摘录,note=想法/笔记,review=书评', + `chapter` VARCHAR(200) DEFAULT NULL COMMENT '在哪一章(导入时带得上的话)', + `loc` VARCHAR(80) DEFAULT NULL COMMENT '位置(页数 / 百分比 / 微信读书的定位串)', + `content` TEXT NOT NULL, + `source` VARCHAR(40) NOT NULL DEFAULT 'manual' COMMENT 'manual / weread / douban / …(哪来的)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_bn_book` (`book_id`, `created_at`), + KEY `idx_bn_person` (`person_id`), + CONSTRAINT `fk_bn_book` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_bn_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='读书笔记 / 划线 / 书评(按人记)' SQL, ]; } diff --git a/app/Task.php b/app/Task.php index a30a758..5f26bb6 100644 --- a/app/Task.php +++ b/app/Task.php @@ -346,6 +346,49 @@ final class Task return $out; } + /** + * 我能看到的、处于某个状态的**父任务**(**跨看板**)。 + * + * v24 第 14 批 用户第 2 条原话:「正在进行的状态的任务,应该在首页中显示。 + * 也就是说,首页上显示的任务,是和任务的状态有关,而不是和它在那个看板有关。」 + * + * ⚠️ 与 board() 的分工:board() 是"某一块看板里有什么"(列 = 看板,按 board_id 过滤); + * 这个是"处于某个状态的所有事"(**不看 board_id**)—— + * 一个人在"家务""装修"两个看板上各有一件进行中的事,首页就该看到两件。 + * 之前首页直接调 board()(它只取默认看板),于是别的看板上的事**永远不会出现在首页**。 + * + * @param string $status todo / doing / done + */ + public static function byStatus(int $familyId, int $personId, string $status): array + { + $status = self::status($status); + $st = Database::get()->prepare( + 'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color, + na.nickname AS assignee_nick, + c.name AS category_name, + b.name AS board_name, + (SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id) AS child_total, + (SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id AND k.status = "done") AS child_done + FROM tasks t + LEFT JOIN persons a ON a.id = t.assignee_id + LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ? + LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ? + LEFT JOIN task_categories c ON c.id = t.category_id + LEFT JOIN task_boards b ON b.id = t.board_id + WHERE t.family_id = ? AND t.parent_id IS NULL AND t.status = ? + AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL) + ORDER BY COALESCE(t.due_at, ?) ASC, t.position, t.id' + ); + $st->execute([$personId, $personId, $familyId, $status, $personId, $personId, '9999-12-31 00:00:00']); + $rows = $st->fetchAll(); + $children = self::childrenFor(array_map('intval', array_column($rows, 'id')), $personId); + foreach ($rows as &$r) { + $r['children'] = $children[(int)$r['id']] ?? []; + } + unset($r); + return $rows; + } + /** @return array 父任务 id => 子任务列表 */ public static function childrenFor(array $parentIds, int $viewerId = 0): array { @@ -778,6 +821,12 @@ final class Task return self::STATUS[$k] ?? '待办'; } + /** 把任意输入归一化成合法状态(路由里写提示文案用:不能拿用户传的原文去查词) */ + public static function normalizeStatus(string $v): string + { + return self::status($v); + } + public static function repeatWord(?string $k): string { $k = (string)$k; diff --git a/app/Trip.php b/app/Trip.php index a42e8ad..fbbc8c3 100644 --- a/app/Trip.php +++ b/app/Trip.php @@ -85,7 +85,159 @@ final class Trip return in_array($key, self::STAGE_ON_CALENDAR, true); } - /** 全部旅程(按出发时间倒序) */ + // ==================== 活动的时间与目的地:**从旅程派生** ==================== + // + // 用户原话(2026-10-05 第 15 批 第 2 条): + // 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间, + // 和最晚的旅程结束时间,计算出来的。目的地也不需要,因为一次活动, + // 可能去好几个城市,所以去的城市,也是根据旅程,得到的。」 + // + // ⚠️ 这三样**不再是活动的字段**(表单里删了),一律读时算出来: + // · 开始 = 最早一段旅程的 starts_at + // · 结束 = 最晚一段旅程的 ends_at(缺 ends_at 就退回它的 starts_at) + // · 城市 = 各段旅程 place 的去重集合 + // 理由与"参与人是合集"完全同一条:同一份数据存两处,迟早对不上。 + // 自己填的时间和行程里的时间一旦打架,看的人不知道该信哪个。 + // + // ⚠️ 老数据里 events.starts_at / location 可能是有值的 —— **保留不动** + // (万一将来要回退),但页面一律不读它们,只读派生值。 + // 迁移成本为 0,而"删列"是不可逆的。 + + /** + * 一条活动的派生时间与城市(单条详情用)。 + * + * @return array{starts_at:?string, ends_at:?string, cities:string[], city:string} + * city 是给"一行文字"用的拼法(如「青岛 · 威海 → 济南」)。 + */ + public static function derived(int $eventId): array + { + $st = Database::get()->prepare( + "SELECT MIN(n.starts_at) AS start_at, + MAX(COALESCE(n.ends_at, n.starts_at)) AS end_at + FROM trip_nodes n + WHERE n.event_id = ? AND n.stage IN ('plan','actual')" + ); + $st->execute([$eventId]); + $r = $st->fetch() ?: []; + $cities = self::cities($eventId); + return [ + 'starts_at' => self::dtOrNull((string)($r['start_at'] ?? '')), + 'ends_at' => self::dtOrNull((string)($r['end_at'] ?? '')), + 'cities' => $cities, + 'city' => self::citiesWord($cities), + ]; + } + + /** + * 一条活动去过的城市(各段旅程 place 去重,按**最早出现的时间**排)。 + * + * ⚠️ 顺序按时间排而不是按字母/插入顺序: + * "青岛 · 威海" 让人一眼看出先后,排成字母序就完全没有这层信息了。 + * ⚠️ skipped(未成行)的**不算去过** —— 计划去但没去成的地方不该出现在 + * "去过哪几个城市"里(这与"不上日程、不进往年今日"是同一条口径)。 + */ + public static function cities(int $eventId): array + { + // ⚠️ ORDER BY 里**不能**写聚合别名(first_at):MySQL/MariaDB 会报 +// `Reference 'first_at' not supported (reference to group function)` —— +// 而且这是**运行时**才炸(php -l 看不出),整个旅程模块直接 Fatal。 +// 写成 `ORDER BY MIN(n.starts_at), n.place` 就没问题(聚合函数本身可用)。 + $st = Database::get()->prepare( + "SELECT n.place, MIN(n.starts_at) AS first_at + FROM trip_nodes n + WHERE n.event_id = ? AND n.stage IN ('plan','actual') + AND n.place IS NOT NULL AND n.place <> '' + GROUP BY n.place + ORDER BY MIN(n.starts_at), n.place" + ); + $st->execute([$eventId]); + $out = []; + foreach ($st->fetchAll() as $r) { + $p = trim((string)$r['place']); + if ($p !== '' && !in_array($p, $out, true)) { + $out[] = mb_substr($p, 0, 60, 'UTF-8'); + } + } + return $out; + } + + /** + * 城市列表 → 一行字。 + * + * 用「 · 」分隔而不是顿号:城市名里本来就可能含顿号或逗号 + * ("海拉尔、满洲里"这种写法真有人用),混在一起会分不清有几个城市。 + */ + public static function citiesWord(array $cities): string + { + return implode(' · ', $cities); + } + + /** + * 给**一批**活动补上派生时间与城市(列表页用;N+1 会让几十趟活动明显变慢)。 + * + * ⚠️ 与 attachAttendees 同一思路:一次查询按 event_id 分桶,再挂回每行。 + */ + public static function attachDerived(array &$rows): void + { + $ids = []; + foreach ($rows as $r) { + $id = (int)($r['id'] ?? 0); + if ($id > 0) { + $ids[] = $id; + } + } + $span = []; + $city = []; + foreach ($ids as $id) { + $span[$id] = ['starts_at' => null, 'ends_at' => null]; + $city[$id] = []; + } + if ($ids) { + $in = implode(',', array_fill(0, count($ids), '?')); + $st = Database::get()->prepare( + "SELECT event_id, MIN(starts_at) AS start_at, + MAX(COALESCE(ends_at, starts_at)) AS end_at + FROM trip_nodes + WHERE event_id IN ($in) AND stage IN ('plan','actual') + GROUP BY event_id" + ); + $st->execute($ids); + foreach ($st->fetchAll() as $r) { + $id = (int)$r['event_id']; + $span[$id] = [ + 'starts_at' => self::dtOrNull((string)($r['start_at'] ?? '')), + 'ends_at' => self::dtOrNull((string)($r['end_at'] ?? '')), + ]; + } + // ⚠️ 同 cities():ORDER BY 只写聚合函数,不写别名 + $st2 = Database::get()->prepare( + "SELECT event_id, place, MIN(starts_at) AS first_at + FROM trip_nodes + WHERE event_id IN ($in) AND stage IN ('plan','actual') + AND place IS NOT NULL AND place <> '' + GROUP BY event_id, place + ORDER BY event_id, MIN(starts_at), place" + ); + $st2->execute($ids); + foreach ($st2->fetchAll() as $r) { + $id = (int)$r['event_id']; + $p = mb_substr(trim((string)$r['place']), 0, 60, 'UTF-8'); + if ($id > 0 && $p !== '' && !in_array($p, $city[$id], true)) { + $city[$id][] = $p; + } + } + } + foreach ($rows as &$r) { + $id = (int)$r['id']; + $r['starts_at'] = $span[$id]['starts_at'] ?? null; + $r['ends_at'] = $span[$id]['ends_at'] ?? null; + $r['cities'] = $city[$id] ?? []; + $r['city'] = self::citiesWord($r['cities']); + } + unset($r); + } + + /** 全部旅程(按**派生**的出发时间倒序 —— 活动自己没有时间字段了) */ public static function all(int $familyId, string $keyword = ''): array { // ⚠️ 用户 2026-10-03 第 4 条:「旅程的活动中,只显示 is_public=1 的活动」。 @@ -93,9 +245,24 @@ final class Trip // 老数据/异常情况下 trip_details 可能缺行,INNER JOIN 会让整趟旅程 // 从列表里消失("行程丢了"比"多显示一条"严重得多), // COALESCE(...,1) 表示"没有这行就算公开"。 + // ⚠️ 参与人 = **所有行程参与人的合集**(v24 第 14 批 用户第 4 条): + // 只在活动上登记、每段行程都没填成员的人也要算进来(否则老数据会"人没了")。 + // ⚠️ e.starts_at / e.ends_at / e.location 这三列**仍然 SELECT 出来**, + // 但马上会被 attachDerived() 的派生值覆盖(用户第 15 批 第 2 条)。 + // 留着 select 是为了:① 不 SELECT 的话 PHP 侧少键,若哪天有代码先读再做 + // 兜底,会出现 Undefined index;② 出问题时肉眼能对着看"老值是什么"。 $sql = 'SELECT e.id, e.title, e.starts_at, e.ends_at, e.location, e.note, d.cover_key, d.summary, COALESCE(d.is_public, 1) AS is_public, - (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count, + /* ⚠️ 不能写成 SELECT COUNT(*) FROM (… WHERE a.event_id = e.id + UNION …) u:派生表里的 e.id 是**外层**列,MySQL 8 之前 + 不支持(要 LATERAL),MariaDB 也一样 —— 直接报列不存在。 + 所以拆成两段相加:活动上登记的 + 行程里**没重复**的那些。 */ + ((SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) + + (SELECT COUNT(DISTINCT m.person_id) FROM trip_node_members m + JOIN trip_nodes n2 ON n2.id = m.node_id + WHERE n2.event_id = e.id + AND m.person_id NOT IN (SELECT a2.person_id FROM event_attendees a2 + WHERE a2.event_id = e.id))) AS attendee_count, (SELECT COUNT(*) FROM trip_nodes n WHERE n.event_id = e.id) AS node_count FROM events e LEFT JOIN trip_details d ON d.event_id = e.id @@ -103,21 +270,32 @@ final class Trip $bind = [$familyId, 'trip']; $kw = trim($keyword); if ($kw !== '') { - $sql .= ' AND (e.title LIKE ? OR e.location LIKE ? OR e.note LIKE ?)'; + // ⚠️ 搜索里的时间/城市也是**派生**的: + // 活动自己的 location 已经不用了(用户第 2 条), + // 搜城市必须搜到"在行程里填过的地方",否则搜"青岛"搜不到那趟青岛之行。 + $sql .= ' AND (e.title LIKE ? OR e.note LIKE ? + OR EXISTS (SELECT 1 FROM trip_nodes n3 + WHERE n3.event_id = e.id AND n3.place LIKE ?))'; $like = '%' . $kw . '%'; array_push($bind, $like, $like, $like); } - $sql .= ' ORDER BY e.starts_at DESC, e.id DESC'; + // 排序也走派生时间:子查询里没有行程时得排到最后(NULL) + $sql .= ' ORDER BY (SELECT MIN(n4.starts_at) FROM trip_nodes n4 + WHERE n4.event_id = e.id AND n4.stage IN ("plan","actual")) DESC, + e.id DESC'; $st = Database::get()->prepare($sql); $st->execute($bind); - return $st->fetchAll(); + $rows = $st->fetchAll(); + // 派生时间与城市:一次查询批量补(见 attachDerived 的说明) + self::attachDerived($rows); + return $rows; } /** - * 给一批旅程补上「同行的人」。 + * 给一批旅程补上「同行的人」= **所有行程参与人的合集**(用户第 4 条)。 * * ⚠️ 必须一次性补(N+1 会让列表在几十趟旅程时明显变慢,观影就是这么踩的)。 - * 一次查询拿全部 event_attendees,再按 event_id 分桶挂回去。 + * 一次查询拿全部(活动登记 ∪ 各段行程成员),再按 event_id 分桶挂回去。 */ public static function attachAttendees(array &$rows, int $viewerId = 0): void { @@ -134,14 +312,23 @@ final class Trip } if ($ids) { $in = implode(',', array_fill(0, count($ids), '?')); + // 合集:**活动上登记的人** ∪ **每段行程的参与人**(用户第 4 条)。 + // UNION 自带去重,所以同一个人既在活动上、又在三段行程里也只出现一次。 $st = Database::get()->prepare( - 'SELECT a.event_id, p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname - FROM event_attendees a JOIN persons p ON p.id = a.person_id + 'SELECT u.event_id, p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname + FROM ( + SELECT a.event_id, a.person_id FROM event_attendees a + WHERE a.event_id IN (' . $in . ') + UNION + SELECT nd.event_id, m.person_id FROM trip_node_members m + JOIN trip_nodes nd ON nd.id = m.node_id + WHERE nd.event_id IN (' . $in . ') + ) u + JOIN persons p ON p.id = u.person_id LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? - WHERE a.event_id IN (' . $in . ') - ORDER BY a.position, p.id' + ORDER BY u.event_id, p.id' ); - $st->execute(array_merge([$viewerId], $ids)); + $st->execute(array_merge($ids, $ids, [$viewerId])); foreach ($st->fetchAll() as $a) { $byEvent[(int)$a['event_id']][] = $a; } @@ -167,11 +354,72 @@ final class Trip if (!$row) { return null; } - $row['attendees'] = self::attendees($id, $viewerId); + // 参与人 = 所有行程参与人的**合集**(用户第 4 条),不是只在活动上登记的那几个 + $row['attendees'] = self::tripPeople($id, $viewerId); $row['photos'] = \App\Attachment::list('trip', $id); + // 时间与城市 = 从各段旅程派生(用户第 15 批 第 2 条)。 + // ⚠️ **覆盖**掉 events 上的老值:老数据可能自己填过起止与目的地, + // 但页面必须以行程为准 —— 否则会出现"活动写着 8/1–8/5,行程只有 8/3 一段" + // 这种自相矛盾的显示,而看的人无从判断该信哪个。 + $d = self::derived($id); + $row['starts_at'] = $d['starts_at']; + $row['ends_at'] = $d['ends_at']; + $row['cities'] = $d['cities']; + $row['city'] = $d['city']; + $row['location'] = $d['city']; // 模板里还有读 location 的地方,一并给派生值 return $row; } + /** + * 一趟旅程的参与人 = **所有行程(节点)参与人的合集**(v24 第 14 批 用户第 4 条)。 + * + * 用户原话:「一个活动的参与人,应该是这个活动下的所有旅行(程)的参与人的合集才对。」 + * + * 为什么不在保存行程时"顺手同步"到活动上: + * 那就是同一份名单存两处,改行程要记得改活动、删行程要记得减人 —— + * 任何一处漏了就对不上。合求是**读的时候算出来的**,永远不会漂移。 + * + * ⚠️ 取的是**并集**(活动上单独登记的人也保留): + * 老数据里很多人只在活动上登记过、每段行程没填成员, + * 只按行程算会让这些人"凭空消失"。 + * + * @return array 去重后的人(带「我叫他什么」与主色) + */ + public static function tripPeople(int $eventId, int $viewerId = 0): array + { + $st = Database::get()->prepare( + 'SELECT DISTINCT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname + FROM ( + SELECT a.person_id FROM event_attendees a WHERE a.event_id = ? + UNION + SELECT m.person_id FROM trip_node_members m + JOIN trip_nodes nd ON nd.id = m.node_id + WHERE nd.event_id = ? + ) u + JOIN persons p ON p.id = u.person_id + LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? + ORDER BY p.id' + ); + $st->execute([$eventId, $eventId, $viewerId]); + return $st->fetchAll(); + } + + /** 同上,但只给一个**人数**(列表页用,避免为每行再拉一堆人) */ + public static function tripPeopleCount(int $eventId): int + { + $st = Database::get()->prepare( + 'SELECT COUNT(*) FROM ( + SELECT a.person_id FROM event_attendees a WHERE a.event_id = ? + UNION + SELECT m.person_id FROM trip_node_members m + JOIN trip_nodes nd ON nd.id = m.node_id + WHERE nd.event_id = ? + ) u' + ); + $st->execute([$eventId, $eventId]); + return (int)$st->fetchColumn(); + } + /** 同行的人(带「我叫他什么」与主色,页面按人画标签) */ public static function attendees(int $eventId, int $viewerId = 0): array { @@ -190,13 +438,16 @@ final class Trip public static function create(int $familyId, ?int $creatorId, array $d): int { $db = Database::get(); + // ⚠️ 用户第 15 批 第 2 条:活动**不存**起止时间与目的地 —— + // 它们从各段旅程派生(见 derived())。这里显式写 NULL, + // 不接表单传来的值(表单里也没有这些字段了)。 + // events.starts_at / ends_at / location 保持可空,只是为了不动表结构。 $db->prepare( "INSERT INTO events (family_id, creator_id, kind, title, starts_at, ends_at, location, note, source_ref) - VALUES (?, ?, 'trip', ?, ?, ?, ?, ?, ?)" + VALUES (?, ?, 'trip', ?, NULL, NULL, NULL, ?, ?)" )->execute([ $familyId, $creatorId, (string)$d['title'], - self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''), - self::textOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''), + self::textOrNull($d['note'] ?? ''), $d['source_ref'] ?? null, ]); $id = (int)$db->lastInsertId(); @@ -208,13 +459,15 @@ final class Trip public static function update(int $familyId, int $id, array $d): void { $db = Database::get(); + // ⚠️ 与 create 同理:**不碰** starts_at / ends_at / location。 + // 连"置 NULL"都不做 —— 老数据里可能存过值,编辑一次活动不该把它抹掉 + // (页面反正不读它们;真出问题时还能拿老值对着看)。 $db->prepare( - "UPDATE events SET title=?, starts_at=?, ends_at=?, location=?, note=? + "UPDATE events SET title=?, note=? WHERE id=? AND family_id=? AND kind='trip'" )->execute([ (string)$d['title'], - self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''), - self::textOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''), + self::textOrNull($d['note'] ?? ''), $id, $familyId, ]); self::saveDetails($id, $d); @@ -552,6 +805,25 @@ final class Trip // ---------------- 展示 ---------------- /** 起止日期的人话展示(与 Movie::formatWatch 同款口径) */ + /** + * 活动的时间文案(用户第 15 批 第 2 条:时间是从旅程算出来的)。 + * + * ⚠️ 空的时候**不能**沿用 formatSpan 的「日期待补」:那是给"一段没填时间的行程" + * 用的短语,放在活动上会让人以为"这里本来该由我填、我忘了填"。 + * 活动的时间本来就该由旅程决定 —— 没有旅程时要说清楚是**去加一段旅程**。 + */ + public static function activitySpan(?string $start, ?string $end): string + { + if ($start === null || $start === '') { + return '还没有旅程'; + } + $s = date('Y年n月j日', (int)strtotime($start)); + if ($end === null || $end === '') { + return $s . ' 起'; + } + return $s . ' – ' . date('Y年n月j日', (int)strtotime($end)); + } + public static function formatSpan(?string $start, ?string $end): string { if ($start === null || $start === '') { diff --git a/assets/css/app.css b/assets/css/app.css index ac8a782..5cf0ab4 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -2507,6 +2507,23 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, .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); } +/* ---- 状态徽标(v24 第 14 批 用户第 2 条:每张卡都有,点一下推进到下一档 ---- + 三档各有颜色:待办灰 / 进行中品牌色 / 已完成绿 —— 不靠位置也能一眼看出做到哪一步。 + ⚠️ .is-static(只读账号)长得一样但不可点:不给可点的错觉。 */ +.kb-st { + display: inline-flex; align-items: center; gap: 4px; + padding: 1px 8px; border-radius: 999px; border: 1px solid transparent; + font-size: 11px; line-height: 1.7; white-space: nowrap; cursor: pointer; +} +.kb-st.is-todo { background: var(--line-2); color: var(--muted); border-color: var(--line); } +.kb-st.is-doing { background: rgba(168, 74, 40, .12); color: var(--brand-ink); border-color: rgba(168, 74, 40, .3); } +.kb-st.is-done { background: rgba(111, 158, 92, .14); color: #4C7038; border-color: rgba(111, 158, 92, .35); } +.kb-st.is-static { cursor: default; } +.kb-st.is-doing:hover { border-color: var(--brand); } +/* .is-on = "当前就是这一档"(读书详情页的四个状态按钮用它标选中): + 加一圈内描边而不是换底色 —— 底色已经表达"这一档是什么颜色", + 再换一次就会分不清"选中"和"这一档"。 */ +.kb-st.is-on { box-shadow: inset 0 0 0 1px currentColor; font-weight: 600; } .kb-due.is-late, .kb-late { color: var(--red); } .kb-ico { white-space: nowrap; } .kb-sub { display: flex; align-items: center; gap: 8px; } @@ -2587,6 +2604,21 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, .avatar-picker #avatar-preview[data-has-avatar="0"] { border-style: dashed; } .avatar-hint { line-height: 1.7; } +/* ---- 旅程题图(v24 第 14 批 用户第 3 条):与头像同一套裁剪,比例 16:9 ---- + 预览块本身就是"选文件"的点击区(与头像一样,省掉一整行重复的文件控件)。 + ⚠️ 比例写死成 16/9 是**故意**的:CSS 不读 ImageSlot,而槽位里的比例就是 16:9, + 两边各写一份会漂移 —— 部署脚本有一条断言比对 app.js 与 CSS 里的比例数字。 */ +.cover-picker { display: flex; align-items: flex-start; gap: 14px; flex-wrap: wrap; } +.cover-picker #cover-preview { + width: 240px; max-width: 100%; aspect-ratio: 16 / 9; + object-fit: cover; border-radius: 12px; + border: 1px solid var(--line); background: #F1E7D6; cursor: pointer; + transition: border-color .15s ease, box-shadow .15s ease; +} +.cover-picker #cover-preview:hover { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(168,74,40,.12); } +.cover-picker #cover-preview[data-has-cover="0"] { border-style: dashed; } +.trip-people-block { display: flex; flex-direction: column; gap: 6px; margin: 8px 0; } + /* ---- 工作经历(用户第 4 条:可以有多个)---- */ .job-list { display: flex; flex-direction: column; gap: 8px; } .job-row { @@ -2790,15 +2822,41 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, /* 下拉里放的是 Semi 的**内联**日历面板,去掉它自己的浮层阴影/描边避免双层框 */ } .cal-jump-drop[hidden] { display: none; } -.cal-jump-drop .semi-datepicker, -.cal-jump-drop .semi-popover { - box-shadow: none !important; - border: 0 !important; - background: var(--surface) !important; +/* ---- v24 第 14 批 #1:下拉里是**自绘**的日历(不再挂 Semi 的浮层面板)---- + 「上一月/上一年」只重画面板、绝不提交(这正是不引第三方控件的原因: + 它把"翻月"与"选中"混在同一个 onChange 里,于是翻月也会跳走)。 */ +.cjd-head { + display: flex; align-items: center; gap: 4px; margin-bottom: 6px; } -.cal-jump-drop .semi-datepicker { - display: block; +.cjd-title { flex: 1; text-align: center; font-weight: 600; font-size: 14px; } +.cjd-nav { + width: 28px; height: 28px; flex: 0 0 auto; + display: inline-flex; align-items: center; justify-content: center; + border: 1px solid var(--line); border-radius: 8px; + background: var(--surface); color: var(--ink); font-size: 14px; line-height: 1; + cursor: pointer; } +.cjd-nav:hover { background: var(--line); } +.cjd-grid { + display: grid; grid-template-columns: repeat(7, 32px); gap: 2px; +} +.cjd-wd { + text-align: center; font-size: 11px; color: var(--muted); padding: 2px 0 4px; +} +.cjd-day { + width: 32px; height: 30px; padding: 0; + border: 0; border-radius: 8px; background: transparent; color: var(--ink); + font-size: 13px; cursor: pointer; +} +.cjd-day:hover { background: var(--line); } +.cjd-day.is-out { color: var(--muted); opacity: .55; } +.cjd-day.is-today { font-weight: 700; box-shadow: inset 0 0 0 1px var(--line); } +.cjd-day.is-on { background: var(--brand); color: #fff; } +.cjd-foot { + display: flex; align-items: center; gap: 8px; + margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line); +} +.cjd-foot .cjd-sel { flex: 1; } /* ==================== 周 / 天:按小时铺的时间网格 ==================== */ /* 结构:左边一条"小时刻度"沟槽(tg-gutter),右边 N 个日期列(tg-col)。 @@ -2960,3 +3018,192 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, padding: 9px 11px; border: 1px dashed var(--line); border-radius: var(--radius-s); background: var(--line-2); font-size: 13px; } + +/* ============================================================ + v24 第 14 批:读书模块(用户第 5 条) + 卡片骨架 / 统计图表一律复用现成的(.card.card-pad / .stats-grid / + .stat-card / .chart-card / .bar-chart / .vgrid),这里只写读书**特有**的部件。 + 图表仍然是纯 CSS:不引图表库,服务端画出来、没 JS 也看得见。 + ============================================================ */ + +/* ---- 书架:状态筛选条 ---- */ +.book-filter { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; } +.bf-btn { + display: inline-flex; align-items: center; gap: 5px; + padding: 5px 14px; border-radius: 999px; + border: 1px solid var(--line); background: var(--surface); + font-size: 13px; color: var(--ink); text-decoration: none; + transition: border-color .15s ease, background .15s ease; +} +.bf-btn:hover { border-color: var(--brand); text-decoration: none; } +.bf-btn.is-on { background: var(--brand); border-color: var(--brand); color: #fff; } +.bf-n { font-size: 11px; opacity: .72; font-variant-numeric: tabular-nums; } + +/* ---- 书架:卡片网格 ---- + 封面按 2:3(书的形状),宽度自适应;窄屏自动减列。 */ +.book-grid { + display: grid; gap: 14px; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); +} +.book-card { + display: flex; flex-direction: column; gap: 8px; + background: var(--surface); border: 1px solid var(--line); + border-radius: var(--radius); padding: 12px; box-shadow: var(--shadow); +} +/* ⚠️ 封面是 (整块可点):居中靠 flex,图自己铺满 —— 只写一次 display, + 写两遍后者会把前者悄悄改掉(这类"重复声明"最容易在改版时留成陷阱)。 */ +.book-cover { + display: flex; align-items: center; justify-content: center; + aspect-ratio: 2 / 3; border-radius: var(--radius-s); + overflow: hidden; background: #F1E7D6; +} +.book-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.book-cover-empty { font-size: 32px; opacity: .5; } +.book-body { display: flex; flex-direction: column; gap: 5px; min-width: 0; } +.book-title { + font-size: 14px; font-weight: 600; color: var(--ink); line-height: 1.45; + text-decoration: none; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +} +.book-title:hover { color: var(--brand); } +.book-author { font-size: 12px; color: var(--muted); } +.book-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; } +.book-rate { font-size: 11px; color: var(--brand-ink); } +.book-note-n { font-size: 11px; color: var(--muted); } +/* 读到第几章的细进度条 */ +.bk-bar { + height: 4px; border-radius: 999px; background: var(--line-2); overflow: hidden; margin: 2px 0; +} +.bk-bar i { display: block; height: 100%; background: var(--brand); } +.book-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: auto; } + +/* ---- 一本书 ---- */ +.book-hero { display: flex; gap: 20px; flex-wrap: wrap; } +.book-hero-cover { + width: 150px; max-width: 46%; flex: 0 0 auto; + aspect-ratio: 2 / 3; border-radius: var(--radius-s); + overflow: hidden; background: #F1E7D6; + display: flex; align-items: center; justify-content: center; +} +.book-hero-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.book-hero-body { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 10px; } +.book-sub { margin: 0; font-size: 14px; color: var(--muted); } +.book-states { display: flex; flex-wrap: wrap; gap: 6px; } +.book-sum { margin-top: 4px; } +.book-toc-box { margin-top: 4px; } +.book-toc { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 3px; } +.book-toc li { font-size: 13.5px; display: flex; align-items: baseline; gap: 8px; } +.book-toc li.is-cur > a { color: var(--brand); font-weight: 600; } +.bk-file-now { margin: 0 0 6px; font-size: 13px; color: var(--muted); } + +/* ---- 笔记 ---- */ +.book-note-form { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; } +.bn-row { display: flex; gap: 8px; flex-wrap: wrap; } +.bn-row .select { flex: 0 0 auto; width: auto; min-width: 96px; } +.bn-row .input { flex: 1 1 160px; min-width: 0; } +.book-notes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } +.book-note { + border: 1px solid var(--line-2); border-radius: var(--radius-s); + padding: 9px 12px; background: #FDFBF7; +} +.book-note.is-quote { border-left: 3px solid var(--brand-2); } +.book-note.is-review { border-left: 3px solid var(--green); } +.bn-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 5px; } +.bn-kind { + font-size: 11px; padding: 1px 7px; border-radius: 999px; + background: var(--line-2); color: var(--brand-ink); +} +.bn-ch, .bn-src, .bn-who { font-size: 11.5px; color: var(--muted); } +.bn-del { margin-left: auto; } +.bn-body { font-size: 14px; line-height: 1.75; color: var(--ink); white-space: normal; } + +/* ---- 阅读器 ---- + 目录在侧栏、正文在右边;窄屏侧栏折到上面(而不是横向挤成一条)。 */ +.book-read { display: flex; gap: 18px; align-items: flex-start; } +.br-side { + flex: 0 0 210px; max-height: 72vh; overflow-y: auto; + background: var(--surface); border: 1px solid var(--line); + border-radius: var(--radius); padding: 12px 14px; +} +.br-side-h { font-size: 13px; font-weight: 600; color: var(--muted); margin-bottom: 8px; } +.br-toc { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } +.br-toc li { font-size: 13px; } +.br-toc li a { + display: block; padding: 4px 8px; border-radius: 6px; color: var(--ink); text-decoration: none; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.br-toc li a:hover { background: var(--line-2); } +.br-toc li.is-cur a { background: rgba(168, 74, 40, .12); color: var(--brand-ink); font-weight: 600; } +.br-main { + flex: 1 1 auto; min-width: 0; + background: var(--surface); border: 1px solid var(--line); + border-radius: var(--radius); padding: 22px 26px; box-shadow: var(--shadow); +} +.br-ch-title { font-size: 19px; margin: 0 0 14px; } +.br-body { font-size: 16px; line-height: 1.95; color: var(--ink); } +.br-nav { display: flex; align-items: center; gap: 12px; margin-top: 22px; flex-wrap: wrap; } +.br-pos { margin-left: auto; } +.br-pdf object { width: 100%; height: 78vh; border: 0; display: block; } +@media (max-width: 860px) { + .book-read { flex-direction: column; } + .br-side { flex: 1 1 auto; width: 100%; max-height: 260px; } + .br-main { padding: 16px 18px; } + .br-body { font-size: 15.5px; line-height: 1.9; } +} + +/* ---- 封面裁剪(书的封面是 2:3,与旅程题图的 16:9 不同)---- + 与 .cover-picker 同一套结构,只是预览框换成竖的。 */ +.book-cover-picker #book-cover-preview { + width: 150px; aspect-ratio: 2 / 3; +} + +/* ---- 导入读书笔记 ---- */ +.imp-how { margin: 0 0 10px; padding-left: 20px; display: flex; flex-direction: column; gap: 5px; } +.imp-how li { font-size: 13.5px; line-height: 1.7; } +.imp-list { list-style: none; margin: 0 0 14px; padding: 0; display: flex; flex-direction: column; gap: 10px; } +.imp-book { + border: 1px solid var(--line-2); border-radius: var(--radius-s); + padding: 10px 12px; background: #FDFBF7; +} +.imp-bh { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; } +.imp-t { font-size: 14.5px; font-weight: 600; } +.imp-n { margin-left: auto; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; } +.imp-notes { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 5px; } +.imp-notes li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; font-size: 13px; } +.imp-notes li.is-quote .imp-txt { color: var(--muted); } +.imp-txt { flex: 1 1 240px; min-width: 0; line-height: 1.6; } +.imp-confirm { margin-top: 6px; } + +/* ---- 柱状图:列数不定的(每年的本数 / 评分档位)---- + 观影那套 .vgrid 是写死 12 列的(12 个月),这里列数随数据变。 */ +.vgrid-auto { grid-template-columns: repeat(auto-fit, minmax(26px, 1fr)); } + +/* ============================================================ + v24 第 15 批:活动(容器)/ 旅程(行程)两级语义 + 术语改了(用户第 1 条),活动表单砍到三项(用户第 4 条)。 + ============================================================ */ + +/* ---- 活动表单里的"说明一句"(时间/城市从哪来)---- + 用左边一条竖线而不是整块底色:它是**解释**、不是输入项, + 染成和输入区一样的底色会让人以为这里要填东西。 */ +.form-note { + margin: 0 0 14px; padding: 8px 0 8px 12px; + border-left: 3px solid var(--line); + font-size: 13px; line-height: 1.75; color: var(--muted); +} +.form-note b { color: var(--ink); font-weight: 600; } + +/* ---- 「更多设置」折叠区:主流程只留三项,其余收在这里 ---- */ +.more-set { + margin: 4px 0 14px; border: 1px solid var(--line); + border-radius: var(--radius-s); background: #FDFBF7; +} +.more-set > summary { + cursor: pointer; padding: 10px 14px; + font-size: 13.5px; color: var(--muted); font-weight: 500; +} +.more-set > summary:hover { color: var(--brand); } +.more-set[open] > summary { border-bottom: 1px solid var(--line-2); } +.more-set .form-grid { padding: 14px; margin: 0; } +/* 折叠区里的字段少一点内边距,看起来是"附带的"而不是主流程 */ +.more-set .field { margin-bottom: 0; } diff --git a/assets/js/app.js b/assets/js/app.js index 7a40d3e..0fb1db4 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -300,6 +300,34 @@ } })(); + /** + * 通用「点预览图 = 打开文件选择框」。 + * + * ⚠️ 为什么必须做成通用的(2026-10-05 第 15 批 用户第 3 条实测踩到): + * 原来这句 `prev.addEventListener('click', () => file.click())` 只写在 + * **templates/people/form.php 的内页脚本**里(那是"头像"专用的)。 + * 后来加的「旅程题图」「图书封面」照抄了同一套 DOM(.cover-picker + + * #cover-preview + 隐藏的 file input),却**没有对应的 JS** —— + * 于是图块看着能点(CSS 给了 cursor:pointer 与 hover 高亮), + * 点下去什么都不会发生,用户以为功能坏了(原话:"点击哪里都没反应")。 + * + * 做法:谁想"点图选文件",就在图上写 `data-pick-input="#文件框id"`, + * 这里统一接管(一个页面有多个也各绑各的)。声明式,模板里不再各写一段 JS。 + */ + Array.prototype.forEach.call( + document.querySelectorAll('[data-pick-input]'), + function (pv) { + var sel = pv.getAttribute('data-pick-input'); + var file = sel ? document.querySelector(sel) : null; + if (!file) { return; } + // ⚠️ 用 + '2:3', 'w' => 600, 'h' => 900, + 'label' => '封面', 'hint' => '']; +$curCover = $isEdit ? (string)($b['cover_key'] ?? '') : ''; +$rate = $isEdit && $b['rating'] !== null ? (int)$b['rating'] : -1; +$ownerSel = $isEdit && (int)($b['owner_id'] ?? 0) > 0 ? [(int)$b['owner_id']] : []; +?> +
+

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

+ 已存文件: + () + 0): ?> + · 已解析 章 + +
再选一个文件会替换它,并按新文件重新解析正文。 +

+ + + + EPUB 与 TXT 会自动读出书名 / 作者 / 简介 / 封面,并把正文拆成章节(可以直接在线读)。 + PDF 只存文件(用浏览器自带的阅读器看)。MOBI / AZW3 存文件、正文解析不了。 + +
+ +
+
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+ + +
+ +
+ + + + + + 点左边那块选封面,选完弹裁剪框。
+ 上传 EPUB 时如果书里自带封面,会**自动**用它(你另外选一张就覆盖它)。 +
+
+
+
+ +
+ + 取消 +
+
+
diff --git a/templates/books/index.php b/templates/books/index.php new file mode 100644 index 0000000..2a03cc6 --- /dev/null +++ b/templates/books/index.php @@ -0,0 +1,164 @@ + '全部'] + \App\Book::STATUS; +?> +
+

读书

+ + 共 本 0 + ? ' · ' . (int)$counts['reading'] . ' 本在读' : '' ?> + +
+ + 添加图书 + + 只读账号 + + +
+
+ + + + + + + +
+ 0 || $status !== '') ? '没有匹配的书' : '书架还是空的' ?> + 0 || $status !== ''): ?> + 换个条件试试,或清除筛选看全部。 + + + 点右上角「添加图书」—— 可以上传 EPUB / TXT 让它自己读出书名作者与正文, + 也可以只手填一条"想读的书"。 + + 家人还没有往书架上放书。 + + +
+ +
+ + +
+ + + + + 📖 + + +
+ + + + +
+ +
+ + + + + /10 + + 0): ?> + ✎ + + 0): ?> + 章 + +
+ 0 && (int)($b['progress'] ?? 0) > 0): ?> + +
+ 读到第 章 + +
+ + 读 + + + 改 + +
+ + + + + + +
+ + +
+
+
+ +
+ diff --git a/templates/books/notes_import.php b/templates/books/notes_import.php new file mode 100644 index 0000000..3dae7c1 --- /dev/null +++ b/templates/books/notes_import.php @@ -0,0 +1,138 @@ + +
+

导入读书笔记

+ 从微信读书、豆瓣等 App 复制出来的,直接粘进来 +
+ 返回书架 + +
+
+ + + +
+
+ +
+ + + + 认得这些写法:书名用 ## 书名 或 《书名》; + 章节用 ◆/● 开头;划线用 > 开头; + 想法用 >> 开头;作者可以写 作者:余华。 + 认不出的行会按上下文归类 —— 宁可归类得不完美,也不丢字。 + +
+
+ + + 只是个标记,笔记旁边会写"来自哪里"。 +
+
+ + 取消 +
+
+
+ +
+

怎么从 App 里复制出来

+
    +
  • 微信读书:书里点右上角「…」→「笔记」→ 长按选中 / 全选 → 复制,粘到这里。
  • +
  • 豆瓣读书:我读过的书 → 某本书的"读书笔记"页 → 选中复制。
  • +
  • 其它:只要文本里有书名行,大多能认出来;认错了下面会先给你看。
  • +
+

+ 导入时按书名匹配书架上已有的书 —— 匹配上就挂到那本书下面, + 匹配不上才新建一本(并标成"读完")。 +

+
+ + + + +
+

+ 认出了 本书、 条笔记 + 看一眼再导,别急着确认 +

+ + +

+ 一段笔记都没认出来。多半是没贴到内容,或者全贴成了空行 —— 退回去重新粘一次。 +

+ + +
    + $b): ?> + +
  • +
    + + + + + 条 +
    + +
      + +
    • + + + + + +
    • + +
    + 3): ?> +

    …还有 条,导入时一起进。

    + + +

    这本书下面没认到笔记,导入时会跳过。

    + +
  • + +
+ + +
+ + + +
+ + 重新粘贴 +
+
+ +
+ + diff --git a/templates/books/read.php b/templates/books/read.php new file mode 100644 index 0000000..533e57b --- /dev/null +++ b/templates/books/read.php @@ -0,0 +1,107 @@ + 1 ? $seq - 1 : 0; +$next = $seq < $total ? $seq + 1 : 0; +?> +
+

+ + + +
+ + +
+ + + + +
+ + 这本书 + 书架 + +
+
+ +
+ 0): ?> + + + +
+ + +
+ +

+ 这个浏览器不能直接内嵌看 PDF。 + 点这里下载后再看。 +

+
+
+ +

+ 这本书还没有解析出正文。 + 到「编辑」里重新上传一次文件(EPUB / TXT 会自动拆章)。 +

+ +
+

+ +
+
+ +
+ 0): ?> + + ← 上一章 + + + / + 0): ?> + + 下一章 → + + + 已经是最后一章了 + +
+ +
+
diff --git a/templates/books/show.php b/templates/books/show.php new file mode 100644 index 0000000..ef70b8c --- /dev/null +++ b/templates/books/show.php @@ -0,0 +1,220 @@ + 0 || \App\BookParser::inlinable($b['file_format'] ?? '')); +?> +
+

+ + · + + +
+ + + 0 ? '继续读' : '开始读' ?> + + + + 编辑 +
+ + + +
+ + 返回书架 + +
+
+ +
+
+ + + + 📖 + +
+
+ +

+ + + +
+ $sv): ?> + +
+ + + + +
+ + + + +
+ +
+ +
作者
+ + +
译者
+ + +
出版社
+ + +
出版时间
+ + +
分类
+ + +
标签
+ + 0): ?> +
页数
页
+ + 0): ?> +
字数
+ + +
评分
/ 10
+ + +
开始读
+ + +
读完于
+ + +
+
文件
+
+ + 0): ?> + · + + · 下载 +
+
+ + +
在读的人
+ +
+ + +
+

内容简介

+

+
+ + + +
+

目录( 章)

+
    + + > + + + + 0): ?> + 字 + + + +
+
+ +
+
+ + +
+

+ 笔记与划线() + 读的时候留下的字;也可以从微信读书等 App 导入 +

+ + +
+ + +
+ + +
+ +
+ + 批量导入 +
+
+ + + +

还没有笔记。读到哪记到哪,以后翻起来才有意思。

+ +
    + + +
  • +
    + + + + + + 来自 + + + + + + +
    + + + +
    + +
    +
    +
  • + +
+ +
diff --git a/templates/books/stats.php b/templates/books/stats.php new file mode 100644 index 0000000..48ed221 --- /dev/null +++ b/templates/books/stats.php @@ -0,0 +1,140 @@ + +
+

读书统计

+ 书架上一共 本 +
+ 返回书架 + +
+
+ +
+
读完
+
在读
+
想读
+
搁置
+
+
0 ? e(\App\Book::wordsWord($s['words'])) : '—' ?>
+
读完的总字数
+
+
+
0 ? $num($s['pages']) : '—' ?>
+
读完的总页数
+
+
笔记条数
+
+
0 ? (float)$s['avgRate'] : '—' ?>
+
平均评分
+
+
+ +
+

这些年来,每年读完几本 按「读完那天」算

+ +

还没有标完「读完」的书。读完一本,这里就多一根。

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

评分分布 0 ~ 10 分各有几本

+ +

还没给任何一本书打过分。

+ +
+ $n): ?> + +
+ + + +
+ +
+ +
+ +
+

读得最多的作者 书架上有几本他的书

+ +

书里还没填作者。

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

分类 书架上的书都堆在哪几类

+ +

还没有给书分类。编辑一本书时填「分类」就有了。

+ +
+ +
+ + + + + +
+ +
+ +
diff --git a/templates/calendar/index.php b/templates/calendar/index.php index 4277526..f713322 100644 --- a/templates/calendar/index.php +++ b/templates/calendar/index.php @@ -82,7 +82,16 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts) 如果是周视图,那么就是跳转到这一天所在的周,如果是月视图,则跳转到这一天所在的月」 · 「选择这个日期,不用弹窗,直接把日历选择,弹出在这个按钮下面就可以了」 → ① 不再固定 view=day:带上**当前视图**(月/周/日各自跳到对应跨度); - ② 日期选择从"居中弹窗"改成**贴着按钮下方展开的下拉日历**。 */ ?> + ② 日期选择从"居中弹窗"改成**贴着按钮下方展开的下拉日历**。 + + 🔴 2026-10-05 第 14 批 #1(用户报严重 bug):「点上一月,页面直接跳转, + 我还没选择日期呢」。根因是这一版下拉里挂的是 Semi 的 DatePicker —— + 它的日历面板**渲染在 body 的 portal 里**(不在下拉容器内), + 而它在面板上翻月时会把"面板当前显示的日期"当成新值回调 onChange, + 于是"翻月"被当成了"选中"。第三方控件把"翻页"与"选中"混在一个回调里, + 这个语义我们改不了。 + → 改成**自绘内联日历**(几十行、零依赖):翻月/翻年只重绘面板、绝不提交, + 只有点**某一天**才跳转,语义 100% 由我们自己定。 */ ?>
- +