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:
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
/**
|
||||
* 读书模块的「设置」下拉 —— 统计 / 导入笔记。
|
||||
*
|
||||
* 与其它模块同一套规矩(用户 2026-10-01 定的四条):
|
||||
* ① 按钮**没有文字**、只有图标,图标是**三条横线**;
|
||||
* ② 排在动作区**最右**(靠 .movie-set 的 margin-left:auto);
|
||||
* ③ 条目文字前都有图标;
|
||||
* ④ 除设置外其它按钮都有颜色。
|
||||
*
|
||||
* 用法(require 前 set 好):
|
||||
* $bkSetCurrent 当前页 key('list'|'show'|'form'|'read'|'stats'|'import')
|
||||
*/
|
||||
$bkSetCurrent = (string)($bkSetCurrent ?? '');
|
||||
$bkSetOn = static function (string $key) use ($bkSetCurrent): string {
|
||||
return $bkSetCurrent === $key ? ' class="on"' : '';
|
||||
};
|
||||
$bkSetIcon = static function (string $name): string {
|
||||
$paths = [
|
||||
// 统计:柱状图
|
||||
'stats' => '<path d="M3 13V9M6.5 13V5M10 13V7.5M13.5 13V3"/>',
|
||||
// 导入笔记:一支笔写在纸上
|
||||
'import' => '<path d="M9.5 2.5l4 4L5 15H2v-3z"/><path d="M8 4l4 4"/>',
|
||||
];
|
||||
$d = $paths[$name] ?? '';
|
||||
if ($d === '') {
|
||||
return '';
|
||||
}
|
||||
return '<svg class="mi" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"'
|
||||
. ' fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"'
|
||||
. ' stroke-linejoin="round">' . $d . '</svg>';
|
||||
};
|
||||
?>
|
||||
<details class="movie-set menu">
|
||||
<summary class="btn btn-icon" title="读书设置" aria-label="读书设置"><svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/></svg></summary>
|
||||
<div class="menu-list">
|
||||
<a href="<?= e(url('books/stats')) ?>"<?= $bkSetOn('stats') ?>><?= $bkSetIcon('stats') ?><span>读书统计</span></a>
|
||||
<a href="<?= e(url('books/notes_import')) ?>"<?= $bkSetOn('import') ?>><?= $bkSetIcon('import') ?><span>导入读书笔记</span></a>
|
||||
</div>
|
||||
</details>
|
||||
<?php
|
||||
// ⚠️ 只 unset **本片段自己**定义的变量(前缀 bkSet*),宿主模板的变量一个都不能碰
|
||||
unset($bkSetCurrent, $bkSetOn, $bkSetIcon);
|
||||
@@ -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>
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
/**
|
||||
* 书架(v24 第 14 批 用户第 5 条)。
|
||||
*
|
||||
* 一眼能看出三件事:**有多少书 / 现在在读哪几本 / 哪些读完了**。
|
||||
* 所以状态筛选条在最上面,卡片上标着状态徽标与评分。
|
||||
*
|
||||
* @param array $rows 书(已带 note_count)
|
||||
* @param array $cats 用过的分类(筛选用)
|
||||
* @param array $counts 各状态的数量
|
||||
* @param array $people 名册(筛"谁的书")
|
||||
* @param string $status 当前状态筛选
|
||||
* @param string $cat 当前分类筛选
|
||||
* @param string $q 关键词
|
||||
* @param int $owner 当前"谁的书"筛选
|
||||
*/
|
||||
$canWrite = \App\Auth::canWrite();
|
||||
$counts = $counts ?? [];
|
||||
$status = (string)($status ?? '');
|
||||
$cat = (string)($cat ?? '');
|
||||
$q = (string)($q ?? '');
|
||||
$owner = (int)($owner ?? 0);
|
||||
$people = $people ?? [];
|
||||
$stList = ['' => '全部'] + \App\Book::STATUS;
|
||||
?>
|
||||
<div class="page-head">
|
||||
<h1>读书</h1>
|
||||
<span class="sub">
|
||||
共 <?= (int)($counts['_all'] ?? 0) ?> 本<?= (int)($counts['reading'] ?? 0) > 0
|
||||
? ' · ' . (int)$counts['reading'] . ' 本在读' : '' ?>
|
||||
</span>
|
||||
<div class="actions">
|
||||
<?php if ($canWrite): ?>
|
||||
<a class="btn" href="<?= e(url('books/new')) ?>">添加图书</a>
|
||||
<?php else: ?>
|
||||
<span class="link-quiet">只读账号</span>
|
||||
<?php endif; ?>
|
||||
<?php $bkSetCurrent = 'list'; require __DIR__ . '/_settings_menu.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php /* 状态筛选条:一段一个筛选,都在同一行(窄屏自动换行)。
|
||||
这四个状态就是用户说的"每个任务都有状态"在读书这边的对应物。 */ ?>
|
||||
<div class="book-filter">
|
||||
<?php foreach ($stList as $k => $v): ?>
|
||||
<a class="bf-btn<?= $status === (string)$k ? ' is-on' : '' ?>"
|
||||
href="<?= e(url('books', array_filter([
|
||||
'status' => (string)$k, 'cat' => $cat, 'q' => $q,
|
||||
'owner' => $owner > 0 ? (string)$owner : '',
|
||||
], static fn($x) => (string)$x !== ''))) ?>">
|
||||
<?= e($v) ?><?= $k !== '' ? ' <span class="bf-n">' . (int)($counts[$k] ?? 0) . '</span>' : '' ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<form class="search" method="get" action="index.php">
|
||||
<input type="hidden" name="r" value="books">
|
||||
<?php if ($status !== ''): ?><input type="hidden" name="status" value="<?= e($status) ?>"><?php endif; ?>
|
||||
<input class="input" type="text" name="q" value="<?= e($q) ?>" placeholder="搜书名 / 作者 / 出版社 / 标签 / ISBN">
|
||||
<?php if ($cats): ?>
|
||||
<select class="select" name="cat">
|
||||
<option value="">全部分类</option>
|
||||
<?php foreach ($cats as $c): ?>
|
||||
<option value="<?= e((string)$c['name']) ?>" <?= $cat === (string)$c['name'] ? 'selected' : '' ?>>
|
||||
<?= e((string)$c['name']) ?>(<?= (int)$c['n'] ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php endif; ?>
|
||||
<?php if ($people): ?>
|
||||
<select class="select" name="owner">
|
||||
<option value="0">所有人的书</option>
|
||||
<?php foreach ($people as $p): ?>
|
||||
<option value="<?= (int)$p['id'] ?>" <?= $owner === (int)$p['id'] ? 'selected' : '' ?>>
|
||||
<?= e(person_name($p)) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php endif; ?>
|
||||
<button class="btn" type="submit">筛选</button>
|
||||
<?php if ($q !== '' || $cat !== '' || $owner > 0 || $status !== ''): ?>
|
||||
<a class="search-clear" href="<?= e(url('books')) ?>">清除</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<?php if (!$rows): ?>
|
||||
<div class="card card-pad empty">
|
||||
<b><?= ($q !== '' || $cat !== '' || $owner > 0 || $status !== '') ? '没有匹配的书' : '书架还是空的' ?></b>
|
||||
<?php if ($q !== '' || $cat !== '' || $owner > 0 || $status !== ''): ?>
|
||||
换个条件试试,或清除筛选看全部。
|
||||
<?php else: ?>
|
||||
<?php if ($canWrite): ?>
|
||||
点右上角「添加图书」—— 可以上传 EPUB / TXT 让它自己读出书名作者与正文,
|
||||
也可以只手填一条"想读的书"。
|
||||
<?php else: ?>
|
||||
家人还没有往书架上放书。
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="book-grid">
|
||||
<?php foreach ($rows as $b): ?>
|
||||
<?php
|
||||
$bid = (int)$b['id'];
|
||||
$st = (string)($b['status'] ?? 'wish');
|
||||
$rate = $b['rating'] === null ? null : (int)$b['rating'];
|
||||
?>
|
||||
<div class="book-card">
|
||||
<a class="book-cover" href="<?= e(url('books/show', ['id' => $bid])) ?>">
|
||||
<?php if (!empty($b['cover_key'])): ?>
|
||||
<img src="<?= e(media_url((string)$b['cover_key'])) ?>" alt="">
|
||||
<?php else: ?>
|
||||
<span class="book-cover-empty">📖</span>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<div class="book-body">
|
||||
<a class="book-title" href="<?= e(url('books/show', ['id' => $bid])) ?>">
|
||||
<?= e((string)$b['title']) ?>
|
||||
</a>
|
||||
<?php if (!empty($b['author'])): ?>
|
||||
<div class="book-author"><?= e((string)$b['author']) ?></div>
|
||||
<?php endif; ?>
|
||||
<div class="book-meta">
|
||||
<span class="kb-st is-<?= e($st === 'reading' ? 'doing' : ($st === 'done' ? 'done' : 'todo')) ?> is-static">
|
||||
<?= e(\App\Book::statusWord($st)) ?>
|
||||
</span>
|
||||
<?php if ($rate !== null): ?>
|
||||
<span class="book-rate" title="评分"><?= $rate ?>/10</span>
|
||||
<?php endif; ?>
|
||||
<?php if ((int)($b['note_count'] ?? 0) > 0): ?>
|
||||
<span class="book-note-n" title="笔记">✎ <?= (int)$b['note_count'] ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ((int)($b['chapter_count'] ?? 0) > 0): ?>
|
||||
<span class="small muted"><?= (int)$b['chapter_count'] ?> 章</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($st === 'reading' && (int)($b['chapter_count'] ?? 0) > 0 && (int)($b['progress'] ?? 0) > 0): ?>
|
||||
<?php $pct = min(100, (int)round((int)$b['progress'] * 100 / max(1, (int)$b['chapter_count']))); ?>
|
||||
<div class="bk-bar"><i style="width: <?= $pct ?>%"></i></div>
|
||||
<span class="small muted">读到第 <?= (int)$b['progress'] ?> 章</span>
|
||||
<?php endif; ?>
|
||||
<div class="book-foot">
|
||||
<?php if (!empty($b['file_key'])): ?>
|
||||
<a class="btn btn-sm" href="<?= e(url('books/read', ['id' => $bid])) ?>">读</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($canWrite): ?>
|
||||
<a class="btn btn-ghost btn-sm" href="<?= e(url('books/edit', ['id' => $bid])) ?>">改</a>
|
||||
<?php if ($st !== 'done'): ?>
|
||||
<form method="post" action="<?= e(url('books/status')) ?>" class="res-inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= $bid ?>">
|
||||
<input type="hidden" name="status" value="done">
|
||||
<?php /* 从列表点的 → 做完还回列表(不然会莫名跳进详情页) */ ?>
|
||||
<input type="hidden" name="back" value="list">
|
||||
<button class="btn btn-ghost btn-sm" type="submit">读完</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
/**
|
||||
* 导入读书笔记(v24 第 14 批 用户第 5 条:「可以导入微信读书等各种读书软件的读书笔记」)。
|
||||
*
|
||||
* 三步:**粘贴 → 预览 → 落库**。
|
||||
* 中间那步不能省(原因见 index.php 的 books/notes_import 注释):解析是宽容的,
|
||||
* 认错了会造出"未命名的一本",让人看见再点确认,比事后删书体面得多。
|
||||
*
|
||||
* @param string $raw 上一次粘贴的原文(预览时回显)
|
||||
* @param array $parsed BookNoteImport::parse() 的结果
|
||||
* @param string $source 来源标记(weread / douban / other)
|
||||
*/
|
||||
$parsed = $parsed ?? [];
|
||||
$raw = (string)($raw ?? '');
|
||||
$source = (string)($source ?? 'weread');
|
||||
$step = $parsed ? 'preview' : 'paste';
|
||||
$total = 0;
|
||||
foreach ($parsed as $b) {
|
||||
$total += count($b['notes'] ?? []);
|
||||
}
|
||||
?>
|
||||
<div class="page-head">
|
||||
<h1>导入读书笔记</h1>
|
||||
<span class="sub">从微信读书、豆瓣等 App 复制出来的,直接粘进来</span>
|
||||
<div class="actions">
|
||||
<a class="btn btn-ghost" href="<?= e(url('books')) ?>">返回书架</a>
|
||||
<?php $bkSetCurrent = 'import'; require __DIR__ . '/_settings_menu.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($step === 'paste'): ?>
|
||||
|
||||
<div class="card card-pad">
|
||||
<form method="post" action="<?= e(url('books/notes_import')) ?>">
|
||||
<?= csrf_field() ?>
|
||||
<div class="field full">
|
||||
<label for="imp-raw">把笔记粘在这里</label>
|
||||
<textarea class="input" id="imp-raw" name="raw" rows="16" required
|
||||
placeholder="## 活着(余华) ◆ 第一章 > 人是为活着本身而活着的。 >> 这一段我读了三遍。"><?= e($raw) ?></textarea>
|
||||
<span class="tip">
|
||||
认得这些写法:书名用 <code>## 书名</code> 或 <code>《书名》</code>;
|
||||
章节用 <code>◆</code>/<code>●</code> 开头;划线用 <code>></code> 开头;
|
||||
想法用 <code>>></code> 开头;作者可以写 <code>作者:余华</code>。
|
||||
认不出的行会按上下文归类 —— 宁可归类得不完美,也不丢字。
|
||||
</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="imp-src">这些笔记来自</label>
|
||||
<select class="select" id="imp-src" name="source">
|
||||
<?php foreach (['weread' => '微信读书', 'douban' => '豆瓣读书', 'other' => '其它 App'] as $k => $v): ?>
|
||||
<option value="<?= e($k) ?>" <?= $source === $k ? 'selected' : '' ?>><?= e($v) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<span class="tip">只是个标记,笔记旁边会写"来自哪里"。</span>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="submit">先看看认出了什么</button>
|
||||
<a class="btn btn-ghost" href="<?= e(url('books')) ?>">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad">
|
||||
<h2 class="cinema-h2">怎么从 App 里复制出来</h2>
|
||||
<ul class="imp-how">
|
||||
<li><b>微信读书</b>:书里点右上角「…」→「笔记」→ 长按选中 / 全选 → 复制,粘到这里。</li>
|
||||
<li><b>豆瓣读书</b>:我读过的书 → 某本书的"读书笔记"页 → 选中复制。</li>
|
||||
<li><b>其它</b>:只要文本里有书名行,大多能认出来;认错了下面会先给你看。</li>
|
||||
</ul>
|
||||
<p class="small muted">
|
||||
导入时按书名匹配书架上已有的书 —— 匹配上就挂到那本书下面,
|
||||
匹配不上才新建一本(并标成"读完")。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
|
||||
<?php /* ==================== 预览:让用户在落库之前看见结果 ==================== */ ?>
|
||||
<div class="card card-pad">
|
||||
<h2 class="cinema-h2">
|
||||
认出了 <?= count($parsed) ?> 本书、<?= $total ?> 条笔记
|
||||
<span class="hint">看一眼再导,别急着确认</span>
|
||||
</h2>
|
||||
|
||||
<?php if ($total === 0): ?>
|
||||
<p class="empty-hint">
|
||||
一段笔记都没认出来。多半是没贴到内容,或者全贴成了空行 —— 退回去重新粘一次。
|
||||
</p>
|
||||
<div class="btn-row"><a class="btn btn-ghost" href="<?= e(url('books/notes_import')) ?>">重新粘贴</a></div>
|
||||
<?php else: ?>
|
||||
<ul class="imp-list">
|
||||
<?php foreach ($parsed as $i => $b): ?>
|
||||
<?php $ns = $b['notes'] ?? []; ?>
|
||||
<li class="imp-book">
|
||||
<div class="imp-bh">
|
||||
<span class="imp-t"><?= e((string)$b['title']) ?></span>
|
||||
<?php if (!empty($b['author'])): ?>
|
||||
<span class="small muted"><?= e((string)$b['author']) ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="imp-n"><?= count($ns) ?> 条</span>
|
||||
</div>
|
||||
<?php if ($ns): ?>
|
||||
<ul class="imp-notes">
|
||||
<?php foreach (array_slice($ns, 0, 3) as $n): ?>
|
||||
<li class="is-<?= e((string)($n['kind'] ?? 'note')) ?>">
|
||||
<span class="bn-kind"><?= e(\App\Book::kindWord((string)($n['kind'] ?? 'note'))) ?></span>
|
||||
<?php if (!empty($n['chapter'])): ?>
|
||||
<span class="bn-ch"><?= e((string)$n['chapter']) ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="imp-txt"><?= e(mb_substr((string)$n['content'], 0, 120, 'UTF-8')) ?></span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php if (count($ns) > 3): ?>
|
||||
<p class="small muted">…还有 <?= count($ns) - 3 ?> 条,导入时一起进。</p>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<p class="small muted">这本书下面没认到笔记,导入时会跳过。</p>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
|
||||
<?php /* ⚠️ 这里回传的是**原文**,不是解析结果 —— 落库那一步会重新解析一遍
|
||||
(表单可以改,信"解析结果"就是让人往库里塞任意结构)。 */ ?>
|
||||
<form method="post" action="<?= e(url('books/notes_import_save')) ?>" class="imp-confirm">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="raw" value="<?= e($raw) ?>">
|
||||
<input type="hidden" name="source" value="<?= e($source) ?>">
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="submit">就按这个导入</button>
|
||||
<a class="btn btn-ghost" href="<?= e(url('books/notes_import')) ?>">重新粘贴</a>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/**
|
||||
* 在线阅读(v24 第 14 批 用户第 5 条:「可以点开图书进行查看、阅读」)。
|
||||
*
|
||||
* 两种读法,按文件类型分:
|
||||
* · 解析出正文的(EPUB / TXT)→ 直接把这一章的文本铺在这里(目录在侧栏);
|
||||
* · PDF(服务端不提取正文)→ 用浏览器自带的 PDF 阅读器内嵌看,排版不会丢。
|
||||
*
|
||||
* ⚠️ 翻章就顺手**记进度**(读到第几章):下次点"继续读"直接回到这里。
|
||||
* 这件事必须自动做 —— 让人手动记"我读到第几章"的阅读器没人会用。
|
||||
*
|
||||
* @param array $b 书
|
||||
* @param array $chapters 目录
|
||||
* @param array|null $chapter 当前章(含正文)
|
||||
* @param int $seq 当前章号
|
||||
*/
|
||||
$canWrite = \App\Auth::canWrite();
|
||||
$seq = (int)($seq ?? 1);
|
||||
$total = count($chapters);
|
||||
$isPdf = \App\BookParser::inlinable($b['file_format'] ?? '');
|
||||
$prev = $seq > 1 ? $seq - 1 : 0;
|
||||
$next = $seq < $total ? $seq + 1 : 0;
|
||||
?>
|
||||
<div class="page-head">
|
||||
<h1><?= e((string)$b['title']) ?></h1>
|
||||
<span class="sub">
|
||||
<?= $isPdf ? 'PDF · 浏览器内嵌阅读'
|
||||
: ('第 ' . $seq . ' / ' . max(1, $total) . ' 章'
|
||||
. ($chapter ? ' · ' . e((string)$chapter['title']) : '')) ?>
|
||||
</span>
|
||||
<div class="actions">
|
||||
<?php if ($canWrite): ?>
|
||||
<?php /* 最后一章读完 → 顺手就能标完(这一步做掉了,人才会真的点) */ ?>
|
||||
<form method="post" action="<?= e(url('books/status')) ?>" class="res-inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$b['id'] ?>">
|
||||
<input type="hidden" name="status" value="done">
|
||||
<button class="btn<?= (string)$b['status'] === 'done' ? ' btn-ghost' : '' ?>" type="submit">
|
||||
<?= (string)$b['status'] === 'done' ? '已读完(点这退回在读)' : '读完了' ?>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<a class="btn btn-ghost" href="<?= e(url('books/show', ['id' => (int)$b['id']])) ?>">这本书</a>
|
||||
<a class="btn btn-ghost" href="<?= e(url('books')) ?>">书架</a>
|
||||
<?php $bkSetCurrent = 'read'; require __DIR__ . '/_settings_menu.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="book-read">
|
||||
<?php if (!$isPdf && $total > 0): ?>
|
||||
<aside class="br-side">
|
||||
<div class="br-side-h">目录</div>
|
||||
<ol class="br-toc">
|
||||
<?php foreach ($chapters as $c): ?>
|
||||
<li<?= (int)$c['seq'] === $seq ? ' class="is-cur"' : '' ?>>
|
||||
<a href="<?= e(url('books/read', ['id' => (int)$b['id'], 'seq' => (int)$c['seq']])) ?>">
|
||||
<?= e((string)$c['title']) ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
</aside>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="br-main">
|
||||
<?php if ($isPdf): ?>
|
||||
<?php /* PDF:交给浏览器自己的阅读器。object 比 iframe 兼容性更好,
|
||||
里面那句提示是"浏览器不支持内嵌"时才显示的兜底。 */ ?>
|
||||
<div class="br-pdf">
|
||||
<object data="<?= e(media_url((string)$b['file_key'])) ?>" type="application/pdf">
|
||||
<p class="empty-hint">
|
||||
这个浏览器不能直接内嵌看 PDF。
|
||||
<a href="<?= e(media_url((string)$b['file_key'], ['dl' => 1])) ?>">点这里下载</a>后再看。
|
||||
</p>
|
||||
</object>
|
||||
</div>
|
||||
<?php elseif (!$chapter): ?>
|
||||
<p class="empty-hint">
|
||||
这本书还没有解析出正文。<?php if ($canWrite): ?>
|
||||
到「编辑」里重新上传一次文件(EPUB / TXT 会自动拆章)。<?php endif; ?>
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<article class="br-text">
|
||||
<h2 class="br-ch-title"><?= e((string)$chapter['title']) ?></h2>
|
||||
<?php /* ⚠️ 正文是**纯文本**:nl2br + 转义,绝不 innerHTML ——
|
||||
书的内容来自用户上传的文件,当成不可信输入处理。 */ ?>
|
||||
<div class="br-body"><?= nl2br(e((string)$chapter['content'])) ?></div>
|
||||
</article>
|
||||
|
||||
<div class="br-nav">
|
||||
<?php if ($prev > 0): ?>
|
||||
<a class="btn btn-ghost" href="<?= e(url('books/read', ['id' => (int)$b['id'], 'seq' => $prev])) ?>">
|
||||
← 上一章
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<span class="br-pos small muted"><?= $seq ?> / <?= $total ?></span>
|
||||
<?php if ($next > 0): ?>
|
||||
<a class="btn" href="<?= e(url('books/read', ['id' => (int)$b['id'], 'seq' => $next])) ?>">
|
||||
下一章 →
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="small muted">已经是最后一章了</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
/**
|
||||
* 一本书(v24 第 14 批 用户第 5 条):封面 + 元信息 + 读到哪 + 笔记。
|
||||
*
|
||||
* 页面两块内容,按"看书的人先想知道什么"排:
|
||||
* ① 这本书本身(封面、作者、读到哪、读完没读完);
|
||||
* ② 读的时候留下的字(划线 / 笔记 / 书评)—— 这是以后回看时最有价值的部分。
|
||||
*
|
||||
* @param array $b 书
|
||||
* @param array $chapters 章节目录(没有正文)
|
||||
* @param array $notes 笔记
|
||||
*/
|
||||
$canWrite = \App\Auth::canWrite();
|
||||
$meId = (int)\App\Auth::personId();
|
||||
$st = (string)($b['status'] ?? 'wish');
|
||||
$chapters = $chapters ?? [];
|
||||
$notes = $notes ?? [];
|
||||
$hasFile = !empty($b['file_key']);
|
||||
$readable = $hasFile && ((int)($b['chapter_count'] ?? 0) > 0 || \App\BookParser::inlinable($b['file_format'] ?? ''));
|
||||
?>
|
||||
<div class="page-head">
|
||||
<h1><?= e((string)$b['title']) ?></h1>
|
||||
<span class="sub">
|
||||
<?php if (!empty($b['author'])): ?><?= e((string)$b['author']) ?> · <?php endif; ?>
|
||||
<?= e(\App\Book::statusWord($st)) ?>
|
||||
</span>
|
||||
<div class="actions">
|
||||
<?php if ($readable): ?>
|
||||
<a class="btn" href="<?= e(url('books/read', ['id' => (int)$b['id']])) ?>">
|
||||
<?= (int)($b['progress'] ?? 0) > 0 ? '继续读' : '开始读' ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($canWrite): ?>
|
||||
<a class="btn btn-ghost" href="<?= e(url('books/edit', ['id' => (int)$b['id']])) ?>">编辑</a>
|
||||
<form method="post" action="<?= e(url('books/destroy')) ?>" class="res-inline"
|
||||
onsubmit="return confirm('把《<?= e((string)$b['title']) ?>》从书架移走吗?文件与笔记会一起删掉,不可恢复。')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$b['id'] ?>">
|
||||
<button class="btn btn-danger" type="submit">移出书架</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<a class="btn btn-ghost" href="<?= e(url('books')) ?>">返回书架</a>
|
||||
<?php $bkSetCurrent = 'show'; require __DIR__ . '/_settings_menu.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad book-hero">
|
||||
<div class="book-hero-cover">
|
||||
<?php if (!empty($b['cover_key'])): ?>
|
||||
<img src="<?= e(media_url((string)$b['cover_key'])) ?>" alt="">
|
||||
<?php else: ?>
|
||||
<span class="book-cover-empty">📖</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="book-hero-body">
|
||||
<?php if (!empty($b['subtitle'])): ?>
|
||||
<p class="book-sub"><?= e((string)$b['subtitle']) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* 状态的一排快捷:想读 → 在读 → 读完(与任务卡片上那个徽标同款思路,
|
||||
都是"点一下就变",不用进编辑页)。 */ ?>
|
||||
<div class="book-states">
|
||||
<?php foreach (\App\Book::STATUS as $sk => $sv): ?>
|
||||
<?php if ($canWrite): ?>
|
||||
<form method="post" action="<?= e(url('books/status')) ?>" class="res-inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$b['id'] ?>">
|
||||
<input type="hidden" name="status" value="<?= e($sk) ?>">
|
||||
<button class="kb-st is-<?= e($sk === 'reading' ? 'doing' : ($sk === 'done' ? 'done' : 'todo')) ?><?= $st === $sk ? ' is-on' : '' ?>"
|
||||
type="submit" title="标成「<?= e($sv) ?>」"><?= e($sv) ?></button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<span class="kb-st is-<?= e($sk === 'reading' ? 'doing' : ($sk === 'done' ? 'done' : 'todo')) ?><?= $st === $sk ? ' is-on' : '' ?> is-static"><?= e($sv) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<dl class="kv">
|
||||
<?php if (!empty($b['author'])): ?>
|
||||
<div class="kv-row"><dt>作者</dt><dd><?= e((string)$b['author']) ?></dd></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($b['translator'])): ?>
|
||||
<div class="kv-row"><dt>译者</dt><dd><?= e((string)$b['translator']) ?></dd></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($b['publisher'])): ?>
|
||||
<div class="kv-row"><dt>出版社</dt><dd><?= e((string)$b['publisher']) ?></dd></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($b['published'])): ?>
|
||||
<div class="kv-row"><dt>出版时间</dt><dd><?= e((string)$b['published']) ?></dd></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($b['category'])): ?>
|
||||
<div class="kv-row"><dt>分类</dt><dd><?= e((string)$b['category']) ?></dd></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($b['tags'])): ?>
|
||||
<div class="kv-row"><dt>标签</dt><dd><?= e((string)$b['tags']) ?></dd></div>
|
||||
<?php endif; ?>
|
||||
<?php if ((int)($b['pages'] ?? 0) > 0): ?>
|
||||
<div class="kv-row"><dt>页数</dt><dd><?= (int)$b['pages'] ?> 页</dd></div>
|
||||
<?php endif; ?>
|
||||
<?php if ((int)($b['words'] ?? 0) > 0): ?>
|
||||
<div class="kv-row"><dt>字数</dt><dd><?= e(\App\Book::wordsWord($b['words'])) ?></dd></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($b['rating'] !== null): ?>
|
||||
<div class="kv-row"><dt>评分</dt><dd><?= (int)$b['rating'] ?> / 10</dd></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($b['started_at'])): ?>
|
||||
<div class="kv-row"><dt>开始读</dt><dd><?= e((string)$b['started_at']) ?></dd></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($b['finished_at'])): ?>
|
||||
<div class="kv-row"><dt>读完于</dt><dd><?= e((string)$b['finished_at']) ?></dd></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($b['file_key'])): ?>
|
||||
<div class="kv-row">
|
||||
<dt>文件</dt>
|
||||
<dd>
|
||||
<?= e(\App\BookParser::formatWord((string)($b['file_format'] ?? ''))) ?>
|
||||
<?php if ((int)($b['file_size'] ?? 0) > 0): ?>
|
||||
· <?= e(\App\Book::sizeWord($b['file_size'])) ?>
|
||||
<?php endif; ?>
|
||||
· <a class="link-quiet" href="<?= e(media_url((string)$b['file_key'], ['dl' => 1])) ?>">下载</a>
|
||||
</dd>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($b['owner_name'])): ?>
|
||||
<div class="kv-row"><dt>在读的人</dt><dd><?= e((string)$b['owner_name']) ?></dd></div>
|
||||
<?php endif; ?>
|
||||
</dl>
|
||||
|
||||
<?php if (!empty($b['summary'])): ?>
|
||||
<div class="book-sum">
|
||||
<h2 class="cinema-h2">内容简介</h2>
|
||||
<p class="info-text"><?= nl2br(e((string)$b['summary'])) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($chapters): ?>
|
||||
<div class="book-toc-box">
|
||||
<h2 class="cinema-h2">目录(<?= count($chapters) ?> 章)</h2>
|
||||
<ol class="book-toc">
|
||||
<?php foreach ($chapters as $c): ?>
|
||||
<li<?= (int)$c['seq'] === (int)($b['progress'] ?? 0) ? ' class="is-cur"' : '' ?>>
|
||||
<a href="<?= e(url('books/read', ['id' => (int)$b['id'], 'seq' => (int)$c['seq']])) ?>">
|
||||
<?= e((string)$c['title']) ?>
|
||||
</a>
|
||||
<?php if ((int)$c['words'] > 0): ?>
|
||||
<span class="small muted"><?= (int)$c['words'] ?> 字</span>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php /* ==================== 笔记 ==================== */ ?>
|
||||
<div class="card card-pad">
|
||||
<h2 class="cinema-h2">
|
||||
笔记与划线(<?= count($notes) ?>)
|
||||
<span class="hint">读的时候留下的字;也可以从微信读书等 App 导入</span>
|
||||
</h2>
|
||||
|
||||
<?php if ($canWrite): ?>
|
||||
<form method="post" action="<?= e(url('books/note_add')) ?>" class="book-note-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$b['id'] ?>">
|
||||
<div class="bn-row">
|
||||
<select class="select" name="kind" aria-label="笔记类型">
|
||||
<?php foreach (\App\Book::NOTE_KINDS as $kk => $kv): ?>
|
||||
<option value="<?= e($kk) ?>"><?= e($kv) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<input class="input" name="chapter" placeholder="在第几章(可不填)">
|
||||
</div>
|
||||
<textarea class="input" name="content" rows="3" required
|
||||
placeholder="记一句想法,或把原文抄下来…"></textarea>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-sm" type="submit">记下来</button>
|
||||
<a class="btn btn-ghost btn-sm" href="<?= e(url('books/notes_import')) ?>">批量导入</a>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$notes): ?>
|
||||
<p class="empty-hint">还没有笔记。读到哪记到哪,以后翻起来才有意思。</p>
|
||||
<?php else: ?>
|
||||
<ul class="book-notes">
|
||||
<?php foreach ($notes as $n): ?>
|
||||
<?php
|
||||
$kind = (string)($n['kind'] ?? 'note');
|
||||
$mine = (int)($n['person_id'] ?? 0) === $meId;
|
||||
?>
|
||||
<li class="book-note is-<?= e($kind) ?>">
|
||||
<div class="bn-head">
|
||||
<span class="bn-kind"><?= e(\App\Book::kindWord($kind)) ?></span>
|
||||
<?php if (!empty($n['chapter'])): ?>
|
||||
<span class="bn-ch"><?= e((string)$n['chapter']) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ((string)($n['source'] ?? 'manual') !== 'manual'): ?>
|
||||
<span class="bn-src">来自 <?= e(\App\Book::sourceWord((string)$n['source'])) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($n['person_name'])): ?>
|
||||
<span class="bn-who"><?= e((string)($n['person_name'])) ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="bn-time small muted"><?= e(substr((string)$n['created_at'], 0, 16)) ?></span>
|
||||
<?php if ($canWrite && ($mine || (int)($n['person_id'] ?? 0) === 0)): ?>
|
||||
<form method="post" action="<?= e(url('books/note_del')) ?>" class="res-inline bn-del">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="note_id" value="<?= (int)$n['id'] ?>">
|
||||
<button class="btn btn-ghost btn-sm" type="submit"
|
||||
onclick="return confirm('删掉这条笔记?')">删</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="bn-body"><?= nl2br(e((string)$n['content'])) ?></div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
/**
|
||||
* 读书统计(v24 第 14 批 用户第 5 条:「有统计功能」)。
|
||||
*
|
||||
* 图表**全用纯 CSS**(与观影统计同一条规矩):项目没有前端构建,
|
||||
* 为几张小图引一个几百 KB 的图表库不划算,而且服务端画出来的图
|
||||
* 在没有 JS 的浏览器里也照样看得见。
|
||||
*
|
||||
* ⚠️ 版式一律**复用观影统计那套现成的类**(.stats-grid / .stat-card / .chart-card /
|
||||
* .bar-chart / .vgrid),不另造一套并行的图表骨架 —— 造过一次,
|
||||
* 结果是同一个站点里两张统计图长得像两个产品。
|
||||
*
|
||||
* @param array $s Book::stats() 的产物
|
||||
*/
|
||||
$s = $s ?? [];
|
||||
$st = $s['status'] ?? [];
|
||||
$byYear = $s['byYear'] ?? [];
|
||||
$dist = $s['ratingDist'] ?? [];
|
||||
$maxYear = 1;
|
||||
foreach ($byYear as $r) {
|
||||
$maxYear = max($maxYear, (int)$r['n']);
|
||||
}
|
||||
$maxRate = 1;
|
||||
foreach ($dist as $n) {
|
||||
$maxRate = max($maxRate, (int)$n);
|
||||
}
|
||||
$maxAuthor = 1;
|
||||
foreach ($s['topAuthors'] ?? [] as $a) {
|
||||
$maxAuthor = max($maxAuthor, (int)$a['n']);
|
||||
}
|
||||
$maxCat = 1;
|
||||
foreach ($s['topCategories'] ?? [] as $c) {
|
||||
$maxCat = max($maxCat, (int)$c['n']);
|
||||
}
|
||||
$num = static function ($v): string {
|
||||
return number_format((int)$v);
|
||||
};
|
||||
?>
|
||||
<div class="page-head">
|
||||
<h1>读书统计</h1>
|
||||
<span class="sub">书架上一共 <?= (int)($s['total'] ?? 0) ?> 本</span>
|
||||
<div class="actions">
|
||||
<a class="btn btn-ghost" href="<?= e(url('books')) ?>">返回书架</a>
|
||||
<?php $bkSetCurrent = 'stats'; require __DIR__ . '/_settings_menu.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="number"><?= (int)($st['done'] ?? 0) ?></div><div class="label">读完</div></div>
|
||||
<div class="stat-card"><div class="number"><?= (int)($st['reading'] ?? 0) ?></div><div class="label">在读</div></div>
|
||||
<div class="stat-card"><div class="number"><?= (int)($st['wish'] ?? 0) ?></div><div class="label">想读</div></div>
|
||||
<div class="stat-card"><div class="number"><?= (int)($st['paused'] ?? 0) ?></div><div class="label">搁置</div></div>
|
||||
<div class="stat-card">
|
||||
<div class="number"><?= (int)($s['words'] ?? 0) > 0 ? e(\App\Book::wordsWord($s['words'])) : '—' ?></div>
|
||||
<div class="label">读完的总字数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="number"><?= (int)($s['pages'] ?? 0) > 0 ? $num($s['pages']) : '—' ?></div>
|
||||
<div class="label">读完的总页数</div>
|
||||
</div>
|
||||
<div class="stat-card"><div class="number"><?= (int)($s['notes'] ?? 0) ?></div><div class="label">笔记条数</div></div>
|
||||
<div class="stat-card">
|
||||
<div class="number"><?= (int)($s['rated'] ?? 0) > 0 ? (float)$s['avgRate'] : '—' ?></div>
|
||||
<div class="label">平均评分</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<h3>这些年来,每年读完几本 <span class="hint">按「读完那天」算</span></h3>
|
||||
<?php if (!$byYear): ?>
|
||||
<p class="empty-hint">还没有标完「读完」的书。读完一本,这里就多一根。</p>
|
||||
<?php else: ?>
|
||||
<div class="vgrid vgrid-auto">
|
||||
<?php foreach ($byYear as $r): ?>
|
||||
<?php $h = (int)round((int)$r['n'] * 100 / $maxYear); ?>
|
||||
<div class="vcol">
|
||||
<span class="vnum"><?= (int)$r['n'] ?></span>
|
||||
<span class="vbar" style="height: <?= max(3, $h) ?>%"></span>
|
||||
<span class="vlab"><?= e((string)$r['y']) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<h3>评分分布 <span class="hint">0 ~ 10 分各有几本</span></h3>
|
||||
<?php if ((int)($s['rated'] ?? 0) === 0): ?>
|
||||
<p class="empty-hint">还没给任何一本书打过分。</p>
|
||||
<?php else: ?>
|
||||
<div class="vgrid vgrid-auto">
|
||||
<?php foreach ($dist as $score => $n): ?>
|
||||
<?php $h = (int)round((int)$n * 100 / $maxRate); ?>
|
||||
<div class="vcol">
|
||||
<span class="vnum"><?= (int)$n ?></span>
|
||||
<span class="vbar" style="height: <?= max(3, $h) ?>%"></span>
|
||||
<span class="vlab"><?= (int)$score ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<h3>读得最多的作者 <span class="hint">书架上有几本他的书</span></h3>
|
||||
<?php if (!($s['topAuthors'] ?? [])): ?>
|
||||
<p class="empty-hint">书里还没填作者。</p>
|
||||
<?php else: ?>
|
||||
<div class="bar-chart">
|
||||
<?php foreach ($s['topAuthors'] as $a): ?>
|
||||
<div class="bar-item">
|
||||
<span class="bar-label"><?= e((string)$a['name']) ?></span>
|
||||
<span class="bar-track">
|
||||
<span class="bar-fill" style="width: <?= (int)round((int)$a['n'] * 100 / $maxAuthor) ?>%"></span>
|
||||
</span>
|
||||
<span class="bar-count"><?= (int)$a['n'] ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<h3>分类 <span class="hint">书架上的书都堆在哪几类</span></h3>
|
||||
<?php if (!($s['topCategories'] ?? [])): ?>
|
||||
<p class="empty-hint">还没有给书分类。编辑一本书时填「分类」就有了。</p>
|
||||
<?php else: ?>
|
||||
<div class="bar-chart">
|
||||
<?php foreach ($s['topCategories'] as $c): ?>
|
||||
<div class="bar-item">
|
||||
<span class="bar-label"><?= e((string)$c['name']) ?></span>
|
||||
<span class="bar-track">
|
||||
<span class="bar-fill bar-fill-alt" style="width: <?= (int)round((int)$c['n'] * 100 / $maxCat) ?>%"></span>
|
||||
</span>
|
||||
<span class="bar-count"><?= (int)$c['n'] ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
Reference in New Issue
Block a user