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
+211
View File
@@ -0,0 +1,211 @@
<?php
/**
* 添加 / 编辑一本书(v24 第 14 批 用户第 5 条)。
*
* 两条进书的路线,**同一张表单**:
* ① 上传文件(EPUB / TXT)→ 服务端解析出书名、作者、简介、封面与**每一章正文**;
* 解析出来的值只填**空着的**字段(用户已经手填的不覆盖)。
* ② 手填一条"想读的书"(还没电子版时先记一笔)。
*
* ⚠️ 文件与封面是两个**不同**的上传位:
* 文件是原书(整存,可能几十 MB),封面是一张图(走裁剪,2:3)。
* 混成一个会让"换封面"变成"重新上传整本书"。
*
* @param array|null $b 编辑时的书;null = 新建
* @param array $people 名册("谁的书")
* @param array $errors 校验错误
*/
$isEdit = !empty($b);
$canWrite = \App\Auth::canWrite();
$people = $people ?? [];
$errors = $errors ?? [];
$cvSlot = \App\ImageSlot::get('bookcover') ?: ['ratio' => '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']] : [];
?>
<div class="page-head">
<h1><?= $isEdit ? '编辑图书' : '添加图书' ?></h1>
<div class="actions">
<a class="btn btn-ghost" href="<?= e($isEdit ? url('books/show', ['id' => (int)$b['id']]) : url('books')) ?>">
<?= $isEdit ? '返回这本书' : '返回书架' ?>
</a>
<?php $bkSetCurrent = 'form'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<div class="card card-pad">
<form method="post" enctype="multipart/form-data"
action="<?= e(url($isEdit ? 'books/update' : 'books/create')) ?>">
<?= csrf_field() ?>
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$b['id'] ?>">
<?php endif; ?>
<?php /* ---- ① 上传图书文件(解析的入口)----
⚠️ 放在最上面:多数人是"先传书、再让系统填好",
把文件框藏在最底下会让人以为只能手填。 */ ?>
<div class="field full">
<label for="bk-file">上传图书(EPUB / TXT / PDF…)</label>
<?php if ($isEdit && !empty($b['file_key'])): ?>
<p class="bk-file-now">
已存文件:<?= e((string)($b['file_name'] ?: $b['file_key'])) ?>
(<?= e(\App\Book::sizeWord($b['file_size'] ?? 0) ?: '大小未知') ?>)
<?php if ((int)($b['chapter_count'] ?? 0) > 0): ?>
· 已解析 <?= (int)$b['chapter_count'] ?> 章
<?php endif; ?>
<br><span class="tip">再选一个文件会替换它,并按新文件重新解析正文。</span>
</p>
<?php endif; ?>
<input class="input" type="file" id="bk-file" name="bookfile"
accept=".epub,.txt,.pdf,.mobi,.azw3,.azw,.doc,.docx,application/epub+zip,text/plain,application/pdf">
<span class="tip">
EPUB 与 TXT 会自动读出书名 / 作者 / 简介 / 封面,并把正文拆成章节(可以直接在线读)。
PDF 只存文件(用浏览器自带的阅读器看)。MOBI / AZW3 存文件、正文解析不了。
</span>
</div>
<div class="form-grid">
<div class="field full">
<label for="bk-title">书名 *</label>
<input class="input" id="bk-title" name="title" required
value="<?= e(old('title', $b['title'] ?? '')) ?>" placeholder="如:活着">
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="bk-sub">副标题</label>
<input class="input" id="bk-sub" name="subtitle"
value="<?= e(old('subtitle', $b['subtitle'] ?? '')) ?>">
</div>
<div class="field">
<label for="bk-author">作者</label>
<input class="input" id="bk-author" name="author"
value="<?= e(old('author', $b['author'] ?? '')) ?>" placeholder="如:余华">
</div>
<div class="field">
<label for="bk-trans">译者</label>
<input class="input" id="bk-trans" name="translator"
value="<?= e(old('translator', $b['translator'] ?? '')) ?>">
</div>
<div class="field">
<label for="bk-pub">出版社</label>
<input class="input" id="bk-pub" name="publisher"
value="<?= e(old('publisher', $b['publisher'] ?? '')) ?>">
</div>
<div class="field">
<label for="bk-date">出版时间</label>
<input class="input" id="bk-date" name="published"
value="<?= e(old('published', $b['published'] ?? '')) ?>" placeholder="如:2012 或 2012-05">
</div>
<div class="field">
<label for="bk-isbn">ISBN</label>
<input class="input" id="bk-isbn" name="isbn" value="<?= e(old('isbn', $b['isbn'] ?? '')) ?>">
</div>
<div class="field">
<label for="bk-lang">语言</label>
<input class="input" id="bk-lang" name="language"
value="<?= e(old('language', $b['language'] ?? '')) ?>" placeholder="如:中文">
</div>
<div class="field">
<label for="bk-cat">分类</label>
<input class="input" id="bk-cat" name="category"
value="<?= e(old('category', $b['category'] ?? '')) ?>" placeholder="如:小说 / 历史 / 技术">
</div>
<div class="field">
<label for="bk-tags">标签(逗号分隔)</label>
<input class="input" id="bk-tags" name="tags" value="<?= e(old('tags', $b['tags'] ?? '')) ?>"
placeholder="如:长篇,当代文学">
</div>
<div class="field">
<label for="bk-status">状态</label>
<select class="select" id="bk-status" name="status">
<?php foreach (\App\Book::STATUS as $sk => $sv): ?>
<option value="<?= e($sk) ?>"
<?= old('status', $b['status'] ?? 'wish') === $sk ? 'selected' : '' ?>><?= e($sv) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="bk-rate">评分</label>
<select class="select" id="bk-rate" name="rating">
<option value="" <?= $rate < 0 ? 'selected' : '' ?>>还没打分</option>
<?php for ($bi = 0; $bi <= 10; $bi++): ?>
<option value="<?= $bi ?>" <?= $rate === $bi ? 'selected' : '' ?>><?= $bi ?> 分</option>
<?php endfor; ?>
</select>
</div>
<div class="field">
<label for="bk-pages">页数</label>
<input class="input" type="number" min="0" id="bk-pages" name="pages"
value="<?= e((string)old('pages', $b['pages'] ?? '')) ?>" placeholder="有就填,用于统计">
</div>
<div class="field">
<label>谁的书</label>
<?php /* 与全站统一的选人组件(弹窗 + 头像 + 拼音联想);
留「不指定」= 全家共用这一本。 */ ?>
<?php
$ppName = 'owner_id';
$ppPeople = $people;
$ppSelected = $ownerSel;
$ppMode = 'single';
$ppOpenBtn = '选择是谁在读';
$ppTitle = '这本书是谁的';
$ppEmptyText = '全家共用';
$ppAllowEmpty = true;
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field full">
<label for="bk-sum">内容简介</label>
<textarea class="input" id="bk-sum" name="summary" rows="4"><?= e(old('summary', $b['summary'] ?? '')) ?></textarea>
</div>
<?php /* ---- ② 封面(与头像同一套裁剪,2:3)---- */ ?>
<div class="field full">
<label>封面</label>
<div class="cover-picker book-cover-picker">
<?php /* ⚠️ data-pick-input 是 app.js 里的通用"点图选文件"绑定 ——
少了它这块图点下去毫无反应(本批用户报的就是这个)。 */ ?>
<img id="book-cover-preview"
src="<?= e($curCover !== '' ? media_url($curCover) : '') ?>"
alt="" title="点击选封面"
data-pick-input="#bk-cover"
data-has-cover="<?= $curCover !== '' ? '1' : '0' ?>">
<input class="sr-only" id="bk-cover" name="cover" type="file" accept="image/*"
data-crop-ratio="<?= e((string)$cvSlot['ratio']) ?>"
data-crop-w="<?= (int)$cvSlot['w'] ?>"
data-crop-h="<?= (int)$cvSlot['h'] ?>"
data-crop-label="<?= e((string)$cvSlot['label']) ?>"
data-crop-hint="<?= e((string)$cvSlot['hint']) ?>"
data-crop-preview="#book-cover-preview">
<input type="hidden" name="cover_crop" value="">
<span class="avatar-hint small muted">
点左边那块选封面,选完弹裁剪框。<br>
上传 EPUB 时如果书里自带封面,会**自动**用它(你另外选一张就覆盖它)。
</span>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEdit ? '保存' : '加进书架' ?></button>
<a class="btn btn-ghost"
href="<?= e($isEdit ? url('books/show', ['id' => (int)$b['id']]) : url('books')) ?>">取消</a>
</div>
</form>
</div>