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:
2026-10-05 10:58:17 +08:00
parent 89b956205d
commit 7138314dc7
30 changed files with 4177 additions and 290 deletions
+489 -38
View File
@@ -14,6 +14,9 @@ use App\Account;
use App\Attachment;
use App\Auth;
use App\Birthday;
use App\Book;
use App\BookNoteImport;
use App\BookParser;
use App\Calendar;
use App\Cinema;
use App\ContactImport;
@@ -688,9 +691,13 @@ switch ($r) {
}));
// 进行中的任务 + 已经过期的待办(**只有这些才值得占首页**;
// 没截止日期的待办放任务看板里看,堆到首页会变成一张长长的清单)
$board = Task::board($familyId, $me);
$tasks = $board['doing'];
foreach ($board['todo'] as $t) {
//
// ⚠️ v24 第 14 批 用户第 2 条:**按状态取,不看板取**。
// 原话:「首页上显示的任务,是和任务的状态有关,而不是和它在那个看板有关」。
// 原来调的是 Task::board() —— 它只取**默认看板**,于是建在别的看板上的
// 「进行中」永远上不了首页(用户看到的就是"我明明在做,首页却没有")。
$tasks = Task::byStatus($familyId, $me, 'doing');
foreach (Task::byStatus($familyId, $me, 'todo') as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) {
$tasks[] = $t;
}
@@ -714,13 +721,14 @@ switch ($r) {
);
$st7->execute([$familyId, $today, $weekEnd, $me, $me]);
$ev7 = (int)$st7->fetchColumn();
$todoN = count($board['todo']);
// ⚠️ 同上(用户第 2 条):这两个数字也是**按状态、跨看板**算的。
// $board 在首页已经不再使用(它只取默认看板)—— 留着它只会让统计与
// 上面的清单对不上("清单上有 3 件,卡片却说 1 件在做")。
$todoN = count(Task::byStatus($familyId, $me, 'todo'));
$lateN = 0;
foreach ([$board['todo'], $board['doing']] as $col) {
foreach ($col as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) {
$lateN++;
}
foreach ($tasks as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) {
$lateN++;
}
}
$meName = Nickname::display($me, $people[$me] ?? null) ?: (string)($people[$me]['name'] ?? '');
@@ -2756,7 +2764,7 @@ switch ($r) {
case 'trips/new':
Auth::requireWrite();
View::render('trips/form', [
'title' => '记一趟旅程',
'title' => '记一次活动',
't' => null,
// 新建时默认勾上「我自己」—— 一趟旅程里几乎总有我,少点一下
'presetAttendees' => [(int)Auth::personId()],
@@ -2770,7 +2778,7 @@ switch ($r) {
$familyId = (int)Auth::familyId();
$t = Trip::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId());
if (!$t) {
flash('err', '找不到这趟旅程。');
flash('err', '找不到这次活动。');
redirect('trips');
}
View::render('trips/form', [
@@ -2790,16 +2798,17 @@ switch ($r) {
$id = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
$t = $isEdit ? Trip::find($familyId, $id) : null;
if ($isEdit && !$t) {
flash('err', '找不到这趟旅程。');
flash('err', '找不到这次活动。');
redirect('trips');
}
$startsAt = read_dt_input('starts_at');
$endsAt = read_dt_input('ends_at');
$d = [
'title' => trim((string)($_POST['title'] ?? '')),
'starts_at' => (string)($startsAt ?? ''),
'ends_at' => (string)($endsAt ?? ''),
'location' => trim((string)($_POST['location'] ?? '')),
// ⚠️ 用户第 15 批 第 2 条:活动**不收**起止时间与目的地 ——
// 时间由各段旅程的起止算出来、城市由各段旅程的 place 去重得到。
// 表单里已经没有这些字段;这里不是"忘了读",是**故意不读**。
// ($startsAt / $endsAt 的 read_dt_input 一并删掉了,
// 否则"表单没这两个字段 → 读出 null → 把库里老值冲成 NULL",
// 会静默毁掉迁移前的数据。)
'note' => trim((string)($_POST['note'] ?? '')),
'summary' => trim((string)($_POST['summary'] ?? '')),
// 用户 2026-10-03 第 4 条:旅程是否公开(只影响列表与日历,不影响能否直接打开)
@@ -2818,16 +2827,19 @@ switch ($r) {
}
$errors = [];
if ($d['title'] === '') {
$errors['title'] = '旅程名字不能为空';
} elseif ($d['starts_at'] !== '' && $d['ends_at'] !== ''
&& strcmp($d['ends_at'], $d['starts_at']) < 0) {
$errors['ends_at'] = '回来的时间不能早于出发';
$errors['title'] = '活动名字不能为空';
}
// 「回来不能早于出发」的校验一并删掉:时间不再由这里填,
// 而是从各段旅程算 max/min —— 二者反了也不可能(min ≤ max 恒成立)。
// 封面图(编辑时换新图才动它;失败不挡保存,与观影海报同一个口径)
$oldCover = $isEdit ? (string)($t['cover_key'] ?? '') : '';
$d['cover_key'] = $oldCover;
$warns = [];
$up = Upload::image($familyId, $_FILES['cover'] ?? [], 'photo');
// v24 第 14 批 用户第 3 条:题图按 **16:9 裁剪**(走 'cover' 槽位,不再是
// 'photo' 那种"只缩不裁"的自由槽位)。裁剪矩形由前端 Cropper 写成归一化
// "x,y,w,h";没裁(空串)时服务端按 16:9 居中裁。
$up = Upload::image($familyId, $_FILES['cover'] ?? [], 'cover',
(string)($_POST['cover_crop'] ?? ''));
if ($up['ok']) {
$d['cover_key'] = (string)$up['file'];
if ($oldCover !== '' && $oldCover !== $d['cover_key']) {
@@ -2839,7 +2851,7 @@ switch ($r) {
if ($errors) {
set_old($_POST);
View::render('trips/form', [
'title' => $isEdit ? '编辑旅程' : '记一趟旅程',
'title' => $isEdit ? '编辑活动' : '记一次活动',
't' => $isEdit ? array_merge($t, $d) : null,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => $errors,
@@ -2854,7 +2866,7 @@ switch ($r) {
}
$newId = Trip::create($familyId, (int)Auth::personId(), $d);
clear_old();
flash('ok', '这趟旅程记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
flash('ok', '这次活动记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('trips/show', ['id' => $newId]);
break;
@@ -2863,7 +2875,7 @@ switch ($r) {
$meId = (int)Auth::personId();
$t = Trip::find($familyId, (int)($_GET['id'] ?? 0), $meId);
if (!$t) {
flash('err', '找不到这趟旅程。');
flash('err', '找不到这次活动。');
redirect('trips');
}
View::render('trips/show', [
@@ -2881,11 +2893,11 @@ switch ($r) {
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) {
flash('err', '找不到这趟旅程。');
flash('err', '找不到这次活动。');
redirect('trips');
}
Trip::destroy($familyId, $id);
flash('ok', '这趟旅程已删除。');
flash('ok', '这次活动已删除。');
redirect('trips');
break;
@@ -2895,7 +2907,7 @@ switch ($r) {
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) {
flash('err', '找不到这趟旅程。');
flash('err', '找不到这次活动。');
redirect('trips');
}
$warns = attach_save_files($familyId, 'trip', $id, 'photo', (int)Auth::personId());
@@ -2913,7 +2925,7 @@ switch ($r) {
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) {
flash('err', '找不到这趟旅程。');
flash('err', '找不到这次活动。');
redirect('trips');
}
\App\Attachment::remove((int)($_POST['photo_id'] ?? 0));
@@ -2931,7 +2943,7 @@ switch ($r) {
$tripId = (int)($_POST['trip_id'] ?? 0);
$t = Trip::find($familyId, $tripId);
if (!$t) {
flash('err', '找不到这趟旅程。');
flash('err', '找不到这次活动。');
redirect('trips');
}
$isEdit = $r === 'trips/node_update';
@@ -2967,7 +2979,7 @@ switch ($r) {
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。');
flash('err', '找不到这次活动。');
redirect('trips');
}
Trip::deleteNode((int)($_POST['id'] ?? 0));
@@ -2990,7 +3002,7 @@ switch ($r) {
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。');
flash('err', '找不到这次活动。');
redirect('trips');
}
$nid = (int)($_POST['id'] ?? 0);
@@ -3013,7 +3025,7 @@ switch ($r) {
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。');
flash('err', '找不到这次活动。');
redirect('trips');
}
Trip::markActual((int)($_POST['id'] ?? 0));
@@ -3028,7 +3040,7 @@ switch ($r) {
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。');
flash('err', '找不到这次活动。');
redirect('trips');
}
Trip::completeNode((int)($_POST['id'] ?? 0));
@@ -3043,11 +3055,11 @@ switch ($r) {
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。');
flash('err', '找不到这次活动。');
redirect('trips');
}
Trip::skipNode((int)($_POST['id'] ?? 0));
flash('ok', '已标记为**未成行**。它不再出现在日程与往年今日,但在这趟旅程里仍然看得到。');
flash('ok', '已标记为未成行。它不再出现在日程与往年今日,但在这段旅程里仍然看得到。');
redirect('trips/show', ['id' => $tripId]);
break;
@@ -3058,7 +3070,7 @@ switch ($r) {
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。');
flash('err', '找不到这次活动。');
redirect('trips');
}
Trip::restoreNode((int)($_POST['id'] ?? 0));
@@ -4250,7 +4262,11 @@ switch ($r) {
// v24 第 9、10 条:分类与看板。Task 侧会校验归属(不认别的家庭的 id)。
'category_id' => (int)($_POST['category_id'] ?? 0),
'board_id' => (int)($_POST['board_id'] ?? 0),
'status' => $isEdit ? (string)$old['status'] : 'todo',
// v24 第 14 批 用户第 2 条:新建时可以**直接定状态**(表单里有这一项)。
// ⚠️ 编辑时**不**在这里改状态:状态由专门的 tasks/status 路由管
// (那个路由还要处理循环任务的"下一次也排上"),这里保持原值 ——
// 否则"只改个标题"会把进行中的任务悄悄打回待办。
'status' => $isEdit ? (string)$old['status'] : (string)($_POST['status'] ?? 'todo'),
];
if ($d['assignee_id'] > 0 && !isset(Person::map($familyId)[$d['assignee_id']])) {
$d['assignee_id'] = 0;
@@ -4310,6 +4326,39 @@ switch ($r) {
redirect('tasks/show', ['id' => $id]);
break;
/**
* 改一条任务的**状态**(v24 第 14 批 用户第 2 条)。
*
* 用户原话:「任务模块,每个任务都应该有不同的状态,例如正在进行、已完成等。」
* → 看板卡片上的状态徽标点一下就走这里:待办 → 进行中 → 已完成 → 待办。
*
* ⚠️ 与 tasks/move 的分工:move 管"搬到哪个看板/哪一列"(改 board_id 或带定位的
* status),这里只管"这件事做到哪一步"(改 status),不带位置信息 ——
* 在看板上改状态不该顺手把卡片挪到列尾。
*/
case 'tasks/status':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$status = (string)($_POST['status'] ?? '');
$t = Task::find($familyId, $id, $me);
if (!$t || !Task::canWrite($t, $me)) {
flash('err', '这项任务不能改。');
redirect('tasks');
}
Task::setStatus($familyId, $id, $status);
$word = Task::statusWord(Task::normalizeStatus($status));
if ($status === 'done' && !empty($t['repeat_rule']) && ($t['due_at'] ?? '') !== '') {
flash('ok', '已标成「' . $word . '」,下一次的也排上了。');
} else {
flash('ok', '已标成「' . $word . '」。');
}
redirect((string)($_POST['back'] ?? '') === 'show' ? 'tasks/show' : 'tasks',
(string)($_POST['back'] ?? '') === 'show' ? ['id' => $id] : []);
break;
case 'tasks/comment_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
@@ -4426,6 +4475,408 @@ switch ($r) {
redirect('tasks');
break;
// ==================== 读书(v24 第 14 批 用户第 5 条) ====================
// ⚠️ 与观影 / 旅程不同,读书**不走 events 骨架**:一本书是"一段持续几个月的状态",
// 不是"某一天发生的事"(详见 app/Book.php 的头注释)。真要上日历的那天是"读完那天"。
//
// 入口就两个:书架(books)与一本书(books/show);阅读器(books/read)从书进去。
// 统计与笔记导入挂在读书页右上角的「☰」里,与观影同一套规矩。
case 'books':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$f = [
'status' => (string)($_GET['status'] ?? ''),
'category' => (string)($_GET['cat'] ?? ''),
'q' => (string)($_GET['q'] ?? ''),
'owner' => (int)($_GET['owner'] ?? 0),
];
View::render('books/index', [
'title' => '读书',
'rows' => Book::all($familyId, $f),
// ⚠️ 计数**不带筛选条件**(见 Book::statusCounts 的注释):
// 否则点进某个状态后其它状态的数字全变 0,筛选条成了死路。
'counts' => Book::statusCounts($familyId),
'cats' => Book::categories($familyId),
'status' => $f['status'],
'cat' => $f['category'],
'q' => $f['q'],
'owner' => $f['owner'],
'people' => Person::roster($familyId, $me, 'member', ''),
], 'books');
break;
case 'books/new':
Auth::requireWrite();
View::render('books/form', [
'title' => '添加图书',
'b' => null,
'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''),
'errors' => [],
], 'books');
break;
case 'books/edit':
Auth::requireWrite();
$familyId = (int)Auth::familyId();
$b = Book::find($familyId, (int)($_GET['id'] ?? 0));
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
View::render('books/form', [
'title' => '编辑图书',
'b' => $b,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => [],
], 'books');
break;
case 'books/create':
case 'books/update':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$isEdit = $r === 'books/update';
$id = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
$b = $isEdit ? Book::find($familyId, $id) : null;
if ($isEdit && !$b) {
flash('err', '找不到这本书。');
redirect('books');
}
$errors = [];
$warns = [];
// ---- ① 图书文件:先存原文件,再解析出元数据与正文 ----
// ⚠️ 顺序不能反:文件是"本来就想要的"(下载 / 浏览器内嵌看 PDF 都靠它),
// 解析只是锦上添花。解析失败也必须有一句人能看懂的话,不能静默。
$fileKey = $isEdit ? (string)($b['file_key'] ?? '') : '';
$fileName = $isEdit ? (string)($b['file_name'] ?? '') : '';
$fileFormat = $isEdit ? (string)($b['file_format'] ?? '') : '';
$fileSize = $isEdit ? (int)($b['file_size'] ?? 0) : 0;
$parsed = null;
$upFile = $_FILES['bookfile'] ?? [];
if (isset($upFile['error']) && !is_array($upFile['error'])
&& (int)$upFile['error'] === UPLOAD_ERR_OK) {
$origName = (string)($upFile['name'] ?? '');
$tmp = (string)($upFile['tmp_name'] ?? '');
$ext = strtolower((string)pathinfo($origName, PATHINFO_EXTENSION));
if (!in_array($ext, Book::FILE_EXT, true)) {
$warns[] = '这个文件的格式不在允许范围内(' . implode(' / ', Book::FILE_EXT)
. '),没有保存。';
} elseif ($tmp === '' || !is_file($tmp)) {
$warns[] = '文件没有传上来(可能是太大了),其他信息已保存。';
} else {
$safeExt = preg_replace('/[^a-z0-9]/', '', $ext) ?? '';
$newKey = 'books/' . bin2hex(random_bytes(12)) . ($safeExt !== '' ? '.' . $safeExt : '');
$fino = new \finfo(FILEINFO_MIME_TYPE);
$mime = (string)$fino->file($tmp);
$res = \App\Storage\Storage::putFile(
$familyId, $tmp, $newKey,
$mime !== '' ? $mime : 'application/octet-stream'
);
if (!empty($res['ok'])) {
if ($fileKey !== '' && $fileKey !== $newKey) {
\App\Storage\Storage::delete($familyId, $fileKey);
}
$fileKey = $newKey;
$fileName = $origName;
$fileFormat = $ext;
$fileSize = (int)@filesize($tmp);
$p = BookParser::parse($tmp, $ext, $origName);
if (empty($p['ok'])) {
// 文件已经存好了 —— 解析失败不等于白传,如实说清楚
$warns[] = '文件存好了,但读不出内容:' . (string)($p['error'] ?? '未知原因');
} else {
$parsed = $p;
if (!empty($p['note'])) {
$warns[] = (string)$p['note'];
}
}
} else {
$warns[] = '文件没有保存:' . (string)($res['error'] ?? '未知错误');
}
}
}
// ---- ② 字段:手填优先,空着的才用解析出来的值补 ----
// ("解析出来的覆盖手填的"是最招人烦的一种自作聪明 —— 用户改了书名,
// 再传一次文件又变回去,他会以为保存坏了。)
$meta = is_array($parsed['meta'] ?? null) ? $parsed['meta'] : [];
$mv = static function (string $k) use ($meta): string {
return trim((string)($meta[$k] ?? ''));
};
$titleIn = trim((string)($_POST['title'] ?? ''));
if ($titleIn === '') {
$titleIn = $mv('title');
}
if ($titleIn === '') {
$errors['title'] = '书名不能为空(上传 EPUB / TXT 会自动读出来)';
}
$d = [
'title' => $titleIn,
'subtitle' => trim((string)($_POST['subtitle'] ?? '')) ?: $mv('subtitle'),
'author' => trim((string)($_POST['author'] ?? '')) ?: $mv('author'),
'publisher' => trim((string)($_POST['publisher'] ?? '')) ?: $mv('publisher'),
'published' => trim((string)($_POST['published'] ?? '')) ?: $mv('published'),
'isbn' => trim((string)($_POST['isbn'] ?? '')) ?: $mv('isbn'),
'language' => trim((string)($_POST['language'] ?? '')) ?: $mv('language'),
'summary' => trim((string)($_POST['summary'] ?? '')) ?: $mv('summary'),
'translator' => trim((string)($_POST['translator'] ?? '')),
'category' => trim((string)($_POST['category'] ?? '')),
'tags' => trim((string)($_POST['tags'] ?? '')),
'pages' => (int)($_POST['pages'] ?? 0),
'status' => (string)($_POST['status'] ?? 'wish'),
'rating' => (string)($_POST['rating'] ?? ''),
'owner_id' => (int)($_POST['owner_id'] ?? 0),
'file_key' => $fileKey,
'file_name' => $fileName,
'file_format' => $fileFormat,
'file_size' => $fileSize,
];
// ---- ③ 封面:用户选的优先,其次用 EPUB 自带的 ----
$oldCover = $isEdit ? (string)($b['cover_key'] ?? '') : '';
$d['cover_key'] = $oldCover;
$up = Upload::image($familyId, $_FILES['cover'] ?? [], 'bookcover',
(string)($_POST['cover_crop'] ?? ''));
if ($up['ok']) {
$d['cover_key'] = (string)$up['file'];
if ($oldCover !== '' && $oldCover !== $d['cover_key']) {
\App\Storage\Storage::delete($familyId, $oldCover);
}
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = '封面没有保存:' . $up['error'];
} elseif ($parsed !== null && !empty($parsed['cover']['tmp'])) {
// 用户没另外选图 → 用书里自带的封面(Upload::image 只认 $_FILES 结构,
// 这里按同样的形状喂一个进去,就能复用同一条"缩放 + 转 JPG"的链路)。
$ctmp = (string)$parsed['cover']['tmp'];
$fake = [
'name' => 'cover',
'type' => (string)($parsed['cover']['mime'] ?? 'image/jpeg'),
'tmp_name' => $ctmp,
'error' => UPLOAD_ERR_OK,
'size' => (int)@filesize($ctmp),
];
$up2 = Upload::image($familyId, $fake, 'bookcover', '');
if ($up2['ok']) {
$d['cover_key'] = (string)$up2['file'];
if ($oldCover !== '' && $oldCover !== $d['cover_key']) {
\App\Storage\Storage::delete($familyId, $oldCover);
}
}
}
// 解析产生的临时封面文件用完即删(它是 BookParser 在 temp 下生成的)
if ($parsed !== null && !empty($parsed['cover']['tmp'])) {
@unlink((string)$parsed['cover']['tmp']);
}
if ($errors) {
set_old($_POST);
View::render('books/form', [
'title' => $isEdit ? '编辑图书' : '添加图书',
'b' => $isEdit ? array_merge($b, $d) : null,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => $errors,
], 'books');
exit;
}
if ($isEdit) {
Book::update($familyId, $id, $d);
// 重新上传了文件 → 章节按新文件整组替换(旧章节留着就是"两本书混在一起")
if ($parsed !== null && is_array($parsed['chapters'] ?? null)) {
Book::replaceChapters($id, $parsed['chapters']);
}
clear_old();
flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('books/show', ['id' => $id]);
}
$newId = Book::create($familyId, (int)Auth::personId(), $d);
if ($parsed !== null && is_array($parsed['chapters'] ?? null)) {
Book::replaceChapters($newId, $parsed['chapters']);
}
clear_old();
flash('ok', '这本书进书架了。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('books/show', ['id' => $newId]);
break;
case 'books/show':
$familyId = (int)Auth::familyId();
$id = (int)($_GET['id'] ?? 0);
$b = Book::find($familyId, $id);
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
View::render('books/show', [
'title' => (string)$b['title'],
'b' => $b,
'chapters' => Book::chapters($id),
'notes' => Book::notes($id, (int)Auth::personId()),
], 'books');
break;
case 'books/read':
$familyId = (int)Auth::familyId();
$id = (int)($_GET['id'] ?? 0);
$b = Book::find($familyId, $id);
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
if (empty($b['file_key'])) {
flash('err', '这本书还没有上传文件,先到「编辑」里传一个。');
redirect('books/show', ['id' => $id]);
}
$chapters = Book::chapters($id);
$total = count($chapters);
$seq = (int)($_GET['seq'] ?? 0);
if ($seq <= 0) {
// 没指定 → 接着上次读到的地方(这就是"继续读"按钮能直接回原位的原因)
$seq = max(1, (int)($b['progress'] ?? 0));
}
$seq = max(1, min($seq, max(1, $total)));
// ⚠️ 翻章就顺手记进度:让人手动记"我读到第几章"的阅读器没人会用。
if (Auth::canWrite() && $total > 0 && (int)($b['progress'] ?? 0) !== $seq) {
Book::setProgress($familyId, $id, $seq);
$b['progress'] = $seq;
}
View::render('books/read', [
'title' => (string)$b['title'],
'b' => $b,
'chapters' => $chapters,
'chapter' => Book::chapter($id, $seq),
'seq' => $seq,
], 'books');
break;
case 'books/status':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$b = Book::find($familyId, $id);
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
$st = Book::normalizeStatus((string)($_POST['status'] ?? ''));
Book::setStatus($familyId, $id, $st);
flash('ok', '《' . (string)$b['title'] . '》标成「' . Book::statusWord($st) . '」了。');
// 列表上的一键 → 回列表;详情页 / 阅读页 → 回这本书
redirect((string)($_POST['back'] ?? '') === 'list' ? 'books' : 'books/show', ['id' => $id]);
break;
case 'books/destroy':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$b = Book::find($familyId, $id);
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
Book::destroy($familyId, $id);
flash('ok', '《' . (string)$b['title'] . '》已从书架移走。');
redirect('books');
break;
case 'books/note_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$b = Book::find($familyId, $id);
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
$nid = Book::addNote($familyId, $id, $me, [
'kind' => (string)($_POST['kind'] ?? 'note'),
'chapter' => (string)($_POST['chapter'] ?? ''),
'content' => (string)($_POST['content'] ?? ''),
'source' => 'manual',
]);
flash($nid > 0 ? 'ok' : 'err', $nid > 0 ? '记下了。' : '笔记是空的,没记。');
redirect('books/show', ['id' => $id], 'notes');
break;
case 'books/note_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$noteId = (int)($_POST['note_id'] ?? 0);
// 只能删自己的或无主的(导入进来的笔记没有归属,谁都能删)
$st = Database::get()->prepare(
'SELECT n.book_id FROM book_notes n JOIN books b ON b.id = n.book_id
WHERE n.id = ? AND b.family_id = ?'
);
$st->execute([$noteId, $familyId]);
$bid = (int)$st->fetchColumn();
Book::deleteNote($familyId, $noteId, $me);
flash('ok', '这条笔记删掉了。');
if ($bid > 0) {
redirect('books/show', ['id' => $bid], 'notes');
}
redirect('books');
break;
case 'books/stats':
$familyId = (int)Auth::familyId();
View::render('books/stats', [
'title' => '读书统计',
's' => Book::stats($familyId),
], 'books');
break;
case 'books/notes_import':
Auth::requireWrite();
// 同一条路由干两件事:GET 给粘贴框,POST 先解析出**预览**。
// ⚠️ 必须有预览这一步:解析是宽容的(宁可归类错也不丢字),
// 直接落库的话,一段没书名的文字会变成一本"未命名的一本"——
// 看不见结果就写进去,是最容易让人以后再也不敢用这个功能的做法。
$raw = '';
$parsed = [];
$source = 'weread';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
csrf_check();
$raw = (string)($_POST['raw'] ?? '');
$source = BookNoteImport::sourceOf((string)($_POST['source'] ?? ''));
$parsed = BookNoteImport::parse($raw);
}
View::render('books/notes_import', [
'title' => '导入读书笔记',
'raw' => $raw,
'parsed' => $parsed,
'source' => $source,
], 'books');
break;
case 'books/notes_import_save':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$raw = (string)($_POST['raw'] ?? '');
$source = BookNoteImport::sourceOf((string)($_POST['source'] ?? ''));
// ⚠️ **重新解析一遍**,不接前端传回来的"解析结果"——
// 表单可以改,信它就是让人往库里塞任意结构。
$parsed = BookNoteImport::parse($raw);
$res = BookNoteImport::import($familyId, $me, $parsed, $source);
if ((int)$res['added'] === 0 && (int)$res['created'] === 0) {
flash('err', '这段里没认出能导入的笔记。检查一下是不是把书名行也贴上了。');
redirect('books/notes_import');
}
flash('ok', '导入完成:新增 ' . (int)$res['created'] . ' 本书、'
. (int)$res['added'] . ' 条笔记。');
redirect('books');
break;
default:
http_response_code(404);
View::render('error', [