v24 第 15 批:活动 / 旅程两级语义(术语 · 派生时间城市 · 表单三项 · 题图可点)

用户原话四条,逐条落地:

1. 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
   → 顶层一律改称**活动**:按钮「记一次活动」、页标题「活动」、`N 次` / `N 段旅程`;
     活动详情三组改叫「计划的旅程 / 走过的旅程 / 没成行的」;
     节点表单「这段旅程叫什么」「阶段」;flash 文案 17 处同步。
     (导航条仍叫「旅程」——用户就是这么称呼这个模块,列表页加副标题说清两级关系。)

2. 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间,和最晚的
     旅程结束时间,计算出来的。目的地也不需要…去的城市,也是根据旅程,得到的。」
   → 活动**不再存**这三样,全部**读时算出来**:
     · Trip::derived()      最早开始 / 最晚结束 / 城市去重
     · Trip::cities()       按最早出现时间排(先后顺序本身是信息,不是字母序)
     · Trip::attachDerived() 列表页批量补(避免 N+1)
     · Trip::activitySpan()  没有旅程时说「还没有旅程」,不用「日期待补」
     create/update 不再写 events 的起止与地点(老值保留不动,页面不读)。
     列表排序/搜索、首页「最近去了」改走子查询。

3. 「题图那里,没有办法上传图片,点击哪里都没反应。」——**真 bug**
   「点图 = 开文件框」那句 JS 只写在 people/form.php 的内页脚本里(头像专用);
   第 14 批加的旅程题图与图书封面照抄了同一套 DOM 却没有对应的 JS,
   于是图块有 cursor:pointer 与 hover 高亮(看着能点),点下去毫无反应。
   → 做成声明式通用绑定:图上写 data-pick-input="#fileInputId",
     app.js 统一接管(一个页面多个也各绑各的),并删掉旧的那份专用实现。
     三处(头像 / 题图 / 图书封面)全挂上。

4. 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」
   → 主区就这三项,顺序照原话;时间/城市不做输入项(只留一句说明它们从哪来,
     不说清用户会满页找"怎么没地方填时间")。
     备注 / 公开 / 同行的人收进「更多设置」折叠区——功能一个没丢。

顺带修掉两处会炸的实现问题:
· ORDER BY 引用聚合别名(first_at)在 MariaDB/MySQL 报 1247,且只在运行时炸、
  php -l 看不出 → 改成 ORDER BY MIN(starts_at),否则整个旅程模块 Fatal。
· e2e 里"详情页显示了派生时间"原本写成整页查找,下面每段旅程自己也渲染日期,
  于是把派生值塞成 null 也照样绿(借光假绿)→ 判据收进页头那一段。

