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:
@@ -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', [
|
||||
|
||||
Reference in New Issue
Block a user