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>
|
||||
+129
-42
@@ -82,7 +82,16 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
如果是周视图,那么就是跳转到这一天所在的周,如果是月视图,则跳转到这一天所在的月」
|
||||
· 「选择这个日期,不用弹窗,直接把日历选择,弹出在这个按钮下面就可以了」
|
||||
→ ① 不再固定 view=day:带上**当前视图**(月/周/日各自跳到对应跨度);
|
||||
② 日期选择从"居中弹窗"改成**贴着按钮下方展开的下拉日历**。 */ ?>
|
||||
② 日期选择从"居中弹窗"改成**贴着按钮下方展开的下拉日历**。
|
||||
|
||||
🔴 2026-10-05 第 14 批 #1(用户报严重 bug):「点上一月,页面直接跳转,
|
||||
我还没选择日期呢」。根因是这一版下拉里挂的是 Semi 的 DatePicker ——
|
||||
它的日历面板**渲染在 body 的 portal 里**(不在下拉容器内),
|
||||
而它在面板上翻月时会把"面板当前显示的日期"当成新值回调 onChange,
|
||||
于是"翻月"被当成了"选中"。第三方控件把"翻页"与"选中"混在一个回调里,
|
||||
这个语义我们改不了。
|
||||
→ 改成**自绘内联日历**(几十行、零依赖):翻月/翻年只重绘面板、绝不提交,
|
||||
只有点**某一天**才跳转,语义 100% 由我们自己定。 */ ?>
|
||||
<form class="cal-jump" method="get" action="index.php">
|
||||
<input type="hidden" name="r" value="calendar">
|
||||
<?php /* 保留当前视图:月→该月 / 周→那一周 / 日→那一天。
|
||||
@@ -102,8 +111,8 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
<path d="M2 6.6h12M5.2 1.8v2.6M10.8 1.8v2.6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<?php /* 下拉日历的宿主:紧贴按钮下方展开(不是弹窗)。JS 就绪后把 Semi
|
||||
日期选择器挂进来;JS 不可用时它是空的、退到下面的 noscript。 */ ?>
|
||||
<?php /* 下拉日历的宿主:紧贴按钮下方展开(不是弹窗)。内容由下面那段 JS 画;
|
||||
JS 不可用时它是空的、退到下面的 noscript(原生日期框 + 跳转按钮)。 */ ?>
|
||||
<div class="cal-jump-drop" id="calJumpDrop" hidden></div>
|
||||
<noscript>
|
||||
<input class="input" type="date" name="d" value="<?= e($anchor) ?>" aria-label="选择日期">
|
||||
@@ -352,10 +361,21 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
/* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
|
||||
})();
|
||||
|
||||
/* ---- v24 第 12 批 #3:「跳到某一天」—— 单个图标按钮 + **按钮下方的下拉日历** ----
|
||||
用户原话:「选择这个日期,不用弹窗,直接把日历选择,弹出在这个按钮下面就可以了。」
|
||||
所以从"居中弹窗(cal-jump-host 全屏遮罩)"改成"贴着按钮下方展开的下拉"。
|
||||
⚠️ 中途会经过"保留视图"那一关:jump() 提交时带上当前 view(月的 ym / 周的 d / 日的 d),
|
||||
/* ---- v24 第 14 批 #1:「跳到某一天」—— 图标按钮 + **按钮下方自绘的下拉日历** ----
|
||||
原话(两条,都要满足):
|
||||
· 「选择这个日期,不用弹窗,直接把日历选择,弹出在这个按钮下面就可以了」
|
||||
· 「我点击上一个月,页面直接跳转,我还没选择日期呢」(严重 bug)
|
||||
|
||||
🔴 为什么**不用** Semi 的 DatePicker(上一版就是它):
|
||||
Semi 的日历面板渲染在 **body 的 portal** 里(不在我们的下拉容器内),
|
||||
而且它把"翻月/翻年"与"选中某天"混在同一个 onChange 回调里 ——
|
||||
于是点"上一月"也被当成"选中了这一天",页面立刻提交跳转。
|
||||
第三方控件的这个语义我们改不了;而这个交互又是"翻月不跳、点天才跳",
|
||||
必须 100% 由我们自己定。所以这里自绘:
|
||||
· « ‹ 年/月 › » → **只重绘面板**,绝不提交;
|
||||
· 点某一天 → 立刻提交跳转(保留当前视图);
|
||||
· 底部「跳到这天」→ 明确的确认入口(键盘/读屏用户也能用)。
|
||||
⚠️ jump() 提交时带上当前 view(月的 ym / 周、日的 d),
|
||||
由服务端决定该视图下这个日期落在哪一屏 —— 见 index.php 的 calendar 路由。 */
|
||||
(function () {
|
||||
var form = document.querySelector('.cal-jump');
|
||||
@@ -363,8 +383,14 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
var hid = document.getElementById('calJumpD');
|
||||
var drop = document.getElementById('calJumpDrop');
|
||||
if (!form || !btn || !hid) { return; }
|
||||
var root = null; // React root(重开时先 unmount)
|
||||
|
||||
var WD = ['一', '二', '三', '四', '五', '六', '日']; // 与月视图一致:周一起
|
||||
var curY = 0, curM = 0; // 面板当前显示的年月
|
||||
var sel = ''; // 已选日期('Y-m-d')
|
||||
|
||||
function ymd(y, m, d) {
|
||||
return y + '-' + String(m).padStart(2, '0') + '-' + String(d).padStart(2, '0');
|
||||
}
|
||||
function jump(v) {
|
||||
if (!v) { return; }
|
||||
hid.value = v;
|
||||
@@ -373,54 +399,115 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
if (ym) { ym.value = String(v).slice(0, 7); }
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function closeDrop() {
|
||||
if (!drop) { return; }
|
||||
drop.hidden = true;
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
if (root) { root.unmount(); root = null; }
|
||||
drop.innerHTML = '';
|
||||
}
|
||||
|
||||
// 原生兜底:Semi 没就绪时用临时原生日期框(与无 JS 时行为一致)
|
||||
function fallback() {
|
||||
var t = document.createElement('input');
|
||||
t.type = 'date';
|
||||
t.value = hid.value;
|
||||
t.style.position = 'fixed';
|
||||
t.style.left = '-9999px';
|
||||
document.body.appendChild(t);
|
||||
t.addEventListener('change', function () { jump(t.value); });
|
||||
if (typeof t.showPicker === 'function') { try { t.showPicker(); } catch (e2) {} }
|
||||
else { t.focus(); t.click(); }
|
||||
/** 画一个月:**只动面板内容**,不做任何跳转 */
|
||||
function paint() {
|
||||
var head = document.createElement('div');
|
||||
head.className = 'cjd-head';
|
||||
head.innerHTML =
|
||||
'<button type="button" class="cjd-nav" data-step="-12" title="上一年" aria-label="上一年">«</button>' +
|
||||
'<button type="button" class="cjd-nav" data-step="-1" title="上一月" aria-label="上一月">‹</button>' +
|
||||
'<span class="cjd-title"></span>' +
|
||||
'<button type="button" class="cjd-nav" data-step="1" title="下一月" aria-label="下一月">›</button>' +
|
||||
'<button type="button" class="cjd-nav" data-step="12" title="下一年" aria-label="下一年">»</button>';
|
||||
head.querySelector('.cjd-title').textContent = curY + ' 年 ' + curM + ' 月';
|
||||
|
||||
var grid = document.createElement('div');
|
||||
grid.className = 'cjd-grid';
|
||||
for (var w = 0; w < 7; w++) {
|
||||
var wd = document.createElement('div');
|
||||
wd.className = 'cjd-wd';
|
||||
wd.textContent = WD[w];
|
||||
grid.appendChild(wd);
|
||||
}
|
||||
// 起算:本月 1 号是周几(周一=0 … 周日=6),前面补上月尾巴
|
||||
var first = new Date(curY, curM - 1, 1);
|
||||
var lead = (first.getDay() + 6) % 7;
|
||||
var days = new Date(curY, curM, 0).getDate();
|
||||
var today = new Date();
|
||||
var todayStr = ymd(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
||||
var start = new Date(curY, curM - 1, 1 - lead);
|
||||
for (var i = 0; i < 42; i++) {
|
||||
var dt = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
|
||||
var s = ymd(dt.getFullYear(), dt.getMonth() + 1, dt.getDate());
|
||||
var cell = document.createElement('button');
|
||||
cell.type = 'button';
|
||||
cell.className = 'cjd-day';
|
||||
if (dt.getMonth() + 1 !== curM) { cell.className += ' is-out'; }
|
||||
if (s === todayStr) { cell.className += ' is-today'; }
|
||||
if (s === sel) { cell.className += ' is-on'; }
|
||||
cell.textContent = String(dt.getDate());
|
||||
cell.setAttribute('data-date', s);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
var foot = document.createElement('div');
|
||||
foot.className = 'cjd-foot';
|
||||
foot.innerHTML =
|
||||
'<span class="cjd-sel small muted"></span>' +
|
||||
'<button type="button" class="btn btn-sm cjd-today">今天</button>' +
|
||||
'<button type="button" class="btn btn-sm cjd-go">跳到这天</button>';
|
||||
foot.querySelector('.cjd-sel').textContent = sel ? ('已选 ' + sel) : '点一天就跳过去';
|
||||
|
||||
drop.innerHTML = '';
|
||||
drop.appendChild(head);
|
||||
drop.appendChild(grid);
|
||||
drop.appendChild(foot);
|
||||
|
||||
// 翻月 / 翻年:**只重画**
|
||||
// ⚠️ 必须 stopPropagation:paint() 会把面板里的节点换掉,等这次 click 冒泡到
|
||||
// document 上的「点外面就关」时,e.target 已经不在 DOM 里了 ——
|
||||
// `drop.contains(e.target)` 为假 → 面板当场被关掉(实测:点一下"上一月",
|
||||
// 面板立刻消失,看起来像"按钮没反应")。
|
||||
Array.prototype.forEach.call(drop.querySelectorAll('.cjd-nav'), function (b) {
|
||||
b.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var step = parseInt(b.getAttribute('data-step'), 10);
|
||||
var t = new Date(curY, curM - 1 + step, 1);
|
||||
curY = t.getFullYear();
|
||||
curM = t.getMonth() + 1;
|
||||
paint();
|
||||
});
|
||||
});
|
||||
// 点某一天 → 跳转(这是**唯一**会自动提交的地方)
|
||||
Array.prototype.forEach.call(drop.querySelectorAll('.cjd-day'), function (b) {
|
||||
b.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
sel = b.getAttribute('data-date');
|
||||
jump(sel);
|
||||
});
|
||||
});
|
||||
// 「跳到这天」:给不需要/不方便"点了就跳"的场景一个明确入口
|
||||
drop.querySelector('.cjd-go').addEventListener('click', function (e) {
|
||||
e.stopPropagation(); jump(sel);
|
||||
});
|
||||
drop.querySelector('.cjd-today').addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var t = new Date();
|
||||
sel = ymd(t.getFullYear(), t.getMonth() + 1, t.getDate());
|
||||
jump(sel);
|
||||
});
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (!drop) { fallback(); return; }
|
||||
if (!drop) { return; }
|
||||
if (!drop.hidden) { closeDrop(); return; }
|
||||
|
||||
if (!window.SemiUI || !window.SemiUI.DatePicker || !window.ReactDOM) { fallback(); return; }
|
||||
|
||||
var seed = String(hid.value || '').slice(0, 10);
|
||||
var d0 = /^\d{4}-\d{2}-\d{2}$/.test(seed) ? new Date(seed + 'T00:00:00') : new Date();
|
||||
if (isNaN(d0.getTime())) { d0 = new Date(); }
|
||||
curY = d0.getFullYear();
|
||||
curM = d0.getMonth() + 1;
|
||||
sel = ymd(curY, curM, d0.getDate());
|
||||
drop.hidden = false;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
var inner = document.createElement('div');
|
||||
drop.innerHTML = '';
|
||||
drop.appendChild(inner);
|
||||
root = ReactDOM.createRoot(inner);
|
||||
root.render(React.createElement(SemiUI.DatePicker, {
|
||||
type: 'date',
|
||||
format: 'yyyy-MM-dd',
|
||||
defaultValue: hid.value ? new Date(hid.value + 'T00:00:00') : new Date(),
|
||||
// ⚠️ 不用 `open: true`:那会让 Semi 自己弹它的浮层(就又成弹窗了)。
|
||||
// 这里要的是"日历长在下拉容器里",所以让它以内联面板渲染。
|
||||
open: false,
|
||||
defaultOpen: true,
|
||||
preventScroll: true,
|
||||
onChange: function (d, ds) {
|
||||
if (ds) { jump(ds); }
|
||||
}
|
||||
}));
|
||||
paint();
|
||||
});
|
||||
|
||||
// 点外面 / Esc 收起(下拉的常规行为)
|
||||
|
||||
@@ -194,6 +194,12 @@ $homeMods = array_values(array_filter(
|
||||
<a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>">
|
||||
<?= e((string)$t['title']) ?>
|
||||
</a>
|
||||
<?php /* v24 第 14 批 用户第 2 条:首页这一块是**按状态**捞出来的
|
||||
(进行中 + 已逾期的待办,跨看板),所以把状态标出来 ——
|
||||
否则"待办为什么出现在这里"看不出来。 */ ?>
|
||||
<span class="kb-st is-<?= e((string)($t['status'] ?? 'todo')) ?> is-static">
|
||||
<?= e(\App\Task::statusWord((string)($t['status'] ?? 'todo'))) ?>
|
||||
</span>
|
||||
<?php if ((int)($t['child_total'] ?? 0) > 0): ?>
|
||||
<span class="dash-tag"><?= (int)$t['child_done'] ?>/<?= (int)$t['child_total'] ?></span>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -86,9 +86,10 @@ $ctHints = [
|
||||
<div class="avatar-picker">
|
||||
<img id="avatar-preview" src="<?= e($avatarUrl) ?>" alt=""
|
||||
title="点击更换头像"
|
||||
data-pick-input="#avatar"
|
||||
data-has-avatar="<?= trim((string)($person['avatar'] ?? '')) !== '' ? '1' : '0' ?>">
|
||||
<?php /* 真正吃文件的 input:视觉上藏起来,由上面的图片代它触发(label/JS 都行,
|
||||
这里用 JS 点它,兼容性最好,也不依赖 for/id 配对)。 */ ?>
|
||||
<?php /* 真正吃文件的 input:视觉上藏起来,由上面的图片代它触发
|
||||
(app.js 的通用绑定认 data-pick-input,不依赖 for/id 配对)。 */ ?>
|
||||
<input class="sr-only" id="avatar" name="avatar" type="file" accept="image/*"
|
||||
data-crop-ratio="<?= e((string)$avSlot['ratio']) ?>"
|
||||
data-crop-w="<?= (int)$avSlot['w'] ?>"
|
||||
@@ -451,16 +452,10 @@ $ctHints = [
|
||||
<script>
|
||||
(function () {
|
||||
/* ---- 头像:点图即选文件(用户 2026-10-03 第 6 条)----
|
||||
真 input[type=file] 视觉上藏起来了(.sr-only),点图代它触发。
|
||||
⚠️ 不要用 <label for> 包住图片来做这件事:一旦图被包进 label,
|
||||
.avatar-picker 的 hover/光标语义会变,而且有的浏览器在 label 里
|
||||
点 <img> 不会激活 file 控件(要额外 CSS 才能稳)。直接点 input 最省事。 */
|
||||
var prev = document.getElementById('avatar-preview');
|
||||
var file = document.getElementById('avatar');
|
||||
if (prev && file) {
|
||||
prev.addEventListener('click', function () { file.click(); });
|
||||
prev.style.cursor = 'pointer';
|
||||
}
|
||||
⚠️ 2026-10-05 起这段**已搬到 app.js 的通用绑定**(模板上写
|
||||
`data-pick-input="#avatar"` 即可)。搬走的原因:只写在"头像"这一页,
|
||||
别的页面照抄同一套 DOM 却没有 JS —— 题图/封面点了没反应。
|
||||
这里不再保留副本(同一件事两处实现,改一处漏一处正是那次事故的成因)。 */
|
||||
|
||||
/* ---- 工作经历:+/× 增删一段(用户第 4 条)----
|
||||
⚠️ 模板里的行是**服务端渲染**的(首屏没有 JS 也能填、也能提交),
|
||||
|
||||
@@ -90,6 +90,19 @@ $lastColIdx = count($colList) - 1;
|
||||
require __DIR__ . '/../_person_picker.php';
|
||||
?>
|
||||
</div>
|
||||
<?php /* v24 第 14 批 用户第 2 条:新建时就能定状态(默认「待办」)。
|
||||
⚠️ 选「已完成»的话它不会出现在看板上(已完成已从看板撤走),
|
||||
只在「任务设置 → 已完成」里能翻到 —— 这句提示必须写上,
|
||||
否则用户会以为"刚建的任务丢了"。 */ ?>
|
||||
<div class="field">
|
||||
<label for="tk-status">状态</label>
|
||||
<select class="select" id="tk-status" name="status">
|
||||
<?php foreach (\App\Task::STATUS as $sk => $sv): ?>
|
||||
<option value="<?= e($sk) ?>" <?= $sk === 'todo' ? 'selected' : '' ?>><?= e($sv) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<span class="tip">选「已完成」的话,它只在「已完成」里出现,看板上不再显示。</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="tk-repeat">循环</label>
|
||||
<select class="select" id="tk-repeat" name="repeat_rule">
|
||||
@@ -154,6 +167,16 @@ $lastColIdx = count($colList) - 1;
|
||||
<input type="hidden" name="before" id="mvBefore">
|
||||
</form>
|
||||
|
||||
<?php /* v24 第 14 批 用户第 2 条:**每张卡上都能看到并改状态**。
|
||||
⚠️ 与上面搬卡那份同理:表单全页只有一份,卡片上只放 `data-st-*`,
|
||||
不然几十张卡就是几十份 form —— 名册就是这么被密码管理器拖垮的。 */ ?>
|
||||
<form method="post" action="<?= e(url('tasks/status')) ?>" id="taskStatusForm" class="hidden-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" id="stId">
|
||||
<input type="hidden" name="status" id="stStatus">
|
||||
<input type="hidden" name="back" value="tasks">
|
||||
</form>
|
||||
|
||||
<div class="kanban" data-cols="<?= count($colList) ?>">
|
||||
<?php foreach ($colList as $ci => $col): $cid = (int)$col['id']; ?>
|
||||
<section class="kb-col" data-board="<?= $cid ?>">
|
||||
@@ -174,7 +197,24 @@ $lastColIdx = count($colList) - 1;
|
||||
<a class="kb-title" href="<?= e(url('tasks/show', ['id' => $tid])) ?>">
|
||||
<?= e((string)$t['title']) ?>
|
||||
</a>
|
||||
<?php /* 状态徽标:每一张卡都有(用户第 2 条「每个任务都应该有不同的状态」)。
|
||||
点一下推进到下一个状态:待办 → 进行中 → 已完成 → 回到待办
|
||||
(最后一档做成可回头,是"点错了能改回来",完成本身另有详情页的按钮)。
|
||||
⚠️ 只读账号给的是**静态**徽标(span),点了没反应还容易误会。 */ ?>
|
||||
<?php
|
||||
$stNow = (string)($t['status'] ?? 'todo');
|
||||
$stNext = ['todo' => 'doing', 'doing' => 'done', 'done' => 'todo'][$stNow] ?? 'doing';
|
||||
?>
|
||||
<div class="kb-meta">
|
||||
<?php if ($canWrite): ?>
|
||||
<button class="kb-st is-<?= e($stNow) ?>" type="button"
|
||||
data-st-id="<?= $tid ?>" data-st="<?= e($stNow) ?>" data-next="<?= e($stNext) ?>"
|
||||
title="现在:<?= e(\App\Task::statusWord($stNow)) ?>;点一下改成「<?= e(\App\Task::statusWord($stNext)) ?>」">
|
||||
<?= e(\App\Task::statusWord($stNow)) ?>
|
||||
</button>
|
||||
<?php else: ?>
|
||||
<span class="kb-st is-<?= e($stNow) ?> is-static"><?= e(\App\Task::statusWord($stNow)) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if (($t['due_at'] ?? '') !== ''): ?>
|
||||
<span class="kb-due<?= $overdue ? ' is-late' : '' ?>">
|
||||
<?= e(\App\Task::dueWord($t['due_at'] ?? null)) ?>
|
||||
@@ -258,6 +298,22 @@ $lastColIdx = count($colList) - 1;
|
||||
document.getElementById('mvBefore').value = before || '';
|
||||
form.submit();
|
||||
}
|
||||
|
||||
/* ---- 状态徽标:点一下推进到下一个状态(用户第 2 条)----
|
||||
走的是**全页一份**的 #taskStatusForm(卡片上只有 data-st-*),
|
||||
与搬卡那份表单同一思路 —— 不这么做的话,几十张卡就是几十份 form。 */
|
||||
var stForm = document.getElementById('taskStatusForm');
|
||||
if (stForm) {
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.kb-st[data-st-id]'), function (b) {
|
||||
b.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.getElementById('stId').value = b.getAttribute('data-st-id');
|
||||
document.getElementById('stStatus').value = b.getAttribute('data-next');
|
||||
stForm.submit();
|
||||
});
|
||||
});
|
||||
}
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-move]'), function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var card = btn.closest('.kb-card');
|
||||
|
||||
@@ -45,7 +45,32 @@ $isChild = !empty($t['parent_id']);
|
||||
|
||||
<div class="card card-pad">
|
||||
<dl class="kv">
|
||||
<div class="kv-row"><dt>状态</dt><dd><?= e(\App\Task::statusWord((string)$t['status'])) ?></dd></div>
|
||||
<?php /* v24 第 14 批 用户第 2 条:状态要**能选**(不只是"标记完成/重新打开"两档)。
|
||||
这里给完整的三档下拉,与看板卡片上那个徽标走同一个路由。 */ ?>
|
||||
<div class="kv-row">
|
||||
<dt>状态</dt>
|
||||
<dd>
|
||||
<?php if ($canWrite): ?>
|
||||
<form method="post" action="<?= e(url('tasks/status')) ?>" class="res-inline st-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
|
||||
<input type="hidden" name="back" value="show">
|
||||
<select class="select" name="status" aria-label="任务状态">
|
||||
<?php foreach (\App\Task::STATUS as $sk => $sv): ?>
|
||||
<option value="<?= e($sk) ?>" <?= (string)$t['status'] === $sk ? 'selected' : '' ?>>
|
||||
<?= e($sv) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button class="btn btn-sm" type="submit">改</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<span class="kb-st is-<?= e((string)$t['status']) ?> is-static">
|
||||
<?= e(\App\Task::statusWord((string)$t['status'])) ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</dd>
|
||||
</div>
|
||||
<?php if (($t['due_at'] ?? '') !== ''): ?>
|
||||
<div class="kv-row"><dt>截止</dt>
|
||||
<dd class="<?= \App\Task::isOverdue($t['due_at'] ?? null) && $t['status'] !== 'done' ? 'kb-late' : '' ?>">
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<?php
|
||||
/**
|
||||
* 行程节点表单(新增 / 编辑共用;被 show.php 循环 require)。
|
||||
* 一段旅程的表单(新增 / 编辑共用;被 show.php 循环 require)。
|
||||
*
|
||||
* v24 第 13 批:旅程重构后,**行程不再分两条轨**,而是**一条记录 + 状态机**。
|
||||
* ⚠️ 术语(用户第 15 批 第 1 条):这是**一段旅程**;「计划 / 实际」是它的阶段。
|
||||
*
|
||||
* v24 第 13 批:旅程重构后,**不再分两条轨**,而是**一条记录 + 状态机**。
|
||||
* 用户原话:「这个实际的行程和计划行程,是一条记录,不是两条」。
|
||||
* 所以这里加一个「这是哪一类」的下拉:计划行程 / 实际行程
|
||||
* 所以这里有「这是哪一类」的下拉:计划 / 实际
|
||||
* (新建时由用户选;编辑时是当前状态)。
|
||||
* 「修正」「标记为实际」「完成」「标记未成行」是**另外三个按钮**(在 show.php 上),
|
||||
* 不走这张表单 —— 它们的语义是"对既有行程的操作",不是"填一遍字段"。
|
||||
* 「修正」「标记为实际」「完成」「标记未成行」是**另外几个按钮**(在 show.php 上),
|
||||
* 不走这张表单 —— 它们的语义是"对既有旅程的操作",不是"填一遍字段"。
|
||||
*
|
||||
* 变量(require 前设好):
|
||||
* $nfNode 被编辑的节点数组,或 null(新增)
|
||||
* $nfTripId 旅程 id(events.id)
|
||||
* $nfTripId 活动 id(events.id)
|
||||
* $nfCategory 【兼容保留】'plan' | 'reality'(新版只用来定默认值)
|
||||
* $nfPeople 可选人名册(选参与人用),为空就不显示成员多选
|
||||
* $nfMembers 已选成员 id 列表
|
||||
@@ -35,7 +37,7 @@ $nfStage = $nfIsEdit
|
||||
|
||||
<div class="nf-grid">
|
||||
<div class="field">
|
||||
<label for="<?= e($nfPrefix) ?>-title">这一段 *</label>
|
||||
<label for="<?= e($nfPrefix) ?>-title">这段旅程叫什么 *</label>
|
||||
<input class="input" id="<?= e($nfPrefix) ?>-title" name="title" required
|
||||
value="<?= e($nfNode['title'] ?? '') ?>" placeholder="如:栈桥看海">
|
||||
</div>
|
||||
@@ -51,12 +53,12 @@ $nfStage = $nfIsEdit
|
||||
</div>
|
||||
<?php if (!$nfIsEdit): ?>
|
||||
<div class="field">
|
||||
<label for="<?= e($nfPrefix) ?>-stage">这是哪一类 *</label>
|
||||
<label for="<?= e($nfPrefix) ?>-stage">阶段 *</label>
|
||||
<select class="select" id="<?= e($nfPrefix) ?>-stage" name="stage_pick">
|
||||
<option value="plan"<?= $nfStage === 'plan' ? ' selected' : '' ?>>计划行程(还没走)</option>
|
||||
<option value="actual"<?= $nfStage === 'actual' ? ' selected' : '' ?>>实际行程(已经走过)</option>
|
||||
<option value="plan"<?= $nfStage === 'plan' ? ' selected' : '' ?>>计划(还没走)</option>
|
||||
<option value="actual"<?= $nfStage === 'actual' ? ' selected' : '' ?>>实际(已经走过)</option>
|
||||
</select>
|
||||
<span class="tip">计划行程走完后可用「修正 / 完成」转成实际行程。</span>
|
||||
<span class="tip">计划走完后可用「修正 / 完成」把它转成实际。</span>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="field">
|
||||
@@ -93,11 +95,11 @@ $nfStage = $nfIsEdit
|
||||
<div class="field full">
|
||||
<label for="<?= e($nfPrefix) ?>-detail">记录</label>
|
||||
<textarea class="input" id="<?= e($nfPrefix) ?>-detail" name="detail" rows="2"
|
||||
placeholder="这一段发生了什么"><?= e($nfNode['detail'] ?? '') ?></textarea>
|
||||
placeholder="这段旅程发生了什么"><?= e($nfNode['detail'] ?? '') ?></textarea>
|
||||
</div>
|
||||
<?php if (!empty($nfPeople)): ?>
|
||||
<div class="field full">
|
||||
<label>这一段里有谁(不勾 = 全趟同行的人)</label>
|
||||
<label>这段旅程里有谁(不勾 = 整个活动同行的人)</label>
|
||||
<div class="nf-members">
|
||||
<?php foreach ($nfPeople as $p): ?>
|
||||
<label class="check-item">
|
||||
@@ -111,6 +113,6 @@ $nfStage = $nfIsEdit
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-sm" type="submit"><?= $nfIsEdit ? '保存' : '加一段' ?></button>
|
||||
<button class="btn btn-sm" type="submit"><?= $nfIsEdit ? '保存' : '加这段旅程' ?></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* 「修正」表单(v24 第 13 批):记录一条计划行程的**实际**执行情况。
|
||||
* 「修正」表单(v24 第 13 批):记录一段计划旅程的**实际**执行情况。
|
||||
*
|
||||
* 用户原话:「另外一个按钮,用来记录实际的情况,按钮名字就叫修正,但是记住,
|
||||
* 这个实际的行程和计划行程,是一条记录,不是两条,点击这个按钮,
|
||||
@@ -14,8 +14,8 @@
|
||||
* 两者都作用在同一条 trip_nodes 记录上(用户明确"是一条记录,不是两条")。
|
||||
*
|
||||
* 变量(require 前设好):
|
||||
* $rvNode 被修正的行程
|
||||
* $rvTripId 旅程 id
|
||||
* $rvNode 被修正的这段旅程
|
||||
* $rvTripId 活动 id
|
||||
* $rvPeople 人名册(选实际参与人)
|
||||
* $rvMembers 当前参与人 id
|
||||
*/
|
||||
|
||||
+142
-96
@@ -1,13 +1,26 @@
|
||||
<?php
|
||||
/**
|
||||
* 记一趟旅程 / 编辑旅程(v12)。
|
||||
* 记一次活动 / 编辑活动(v24 第 15 批重排)。
|
||||
*
|
||||
* 字段分两组:
|
||||
* · 骨架(落在 events 上):标题 / 出发 / 回来 / 目的地 / 备注 —— 与观影同一套原语;
|
||||
* · 旅程特有(落在 trip_details 上):封面图 + 一段话小结。
|
||||
* ⚠️ 用户原话(2026-10-05 第 15 批):
|
||||
* 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
|
||||
* 「活动不需要开始和结束时间,是根据活动中的旅程…计算出来的。目的地也不需要…
|
||||
* 去的城市,也是根据旅程,得到的。」
|
||||
* 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」
|
||||
*
|
||||
* ⚠️ 同行的人用多选框(人不多,一次性列全比"搜索+联想"快);
|
||||
* 显示名一律是「我叫他什么」(person_name),提交的是 person_id。
|
||||
* 所以表单**主区就三样**(顺序就是用户说的顺序):
|
||||
* ① 标题 ② 题图(上传 + 16:9 裁剪)
|
||||
* ③ 简介
|
||||
* 时间 / 城市**不在表单里** —— 它们是"旅程算出来的",不是"这里填进去的"。
|
||||
* 页面上只做一句说明,让人知道这两个东西从哪来(不然会去找"怎么没地方填")。
|
||||
*
|
||||
* 「备注 / 公开 / 同行的人」功能没丢,收进「更多设置」折叠区:
|
||||
* 用户说的是"就可以了"(主流程够短),不是"把这些删掉"。
|
||||
* ⚠️ 但它们在同一个 <form> 里、只是视觉上折叠 —— 少了哪个字段都会静默丢数据。
|
||||
*
|
||||
* ⚠️ 同行的人:活动上登记的这份名单**仍然有用**(参与人 = 活动登记 ∪ 各段旅程成员)。
|
||||
* 但既然活动现在"没有时间也没有地点",多数人会直接在每段旅程里填成员;
|
||||
* 所以它退到折叠区,默认不占地方。
|
||||
*/
|
||||
$isEdit = !empty($t);
|
||||
$canWrite = \App\Auth::canWrite();
|
||||
@@ -19,12 +32,20 @@ if ($isEdit) {
|
||||
} else {
|
||||
$selIds = array_map('intval', (array)($presetAttendees ?? []));
|
||||
}
|
||||
$cvSlot = \App\ImageSlot::get('cover') ?: ['ratio' => '16:9', 'w' => 1600, 'h' => 900,
|
||||
'label' => '题图', 'hint' => ''];
|
||||
$hasCover = $isEdit && !empty($t['cover_key']);
|
||||
// 折叠区里"有没有填过东西" —— 有就默认展开,别让改过的人还要再点一次
|
||||
$moreOpen = $isEdit && (trim((string)($t['note'] ?? '')) !== ''
|
||||
|| (string)($t['is_public'] ?? '1') === '0'
|
||||
|| $selIds);
|
||||
?>
|
||||
<div class="page-head">
|
||||
<h1><?= $isEdit ? '编辑旅程' : '记一趟旅程' ?></h1>
|
||||
<h1><?= $isEdit ? '编辑活动' : '记一次活动' ?></h1>
|
||||
<span class="sub">活动是容器;里面一段一段的旅程才是日程</span>
|
||||
<div class="actions">
|
||||
<a class="btn btn-ghost"
|
||||
href="<?= e($isEdit ? url('trips/show', ['id' => (int)$t['id']]) : url('trips')) ?>">返回旅程</a>
|
||||
href="<?= e($isEdit ? url('trips/show', ['id' => (int)$t['id']]) : url('trips')) ?>">返回</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,101 +57,126 @@ if ($isEdit) {
|
||||
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field full">
|
||||
<label for="t-title">这趟旅程叫什么 *</label>
|
||||
<input class="input" id="t-title" name="title" required
|
||||
value="<?= e(old('title', $t['title'] ?? '')) ?>" placeholder="如:青岛三日 · 暑假">
|
||||
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
|
||||
</div>
|
||||
<?php /* ==================== ① 标题 ==================== */ ?>
|
||||
<div class="field full">
|
||||
<label for="t-title">这次活动叫什么 *</label>
|
||||
<input class="input" id="t-title" name="title" required autofocus
|
||||
value="<?= e(old('title', $t['title'] ?? '')) ?>" placeholder="如:青岛之行 · 2026 暑假">
|
||||
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php /* 用户 2026-10-04 第 7 条:出发/回来是一个**时间范围**,
|
||||
用一个范围选择器一次选完(不再是并列两个控件)。 */ ?>
|
||||
<div class="field dt-range">
|
||||
<label for="t-start">出发 / 回来</label>
|
||||
<?php $dtName = 'starts_at'; $dtValue = $t['starts_at'] ?? '';
|
||||
$dtId = 't-start'; $dtSeconds = true; $dtRange = 'start';
|
||||
require __DIR__ . '/../_datetime.php'; ?>
|
||||
<?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?>
|
||||
<?php $dtName = 'ends_at'; $dtValue = $t['ends_at'] ?? ''; $dtId = 't-end'; $dtSeconds = true;
|
||||
$dtRange = 'end';
|
||||
require __DIR__ . '/../_datetime.php'; ?>
|
||||
<?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?>
|
||||
<span class="tip">回来的时间可不填。</span>
|
||||
</div>
|
||||
|
||||
<div class="field full">
|
||||
<label for="t-loc">目的地</label>
|
||||
<input class="input" id="t-loc" name="location"
|
||||
value="<?= e(old('location', $t['location'] ?? '')) ?>" placeholder="如:青岛">
|
||||
</div>
|
||||
|
||||
<div class="field full">
|
||||
<label for="t-sum">一句话小结</label>
|
||||
<textarea class="input" id="t-sum" name="summary" rows="3"
|
||||
placeholder="回看时最能勾起记忆的那一句"><?= e(old('summary', $t['summary'] ?? '')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field full">
|
||||
<label for="t-note">备注</label>
|
||||
<textarea class="input" id="t-note" name="note" rows="3"><?= e(old('note', $t['note'] ?? '')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<?php /* 用户 2026-10-03 第 4 条:旅程列表只显示公开的(is_public=1)。
|
||||
默认**勾上**(公开)—— 新建一趟行程却默认藏起来是反直觉的;
|
||||
编辑时按库里的值回填,老数据 COALESCE 成 1 也是勾上。
|
||||
⚠️ 提交的是 `is_public=1`,不勾则完全不提交这个键,
|
||||
saveDetails 里 `!empty($d['is_public'])` 自然得到 0。 */ ?>
|
||||
<?php $isPub = old('is_public', (string)($t['is_public'] ?? 1)) !== '0'; ?>
|
||||
<div class="field full">
|
||||
<label>公开</label>
|
||||
<label class="chk-inline">
|
||||
<?php /* ⚠️ 先放一个 hidden=0:不勾选时提交的就是"明确的不公开",
|
||||
而不是"这个字段没出现" —— 后者会被当成"没传"而默认公开,
|
||||
于是"取消勾选"看起来毫无作用(经典 checkbox 陷阱)。 */ ?>
|
||||
<input type="hidden" name="is_public" value="0">
|
||||
<input type="checkbox" name="is_public" value="1" <?= $isPub ? 'checked' : '' ?>>
|
||||
<span>在旅程列表与日历里显示这趟行程</span>
|
||||
</label>
|
||||
<span class="tip">不勾 = 只留着自己看:不进列表、不上日历,但有链接仍能打开。</span>
|
||||
</div>
|
||||
|
||||
<div class="field full">
|
||||
<label>同行的人</label>
|
||||
<?php /* v24 第 11 条:与全局选人组件统一(原来是平铺的 checkbox 网格,
|
||||
名册一多就变成"在一长串里瞪眼找",也搜不了拼音) */ ?>
|
||||
<?php if (!$people): ?>
|
||||
<p class="empty-hint">名册里还没有人。</p>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
$ppName = 'attendees[]';
|
||||
$ppPeople = $people;
|
||||
$ppSelected = $selIds;
|
||||
$ppMode = 'multi';
|
||||
$ppOpenBtn = '+ 添加同行人';
|
||||
$ppTitle = '选择同行的人';
|
||||
$ppTip = '';
|
||||
require __DIR__ . '/../_person_picker.php';
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="field full">
|
||||
<label for="t-cover">封面图</label>
|
||||
<?php if ($isEdit && !empty($t['cover_key'])): ?>
|
||||
<div class="cover-now">
|
||||
<img src="<?= e(media_url((string)$t['cover_key'])) ?>" alt="">
|
||||
<span class="tip">不换图就不用管这里;选一张新图会替换它。</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<input class="input" type="file" id="t-cover" name="cover" accept="image/*">
|
||||
<?php /* ==================== ② 题图(上传 + 裁剪)====================
|
||||
⚠️ 用声明式绑定:`data-pick-input="#t-cover"` 让 app.js 的通用逻辑接管
|
||||
"点图 = 打开文件选择框"。上一批这里**没有**这段绑定(JS 只写在
|
||||
people/form.php 里),于是点图毫无反应 —— 用户当轮就报了这个问题。 */ ?>
|
||||
<div class="field full">
|
||||
<label>题图</label>
|
||||
<div class="cover-picker">
|
||||
<img id="cover-preview"
|
||||
src="<?= e($hasCover ? media_url((string)$t['cover_key']) : '') ?>"
|
||||
alt="" title="点击选择题图"
|
||||
data-pick-input="#t-cover"
|
||||
data-has-cover="<?= $hasCover ? '1' : '0' ?>">
|
||||
<input class="sr-only" id="t-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="#cover-preview">
|
||||
<?php /* 用户挑的裁剪框(归一化 0..1);没裁就留空 = 服务端按 16:9 居中裁 */ ?>
|
||||
<input type="hidden" name="cover_crop" value="">
|
||||
<span class="avatar-hint small muted">
|
||||
点左边那块选题图,选完弹裁剪框。<br>
|
||||
题图是 16:9 的宽幅横图,多大都能传,保存时自动缩成
|
||||
<?= (int)$cvSlot['w'] ?>×<?= (int)$cvSlot['h'] ?>。
|
||||
<?php if ($hasCover): ?>不换图就不用管这里。<?php endif; ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php /* ==================== ③ 简介 ==================== */ ?>
|
||||
<div class="field full">
|
||||
<label for="t-sum">一段简介</label>
|
||||
<textarea class="input" id="t-sum" name="summary" rows="3"
|
||||
placeholder="这趟玩得怎么样、为什么想记下来"><?= e(old('summary', $t['summary'] ?? '')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<?php if (!$isEdit): ?>
|
||||
<?php /* 时间与城市**不是输入项** —— 说清楚它们从哪来,
|
||||
否则用户会满页找"怎么没地方填时间"。 */ ?>
|
||||
<p class="form-note">
|
||||
时间与去过的城市不用填:加进一段段旅程之后,会自动取<b>最早的一段</b>、
|
||||
<b>最晚的一段</b>和<b>各段去的地方</b>算出来。
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
/* 编辑时把"已经算出来什么"直接摆出来:
|
||||
不显示的话,改完标题保存会以为"我把时间弄丢了"。 */
|
||||
$tSpan = \App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null);
|
||||
$tCity = (string)($t['city'] ?? '');
|
||||
?>
|
||||
<p class="form-note">
|
||||
当前时间:<b><?= e($tSpan) ?></b>
|
||||
<?php if ($tCity !== ''): ?>· 去过:<b><?= e($tCity) ?></b><?php endif; ?>
|
||||
<br>这两样都是从下面各段旅程算出来的,改这里没用;要改就去改对应的那段旅程。
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* ==================== 更多设置(折叠)====================
|
||||
功能一个都没删,只是主流程只留用户点名的那三项。 */ ?>
|
||||
<details class="more-set"<?= $moreOpen ? ' open' : '' ?>>
|
||||
<summary>更多设置(备注 / 公开 / 同行的人)</summary>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field full">
|
||||
<label for="t-note">备注</label>
|
||||
<textarea class="input" id="t-note" name="note" rows="3"
|
||||
placeholder="选填。不显示在列表里,只在这趟活动的页面上"><?= e(old('note', $t['note'] ?? '')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<?php /* 用户 2026-10-03 第 4 条:不公开的活动只留着自己看。
|
||||
⚠️ 先放 hidden=0:不勾选时提交的是"明确的不公开",
|
||||
而不是"这个字段没出现"(后者会被当成"没传"而默认公开)。 */ ?>
|
||||
<?php $isPub = old('is_public', (string)($t['is_public'] ?? 1)) !== '0'; ?>
|
||||
<div class="field full">
|
||||
<label>公开</label>
|
||||
<label class="chk-inline">
|
||||
<input type="hidden" name="is_public" value="0">
|
||||
<input type="checkbox" name="is_public" value="1" <?= $isPub ? 'checked' : '' ?>>
|
||||
<span>在活动列表与日历里显示这次活动</span>
|
||||
</label>
|
||||
<span class="tip">不勾 = 只留着自己看:不进列表、不上日历,但有链接仍能打开。</span>
|
||||
</div>
|
||||
|
||||
<div class="field full">
|
||||
<label>同行的人</label>
|
||||
<?php if (!$people): ?>
|
||||
<p class="empty-hint">名册里还没有人。</p>
|
||||
<?php else: ?>
|
||||
<span class="tip">
|
||||
这里登记的是"整趟活动都在的人"。只在某一段旅程里的人,
|
||||
到那段旅程里勾就行 —— 活动页上的名单是两边合起来算的。
|
||||
</span>
|
||||
<?php
|
||||
$ppName = 'attendees[]';
|
||||
$ppPeople = $people;
|
||||
$ppSelected = $selIds;
|
||||
$ppMode = 'multi';
|
||||
$ppOpenBtn = '+ 添加同行人';
|
||||
$ppTitle = '选择同行的人';
|
||||
$ppTip = '';
|
||||
require __DIR__ . '/../_person_picker.php';
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="submit"><?= $isEdit ? '保存' : '记下来' ?></button>
|
||||
<a class="btn btn-ghost"
|
||||
href="<?= e($isEdit ? url('trips/show', ['id' => (int)$t['id']]) : url('trips')) ?>">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
+23
-13
@@ -1,18 +1,26 @@
|
||||
<?php
|
||||
/**
|
||||
* 旅程列表(v12,TripLog activities/index 迁来,但按家书的统一事件原语重写)。
|
||||
* 活动列表(v12 建立,v24 第 15 批把术语理清)。
|
||||
*
|
||||
* 与观影列表同一个骨架:页头 + 搜索 + 卡片列表。
|
||||
* ⚠️ 卡片上的人是「我叫他什么」(person_chip),不是姓名(用户第 3 条)。
|
||||
* ⚠️ 术语(用户 2026-10-05 第 15 批 第 1 条原话):
|
||||
* 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
|
||||
* 所以:这一页是**活动**的列表,一条活动里头才是若干段**旅程**。
|
||||
* 页面上的"趟"一律改成"次"(一次活动);"段行程"改成"段旅程"。
|
||||
*
|
||||
* 时间与城市都是从各段旅程**派生**的(用户第 2 条)——
|
||||
* 列表里读的是 Trip::attachDerived() 挂上来的值,不是活动自己的字段。
|
||||
*/
|
||||
$canWrite = \App\Auth::canWrite();
|
||||
?>
|
||||
<div class="page-head">
|
||||
<h1>旅程</h1>
|
||||
<span class="sub">共 <?= (int)$total ?> 趟</span>
|
||||
<h1>活动</h1>
|
||||
<?php /* 导航条上这个模块仍叫「旅程」(用户就是这么称呼它的),
|
||||
但这一页列的顶层单位是**活动** —— 一句副标题把两级关系说清楚,
|
||||
否则"导航写旅程、页面写活动"会让人以为点错了地方。 */ ?>
|
||||
<span class="sub">共 <?= (int)$total ?> 次 · 每次活动里可以有若干段旅程</span>
|
||||
<div class="actions">
|
||||
<?php if ($canWrite): ?>
|
||||
<a class="btn" href="<?= e(url('trips/new')) ?>">记一趟旅程</a>
|
||||
<a class="btn" href="<?= e(url('trips/new')) ?>">记一次活动</a>
|
||||
<?php else: ?>
|
||||
<span class="link-quiet">只读账号</span>
|
||||
<?php endif; ?>
|
||||
@@ -21,7 +29,7 @@ $canWrite = \App\Auth::canWrite();
|
||||
|
||||
<form class="search" method="get" action="index.php">
|
||||
<input type="hidden" name="r" value="trips">
|
||||
<input class="input" type="text" name="keyword" value="<?= e($keyword) ?>" placeholder="搜标题 / 目的地 / 备注">
|
||||
<input class="input" type="text" name="keyword" value="<?= e($keyword) ?>" placeholder="搜活动名 / 去过的城市 / 备注">
|
||||
<button class="btn" type="submit">搜索</button>
|
||||
<?php if ($keyword !== ''): ?>
|
||||
<a class="search-clear" href="<?= e(url('trips')) ?>">清除</a>
|
||||
@@ -30,9 +38,10 @@ $canWrite = \App\Auth::canWrite();
|
||||
|
||||
<?php if (!$rows): ?>
|
||||
<div class="card card-pad empty">
|
||||
<b><?= $keyword !== '' ? '没有匹配的旅程' : '还没有旅程记录' ?></b>
|
||||
<b><?= $keyword !== '' ? '没有匹配的活动' : '还没有活动记录' ?></b>
|
||||
<?= $keyword !== '' ? '换个关键词试试,或清除搜索看全部。'
|
||||
: ($canWrite ? '点右上角「记一趟旅程」,把一次出行记下来。' : '家人还没有记录旅程。') ?>
|
||||
: ($canWrite ? '点右上角「记一次活动」,把一次出行记下来;进去之后再加一段段旅程。'
|
||||
: '家人还没有记录活动。') ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="trip-list">
|
||||
@@ -48,11 +57,12 @@ $canWrite = \App\Auth::canWrite();
|
||||
<div class="trip-main">
|
||||
<h2 class="trip-title"><?= e((string)$t['title']) ?></h2>
|
||||
<div class="trip-meta">
|
||||
<span><?= e(\App\Trip::formatSpan($t['starts_at'] ?? null, $t['ends_at'] ?? null)) ?></span>
|
||||
<?php if (!empty($t['location'])): ?>
|
||||
<span>📍 <?= e((string)$t['location']) ?></span>
|
||||
<?php /* 时间是从各段旅程算的;一段都没有时如实说"还没有旅程" */ ?>
|
||||
<span><?= e(\App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null)) ?></span>
|
||||
<?php if (!empty($t['city'])): ?>
|
||||
<span>📍 <?= e((string)$t['city']) ?></span>
|
||||
<?php endif; ?>
|
||||
<span><?= (int)($t['node_count'] ?? 0) ?> 段行程</span>
|
||||
<span><?= (int)($t['node_count'] ?? 0) ?> 段旅程</span>
|
||||
</div>
|
||||
<?php if (!empty($t['summary'])): ?>
|
||||
<p class="trip-sum"><?= e(mb_substr((string)$t['summary'], 0, 80, 'UTF-8')) ?></p>
|
||||
|
||||
+52
-28
@@ -1,9 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* 旅程详情(v12):封面 + 小结 + 同行的人 + 照片 + 行程节点(计划 / 执行双轨)。
|
||||
* 活动详情(v12 建立,v24 第 15 批整理术语与派生值)。
|
||||
*
|
||||
* ⚠️ 双轨的意义:出发前排的是「打算怎么走」,走完记的是「实际怎么走的」。
|
||||
* 两轨并存才能回看"本来想去哪、后来去了哪"—— 这是 TripLog 最有价值的一点,搬过来时保留了。
|
||||
* 页面结构:封面 + 简介 + 同行的人 + 照片 + 下面的若干段**旅程**。
|
||||
*
|
||||
* ⚠️ 术语(用户 2026-10-05 第 15 批 第 1 条):这里是**一次活动**,
|
||||
* 里头一段一段的才是**旅程**。所以「+ 加一段计划行程」这类按钮
|
||||
* 统一改成「+ 加一段旅程」(阶段用「计划 / 实际」修饰)。
|
||||
*
|
||||
* ⚠️ 时间与城市都来自 $t 上挂的**派生值**(Trip::derived(),用户第 2 条)——
|
||||
* 页面不再读 events.starts_at / location。
|
||||
*/
|
||||
$canWrite = \App\Auth::canWrite();
|
||||
// v24 第 13 批:不再按 category 双轨分,按**状态机 stage** 分三组展示。
|
||||
@@ -26,18 +32,19 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
|
||||
?>
|
||||
<div class="page-head">
|
||||
<h1><?= e((string)$t['title']) ?></h1>
|
||||
<span class="sub"><?= e(\App\Trip::formatSpan($t['starts_at'] ?? null, $t['ends_at'] ?? null)) ?></span>
|
||||
<?php /* 时间/城市都是派生的:一段旅程都没加时显示「还没有旅程」,不是「日期待补」 */ ?>
|
||||
<span class="sub"><?= e(\App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null)) ?></span>
|
||||
<div class="actions">
|
||||
<?php if ($canWrite): ?>
|
||||
<a class="btn btn-ghost" href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">编辑</a>
|
||||
<form method="post" action="<?= e(url('trips/destroy')) ?>"
|
||||
onsubmit="return confirm('确定删除「<?= e((string)$t['title']) ?>」吗?照片与行程节点会一起删掉,不可恢复。')">
|
||||
onsubmit="return confirm('确定删除「<?= e((string)$t['title']) ?>」吗?里面的旅程、照片会一起删掉,不可恢复。')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
|
||||
<button class="btn btn-danger" type="submit">删除</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回旅程</a>
|
||||
<a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回活动</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,17 +57,29 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="trip-hero-body">
|
||||
<?php if (!empty($t['location'])): ?>
|
||||
<p class="trip-loc">📍 <?= e((string)$t['location']) ?></p>
|
||||
<?php /* 地名可能是好几个城市 → 用派生出来的列表逐颗画(用户第 2 条) */ ?>
|
||||
<?php if (!empty($t['cities'])): ?>
|
||||
<p class="trip-loc">
|
||||
<?php foreach ((array)$t['cities'] as $i => $city): ?>
|
||||
<?= $i > 0 ? '<span class="city-sep">·</span>' : '📍' ?>
|
||||
<span class="city-chip"><?= e((string)$city) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($t['summary'])): ?>
|
||||
<p class="info-text"><?= nl2br(e((string)$t['summary'])) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($t['attendees'])): ?>
|
||||
<div class="viewers-list">
|
||||
<?php foreach ($t['attendees'] as $a): ?>
|
||||
<?= person_chip($a, url('people/show', ['id' => (int)$a['id']])) ?>
|
||||
<?php endforeach; ?>
|
||||
<?php /* v24 第 14 批 用户第 4 条:「一个活动的参与人,应该是这个活动下的
|
||||
所有旅程的参与人的合集」—— 这上面的名单是**每段行程参与人的并集**,
|
||||
在行程里加了人,这里自动出现,不用再到活动上登记一遍。 */ ?>
|
||||
<div class="trip-people-block">
|
||||
<span class="small muted">参与的人(各段行程的合集)</span>
|
||||
<div class="viewers-list">
|
||||
<?php foreach ($t['attendees'] as $a): ?>
|
||||
<?= person_chip($a, url('people/show', ['id' => (int)$a['id']])) ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($t['note'])): ?>
|
||||
@@ -110,19 +129,22 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php /* ==================== 行程(v24 第 13 批重构)====================
|
||||
用户 2026-10-05 原话要点:
|
||||
· 刚开始添加时选「计划行程 / 实际行程」
|
||||
· 计划行程有三个按钮:删除(**标记未成行,不真删**)、修正(记实际执行,可无限次)、
|
||||
<?php /* ==================== 这个活动里的旅程(v24 第 13 批重构)====================
|
||||
⚠️ 术语(用户第 15 批 第 1 条):这一块里每一条都是**一段旅程**,
|
||||
不是"行程";分三组是按它的**阶段**(计划 / 实际 / 未成行)。
|
||||
|
||||
用户 2026-10-05(第 13 批)原话要点:
|
||||
· 刚开始添加时选「计划 / 实际」
|
||||
· 计划的三段有三个按钮:删除(**标记未成行,不真删**)、修正(记实际执行,可无限次)、
|
||||
完成(实际=计划,直接变实际)
|
||||
· 未成行的仍显示、但带标记;不上日程、不进往年今日
|
||||
· 实际行程上日程 + 往年今日
|
||||
分三组渲染:计划 / 实际 / 未成行(未成行放最后,用灰调)。 */ ?>
|
||||
· 实际的会同时上日程 + 往年今日
|
||||
分三组渲染(未成行放最后,用灰调)。 */ ?>
|
||||
<?php
|
||||
$stageMeta = [
|
||||
'plan' => ['计划行程', '还没走,下面的「修正 / 完成 / 标记未成行」都能用'],
|
||||
'actual' => ['实际行程', '已走过 —— 会出现在日历与首页「往年今日」'],
|
||||
'skipped' => ['未成行', '这次计划没成行;不再出现在日程与「往年今日」,但在这里保留可查'],
|
||||
'plan' => ['计划的旅程', '还没走。每段下面的「修正 / 完成 / 标记未成行」都能用'],
|
||||
'actual' => ['走过的旅程', '已走过 —— 会出现在日历与首页「往年今日」'],
|
||||
'skipped' => ['没成行的', '这次没去成;不再出现在日程与「往年今日」,但在这里保留可查'],
|
||||
];
|
||||
foreach (['plan', 'actual', 'skipped'] as $sg):
|
||||
$list = $bysStage[$sg];
|
||||
@@ -136,7 +158,8 @@ foreach (['plan', 'actual', 'skipped'] as $sg):
|
||||
|
||||
<?php if (!$list): ?>
|
||||
<p class="empty-hint">
|
||||
<?= $sg === 'plan' ? '还没有排计划。' : ($sg === 'actual' ? '还没有走过的行程。' : '没有未成行的计划。') ?>
|
||||
<?= $sg === 'plan' ? '还没有排计划。点下面的「+ 加一段旅程」,把打算走的地方排进来。'
|
||||
: ($sg === 'actual' ? '还没有走过的旅程。' : '没有未成行的。') ?>
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<ol class="node-list">
|
||||
@@ -212,9 +235,9 @@ foreach (['plan', 'actual', 'skipped'] as $sg):
|
||||
<summary class="btn btn-ghost btn-sm">修正</summary>
|
||||
<div class="rev-panel">
|
||||
<p class="rev-tip">
|
||||
记录**实际**执行情况。这仍与上面那条计划是<b>同一条记录</b>,
|
||||
记录实际执行情况。这仍与上面那条计划是<b>同一条记录</b>,
|
||||
原来的计划数据会保留在「修正记录」里,方便日后对比。
|
||||
点完这里<b>行程仍是计划</b>;确认无误后再点「标记为实际」。
|
||||
点完这里<b>这段旅程仍是计划</b>;确认无误后再点「标记为实际」。
|
||||
</p>
|
||||
<?php
|
||||
$rvNode = $n; $rvTripId = (int)$t['id'];
|
||||
@@ -225,14 +248,14 @@ foreach (['plan', 'actual', 'skipped'] as $sg):
|
||||
</div>
|
||||
</details>
|
||||
<form method="post" action="<?= e(url('trips/node_done')) ?>"
|
||||
onsubmit="return confirm('实际执行与计划完全相同,直接转为实际行程?')">
|
||||
onsubmit="return confirm('实际执行与计划完全相同,直接转为实际?')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
|
||||
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
|
||||
<button class="btn btn-sm" type="submit">完成</button>
|
||||
</form>
|
||||
<form method="post" action="<?= e(url('trips/node_skip')) ?>"
|
||||
onsubmit="return confirm('标记为「未成行」?它不会再出现在日程与往年今日,但在这趟旅程里仍然看得到。')">
|
||||
onsubmit="return confirm('标记为「未成行」?它不会再出现在日程与往年今日,但在这个活动里仍然看得到。')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
|
||||
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
|
||||
@@ -265,7 +288,7 @@ foreach (['plan', 'actual', 'skipped'] as $sg):
|
||||
?>
|
||||
</details>
|
||||
<form method="post" action="<?= e(url('trips/node_del')) ?>"
|
||||
onsubmit="return confirm('**彻底删除**这一段?连同它的修正记录一起消失,不可恢复。\n\n如果你只是想表达"这次没去成",请用「标记未成行」。')">
|
||||
onsubmit="return confirm('彻底删除这一段旅程?连同它的修正记录一起消失,不可恢复。\n\n如果你只是想表达「这次没去成」,请用「标记未成行」。')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
|
||||
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
|
||||
@@ -280,7 +303,8 @@ foreach (['plan', 'actual', 'skipped'] as $sg):
|
||||
|
||||
<?php if ($canWrite && $sg !== 'skipped'): ?>
|
||||
<details class="node-add">
|
||||
<summary class="link-quiet">+ 加一段<?= e($stageMeta[$sg][0]) ?></summary>
|
||||
<?php /* 术语:加的是"一段旅程",阶段只是它的属性 */ ?>
|
||||
<summary class="link-quiet">+ 加一段旅程(<?= $sg === 'actual' ? '实际' : '计划' ?>)</summary>
|
||||
<?php
|
||||
$nfNode = null; $nfTripId = (int)$t['id']; $nfCategory = $sg;
|
||||
$nfPeople = $people ?? []; $nfMembers = $memberIds;
|
||||
|
||||
Reference in New Issue
Block a user