验证:e2e 845/0(+15 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 移动几何 688/0 ·
交互 92/0 · 题图真机点击 4/0 · 读书窄屏 15/0 · 家谱 34/0 + 10/0 + 反向 3/3 ·
观影按钮 60/0 + 反向 3/3 · 本批反向验证 4/4(题图绑定抽掉→红、派生值不挂→红、
城市退回读 location→红、时间字段加回表单→红)· 部署静态断言新增两组(含"活动不许
再写起止时间/目的地"与"ORDER BY 不许引用聚合别名"的反退化防线)。
部署后服务器自检 19/19,五个关键文件 md5 与线上逐一核对一致。
This commit is contained in:
2026-10-05 10:58:17 +08:00
parent 89b956205d
commit 7138314dc7
30 changed files with 4177 additions and 290 deletions
+43
View File
@@ -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);
+211
View File
@@ -0,0 +1,211 @@
<?php
/**
* 添加 / 编辑一本书(v24 第 14 批 用户第 5 条)。
*
* 两条进书的路线,**同一张表单**:
* ① 上传文件(EPUB / TXT)→ 服务端解析出书名、作者、简介、封面与**每一章正文**;
* 解析出来的值只填**空着的**字段(用户已经手填的不覆盖)。
* ② 手填一条"想读的书"(还没电子版时先记一笔)。
*
* ⚠️ 文件与封面是两个**不同**的上传位:
* 文件是原书(整存,可能几十 MB),封面是一张图(走裁剪,2:3)。
* 混成一个会让"换封面"变成"重新上传整本书"。
*
* @param array|null $b 编辑时的书;null = 新建
* @param array $people 名册("谁的书")
* @param array $errors 校验错误
*/
$isEdit = !empty($b);
$canWrite = \App\Auth::canWrite();
$people = $people ?? [];
$errors = $errors ?? [];
$cvSlot = \App\ImageSlot::get('bookcover') ?: ['ratio' => '2:3', 'w' => 600, 'h' => 900,
'label' => '封面', 'hint' => ''];
$curCover = $isEdit ? (string)($b['cover_key'] ?? '') : '';
$rate = $isEdit && $b['rating'] !== null ? (int)$b['rating'] : -1;
$ownerSel = $isEdit && (int)($b['owner_id'] ?? 0) > 0 ? [(int)$b['owner_id']] : [];
?>
<div class="page-head">
<h1><?= $isEdit ? '编辑图书' : '添加图书' ?></h1>
<div class="actions">
<a class="btn btn-ghost" href="<?= e($isEdit ? url('books/show', ['id' => (int)$b['id']]) : url('books')) ?>">
<?= $isEdit ? '返回这本书' : '返回书架' ?>
</a>
<?php $bkSetCurrent = 'form'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<div class="card card-pad">
<form method="post" enctype="multipart/form-data"
action="<?= e(url($isEdit ? 'books/update' : 'books/create')) ?>">
<?= csrf_field() ?>
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$b['id'] ?>">
<?php endif; ?>
<?php /* ---- ① 上传图书文件(解析的入口)----
⚠️ 放在最上面:多数人是"先传书、再让系统填好",
把文件框藏在最底下会让人以为只能手填。 */ ?>
<div class="field full">
<label for="bk-file">上传图书(EPUB / TXT / PDF…)</label>
<?php if ($isEdit && !empty($b['file_key'])): ?>
<p class="bk-file-now">
已存文件:<?= e((string)($b['file_name'] ?: $b['file_key'])) ?>
(<?= e(\App\Book::sizeWord($b['file_size'] ?? 0) ?: '大小未知') ?>)
<?php if ((int)($b['chapter_count'] ?? 0) > 0): ?>
· 已解析 <?= (int)$b['chapter_count'] ?> 章
<?php endif; ?>
<br><span class="tip">再选一个文件会替换它,并按新文件重新解析正文。</span>
</p>
<?php endif; ?>
<input class="input" type="file" id="bk-file" name="bookfile"
accept=".epub,.txt,.pdf,.mobi,.azw3,.azw,.doc,.docx,application/epub+zip,text/plain,application/pdf">
<span class="tip">
EPUB 与 TXT 会自动读出书名 / 作者 / 简介 / 封面,并把正文拆成章节(可以直接在线读)。
PDF 只存文件(用浏览器自带的阅读器看)。MOBI / AZW3 存文件、正文解析不了。
</span>
</div>
<div class="form-grid">
<div class="field full">
<label for="bk-title">书名 *</label>
<input class="input" id="bk-title" name="title" required
value="<?= e(old('title', $b['title'] ?? '')) ?>" placeholder="如:活着">
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="bk-sub">副标题</label>
<input class="input" id="bk-sub" name="subtitle"
value="<?= e(old('subtitle', $b['subtitle'] ?? '')) ?>">
</div>
<div class="field">
<label for="bk-author">作者</label>
<input class="input" id="bk-author" name="author"
value="<?= e(old('author', $b['author'] ?? '')) ?>" placeholder="如:余华">
</div>
<div class="field">
<label for="bk-trans">译者</label>
<input class="input" id="bk-trans" name="translator"
value="<?= e(old('translator', $b['translator'] ?? '')) ?>">
</div>
<div class="field">
<label for="bk-pub">出版社</label>
<input class="input" id="bk-pub" name="publisher"
value="<?= e(old('publisher', $b['publisher'] ?? '')) ?>">
</div>
<div class="field">
<label for="bk-date">出版时间</label>
<input class="input" id="bk-date" name="published"
value="<?= e(old('published', $b['published'] ?? '')) ?>" placeholder="如:2012 或 2012-05">
</div>
<div class="field">
<label for="bk-isbn">ISBN</label>
<input class="input" id="bk-isbn" name="isbn" value="<?= e(old('isbn', $b['isbn'] ?? '')) ?>">
</div>
<div class="field">
<label for="bk-lang">语言</label>
<input class="input" id="bk-lang" name="language"
value="<?= e(old('language', $b['language'] ?? '')) ?>" placeholder="如:中文">
</div>
<div class="field">
<label for="bk-cat">分类</label>
<input class="input" id="bk-cat" name="category"
value="<?= e(old('category', $b['category'] ?? '')) ?>" placeholder="如:小说 / 历史 / 技术">
</div>
<div class="field">
<label for="bk-tags">标签(逗号分隔)</label>
<input class="input" id="bk-tags" name="tags" value="<?= e(old('tags', $b['tags'] ?? '')) ?>"
placeholder="如:长篇,当代文学">
</div>
<div class="field">
<label for="bk-status">状态</label>
<select class="select" id="bk-status" name="status">
<?php foreach (\App\Book::STATUS as $sk => $sv): ?>
<option value="<?= e($sk) ?>"
<?= old('status', $b['status'] ?? 'wish') === $sk ? 'selected' : '' ?>><?= e($sv) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="bk-rate">评分</label>
<select class="select" id="bk-rate" name="rating">
<option value="" <?= $rate < 0 ? 'selected' : '' ?>>还没打分</option>
<?php for ($bi = 0; $bi <= 10; $bi++): ?>
<option value="<?= $bi ?>" <?= $rate === $bi ? 'selected' : '' ?>><?= $bi ?> 分</option>
<?php endfor; ?>
</select>
</div>
<div class="field">
<label for="bk-pages">页数</label>
<input class="input" type="number" min="0" id="bk-pages" name="pages"
value="<?= e((string)old('pages', $b['pages'] ?? '')) ?>" placeholder="有就填,用于统计">
</div>
<div class="field">
<label>谁的书</label>
<?php /* 与全站统一的选人组件(弹窗 + 头像 + 拼音联想);
留「不指定」= 全家共用这一本。 */ ?>
<?php
$ppName = 'owner_id';
$ppPeople = $people;
$ppSelected = $ownerSel;
$ppMode = 'single';
$ppOpenBtn = '选择是谁在读';
$ppTitle = '这本书是谁的';
$ppEmptyText = '全家共用';
$ppAllowEmpty = true;
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field full">
<label for="bk-sum">内容简介</label>
<textarea class="input" id="bk-sum" name="summary" rows="4"><?= e(old('summary', $b['summary'] ?? '')) ?></textarea>
</div>
<?php /* ---- ② 封面(与头像同一套裁剪,2:3)---- */ ?>
<div class="field full">
<label>封面</label>
<div class="cover-picker book-cover-picker">
<?php /* ⚠️ data-pick-input 是 app.js 里的通用"点图选文件"绑定 ——
少了它这块图点下去毫无反应(本批用户报的就是这个)。 */ ?>
<img id="book-cover-preview"
src="<?= e($curCover !== '' ? media_url($curCover) : '') ?>"
alt="" title="点击选封面"
data-pick-input="#bk-cover"
data-has-cover="<?= $curCover !== '' ? '1' : '0' ?>">
<input class="sr-only" id="bk-cover" name="cover" type="file" accept="image/*"
data-crop-ratio="<?= e((string)$cvSlot['ratio']) ?>"
data-crop-w="<?= (int)$cvSlot['w'] ?>"
data-crop-h="<?= (int)$cvSlot['h'] ?>"
data-crop-label="<?= e((string)$cvSlot['label']) ?>"
data-crop-hint="<?= e((string)$cvSlot['hint']) ?>"
data-crop-preview="#book-cover-preview">
<input type="hidden" name="cover_crop" value="">
<span class="avatar-hint small muted">
点左边那块选封面,选完弹裁剪框。<br>
上传 EPUB 时如果书里自带封面,会**自动**用它(你另外选一张就覆盖它)。
</span>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEdit ? '保存' : '加进书架' ?></button>
<a class="btn btn-ghost"
href="<?= e($isEdit ? url('books/show', ['id' => (int)$b['id']]) : url('books')) ?>">取消</a>
</div>
</form>
</div>
+164
View File
@@ -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; ?>
+138
View File
@@ -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="## 活着(余华)&#10;◆ 第一章&#10;> 人是为活着本身而活着的。&#10;>> 这一段我读了三遍。"><?= e($raw) ?></textarea>
<span class="tip">
认得这些写法:书名用 <code>## 书名</code> 或 <code>《书名》</code>;
章节用 <code>◆</code>/<code>●</code> 开头;划线用 <code>&gt;</code> 开头;
想法用 <code>&gt;&gt;</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; ?>
+107
View File
@@ -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>
+220
View File
@@ -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>
+140
View File
@@ -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
View File
@@ -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 收起(下拉的常规行为)
+6
View File
@@ -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; ?>
+7 -12
View File
@@ -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 也能填、也能提交),
+56
View File
@@ -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');
+26 -1
View File
@@ -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' : '' ?>">
+16 -14
View File
@@ -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>
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;