v24 第 15 批:活动 / 旅程两级语义(术语 · 派生时间城市 · 表单三项 · 题图可点)
用户原话四条,逐条落地:
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 与线上逐一核对一致。
This commit is contained in:
+638
@@ -0,0 +1,638 @@
|
||||
<?php
|
||||
/**
|
||||
* 读书(v24 第 14 批 用户第 5 条):像 calibre 那样管自己的书。
|
||||
*
|
||||
* 用户原话:「增加读书模块,就像 calibre 一样管理自己的图书,可以导入图书,
|
||||
* 可以上传图书,可以解析各种格式的图书,可以点开图书进行查看、阅读,
|
||||
* 可以标记为读完,有统计功能,有笔记功能,可以导入微信读书等
|
||||
* 各种读书软件的读书笔记等功能,在导航条上进行展示。」
|
||||
*
|
||||
* 三张表(见 Schema v20):
|
||||
* books 书本身(元数据 + 文件 + 读到哪 + 读完没读完)
|
||||
* book_chapters 解析出来的正文
|
||||
* book_notes 笔记 / 划线 / 书评(**按人**记)
|
||||
*
|
||||
* ⚠️ 与观影/旅程不同,读书**不走统一事件原语**:
|
||||
* 一次观影、一趟旅行都是"某一天发生的事"(有时间、有人、要上日历),
|
||||
* 而一本书是"一段持续几个月的状态"(哪天读到第几章才勉强算事件)。
|
||||
* 硬塞进 events 会让日历上挂一堆"在读《活着》"的日常噪音。
|
||||
* 真要上日历的那天是"读完的那天"—— 将来要做的话,也是从 finished_at 派生。
|
||||
*
|
||||
* 状态机(wish → reading → done,另有 paused 搁置):
|
||||
* · 标成 done 时记 finished_at;退回非 done 时**清空**它(不然统计口径会漂:
|
||||
* "读完的书"里混着一本其实还在读的)。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Book
|
||||
{
|
||||
/** 状态:想读 / 在读 / 读完 / 搁置 */
|
||||
public const STATUS = [
|
||||
'wish' => '想读',
|
||||
'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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user