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
+638
View File
@@ -0,0 +1,638 @@
<?php
/**
* 读书(v24 第 14 批 用户第 5 条):像 calibre 那样管自己的书。
*
* 用户原话:「增加读书模块,就像 calibre 一样管理自己的图书,可以导入图书,
* 可以上传图书,可以解析各种格式的图书,可以点开图书进行查看、阅读,
* 可以标记为读完,有统计功能,有笔记功能,可以导入微信读书等
* 各种读书软件的读书笔记等功能,在导航条上进行展示。」
*
* 三张表(见 Schema v20):
* books 书本身(元数据 + 文件 + 读到哪 + 读完没读完)
* book_chapters 解析出来的正文
* book_notes 笔记 / 划线 / 书评(**按人**记)
*
* ⚠️ 与观影/旅程不同,读书**不走统一事件原语**:
* 一次观影、一趟旅行都是"某一天发生的事"(有时间、有人、要上日历),
* 而一本书是"一段持续几个月的状态"(哪天读到第几章才勉强算事件)。
* 硬塞进 events 会让日历上挂一堆"在读《活着》"的日常噪音。
* 真要上日历的那天是"读完的那天"—— 将来要做的话,也是从 finished_at 派生。
*
* 状态机(wish → reading → done,另有 paused 搁置):
* · 标成 done 时记 finished_at;退回非 done 时**清空**它(不然统计口径会漂:
* "读完的书"里混着一本其实还在读的)。
*/
declare(strict_types=1);
namespace App;
final class Book
{
/** 状态:想读 / 在读 / 读完 / 搁置 */
public const STATUS = [
'wish' => '想读',
'reading' => '在读',
'done' => '读完',
'paused' => '搁置',
];
/** 笔记种类 */
public const NOTE_KINDS = [
'quote' => '划线',
'note' => '笔记',
'review' => '书评',
];
/** 笔记来源 */
public const NOTE_SOURCES = [
'manual' => '本站',
'weread' => '微信读书',
'douban' => '豆瓣',
'other' => '其它',
];
/** 上传图书允许的格式(解析能力见 BookParser) */
public const FILE_EXT = ['epub', 'txt', 'pdf', 'mobi', 'azw3', 'azw', 'doc', 'docx'];
// ---------------- 读 ----------------
/**
* 书架。
*
* @param array $f 过滤:status / category / q(书名作者标签)/ owner(谁的书)
*/
public static function all(int $familyId, array $f = []): array
{
$sql = 'SELECT b.*, p.name AS owner_name, p.avatar AS owner_avatar,
p.avatar_color AS owner_color,
(SELECT COUNT(*) FROM book_notes n WHERE n.book_id = b.id) AS note_count
FROM books b
LEFT JOIN persons p ON p.id = b.owner_id
WHERE b.family_id = ?';
$args = [$familyId];
$st = (string)($f['status'] ?? '');
if ($st !== '' && isset(self::STATUS[$st])) {
$sql .= ' AND b.status = ?';
$args[] = $st;
}
$cat = trim((string)($f['category'] ?? ''));
if ($cat !== '') {
$sql .= ' AND b.category = ?';
$args[] = $cat;
}
$owner = (int)($f['owner'] ?? 0);
if ($owner > 0) {
$sql .= ' AND b.owner_id = ?';
$args[] = $owner;
}
$q = trim((string)($f['q'] ?? ''));
if ($q !== '') {
$like = '%' . $q . '%';
$sql .= ' AND (b.title LIKE ? OR b.author LIKE ? OR b.subtitle LIKE ?
OR b.publisher LIKE ? OR b.tags LIKE ? OR b.isbn LIKE ?)';
array_push($args, $like, $like, $like, $like, $like, $like);
}
// 在读的排最前(那是"手头正在看什么"),其次是最近更新过的
$sql .= ' ORDER BY FIELD(b.status, "reading", "wish", "paused", "done"), b.updated_at DESC, b.id DESC';
$st2 = Database::get()->prepare($sql);
$st2->execute($args);
return $st2->fetchAll();
}
/**
* 各状态的数量(书架页的筛选条用;'_all' 是总数)。
*
* ⚠️ 与筛选条件**无关**:这里要的是"这个状态一共有几本",
* 不是"当前筛选下有几本"(否则点进"在读"后,其它状态的数字全变 0,
* 筛选条就成了自我实现的死路 —— 再也点不回去)。
*/
public static function statusCounts(int $familyId): array
{
$out = ['_all' => 0];
foreach (self::STATUS as $k => $v) {
$out[$k] = 0;
}
$st = Database::get()->prepare(
'SELECT status, COUNT(*) AS n FROM books WHERE family_id = ? GROUP BY status'
);
$st->execute([$familyId]);
foreach ($st->fetchAll() as $r) {
$out['_all'] += (int)$r['n'];
$k = (string)$r['status'];
if (isset($out[$k])) {
$out[$k] = (int)$r['n'];
}
}
return $out;
}
/** 用过的分类(筛选用) */
public static function categories(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT category AS name, COUNT(*) AS n FROM books
WHERE family_id = ? AND category IS NOT NULL AND category <> ""
GROUP BY category ORDER BY n DESC, category'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
public static function find(int $familyId, int $id): ?array
{
$st = Database::get()->prepare(
'SELECT b.*, p.name AS owner_name, p.avatar AS owner_avatar, p.avatar_color AS owner_color
FROM books b LEFT JOIN persons p ON p.id = b.owner_id
WHERE b.family_id = ? AND b.id = ?'
);
$st->execute([$familyId, $id]);
$r = $st->fetch();
return $r ?: null;
}
/**
* 章节目录(**不取正文** —— 一本书的正文有几 MB,列表页取它就是自杀)。
*/
public static function chapters(int $bookId): array
{
$st = Database::get()->prepare(
'SELECT id, seq, title, words FROM book_chapters WHERE book_id = ? ORDER BY seq'
);
$st->execute([$bookId]);
return $st->fetchAll();
}
/** 一章(含正文) */
public static function chapter(int $bookId, int $seq): ?array
{
$st = Database::get()->prepare(
'SELECT * FROM book_chapters WHERE book_id = ? AND seq = ?'
);
$st->execute([$bookId, $seq]);
$r = $st->fetch();
return $r ?: null;
}
/** 整本正文(导出/统计字数用;量很大,只在必要时调) */
public static function chapterCount(int $bookId): int
{
$st = Database::get()->prepare('SELECT COUNT(*) FROM book_chapters WHERE book_id = ?');
$st->execute([$bookId]);
return (int)$st->fetchColumn();
}
// ---------------- 写 ----------------
/** @return int 新书 id */
public static function create(int $familyId, ?int $creatorId, array $d): int
{
$db = Database::get();
$db->prepare(
'INSERT INTO books (family_id, owner_id, creator_id, title, subtitle, author, translator,
publisher, published, isbn, language, category, tags, summary,
cover_key, file_key, file_name, file_format, file_size,
pages, words, status, rating, started_at, finished_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([
$familyId,
self::ownerId($familyId, (int)($d['owner_id'] ?? 0)),
$creatorId ?: null,
self::title($d['title'] ?? ''),
self::txt($d['subtitle'] ?? ''),
self::txt($d['author'] ?? ''),
self::txt($d['translator'] ?? ''),
self::txt($d['publisher'] ?? ''),
self::txt($d['published'] ?? ''),
self::txt($d['isbn'] ?? ''),
self::txt($d['language'] ?? ''),
self::txt($d['category'] ?? ''),
self::txt($d['tags'] ?? ''),
self::longText($d['summary'] ?? ''),
self::key($d['cover_key'] ?? ''),
self::key($d['file_key'] ?? ''),
self::txt($d['file_name'] ?? ''),
self::fmt($d['file_format'] ?? ''),
self::intOrNull($d['file_size'] ?? 0),
self::intOrNull($d['pages'] ?? 0),
self::intOrNull($d['words'] ?? 0),
self::status($d['status'] ?? ''),
self::rating($d['rating'] ?? null),
self::dateOrNull($d['started_at'] ?? ''),
self::dateOrNull($d['finished_at'] ?? ''),
]);
return (int)$db->lastInsertId();
}
public static function update(int $familyId, int $id, array $d): void
{
$old = self::find($familyId, $id);
if (!$old) {
return;
}
$status = self::status($d['status'] ?? '');
// ⚠️ 状态与"读完那天"必须一起维护:
// · 变成 done 且还没记过 → 记今天;
// · 从 done 退回来 → **清空**(留着会让"今年读完几本"一直虚高)。
$fin = self::dateOrNull($d['finished_at'] ?? '') ?? ((string)$old['finished_at'] ?: null);
if ($status === 'done' && $fin === null) {
$fin = date('Y-m-d');
} elseif ($status !== 'done') {
$fin = null;
}
$db = Database::get();
$db->prepare(
'UPDATE books SET owner_id=?, title=?, subtitle=?, author=?, translator=?, publisher=?,
published=?, isbn=?, language=?, category=?, tags=?, summary=?, cover_key=?,
file_key=?, file_name=?, file_format=?, file_size=?, pages=?, words=?,
status=?, rating=?, started_at=?, finished_at=?
WHERE id=? AND family_id=?'
)->execute([
self::ownerId($familyId, (int)($d['owner_id'] ?? 0)),
self::title($d['title'] ?? ''),
self::txt($d['subtitle'] ?? ''),
self::txt($d['author'] ?? ''),
self::txt($d['translator'] ?? ''),
self::txt($d['publisher'] ?? ''),
self::txt($d['published'] ?? ''),
self::txt($d['isbn'] ?? ''),
self::txt($d['language'] ?? ''),
self::txt($d['category'] ?? ''),
self::txt($d['tags'] ?? ''),
self::longText($d['summary'] ?? ''),
self::key($d['cover_key'] ?? ''),
self::key($d['file_key'] ?? ''),
self::txt($d['file_name'] ?? ''),
self::fmt($d['file_format'] ?? ''),
self::intOrNull($d['file_size'] ?? 0),
self::intOrNull($d['pages'] ?? 0),
self::intOrNull($d['words'] ?? 0),
$status,
self::rating($d['rating'] ?? null),
self::dateOrNull($d['started_at'] ?? ''),
$fin,
$id, $familyId,
]);
}
/**
* 改状态(列表上的一键:想读 → 在读 → 读完)。
*
* @return string 改完之后的状态
*/
public static function setStatus(int $familyId, int $id, string $status): string
{
$old = self::find($familyId, $id);
if (!$old) {
return '';
}
$status = self::status($status);
$db = Database::get();
if ($status === 'done') {
$db->prepare(
'UPDATE books SET status=?, finished_at=COALESCE(finished_at, ?),
started_at=COALESCE(started_at, ?) WHERE id=? AND family_id=?'
)->execute([$status, date('Y-m-d'), date('Y-m-d'), $id, $familyId]);
} elseif ($status === 'reading') {
$db->prepare(
'UPDATE books SET status=?, finished_at=NULL, started_at=COALESCE(started_at, ?)
WHERE id=? AND family_id=?'
)->execute([$status, date('Y-m-d'), $id, $familyId]);
} else {
$db->prepare('UPDATE books SET status=?, finished_at=NULL WHERE id=? AND family_id=?')
->execute([$status, $id, $familyId]);
}
return $status;
}
/** 读到第几章(阅读器翻页时记) */
public static function setProgress(int $familyId, int $id, int $seq): void
{
Database::get()->prepare(
'UPDATE books SET progress = ? WHERE id = ? AND family_id = ?'
)->execute([max(0, $seq), $id, $familyId]);
}
public static function destroy(int $familyId, int $id): void
{
$b = self::find($familyId, $id);
if (!$b) {
return;
}
// 文件与封面是自己存的,数据库级联删不掉存储对象
if (!empty($b['file_key'])) {
\App\Storage\Storage::delete($familyId, (string)$b['file_key']);
}
if (!empty($b['cover_key'])) {
\App\Storage\Storage::delete($familyId, (string)$b['cover_key']);
}
Database::get()->prepare('DELETE FROM books WHERE id = ? AND family_id = ?')->execute([$id, $familyId]);
}
/** 整组替换章节(重新解析 / 重新上传文件时用) */
public static function replaceChapters(int $bookId, array $chapters): int
{
$db = Database::get();
$db->prepare('DELETE FROM book_chapters WHERE book_id = ?')->execute([$bookId]);
$st = $db->prepare(
'INSERT INTO book_chapters (book_id, seq, title, content, words) VALUES (?,?,?,?,?)'
);
$seq = 0;
$words = 0;
foreach ($chapters as $c) {
$seq++;
$body = (string)($c['content'] ?? '');
$w = self::countWords($body);
$words += $w;
$st->execute([$bookId, $seq, self::txt($c['title'] ?? ''), $body, $w]);
}
$db->prepare('UPDATE books SET chapter_count = ?, words = ? WHERE id = ?')
->execute([$seq, $words > 0 ? $words : null, $bookId]);
return $seq;
}
// ---------------- 笔记 ----------------
/**
* 一本书的笔记(按时间正序,**全部人**的都给)。
*
* ⚠️ 昵称必须按**看的人**($viewerId)取,不能写成 `nk.viewer_id = n.person_id`
* —— 那是"这人对自己的称呼",页面上会冒出一个跟谁都不对应的名字。
* (nicknames 是 (viewer_id, target_id) 二元组,缺了 viewer 就取错人。)
*
* 笔记不按人过滤:笔记是"读的人留下的字",家庭里没必要互相藏。
*
* @param int $viewerId 谁在看(决定"我怎么称呼这条笔记的作者")
*/
public static function notes(int $bookId, int $viewerId = 0): array
{
$st = Database::get()->prepare(
'SELECT n.*, p.name AS person_name, p.avatar, p.avatar_color,
nk.nickname AS my_nickname
FROM book_notes n
LEFT JOIN persons p ON p.id = n.person_id
LEFT JOIN nicknames nk ON nk.target_id = n.person_id AND nk.viewer_id = ?
WHERE n.book_id = ? ORDER BY n.created_at, n.id'
);
$st->execute([$viewerId, $bookId]);
return $st->fetchAll();
}
/** @return int 笔记 id */
public static function addNote(int $familyId, int $bookId, ?int $personId, array $d): int
{
$content = trim((string)($d['content'] ?? ''));
if ($content === '') {
return 0;
}
$db = Database::get();
$db->prepare(
'INSERT INTO book_notes (book_id, person_id, kind, chapter, loc, content, source)
VALUES (?,?,?,?,?,?,?)'
)->execute([
$bookId,
$personId > 0 ? $personId : null,
isset(self::NOTE_KINDS[(string)($d['kind'] ?? '')]) ? (string)$d['kind'] : 'note',
self::txt($d['chapter'] ?? ''),
self::txt($d['loc'] ?? ''),
mb_substr($content, 0, 8000, 'UTF-8'),
self::source($d['source'] ?? ''),
]);
return (int)$db->lastInsertId();
}
public static function deleteNote(int $familyId, int $noteId, int $personId): void
{
// 只能删自己写的(导入的无主笔记谁都能删 —— 不然它永远留在那儿)
$st = Database::get()->prepare(
'SELECT n.id FROM book_notes n JOIN books b ON b.id = n.book_id
WHERE n.id = ? AND b.family_id = ? AND (n.person_id IS NULL OR n.person_id = ?)'
);
$st->execute([$noteId, $familyId, $personId]);
if ($st->fetchColumn() === false) {
return;
}
Database::get()->prepare('DELETE FROM book_notes WHERE id = ?')->execute([$noteId]);
}
// ---------------- 统计 ----------------
/**
* 书架的整体数字。
*
* ⚠️ 读完的书按 **finished_at 的年份**分桶(不是 created_at):
* "今年读完了几本"问的是读完的时间,加进书架的时间没有意义
* (很多人是年底一次性把看过的书补进去的)。
*/
public static function stats(int $familyId): array
{
$db = Database::get();
$byStatus = [];
foreach (self::STATUS as $k => $v) {
$byStatus[$k] = 0;
}
$st = $db->prepare('SELECT status, COUNT(*) AS n FROM books WHERE family_id = ? GROUP BY status');
$st->execute([$familyId]);
foreach ($st->fetchAll() as $r) {
$byStatus[(string)$r['status']] = (int)$r['n'];
}
$one = static function (string $sql) use ($db, $familyId): int {
$s = $db->prepare($sql);
$s->execute([$familyId]);
return (int)$s->fetchColumn();
};
$out = [
'total' => array_sum($byStatus),
'status' => $byStatus,
'pages' => $one('SELECT COALESCE(SUM(pages),0) FROM books WHERE family_id = ? AND status = "done"'),
'words' => $one('SELECT COALESCE(SUM(words),0) FROM books WHERE family_id = ? AND status = "done"'),
'rated' => $one('SELECT COUNT(*) FROM books WHERE family_id = ? AND rating IS NOT NULL'),
'avgRate' => 0.0,
'notes' => $one('SELECT COUNT(*) FROM book_notes n JOIN books b ON b.id = n.book_id
WHERE b.family_id = ?'),
];
$sum = $one('SELECT COALESCE(SUM(rating),0) FROM books WHERE family_id = ? AND rating IS NOT NULL');
$out['avgRate'] = $out['rated'] > 0 ? round($sum / $out['rated'], 1) : 0.0;
// 按年读完(近 8 年)
$st = $db->prepare(
'SELECT YEAR(finished_at) AS y, COUNT(*) AS n FROM books
WHERE family_id = ? AND status = "done" AND finished_at IS NOT NULL
GROUP BY YEAR(finished_at) ORDER BY y'
);
$st->execute([$familyId]);
$out['byYear'] = $st->fetchAll();
// 评分分布(0-10)
$dist = array_fill(0, 11, 0);
$st = $db->prepare(
'SELECT rating, COUNT(*) AS n FROM books
WHERE family_id = ? AND rating IS NOT NULL GROUP BY rating'
);
$st->execute([$familyId]);
foreach ($st->fetchAll() as $r) {
$dist[max(0, min(10, (int)$r['rating']))] = (int)$r['n'];
}
$out['ratingDist'] = $dist;
// 读得最多的作者 / 分类
$st = $db->prepare(
'SELECT author AS name, COUNT(*) AS n FROM books
WHERE family_id = ? AND author IS NOT NULL AND author <> ""
GROUP BY author ORDER BY n DESC, author LIMIT 8'
);
$st->execute([$familyId]);
$out['topAuthors'] = $st->fetchAll();
$st = $db->prepare(
'SELECT category AS name, COUNT(*) AS n FROM books
WHERE family_id = ? AND category IS NOT NULL AND category <> ""
GROUP BY category ORDER BY n DESC, category LIMIT 8'
);
$st->execute([$familyId]);
$out['topCategories'] = $st->fetchAll();
return $out;
}
// ---------------- 展示 ----------------
public static function statusWord(string $k): string
{
return self::STATUS[$k] ?? '想读';
}
public static function normalizeStatus(string $v): string
{
return self::status($v);
}
public static function kindWord(string $k): string
{
return self::NOTE_KINDS[$k] ?? '笔记';
}
public static function sourceWord(string $k): string
{
return self::NOTE_SOURCES[$k] ?? (string)$k;
}
/** 文件大小的"人话" */
public static function sizeWord($bytes): string
{
$b = (int)$bytes;
if ($b <= 0) {
return '';
}
if ($b < 1024) {
return $b . ' B';
}
if ($b < 1024 * 1024) {
return round($b / 1024, 1) . ' KB';
}
return round($b / 1024 / 1024, 1) . ' MB';
}
/** 字数的"人话"(按中文习惯说"万字") */
public static function wordsWord($words): string
{
$w = (int)$words;
if ($w <= 0) {
return '';
}
if ($w < 10000) {
return number_format($w) . ' 字';
}
return round($w / 10000, 1) . ' 万字';
}
/** 中英混排的字数(中文按字、西文按词)—— 与 BookParser 同一口径 */
public static function countWords(string $s): int
{
$s = trim($s);
if ($s === '') {
return 0;
}
$cn = preg_match_all('/[\x{4e00}-\x{9fff}]/u', $s);
$noCn = preg_replace('/[\x{4e00}-\x{9fff}]/u', ' ', $s);
$en = preg_match_all('/[A-Za-z0-9]+/u', $noCn);
return (int)$cn + (int)$en;
}
// ---------------- 内部 ----------------
private static function ownerId(int $familyId, int $id): ?int
{
if ($id <= 0) {
return null;
}
$st = Database::get()->prepare('SELECT id FROM persons WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
return $st->fetchColumn() === false ? null : $id;
}
private static function status(string $v): string
{
return isset(self::STATUS[$v]) ? $v : 'wish';
}
private static function source(string $v): string
{
$v = trim($v);
return $v !== '' ? mb_substr($v, 0, 40, 'UTF-8') : 'manual';
}
private static function title(string $v): string
{
return mb_substr(trim($v), 0, 300, 'UTF-8');
}
private static function txt(string $v): ?string
{
$v = trim($v);
return $v !== '' ? $v : null;
}
private static function longText(string $v): ?string
{
$v = trim($v);
return $v !== '' ? mb_substr($v, 0, 20000, 'UTF-8') : null;
}
private static function key(string $v): ?string
{
$v = ltrim(trim($v), '/');
if ($v === '' || strpos($v, '..') !== false || strpos($v, "\0") !== false) {
return null;
}
return $v;
}
private static function fmt(string $v): ?string
{
$v = strtolower(trim($v));
$v = preg_replace('/[^a-z0-9]/', '', $v) ?? '';
return $v !== '' ? mb_substr($v, 0, 20, 'UTF-8') : null;
}
private static function rating($v): ?int
{
if ($v === null || $v === '') {
return null;
}
$n = (int)$v;
return ($n >= 0 && $n <= 10) ? $n : null;
}
private static function intOrNull($v): ?int
{
$n = (int)$v;
return $n > 0 ? $n : null;
}
private static function dateOrNull(string $v): ?string
{
$v = trim($v);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : null;
}
}
+252
View File
@@ -0,0 +1,252 @@
<?php
/**
* 读书笔记导入(v24 第 14 批 用户第 5 条:「可以导入微信读书等各种读书软件的读书笔记」)。
*
* 为什么自己写解析而不是要求用户填 CSV:
* 各家读书 App 的导出都是**给人看的文本**(微信读书复制出来就是一大段 Markdown),
* 没有两家格式一样,也没有稳定的机器格式。让人先去"整理成 CSV"等于没做这个功能。
* 所以这里做的是**宽容解析**:认得出书名的分行、认得出划线/想法的标记,
* 认不出的部分按上下文归类 —— 宁可归类得不完美,也不要丢字。
*
* 认得的标记(都来自真机复制出来的样本):
* · 书名 —— `## 活着` / `# 活着` / `《活着》` / `活着(余华)`
* · 作者 —— 书名行里的括号、或 `作者:余华`
* · 章节 —— `◆ 第一章 xxx`
* · 划线 —— `> 原文`、`### 划线` 之后的裸行
* · 想法 —— `>> 我的想法`、`### 笔记` / `### 想法` 之后的裸行
*
* ⚠️ 只做**纯文本**:不解析 HTML(微信读书导出还有 HTML 版,但那种各家模板不同,
* 而且要防注入 —— 用户贴进来的东西一律当纯文本存,页面展示时转义)。
*/
declare(strict_types=1);
namespace App;
final class BookNoteImport
{
/** 解析一大段文本 → 若干本书 + 各自的笔记 */
public static function parse(string $raw): array
{
$raw = str_replace(["\r\n", "\r"], "\n", trim($raw));
$lines = explode("\n", $raw);
$books = [];
$cur = -1; // 当前书在 $books 里的下标
$mode = 'quote'; // 当前归类:quote(划线)/ note(想法)
$chapter = '';
foreach ($lines as $ln) {
$t = trim($ln);
if ($t === '') {
continue;
}
// ---- 书名:Markdown 标题 / 《》 / 单独一行且很短 ----
$heading = self::headingText($t);
$isSection = $heading !== null && self::isSectionWord($heading);
if ($heading !== null && !$isSection) {
[$title, $author] = self::splitTitleAuthor($heading);
if ($title !== '') {
$books[] = ['title' => $title, 'author' => $author, 'notes' => []];
$cur = count($books) - 1;
$mode = 'quote';
$chapter = '';
continue;
}
}
if (preg_match('/^《(.+?)》/u', $t, $m)) {
[$title, $author] = self::splitTitleAuthor(trim($m[1]) . mb_substr($t, mb_strlen($m[0], 'UTF-8'), null, 'UTF-8'));
$books[] = ['title' => $title, 'author' => $author, 'notes' => []];
$cur = count($books) - 1;
$mode = 'quote';
$chapter = '';
continue;
}
// ---- 分节标记:划线 / 笔记 / 想法 ----
if ($isSection) {
$mode = self::modeOfWord($heading);
continue;
}
// ---- 作者行 ----
if ($cur >= 0 && preg_match('/^(作者|作者名)\s*[::]\s*(.+)$/u', $t, $m)) {
if ((string)$books[$cur]['author'] === '') {
$books[$cur]['author'] = trim($m[2]);
}
continue;
}
// ---- 章节:◆ / ● 开头 ----
if (preg_match('/^[◆●]\s*(.+)$/u', $t, $m)) {
$chapter = mb_substr(trim($m[1]), 0, 200, 'UTF-8');
continue;
}
// ---- 时间行(2023-05-12 / 2023年5月12日)→ 丢掉,它不是笔记内容 ----
if (preg_match('/^\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}/u', $t)
&& mb_strlen($t, 'UTF-8') <= 30) {
continue;
}
// ---- 想法:`>>` 开头 ----
if (strpos($t, '>>') === 0) {
$c = trim(mb_substr($t, 2, null, 'UTF-8'));
if ($c !== '') {
self::push($books, $cur, 'note', $chapter, $c);
}
continue;
}
// ---- 划线:`>` 开头 ----
if (strpos($t, '>') === 0) {
$c = trim(mb_substr($t, 1, null, 'UTF-8'));
if ($c !== '') {
self::push($books, $cur, 'quote', $chapter, $c);
}
continue;
}
// ---- 裸行:按当前归类 ----
self::push($books, $cur, $mode, $chapter, $t);
}
// 一条笔记都没归到任何书(用户贴了一段没有书名的话)→ 归到"未命名的一本"
return array_values(array_filter($books, static function (array $b): bool {
return count($b['notes']) > 0 || $b['title'] !== '';
}));
}
/** 把解析结果落库:能匹配到已有书就挂上去,匹配不到就新建一本 */
public static function import(int $familyId, int $personId, array $books, string $source): array
{
$created = 0;
$added = 0;
$skipped = 0;
$names = [];
foreach ($books as $b) {
$title = trim((string)($b['title'] ?? ''));
if ($title === '') {
continue;
}
$notes = $b['notes'] ?? [];
if (!$notes) {
$skipped++;
continue;
}
$bookId = self::matchBook($familyId, $title);
if ($bookId === 0) {
$bookId = Book::create($familyId, $personId, [
'title' => $title,
'author' => (string)($b['author'] ?? ''),
'status' => 'done', // 有笔记 = 至少读过;标成"读完"比"想读"贴近事实
'owner_id' => $personId,
]);
$created++;
}
foreach ($notes as $n) {
$id = Book::addNote($familyId, $bookId, $personId, [
'kind' => (string)($n['kind'] ?? 'note'),
'chapter' => (string)($n['chapter'] ?? ''),
'content' => (string)($n['content'] ?? ''),
'source' => $source,
]);
if ($id > 0) {
$added++;
}
}
$names[] = $title;
}
return ['created' => $created, 'added' => $added, 'skipped' => $skipped, 'titles' => $names];
}
/** 按书名找已有的一本(去空格、去大小写;找不到返回 0) */
public static function matchBook(int $familyId, string $title): int
{
$key = self::key($title);
if ($key === '') {
return 0;
}
$st = Database::get()->prepare('SELECT id, title FROM books WHERE family_id = ?');
$st->execute([$familyId]);
foreach ($st->fetchAll() as $r) {
if (self::key((string)$r['title']) === $key) {
return (int)$r['id'];
}
}
return 0;
}
public static function sourceOf(?string $raw): string
{
$s = strtolower((string)$raw);
if (strpos($s, 'weread') !== false || strpos($s, '微信读书') !== false) {
return 'weread';
}
if (strpos($s, 'douban') !== false || strpos($s, '豆瓣') !== false) {
return 'douban';
}
return 'other';
}
// ---------------- 内部 ----------------
/**
* @param int $cur **按引用传**:第一次遇到"没有书名就先来了一段笔记"时,
* 这里会建一本「未命名的一本」,而外面的 $cur 必须跟着指向它 ——
* 否则后面每一段都会各自新建一本(实测:一段话变出 7 本书)。
*/
private static function push(array &$books, int &$cur, string $kind, string $chapter, string $content): void
{
if ($cur < 0) {
// 第一段文字之前没有书名 → 先建一本"未命名",用户导入后可以改
$books[] = ['title' => '未命名的一本', 'author' => '', 'notes' => []];
$cur = count($books) - 1;
}
$books[$cur]['notes'][] = [
'kind' => $kind,
'chapter' => $chapter,
'content' => mb_substr($content, 0, 8000, 'UTF-8'),
];
}
/** `# xxx` / `## xxx` → xxx;不是标题返回 null */
private static function headingText(string $t): ?string
{
if (!preg_match('/^#{1,6}\s*(.+)$/u', $t, $m)) {
return null;
}
return trim($m[1]);
}
/** 这个标题是"划线/笔记/想法"这类**分节**标记,还是书名 */
private static function isSectionWord(?string $h): bool
{
$h = (string)$h;
return (bool)preg_match('/^(划线|笔记|想法|感想|书评|摘录|原文|批注)/u', $h);
}
private static function modeOfWord(?string $h): string
{
$h = (string)$h;
if (preg_match('/^(笔记|想法|感想|书评|批注)/u', $h)) {
return 'note';
}
return 'quote';
}
/** 「活着(余华)」/「活着 - 刘慈欣」→ [书名, 作者] */
private static function splitTitleAuthor(string $s): array
{
$s = trim($s);
$author = '';
if (preg_match('/^(.*?)[((【]([^))】]+)[))】]\s*$/u', $s, $m)) {
$s = trim($m[1]);
$author = trim($m[2]);
} elseif (preg_match('/^(.*?)\s+-\s+(.+)$/u', $s, $m)) {
$s = trim($m[1]);
$author = trim($m[2]);
}
$s = trim(preg_replace('/^[《](.+?)[》]/u', '$1', $s) ?? $s);
return [mb_substr($s, 0, 300, 'UTF-8'), mb_substr($author, 0, 200, 'UTF-8')];
}
private static function key(string $s): string
{
$s = preg_replace('/\s+/u', '', $s) ?? $s;
return mb_strtolower(trim($s), 'UTF-8');
}
}
+545
View File
@@ -0,0 +1,545 @@
<?php
/**
* 图书解析(v24 第 14 批 用户第 5 条):上传一本书 → 读出元数据与正文。
*
* 用户原话:「可以上传图书,可以解析各种格式的图书」。
*
* 能力边界(**页面上必须如实写出来**,别让人以为传什么都能读):
* · **EPUB** —— 完整解析:元数据(书名/作者/出版社/语言/ISBN)+ 封面 + 按 spine 顺序
* 拆出每一章的正文。这是电子书的事实标准,优先做好。
* · **TXT** —— 解析:自动认编码(UTF-8 / GBK / BIG5),按"第X章"切章;
* 切不出来就按 5000 字一段(否则一本 50 万字的书会变成一整页)。
* · **PDF** —— **不提取正文**(服务端没有 PDF 解析扩展,硬写会错得很自信)。
* 文件照存,阅读页用浏览器内嵌的 PDF 阅读器看(每家浏览器都有)。
* · **MOBI / AZW3 / 其它** —— 不解析正文(亚马逊的私有格式,解析要一本书的成本)。
* 文件照存、元数据照手填,仍然能进书架、能记笔记、能统计。
*
* ⚠️ 解析**只做纯文本**:不保留排版、图片、脚注。
* 这是"能读"与"原样还原"之间的取舍 —— 家庭书架要的是"随时翻两页",
* 不是排版引擎(那是一个独立项目的量)。
*
* @return array{ok:bool,error?:string,meta:array,chapters:array,cover?:array}
* cover = ['tmp'=>临时文件路径, 'mime'=>…](有封面才有;调用方负责上传与删临时文件)
*/
declare(strict_types=1);
namespace App;
final class BookParser
{
/** 单章上限(字符):超过就截断,防止一本巨书把库撑爆 */
private const CHAPTER_MAX = 200000;
/** 一本书最多解析多少章 */
private const CHAPTER_LIMIT = 600;
/**
* @param string $path 临时文件路径
* @param string $ext 扩展名(小写)
* @param string $name 原始文件名(用来兜底猜书名/作者)
*/
public static function parse(string $path, string $ext, string $name = ''): array
{
if (!is_file($path)) {
return ['ok' => false, 'error' => '文件读不到', 'meta' => [], 'chapters' => []];
}
$ext = strtolower($ext);
if ($ext === '' && $name !== '') {
$ext = strtolower((string)pathinfo($name, PATHINFO_EXTENSION));
}
switch ($ext) {
case 'epub':
return self::parseEpub($path);
case 'txt':
case 'text':
return self::parseTxt($path, $name);
case 'pdf':
return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name),
'chapters' => [], 'note' => 'PDF 只存文件(服务端不提取正文),阅读时用浏览器自带的 PDF 阅读器看。'];
default:
return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name),
'chapters' => [], 'note' => self::formatWord($ext) . ' 正文解析不了,文件已经存好了,元数据请手填。'];
}
}
// ---------------- EPUB ----------------
/**
* EPUB = 一个 zip 包:META-INF/container.xml 指路 → .opf 里放元数据与清单 →
* spine 决定章节顺序 → 每一章是一个 XHTML 文件。
*
* ⚠️ 路径要**相对 opf 所在目录**拼(很多书的 OEBPS/ 与 opf 不在一起),
* 直接用 opf 的路径当基准去拼会 404(ZipArchive::getFromName 返回 false)。
*/
private static function parseEpub(string $path): array
{
if (!class_exists('ZipArchive')) {
return ['ok' => true, 'error' => '', 'meta' => [], 'chapters' => [],
'note' => '服务器没有 zip 扩展,EPUB 的正文解析不了;文件已保存,元数据请手填。'];
}
$zip = new \ZipArchive();
$rc = $zip->open($path);
if ($rc !== true) {
return ['ok' => false, 'error' => '这个 EPUB 打不开(错误码 ' . (int)$rc . ')',
'meta' => [], 'chapters' => []];
}
// ① 找 OPF(书的"目录清单")
$opfPath = '';
$container = $zip->getFromName('META-INF/container.xml');
if ($container !== false && $container !== '') {
if (preg_match('/full-path\s*=\s*["\']([^"\']+)["\']/i', $container, $m)) {
$opfPath = trim($m[1]);
}
}
if ($opfPath === '') {
// 有些书没写 container.xml(不合规范但确实存在)→ 自己找第一个 .opf
for ($i = 0; $i < $zip->numFiles; $i++) {
$n = (string)$zip->getNameIndex($i);
if (substr(strtolower($n), -4) === '.opf') {
$opfPath = $n;
break;
}
}
}
if ($opfPath === '') {
$zip->close();
return ['ok' => false, 'error' => '这个 EPUB 里找不到目录文件(.opf),可能已损坏',
'meta' => [], 'chapters' => []];
}
$opfDir = self::dirOf($opfPath);
$opf = (string)$zip->getFromName($opfPath);
$zip->close();
if ($opf === '') {
return ['ok' => false, 'error' => '这个 EPUB 的目录文件是空的', 'meta' => [], 'chapters' => []];
}
$meta = self::opfMeta($opf);
// ② 清单:id → 文件
$manifest = self::opfManifest($opf);
// ③ 顺序:spine 里的 idref
$spine = self::opfSpine($opf, $manifest);
// ④ 封面:metadata 里的 <meta name="cover" content="id">,或 manifest 里 properties 带 cover-image
$cover = self::epubCover($path, $opf, $opfDir, $manifest);
$zip2 = new \ZipArchive();
if ($zip2->open($path) !== true) {
return ['ok' => true, 'meta' => $meta, 'chapters' => [], 'cover' => $cover,
'note' => '元数据读到了,正文没读出来(文件打不开第二次)。'];
}
$chapters = [];
$seq = 0;
foreach ($spine as $href) {
if (count($chapters) >= self::CHAPTER_LIMIT) {
break;
}
$full = self::joinPath($opfDir, $href);
$raw = $zip2->getFromName($full);
// ⚠️ 有些书的 spine 里写了 HTML 实体或大小写不一致 → 退一步按名字再试
if ($raw === false) {
$raw = $zip2->getFromName(ltrim($href, '/'));
}
if ($raw === false || $raw === '') {
continue;
}
[$title, $body] = self::xhtmlToText($raw);
if (trim($body) === '') {
continue;
}
$seq++;
$chapters[] = [
'seq' => $seq,
'title' => $title !== '' ? $title : ('第 ' . $seq . ' 章'),
'content' => mb_substr($body, 0, self::CHAPTER_MAX, 'UTF-8'),
];
}
$zip2->close();
return ['ok' => true, 'error' => '', 'meta' => $meta, 'chapters' => $chapters, 'cover' => $cover];
}
/** OPF 的 metadata:书名 / 作者 / 出版社 / 语言 / ISBN / 简介 */
private static function opfMeta(string $opf): array
{
$get = static function (string $tag) use ($opf): string {
if (preg_match_all('#<dc:' . $tag . '[^>]*>(.*?)</dc:' . $tag . '>#is', $opf, $m)) {
foreach ($m[1] as $v) {
$v = trim(html_entity_decode(strip_tags($v), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($v !== '') {
return $v;
}
}
}
return '';
};
$isbn = '';
if (preg_match_all('#<dc:identifier[^>]*>(.*?)</dc:identifier>#is', $opf, $mm)) {
foreach ($mm[0] as $i => $full) {
$v = trim(strip_tags($mm[1][$i]));
// opf:scheme="ISBN" 或 urn:isbn:xxxx
if (stripos($full, 'ISBN') !== false || stripos($v, 'isbn') === 0) {
$isbn = preg_replace('/^urn:isbn:/i', '', $v) ?? '';
break;
}
if ($isbn === '' && preg_match('/^(97[89])?\d{9}[\dXx]$/', $v)) {
$isbn = $v;
}
}
}
$date = $get('date');
return [
'title' => $get('title'),
'subtitle' => '',
'author' => $get('creator'),
'publisher' => $get('publisher'),
'language' => $get('language'),
'isbn' => $isbn,
'published' => $date !== '' ? substr($date, 0, 10) : '',
'summary' => $get('description'),
];
}
/** @return array<string,array{href:string,type:string,props:string}> id => 项 */
private static function opfManifest(string $opf): array
{
$out = [];
if (!preg_match_all('#<item\s+([^>]*?)/?>#is', $opf, $m)) {
return $out;
}
foreach ($m[1] as $attr) {
$a = self::attrs($attr);
$id = (string)($a['id'] ?? '');
if ($id === '') {
continue;
}
$out[$id] = [
'href' => html_entity_decode((string)($a['href'] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'type' => strtolower((string)($a['media-type'] ?? '')),
'props' => strtolower((string)($a['properties'] ?? '')),
];
}
return $out;
}
/** @return string[] 按阅读顺序排的正文文件(相对 opf 目录) */
private static function opfSpine(string $opf, array $manifest): array
{
$ids = [];
if (preg_match('#<spine\b[^>]*>(.*?)</spine>#is', $opf, $m)) {
if (preg_match_all('#<itemref\s+([^>]*?)/?>#is', $m[1], $mm)) {
foreach ($mm[1] as $attr) {
$a = self::attrs($attr);
$id = (string)($a['idref'] ?? '');
if ($id !== '') {
$ids[] = $id;
}
}
}
}
$out = [];
foreach ($ids as $id) {
$it = $manifest[$id] ?? null;
if (!$it) {
continue;
}
$t = (string)$it['type'];
// 只收正文档(xhtml / html / xml);图片、css、ncx 跳过
if ($t !== '' && strpos($t, 'html') === false && strpos($t, 'xml') === false) {
continue;
}
$out[] = (string)$it['href'];
}
// spine 为空(不合规范)→ 退一步:按 manifest 里所有 html 收
if (!$out) {
foreach ($manifest as $it) {
$t = (string)$it['type'];
if (strpos($t, 'html') !== false) {
$out[] = (string)$it['href'];
}
}
}
return $out;
}
/** 封面:找出图并存到临时文件(调用方再交给 Upload) */
private static function epubCover(string $path, string $opf, string $opfDir, array $manifest): ?array
{
$href = '';
if (preg_match('#<meta\s+[^>]*name\s*=\s*["\']cover["\'][^>]*content\s*=\s*["\']([^"\']+)["\']#is', $opf, $m)) {
$id = trim($m[1]);
$href = (string)($manifest[$id]['href'] ?? '');
}
if ($href === '') {
if (preg_match('#<meta\s+[^>]*content\s*=\s*["\']([^"\']+)["\'][^>]*name\s*=\s*["\']cover["\']#is', $opf, $m2)) {
$id = trim($m2[1]);
$href = (string)($manifest[$id]['href'] ?? '');
}
}
if ($href === '') {
foreach ($manifest as $it) {
if (strpos((string)$it['props'], 'cover-image') !== false) {
$href = (string)$it['href'];
break;
}
}
}
if ($href === '') {
foreach ($manifest as $it) {
if (strpos((string)$it['type'], 'image/') === 0
&& stripos((string)$it['href'], 'cover') !== false) {
$href = (string)$it['href'];
break;
}
}
}
if ($href === '') {
return null;
}
if (!class_exists('ZipArchive')) {
return null;
}
$zip = new \ZipArchive();
if ($zip->open($path) !== true) {
return null;
}
$full = self::joinPath($opfDir, $href);
$bin = $zip->getFromName($full);
if ($bin === false) {
$bin = $zip->getFromName(ltrim($href, '/'));
}
$zip->close();
if ($bin === false || $bin === '') {
return null;
}
$tmp = tempnam(sys_get_temp_dir(), 'fbcov');
if ($tmp === false || @file_put_contents($tmp, $bin) === false) {
return null;
}
$fino = new \finfo(FILEINFO_MIME_TYPE);
$mime = (string)$fino->file($tmp);
if (strpos($mime, 'image/') !== 0) {
@unlink($tmp);
return null;
}
return ['tmp' => $tmp, 'mime' => $mime];
}
/**
* XHTML → 纯文本(保留段落换行)。
*
* ⚠️ 不是"去标签"就完事:
* ① 先砍掉 script / style / head(不然 CSS 与 JS 会混进正文);
* ② 块级标签换成换行(<p><div><br><h*>),否则整章粘成一团;
* ③ 实体还原(&nbsp; &amp; …)之后再 strip_tags;
* ④ 连续空行压成一个 —— 电子书的源文件里空行多到能占一半篇幅。
*/
private static function xhtmlToText(string $html): array
{
$title = '';
if (preg_match('#<title[^>]*>(.*?)</title>#is', $html, $m)) {
$title = trim(html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
$body = $html;
if (preg_match('#<body\b[^>]*>(.*)</body>#is', $body, $m2)) {
$body = $m2[1];
if ($title === '' && preg_match('#<h[1-4][^>]*>(.*?)</h[1-4]>#is', $body, $m3)) {
$title = trim(html_entity_decode(strip_tags($m3[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
}
$body = preg_replace('#<(script|style|noscript)\b[^>]*>.*?</\1>#is', '', $body) ?? $body;
$body = preg_replace('#<br\s*/?>#i', "\n", $body) ?? $body;
$body = preg_replace('#</(p|div|h[1-6]|li|tr|blockquote)>#i', "\n", $body) ?? $body;
$body = html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$body = str_replace(["\r\n", "\r", "\xC2\xA0"], ["\n", "\n", ' '], $body);
$body = preg_replace('/[ \t]+\n/', "\n", $body) ?? $body;
$body = preg_replace('/\n{3,}/', "\n\n", $body) ?? $body;
$body = preg_replace('/^\n+|\n+$/', '', $body) ?? $body;
return [mb_substr($title, 0, 300, 'UTF-8'), $body];
}
// ---------------- TXT ----------------
private static function parseTxt(string $path, string $name): array
{
$raw = (string)@file_get_contents($path);
if ($raw === '') {
return ['ok' => false, 'error' => '这个文本文件是空的', 'meta' => [], 'chapters' => []];
}
$text = self::toUtf8($raw);
$chapters = self::splitChapters($text);
return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name), 'chapters' => $chapters];
}
/**
* 编码兜底:UTF-8 → GB18030(中文电子书最常见的老编码)→ BIG5 → Latin1。
* ⚠️ 不认编码就硬读,中文会整页乱码 —— 那是"看起来解析了、其实全是问号"。
*/
private static function toUtf8(string $raw): string
{
if (substr($raw, 0, 3) === "\xEF\xBB\xBF") {
return substr($raw, 3);
}
if (mb_check_encoding($raw, 'UTF-8')) {
return $raw;
}
foreach (['GB18030', 'BIG-5', 'ISO-8859-1'] as $enc) {
$s = @iconv($enc, 'UTF-8//IGNORE', $raw);
if ($s !== false && $s !== '') {
return $s;
}
}
return $raw;
}
/**
* 切章:认"第X章/第X节/序/前言/后记/Chapter N"这类行;
* 认不出来就按 5000 字一段(不然一本长篇会变成一整页,浏览器直接卡死)。
*/
private static function splitChapters(string $text): array
{
$text = str_replace(["\r\n", "\r"], "\n", $text);
$lines = explode("\n", $text);
$re = '/^\s*(第\s*[0-9零一二三四五六七八九十百千万]+\s*[章节回部篇卷集]\s*'
. '|chapter\s+[0-9ivx]+\b'
. '|序章|序言|前言|自序|后记|附录|尾声|楔子)/iu';
$chapters = [];
$curTitle = '';
$buf = '';
$flush = static function () use (&$chapters, &$curTitle, &$buf): void {
$body = trim($buf);
if ($body !== '') {
$chapters[] = ['title' => $curTitle, 'content' => mb_substr($body, 0, self::CHAPTER_MAX, 'UTF-8')];
}
$buf = '';
};
foreach ($lines as $ln) {
$t = trim($ln);
if ($t !== '' && mb_strlen($t, 'UTF-8') <= 60 && preg_match($re, $t)) {
$flush();
$curTitle = mb_substr($t, 0, 200, 'UTF-8');
continue;
}
$buf .= $ln . "\n";
}
$flush();
if (count($chapters) <= 1) {
// 没切出来 → 按字数均分
$chapters = [];
$n = mb_strlen($text, 'UTF-8');
$size = 5000;
$parts = (int)max(1, ceil($n / $size));
for ($i = 0; $i < $parts; $i++) {
$seg = mb_substr($text, $i * $size, $size, 'UTF-8');
if (trim($seg) === '') {
continue;
}
$chapters[] = ['title' => '第 ' . ($i + 1) . ' 段', 'content' => $seg];
}
}
$out = [];
$seq = 0;
foreach ($chapters as $c) {
if (count($out) >= self::CHAPTER_LIMIT) {
break;
}
$seq++;
$out[] = ['seq' => $seq, 'title' => (string)$c['title'], 'content' => (string)$c['content']];
}
return $out;
}
// ---------------- 文件名猜书名 / 作者 ----------------
/**
* 从 "三体(全三册)- 刘慈欣.epub" 这类文件名里猜。
* ⚠️ 只是**兜底**:猜错了用户一眼能改,比空着强(空着要一个字一个字敲)。
*/
private static function guessFromName(string $name): array
{
$base = pathinfo((string)$name, PATHINFO_FILENAME);
$base = trim(preg_replace('/[\[\((【].*?[\]\))】]/u', '', $base) ?? $base);
$author = '';
$title = $base;
foreach ([' - ', '—', '-', '_'] as $sep) {
if (strpos($base, $sep) !== false) {
$parts = explode($sep, $base);
$title = trim((string)array_pop($parts));
$author = trim(implode(' ', $parts));
break;
}
}
// "作者 书名" 反过来也常见:名字很短的那一段更像作者
if ($author === '' && preg_match('/^(.+?)\s{1}(.+)$/u', $base, $m)) {
$author = trim($m[1]);
$title = trim($m[2]);
}
$title = trim(preg_replace('/\.(epub|txt|pdf|mobi|azw3?)$/i', '', $title) ?? $title);
return [
'title' => mb_substr($title, 0, 300, 'UTF-8'),
'subtitle' => '',
'author' => mb_substr($author, 0, 200, 'UTF-8'),
'publisher' => '',
'language' => '',
'isbn' => '',
'published' => '',
'summary' => '',
];
}
public static function formatWord(string $ext): string
{
$map = ['epub' => 'EPUB', 'txt' => 'TXT', 'pdf' => 'PDF', 'mobi' => 'MOBI',
'azw' => 'AZW', 'azw3' => 'AZW3', 'doc' => 'DOC', 'docx' => 'DOCX'];
return $map[strtolower($ext)] ?? strtoupper($ext);
}
/** 这个文件能不能直接放进浏览器内嵌阅读(PDF 就是) */
public static function inlinable(?string $fmt): bool
{
return strtolower((string)$fmt) === 'pdf';
}
// ---------------- 小工具 ----------------
/** @return array<string,string> */
private static function attrs(string $s): array
{
$out = [];
if (preg_match_all('/([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(["\'])(.*?)\2/s', $s, $m, PREG_SET_ORDER)) {
foreach ($m as $x) {
$out[strtolower($x[1])] = $x[3];
}
}
return $out;
}
private static function dirOf(string $p): string
{
$p = str_replace('\\', '/', $p);
$i = strrpos($p, '/');
return $i === false ? '' : substr($p, 0, $i + 1);
}
/** 把 opf 目录与 href 拼成 zip 内的完整路径(顺手去掉 ../ 与锚点) */
private static function joinPath(string $dir, string $href): string
{
$href = preg_replace('/#.*$/', '', $href) ?? $href;
$href = str_replace('\\', '/', trim(urldecode($href)));
$full = $dir . $href;
// 消掉 a/b/../c
$parts = [];
foreach (explode('/', str_replace('\\', '/', $full)) as $seg) {
if ($seg === '' || $seg === '.') {
continue;
}
if ($seg === '..') {
array_pop($parts);
continue;
}
$parts[] = $seg;
}
return implode('/', $parts);
}
}
+36 -11
View File
@@ -327,11 +327,18 @@ final class Home
); );
$st->execute([$familyId]); $st->execute([$familyId]);
$tripN = (int)$st->fetchColumn(); $tripN = (int)$st->fetchColumn();
// ⚠️ 用户第 15 批 第 2 条:活动自己没有时间了 —— "最近去过哪"要按
// **各段旅程里最晚的那个时间**排,不能再看 e.starts_at(那列已经不用了,
// 恒为 NULL,等于每次取到的都是"最后插入的那条",看起来像随机)。
$st = Database::get()->prepare( $st = Database::get()->prepare(
"SELECT e.title FROM events e "SELECT e.title FROM events e
LEFT JOIN trip_details d ON d.event_id = e.id LEFT JOIN trip_details d ON d.event_id = e.id
WHERE e.family_id = ? AND e.kind = 'trip' AND COALESCE(d.is_public,1) = 1 WHERE e.family_id = ? AND e.kind = 'trip' AND COALESCE(d.is_public,1) = 1
ORDER BY e.starts_at DESC, e.id DESC LIMIT 1" ORDER BY GREATEST(
COALESCE((SELECT MAX(n.starts_at) FROM trip_nodes n
WHERE n.event_id = e.id AND n.stage IN ('plan','actual')), '1000-01-01'),
'1000-01-01') DESC,
e.id DESC LIMIT 1"
); );
$st->execute([$familyId]); $st->execute([$familyId]);
$lastTrip = (string)($st->fetchColumn() ?: ''); $lastTrip = (string)($st->fetchColumn() ?: '');
@@ -340,8 +347,8 @@ final class Home
'icon' => self::icon('trips'), 'icon' => self::icon('trips'),
'color' => Module::color('trips', $familyId), 'color' => Module::color('trips', $familyId),
'href' => url('trips'), 'href' => url('trips'),
'big' => $tripN . ' 趟', 'big' => $tripN . ' 次',
'lines' => $lastTrip !== '' ? ['最近去了' . $lastTrip] : ['还没有旅程记录'], 'lines' => $lastTrip !== '' ? ['最近去了' . $lastTrip] : ['还没有活动记录'],
]; ];
// ---- 日程 ---- // ---- 日程 ----
@@ -372,15 +379,16 @@ final class Home
]; ];
// ---- 任务 ---- // ---- 任务 ----
$board = Task::board($familyId, $me); // ⚠️ v24 第 14 批 用户第 2 条:**按状态算,不看板算**。
$doing = count($board['doing']); // 看板是"这件事属于哪摊事",状态是"做到哪一步";首页关心的是后者,
$todo = count($board['todo']); // 所以这里用 Task::byStatus()(跨看板)—— 用 board() 只会数到默认看板里的。
$doing = count(Task::byStatus($familyId, $me, 'doing'));
$todo = count(Task::byStatus($familyId, $me, 'todo'));
$late = 0; $late = 0;
foreach ([$board['todo'], $board['doing']] as $col) { foreach (array_merge(Task::byStatus($familyId, $me, 'doing'),
foreach ($col as $t) { Task::byStatus($familyId, $me, 'todo')) as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) { if (Task::isOverdue($t['due_at'] ?? null)) {
$late++; $late++;
}
} }
} }
$cards['tasks'] = [ $cards['tasks'] = [
@@ -421,6 +429,21 @@ final class Home
'lines' => [$hasBook ? count($books) . ' 本账(净资产)' : '先建一本家庭账'], 'lines' => [$hasBook ? count($books) . ' 本账(净资产)' : '先建一本家庭账'],
]; ];
// ---- 读书(v24 第 14 批 用户第 5 条)----
$bs = Book::stats($familyId);
$reading = (int)($bs['status']['reading'] ?? 0);
$doneN = (int)($bs['status']['done'] ?? 0);
$cards['books'] = [
'name' => '读书',
'icon' => self::icon('books'),
'color' => Module::color('books', $familyId),
'href' => url('books'),
'big' => $bs['total'] > 0 ? ($reading > 0 ? $reading . ' 本在读' : $doneN . ' 本读过') : '空书架',
'lines' => [$bs['total'] > 0
? ($doneN . ' 本读完 · ' . (int)$bs['notes'] . ' 条笔记')
: '传一本进来,或先记下想读的书'],
];
return $cards; return $cards;
} }
@@ -437,6 +460,8 @@ final class Home
'tasks' => '<rect x="2.4" y="2.4" width="13.2" height="13.2" rx="2"/><path d="M5.6 9.2l2.4 2.4 4.4-4.8"/>', 'tasks' => '<rect x="2.4" y="2.4" width="13.2" height="13.2" rx="2"/><path d="M5.6 9.2l2.4 2.4 4.4-4.8"/>',
'days' => '<path d="M9 2.2l2 4.2 4.6.6-3.4 3.2.8 4.6L9 12.6l-4 2.2.8-4.6L2.4 7l4.6-.6z"/>', 'days' => '<path d="M9 2.2l2 4.2 4.6.6-3.4 3.2.8 4.6L9 12.6l-4 2.2.8-4.6L2.4 7l4.6-.6z"/>',
'ledger' => '<rect x="2.4" y="3" width="13.2" height="12" rx="2"/><path d="M5.6 6.6h6.8M5.6 9.4h6.8M5.6 12.2h4"/>', 'ledger' => '<rect x="2.4" y="3" width="13.2" height="12" rx="2"/><path d="M5.6 6.6h6.8M5.6 9.4h6.8M5.6 12.2h4"/>',
// 读书:一本摊开的书(两页 + 书脊)
'books' => '<path d="M2.2 4.2A1.6 1.6 0 0 1 3.8 2.6h3.4c.9 0 1.8.4 1.8 1.3v10.2c0-.9-.9-1.3-1.8-1.3H3.8a1.6 1.6 0 0 1-1.6-1.6z"/><path d="M15.8 4.2a1.6 1.6 0 0 0-1.6-1.6h-3.4c-.9 0-1.8.4-1.8 1.3v10.2c0-.9.9-1.3 1.8-1.3h3.4a1.6 1.6 0 0 0 1.6-1.6z"/>',
]; ];
$d = $paths[$key] ?? ''; $d = $paths[$key] ?? '';
if ($d === '') { if ($d === '') {
+1 -1
View File
@@ -29,7 +29,7 @@ final class HomeMods
* 比默认导航条短一些:首页是"概览",把九个模块都铺上去就成了一面墙。 * 比默认导航条短一些:首页是"概览",把九个模块都铺上去就成了一面墙。
* 选的是"每天想看的那几个"。 * 选的是"每天想看的那几个"。
*/ */
public const DEFAULT_HOME = ['tasks', 'calendar', 'days', 'movies', 'trips', 'ledger']; public const DEFAULT_HOME = ['tasks', 'calendar', 'days', 'movies', 'trips', 'ledger', 'books'];
/** 这个人首页上要显示哪些模块(按顺序) */ /** 这个人首页上要显示哪些模块(按顺序) */
public static function forPerson(int $personId): array public static function forPerson(int $personId): array
+22 -3
View File
@@ -68,9 +68,28 @@ final class ImageSlot
'dir' => 'photos', 'dir' => 'photos',
'hint' => '现场照片不裁剪,等比缩到最长边 1600。', 'hint' => '现场照片不裁剪,等比缩到最长边 1600。',
], ],
// 题图按 16:9 —— 宽屏横幅。需要时把它启用即可: // v24 第 14 批(用户第 5 条):**书的封面**。与海报一样是竖版(常见 2:3),
// 'cover' => ['label' => '题图', 'ratio' => '16:9', 'w' => 1920, 'h' => 1080, // 单独一个槽位而不是复用 poster —— 以后想给书封面换个尺寸/目录,
// 'dir' => 'covers', 'hint' => '宽屏横幅,显示在详情页顶部。'], // 改这一行就行,不会连观影海报一起改了。
'bookcover' => [
'label' => '封面',
'ratio' => '2:3',
'w' => 600,
'h' => 900,
'dir' => 'bookcovers',
'hint' => '书的封面是竖版,按 2:3 裁。拖动图片挪位置,滚轮缩放。',
],
// v24 第 14 批(用户第 3 条):旅程活动的**题图**,16:9 宽屏横幅,可裁剪。
// ⚠️ 与头像同一套机制:比例写在这一处,前端裁剪框与服务端最终裁切都读它,
// 前端**不许**再自己写一个数字('16:9' 用 parseFloat 会得到 16 → 裁成 16:1)。
'cover' => [
'label' => '题图',
'ratio' => '16:9',
'w' => 1600,
'h' => 900,
'dir' => 'covers',
'hint' => '宽屏横幅,按 16:9 裁。拖动图片挪位置,滚轮缩放。',
],
]; ];
public static function exists(string $key): bool public static function exists(string $key): bool
+4 -1
View File
@@ -45,6 +45,8 @@ final class Module
'days' => ['纪念日', '#B4633C', 'days'], 'days' => ['纪念日', '#B4633C', 'days'],
// v24 第 15 条:财务(账本 + 账目 + 共享) // v24 第 15 条:财务(账本 + 账目 + 共享)
'ledger' => ['财务', '#5E7A3C', 'ledger'], 'ledger' => ['财务', '#5E7A3C', 'ledger'],
// v24 第 14 批:读书(书架 + 上传解析 + 在线读 + 笔记 + 统计)
'books' => ['读书', '#6B5B95', 'books'],
]; ];
/** /**
@@ -54,7 +56,8 @@ final class Module
* "毕竟,不需要每天都查看家谱"。家谱模块本身还在(个人设置里能勾回来), * "毕竟,不需要每天都查看家谱"。家谱模块本身还在(个人设置里能勾回来),
* 只是不再占顶栏的位置。 * 只是不再占顶栏的位置。
*/ */
public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources', 'tasks', 'days', 'ledger']; public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources',
'tasks', 'days', 'ledger', 'books'];
/** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */ /** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */
private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24'; private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24';
+85 -1
View File
@@ -80,7 +80,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/ */
public const VERSION = '19'; public const VERSION = '20';
/** /**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -646,6 +646,90 @@ CREATE TABLE IF NOT EXISTS `regions` (
PRIMARY KEY (`code`), PRIMARY KEY (`code`),
KEY `idx_region_parent` (`parent_code`) KEY `idx_region_parent` (`parent_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='全国行政区划(省/市/区县三级):地址结构化与导入时对齐用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='全国行政区划(省/市/区县三级):地址结构化与导入时对齐用'
SQL,
// ---- 读书(版本 20,用户 2026-10-05 第 14 批第 5 条)----
// 像 calibre 那样管自己的书:书架 + 上传/导入 + 在线读 + 读完 + 笔记 + 统计。
//
// 三张表,各管一件事,**不要**合并:
// · books —— 书本身(元数据 + 文件 + 读到哪 + 读完没读完)
// · book_chapters—— 解析出来的正文(一本书几十上百段,单独一张表,
// 否则列表页 SELECT * 会把整本书一起捞出来)
// · book_notes —— 笔记 / 划线 / 感想,**按人**记(同一个家里两个人读
// 同一本书,各自的笔记不能混)
<<<'SQL'
CREATE TABLE IF NOT EXISTS `books` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`owner_id` INT UNSIGNED DEFAULT NULL COMMENT '谁在读(NULL = 全家共用一本)',
`creator_id` INT UNSIGNED DEFAULT NULL COMMENT '谁把它加进书架的',
`title` VARCHAR(300) NOT NULL,
`subtitle` VARCHAR(300) DEFAULT NULL,
`author` VARCHAR(200) DEFAULT NULL,
`translator` VARCHAR(200) DEFAULT NULL,
`publisher` VARCHAR(120) DEFAULT NULL,
`published` VARCHAR(40) DEFAULT NULL COMMENT '出版时间(自由文本:2012 / 2012-05 / 2012年5月)',
`isbn` VARCHAR(40) DEFAULT NULL,
`language` VARCHAR(40) DEFAULT NULL,
`category` VARCHAR(80) DEFAULT NULL COMMENT '分类(自由文本,家庭自己约定叫法)',
`tags` VARCHAR(300) DEFAULT NULL COMMENT '标签,逗号分隔',
`summary` TEXT DEFAULT NULL COMMENT '内容简介',
`cover_key` VARCHAR(255) DEFAULT NULL COMMENT '封面(走 media.php 代理,与头像同一套)',
`file_key` VARCHAR(255) DEFAULT NULL COMMENT '原书文件(books 目录下的相对键,如 books/xxx.epub)',
`file_name` VARCHAR(255) DEFAULT NULL COMMENT '上传时的文件名(下载时用)',
`file_format` VARCHAR(20) DEFAULT NULL COMMENT 'epub / txt / pdf / mobi / azw3 …',
`file_size` INT UNSIGNED DEFAULT NULL,
`pages` INT UNSIGNED DEFAULT NULL COMMENT '页数(有就填,用于统计)',
`words` INT UNSIGNED DEFAULT NULL COMMENT '字数(解析时估算)',
`chapter_count` INT NOT NULL DEFAULT 0 COMMENT '解析出多少章(0 = 没解析过)',
`status` ENUM('wish','reading','done','paused') NOT NULL DEFAULT 'wish'
COMMENT '想读 / 在读 / 读完 / 搁置',
`rating` TINYINT DEFAULT NULL COMMENT '评分 0-10(NULL = 还没打分)',
`progress` INT NOT NULL DEFAULT 0 COMMENT '读到第几章(0 = 还没开始)',
`started_at` DATE DEFAULT NULL,
`finished_at` DATE DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_book_family` (`family_id`, `status`, `updated_at`),
KEY `idx_book_owner` (`family_id`, `owner_id`),
CONSTRAINT `fk_book_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_book_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_book_creator` FOREIGN KEY (`creator_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='书架:一本书一行(元数据 + 文件 + 读到哪 + 读完没读完)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `book_chapters` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`book_id` INT UNSIGNED NOT NULL,
`seq` INT NOT NULL DEFAULT 1 COMMENT '第几章(从 1 开始)',
`title` VARCHAR(300) DEFAULT NULL,
`content` MEDIUMTEXT DEFAULT NULL COMMENT '正文(已去标签的纯文本,段落之间换行)',
`words` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_bc_book` (`book_id`, `seq`),
CONSTRAINT `fk_bc_book` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='书的正文(解析出来的章节)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `book_notes` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`book_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED DEFAULT NULL COMMENT '谁的笔记(导入时可能不知道是谁 → NULL)',
`kind` ENUM('quote','note','review') NOT NULL DEFAULT 'note'
COMMENT 'quote=划线/原文摘录,note=想法/笔记,review=书评',
`chapter` VARCHAR(200) DEFAULT NULL COMMENT '在哪一章(导入时带得上的话)',
`loc` VARCHAR(80) DEFAULT NULL COMMENT '位置(页数 / 百分比 / 微信读书的定位串)',
`content` TEXT NOT NULL,
`source` VARCHAR(40) NOT NULL DEFAULT 'manual' COMMENT 'manual / weread / douban / …(哪来的)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_bn_book` (`book_id`, `created_at`),
KEY `idx_bn_person` (`person_id`),
CONSTRAINT `fk_bn_book` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_bn_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='读书笔记 / 划线 / 书评(按人记)'
SQL, SQL,
]; ];
} }
+49
View File
@@ -346,6 +346,49 @@ final class Task
return $out; return $out;
} }
/**
* 我能看到的、处于某个状态的**父任务**(**跨看板**)。
*
* v24 第 14 批 用户第 2 条原话:「正在进行的状态的任务,应该在首页中显示。
* 也就是说,首页上显示的任务,是和任务的状态有关,而不是和它在那个看板有关。」
*
* ⚠️ 与 board() 的分工:board() 是"某一块看板里有什么"(列 = 看板,按 board_id 过滤);
* 这个是"处于某个状态的所有事"(**不看 board_id**)——
* 一个人在"家务""装修"两个看板上各有一件进行中的事,首页就该看到两件。
* 之前首页直接调 board()(它只取默认看板),于是别的看板上的事**永远不会出现在首页**。
*
* @param string $status todo / doing / done
*/
public static function byStatus(int $familyId, int $personId, string $status): array
{
$status = self::status($status);
$st = Database::get()->prepare(
'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color,
na.nickname AS assignee_nick,
c.name AS category_name,
b.name AS board_name,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id) AS child_total,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id AND k.status = "done") AS child_done
FROM tasks t
LEFT JOIN persons a ON a.id = t.assignee_id
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
LEFT JOIN task_categories c ON c.id = t.category_id
LEFT JOIN task_boards b ON b.id = t.board_id
WHERE t.family_id = ? AND t.parent_id IS NULL AND t.status = ?
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
ORDER BY COALESCE(t.due_at, ?) ASC, t.position, t.id'
);
$st->execute([$personId, $personId, $familyId, $status, $personId, $personId, '9999-12-31 00:00:00']);
$rows = $st->fetchAll();
$children = self::childrenFor(array_map('intval', array_column($rows, 'id')), $personId);
foreach ($rows as &$r) {
$r['children'] = $children[(int)$r['id']] ?? [];
}
unset($r);
return $rows;
}
/** @return array<int,array> 父任务 id => 子任务列表 */ /** @return array<int,array> 父任务 id => 子任务列表 */
public static function childrenFor(array $parentIds, int $viewerId = 0): array public static function childrenFor(array $parentIds, int $viewerId = 0): array
{ {
@@ -778,6 +821,12 @@ final class Task
return self::STATUS[$k] ?? '待办'; return self::STATUS[$k] ?? '待办';
} }
/** 把任意输入归一化成合法状态(路由里写提示文案用:不能拿用户传的原文去查词) */
public static function normalizeStatus(string $v): string
{
return self::status($v);
}
public static function repeatWord(?string $k): string public static function repeatWord(?string $k): string
{ {
$k = (string)$k; $k = (string)$k;
+291 -19
View File
@@ -85,7 +85,159 @@ final class Trip
return in_array($key, self::STAGE_ON_CALENDAR, true); return in_array($key, self::STAGE_ON_CALENDAR, true);
} }
/** 全部旅程(按出发时间倒序) */ // ==================== 活动的时间与目的地:**从旅程派生** ====================
//
// 用户原话(2026-10-05 第 15 批 第 2 条):
// 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间,
// 和最晚的旅程结束时间,计算出来的。目的地也不需要,因为一次活动,
// 可能去好几个城市,所以去的城市,也是根据旅程,得到的。」
//
// ⚠️ 这三样**不再是活动的字段**(表单里删了),一律读时算出来:
// · 开始 = 最早一段旅程的 starts_at
// · 结束 = 最晚一段旅程的 ends_at(缺 ends_at 就退回它的 starts_at)
// · 城市 = 各段旅程 place 的去重集合
// 理由与"参与人是合集"完全同一条:同一份数据存两处,迟早对不上。
// 自己填的时间和行程里的时间一旦打架,看的人不知道该信哪个。
//
// ⚠️ 老数据里 events.starts_at / location 可能是有值的 —— **保留不动**
// (万一将来要回退),但页面一律不读它们,只读派生值。
// 迁移成本为 0,而"删列"是不可逆的。
/**
* 一条活动的派生时间与城市(单条详情用)。
*
* @return array{starts_at:?string, ends_at:?string, cities:string[], city:string}
* city 是给"一行文字"用的拼法(如「青岛 · 威海 → 济南」)。
*/
public static function derived(int $eventId): array
{
$st = Database::get()->prepare(
"SELECT MIN(n.starts_at) AS start_at,
MAX(COALESCE(n.ends_at, n.starts_at)) AS end_at
FROM trip_nodes n
WHERE n.event_id = ? AND n.stage IN ('plan','actual')"
);
$st->execute([$eventId]);
$r = $st->fetch() ?: [];
$cities = self::cities($eventId);
return [
'starts_at' => self::dtOrNull((string)($r['start_at'] ?? '')),
'ends_at' => self::dtOrNull((string)($r['end_at'] ?? '')),
'cities' => $cities,
'city' => self::citiesWord($cities),
];
}
/**
* 一条活动去过的城市(各段旅程 place 去重,按**最早出现的时间**排)。
*
* ⚠️ 顺序按时间排而不是按字母/插入顺序:
* "青岛 · 威海" 让人一眼看出先后,排成字母序就完全没有这层信息了。
* ⚠️ skipped(未成行)的**不算去过** —— 计划去但没去成的地方不该出现在
* "去过哪几个城市"里(这与"不上日程、不进往年今日"是同一条口径)。
*/
public static function cities(int $eventId): array
{
// ⚠️ ORDER BY 里**不能**写聚合别名(first_at):MySQL/MariaDB 会报
// `Reference 'first_at' not supported (reference to group function)` ——
// 而且这是**运行时**才炸(php -l 看不出),整个旅程模块直接 Fatal。
// 写成 `ORDER BY MIN(n.starts_at), n.place` 就没问题(聚合函数本身可用)。
$st = Database::get()->prepare(
"SELECT n.place, MIN(n.starts_at) AS first_at
FROM trip_nodes n
WHERE n.event_id = ? AND n.stage IN ('plan','actual')
AND n.place IS NOT NULL AND n.place <> ''
GROUP BY n.place
ORDER BY MIN(n.starts_at), n.place"
);
$st->execute([$eventId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$p = trim((string)$r['place']);
if ($p !== '' && !in_array($p, $out, true)) {
$out[] = mb_substr($p, 0, 60, 'UTF-8');
}
}
return $out;
}
/**
* 城市列表 → 一行字。
*
* 用「 · 」分隔而不是顿号:城市名里本来就可能含顿号或逗号
* ("海拉尔、满洲里"这种写法真有人用),混在一起会分不清有几个城市。
*/
public static function citiesWord(array $cities): string
{
return implode(' · ', $cities);
}
/**
* 给**一批**活动补上派生时间与城市(列表页用;N+1 会让几十趟活动明显变慢)。
*
* ⚠️ 与 attachAttendees 同一思路:一次查询按 event_id 分桶,再挂回每行。
*/
public static function attachDerived(array &$rows): void
{
$ids = [];
foreach ($rows as $r) {
$id = (int)($r['id'] ?? 0);
if ($id > 0) {
$ids[] = $id;
}
}
$span = [];
$city = [];
foreach ($ids as $id) {
$span[$id] = ['starts_at' => null, 'ends_at' => null];
$city[$id] = [];
}
if ($ids) {
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT event_id, MIN(starts_at) AS start_at,
MAX(COALESCE(ends_at, starts_at)) AS end_at
FROM trip_nodes
WHERE event_id IN ($in) AND stage IN ('plan','actual')
GROUP BY event_id"
);
$st->execute($ids);
foreach ($st->fetchAll() as $r) {
$id = (int)$r['event_id'];
$span[$id] = [
'starts_at' => self::dtOrNull((string)($r['start_at'] ?? '')),
'ends_at' => self::dtOrNull((string)($r['end_at'] ?? '')),
];
}
// ⚠️ 同 cities():ORDER BY 只写聚合函数,不写别名
$st2 = Database::get()->prepare(
"SELECT event_id, place, MIN(starts_at) AS first_at
FROM trip_nodes
WHERE event_id IN ($in) AND stage IN ('plan','actual')
AND place IS NOT NULL AND place <> ''
GROUP BY event_id, place
ORDER BY event_id, MIN(starts_at), place"
);
$st2->execute($ids);
foreach ($st2->fetchAll() as $r) {
$id = (int)$r['event_id'];
$p = mb_substr(trim((string)$r['place']), 0, 60, 'UTF-8');
if ($id > 0 && $p !== '' && !in_array($p, $city[$id], true)) {
$city[$id][] = $p;
}
}
}
foreach ($rows as &$r) {
$id = (int)$r['id'];
$r['starts_at'] = $span[$id]['starts_at'] ?? null;
$r['ends_at'] = $span[$id]['ends_at'] ?? null;
$r['cities'] = $city[$id] ?? [];
$r['city'] = self::citiesWord($r['cities']);
}
unset($r);
}
/** 全部旅程(按**派生**的出发时间倒序 —— 活动自己没有时间字段了) */
public static function all(int $familyId, string $keyword = ''): array public static function all(int $familyId, string $keyword = ''): array
{ {
// ⚠️ 用户 2026-10-03 第 4 条:「旅程的活动中,只显示 is_public=1 的活动」。 // ⚠️ 用户 2026-10-03 第 4 条:「旅程的活动中,只显示 is_public=1 的活动」。
@@ -93,9 +245,24 @@ final class Trip
// 老数据/异常情况下 trip_details 可能缺行,INNER JOIN 会让整趟旅程 // 老数据/异常情况下 trip_details 可能缺行,INNER JOIN 会让整趟旅程
// 从列表里消失("行程丢了"比"多显示一条"严重得多), // 从列表里消失("行程丢了"比"多显示一条"严重得多),
// COALESCE(...,1) 表示"没有这行就算公开"。 // COALESCE(...,1) 表示"没有这行就算公开"。
// ⚠️ 参与人 = **所有行程参与人的合集**(v24 第 14 批 用户第 4 条):
// 只在活动上登记、每段行程都没填成员的人也要算进来(否则老数据会"人没了")。
// ⚠️ e.starts_at / e.ends_at / e.location 这三列**仍然 SELECT 出来**,
// 但马上会被 attachDerived() 的派生值覆盖(用户第 15 批 第 2 条)。
// 留着 select 是为了:① 不 SELECT 的话 PHP 侧少键,若哪天有代码先读再做
// 兜底,会出现 Undefined index;② 出问题时肉眼能对着看"老值是什么"。
$sql = 'SELECT e.id, e.title, e.starts_at, e.ends_at, e.location, e.note, $sql = 'SELECT e.id, e.title, e.starts_at, e.ends_at, e.location, e.note,
d.cover_key, d.summary, COALESCE(d.is_public, 1) AS is_public, d.cover_key, d.summary, COALESCE(d.is_public, 1) AS is_public,
(SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count, /* ⚠️ 不能写成 SELECT COUNT(*) FROM (… WHERE a.event_id = e.id
UNION …) u:派生表里的 e.id 是**外层**列,MySQL 8 之前
不支持(要 LATERAL),MariaDB 也一样 —— 直接报列不存在。
所以拆成两段相加:活动上登记的 + 行程里**没重复**的那些。 */
((SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id)
+ (SELECT COUNT(DISTINCT m.person_id) FROM trip_node_members m
JOIN trip_nodes n2 ON n2.id = m.node_id
WHERE n2.event_id = e.id
AND m.person_id NOT IN (SELECT a2.person_id FROM event_attendees a2
WHERE a2.event_id = e.id))) AS attendee_count,
(SELECT COUNT(*) FROM trip_nodes n WHERE n.event_id = e.id) AS node_count (SELECT COUNT(*) FROM trip_nodes n WHERE n.event_id = e.id) AS node_count
FROM events e FROM events e
LEFT JOIN trip_details d ON d.event_id = e.id LEFT JOIN trip_details d ON d.event_id = e.id
@@ -103,21 +270,32 @@ final class Trip
$bind = [$familyId, 'trip']; $bind = [$familyId, 'trip'];
$kw = trim($keyword); $kw = trim($keyword);
if ($kw !== '') { if ($kw !== '') {
$sql .= ' AND (e.title LIKE ? OR e.location LIKE ? OR e.note LIKE ?)'; // ⚠️ 搜索里的时间/城市也是**派生**的:
// 活动自己的 location 已经不用了(用户第 2 条),
// 搜城市必须搜到"在行程里填过的地方",否则搜"青岛"搜不到那趟青岛之行。
$sql .= ' AND (e.title LIKE ? OR e.note LIKE ?
OR EXISTS (SELECT 1 FROM trip_nodes n3
WHERE n3.event_id = e.id AND n3.place LIKE ?))';
$like = '%' . $kw . '%'; $like = '%' . $kw . '%';
array_push($bind, $like, $like, $like); array_push($bind, $like, $like, $like);
} }
$sql .= ' ORDER BY e.starts_at DESC, e.id DESC'; // 排序也走派生时间:子查询里没有行程时得排到最后(NULL)
$sql .= ' ORDER BY (SELECT MIN(n4.starts_at) FROM trip_nodes n4
WHERE n4.event_id = e.id AND n4.stage IN ("plan","actual")) DESC,
e.id DESC';
$st = Database::get()->prepare($sql); $st = Database::get()->prepare($sql);
$st->execute($bind); $st->execute($bind);
return $st->fetchAll(); $rows = $st->fetchAll();
// 派生时间与城市:一次查询批量补(见 attachDerived 的说明)
self::attachDerived($rows);
return $rows;
} }
/** /**
* 给一批旅程补上「同行的人」。 * 给一批旅程补上「同行的人」= **所有行程参与人的合集**(用户第 4 条)。
* *
* ⚠️ 必须一次性补(N+1 会让列表在几十趟旅程时明显变慢,观影就是这么踩的)。 * ⚠️ 必须一次性补(N+1 会让列表在几十趟旅程时明显变慢,观影就是这么踩的)。
* 一次查询拿全部 event_attendees,再按 event_id 分桶挂回去。 * 一次查询拿全部(活动登记 ∪ 各段行程成员),再按 event_id 分桶挂回去。
*/ */
public static function attachAttendees(array &$rows, int $viewerId = 0): void public static function attachAttendees(array &$rows, int $viewerId = 0): void
{ {
@@ -134,14 +312,23 @@ final class Trip
} }
if ($ids) { if ($ids) {
$in = implode(',', array_fill(0, count($ids), '?')); $in = implode(',', array_fill(0, count($ids), '?'));
// 合集:**活动上登记的人** ∪ **每段行程的参与人**(用户第 4 条)。
// UNION 自带去重,所以同一个人既在活动上、又在三段行程里也只出现一次。
$st = Database::get()->prepare( $st = Database::get()->prepare(
'SELECT a.event_id, p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname 'SELECT u.event_id, p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
FROM event_attendees a JOIN persons p ON p.id = a.person_id FROM (
SELECT a.event_id, a.person_id FROM event_attendees a
WHERE a.event_id IN (' . $in . ')
UNION
SELECT nd.event_id, m.person_id FROM trip_node_members m
JOIN trip_nodes nd ON nd.id = m.node_id
WHERE nd.event_id IN (' . $in . ')
) u
JOIN persons p ON p.id = u.person_id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE a.event_id IN (' . $in . ') ORDER BY u.event_id, p.id'
ORDER BY a.position, p.id'
); );
$st->execute(array_merge([$viewerId], $ids)); $st->execute(array_merge($ids, $ids, [$viewerId]));
foreach ($st->fetchAll() as $a) { foreach ($st->fetchAll() as $a) {
$byEvent[(int)$a['event_id']][] = $a; $byEvent[(int)$a['event_id']][] = $a;
} }
@@ -167,11 +354,72 @@ final class Trip
if (!$row) { if (!$row) {
return null; return null;
} }
$row['attendees'] = self::attendees($id, $viewerId); // 参与人 = 所有行程参与人的**合集**(用户第 4 条),不是只在活动上登记的那几个
$row['attendees'] = self::tripPeople($id, $viewerId);
$row['photos'] = \App\Attachment::list('trip', $id); $row['photos'] = \App\Attachment::list('trip', $id);
// 时间与城市 = 从各段旅程派生(用户第 15 批 第 2 条)。
// ⚠️ **覆盖**掉 events 上的老值:老数据可能自己填过起止与目的地,
// 但页面必须以行程为准 —— 否则会出现"活动写着 8/1–8/5,行程只有 8/3 一段"
// 这种自相矛盾的显示,而看的人无从判断该信哪个。
$d = self::derived($id);
$row['starts_at'] = $d['starts_at'];
$row['ends_at'] = $d['ends_at'];
$row['cities'] = $d['cities'];
$row['city'] = $d['city'];
$row['location'] = $d['city']; // 模板里还有读 location 的地方,一并给派生值
return $row; return $row;
} }
/**
* 一趟旅程的参与人 = **所有行程(节点)参与人的合集**(v24 第 14 批 用户第 4 条)。
*
* 用户原话:「一个活动的参与人,应该是这个活动下的所有旅行(程)的参与人的合集才对。」
*
* 为什么不在保存行程时"顺手同步"到活动上:
* 那就是同一份名单存两处,改行程要记得改活动、删行程要记得减人 ——
* 任何一处漏了就对不上。合求是**读的时候算出来的**,永远不会漂移。
*
* ⚠️ 取的是**并集**(活动上单独登记的人也保留):
* 老数据里很多人只在活动上登记过、每段行程没填成员,
* 只按行程算会让这些人"凭空消失"。
*
* @return array 去重后的人(带「我叫他什么」与主色)
*/
public static function tripPeople(int $eventId, int $viewerId = 0): array
{
$st = Database::get()->prepare(
'SELECT DISTINCT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
FROM (
SELECT a.person_id FROM event_attendees a WHERE a.event_id = ?
UNION
SELECT m.person_id FROM trip_node_members m
JOIN trip_nodes nd ON nd.id = m.node_id
WHERE nd.event_id = ?
) u
JOIN persons p ON p.id = u.person_id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
ORDER BY p.id'
);
$st->execute([$eventId, $eventId, $viewerId]);
return $st->fetchAll();
}
/** 同上,但只给一个**人数**(列表页用,避免为每行再拉一堆人) */
public static function tripPeopleCount(int $eventId): int
{
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM (
SELECT a.person_id FROM event_attendees a WHERE a.event_id = ?
UNION
SELECT m.person_id FROM trip_node_members m
JOIN trip_nodes nd ON nd.id = m.node_id
WHERE nd.event_id = ?
) u'
);
$st->execute([$eventId, $eventId]);
return (int)$st->fetchColumn();
}
/** 同行的人(带「我叫他什么」与主色,页面按人画标签) */ /** 同行的人(带「我叫他什么」与主色,页面按人画标签) */
public static function attendees(int $eventId, int $viewerId = 0): array public static function attendees(int $eventId, int $viewerId = 0): array
{ {
@@ -190,13 +438,16 @@ final class Trip
public static function create(int $familyId, ?int $creatorId, array $d): int public static function create(int $familyId, ?int $creatorId, array $d): int
{ {
$db = Database::get(); $db = Database::get();
// ⚠️ 用户第 15 批 第 2 条:活动**不存**起止时间与目的地 ——
// 它们从各段旅程派生(见 derived())。这里显式写 NULL,
// 不接表单传来的值(表单里也没有这些字段了)。
// events.starts_at / ends_at / location 保持可空,只是为了不动表结构。
$db->prepare( $db->prepare(
"INSERT INTO events (family_id, creator_id, kind, title, starts_at, ends_at, location, note, source_ref) "INSERT INTO events (family_id, creator_id, kind, title, starts_at, ends_at, location, note, source_ref)
VALUES (?, ?, 'trip', ?, ?, ?, ?, ?, ?)" VALUES (?, ?, 'trip', ?, NULL, NULL, NULL, ?, ?)"
)->execute([ )->execute([
$familyId, $creatorId, (string)$d['title'], $familyId, $creatorId, (string)$d['title'],
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''), self::textOrNull($d['note'] ?? ''),
self::textOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
$d['source_ref'] ?? null, $d['source_ref'] ?? null,
]); ]);
$id = (int)$db->lastInsertId(); $id = (int)$db->lastInsertId();
@@ -208,13 +459,15 @@ final class Trip
public static function update(int $familyId, int $id, array $d): void public static function update(int $familyId, int $id, array $d): void
{ {
$db = Database::get(); $db = Database::get();
// ⚠️ 与 create 同理:**不碰** starts_at / ends_at / location。
// 连"置 NULL"都不做 —— 老数据里可能存过值,编辑一次活动不该把它抹掉
// (页面反正不读它们;真出问题时还能拿老值对着看)。
$db->prepare( $db->prepare(
"UPDATE events SET title=?, starts_at=?, ends_at=?, location=?, note=? "UPDATE events SET title=?, note=?
WHERE id=? AND family_id=? AND kind='trip'" WHERE id=? AND family_id=? AND kind='trip'"
)->execute([ )->execute([
(string)$d['title'], (string)$d['title'],
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''), self::textOrNull($d['note'] ?? ''),
self::textOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
$id, $familyId, $id, $familyId,
]); ]);
self::saveDetails($id, $d); self::saveDetails($id, $d);
@@ -552,6 +805,25 @@ final class Trip
// ---------------- 展示 ---------------- // ---------------- 展示 ----------------
/** 起止日期的人话展示(与 Movie::formatWatch 同款口径) */ /** 起止日期的人话展示(与 Movie::formatWatch 同款口径) */
/**
* 活动的时间文案(用户第 15 批 第 2 条:时间是从旅程算出来的)。
*
* ⚠️ 空的时候**不能**沿用 formatSpan 的「日期待补」:那是给"一段没填时间的行程"
* 用的短语,放在活动上会让人以为"这里本来该由我填、我忘了填"。
* 活动的时间本来就该由旅程决定 —— 没有旅程时要说清楚是**去加一段旅程**。
*/
public static function activitySpan(?string $start, ?string $end): string
{
if ($start === null || $start === '') {
return '还没有旅程';
}
$s = date('Y年n月j日', (int)strtotime($start));
if ($end === null || $end === '') {
return $s . ' 起';
}
return $s . ' – ' . date('Y年n月j日', (int)strtotime($end));
}
public static function formatSpan(?string $start, ?string $end): string public static function formatSpan(?string $start, ?string $end): string
{ {
if ($start === null || $start === '') { if ($start === null || $start === '') {
+254 -7
View File
@@ -2507,6 +2507,23 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
.kb-title { font-size: 14px; font-weight: 500; color: var(--ink); } .kb-title { font-size: 14px; font-weight: 500; color: var(--ink); }
.kb-title:hover { color: var(--brand); } .kb-title:hover { color: var(--brand); }
.kb-meta { display: flex; flex-wrap: wrap; gap: 4px 8px; font-size: 12px; color: var(--muted); } .kb-meta { display: flex; flex-wrap: wrap; gap: 4px 8px; font-size: 12px; color: var(--muted); }
/* ---- 状态徽标(v24 第 14 批 用户第 2 条:每张卡都有,点一下推进到下一档 ----
三档各有颜色:待办灰 / 进行中品牌色 / 已完成绿 —— 不靠位置也能一眼看出做到哪一步。
⚠️ .is-static(只读账号)长得一样但不可点:不给可点的错觉。 */
.kb-st {
display: inline-flex; align-items: center; gap: 4px;
padding: 1px 8px; border-radius: 999px; border: 1px solid transparent;
font-size: 11px; line-height: 1.7; white-space: nowrap; cursor: pointer;
}
.kb-st.is-todo { background: var(--line-2); color: var(--muted); border-color: var(--line); }
.kb-st.is-doing { background: rgba(168, 74, 40, .12); color: var(--brand-ink); border-color: rgba(168, 74, 40, .3); }
.kb-st.is-done { background: rgba(111, 158, 92, .14); color: #4C7038; border-color: rgba(111, 158, 92, .35); }
.kb-st.is-static { cursor: default; }
.kb-st.is-doing:hover { border-color: var(--brand); }
/* .is-on = "当前就是这一档"(读书详情页的四个状态按钮用它标选中):
加一圈内描边而不是换底色 —— 底色已经表达"这一档是什么颜色",
再换一次就会分不清"选中"和"这一档"。 */
.kb-st.is-on { box-shadow: inset 0 0 0 1px currentColor; font-weight: 600; }
.kb-due.is-late, .kb-late { color: var(--red); } .kb-due.is-late, .kb-late { color: var(--red); }
.kb-ico { white-space: nowrap; } .kb-ico { white-space: nowrap; }
.kb-sub { display: flex; align-items: center; gap: 8px; } .kb-sub { display: flex; align-items: center; gap: 8px; }
@@ -2587,6 +2604,21 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
.avatar-picker #avatar-preview[data-has-avatar="0"] { border-style: dashed; } .avatar-picker #avatar-preview[data-has-avatar="0"] { border-style: dashed; }
.avatar-hint { line-height: 1.7; } .avatar-hint { line-height: 1.7; }
/* ---- 旅程题图(v24 第 14 批 用户第 3 条):与头像同一套裁剪,比例 16:9 ----
预览块本身就是"选文件"的点击区(与头像一样,省掉一整行重复的文件控件)。
⚠️ 比例写死成 16/9 是**故意**的:CSS 不读 ImageSlot,而槽位里的比例就是 16:9,
两边各写一份会漂移 —— 部署脚本有一条断言比对 app.js 与 CSS 里的比例数字。 */
.cover-picker { display: flex; align-items: flex-start; gap: 14px; flex-wrap: wrap; }
.cover-picker #cover-preview {
width: 240px; max-width: 100%; aspect-ratio: 16 / 9;
object-fit: cover; border-radius: 12px;
border: 1px solid var(--line); background: #F1E7D6; cursor: pointer;
transition: border-color .15s ease, box-shadow .15s ease;
}
.cover-picker #cover-preview:hover { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(168,74,40,.12); }
.cover-picker #cover-preview[data-has-cover="0"] { border-style: dashed; }
.trip-people-block { display: flex; flex-direction: column; gap: 6px; margin: 8px 0; }
/* ---- 工作经历(用户第 4 条:可以有多个)---- */ /* ---- 工作经历(用户第 4 条:可以有多个)---- */
.job-list { display: flex; flex-direction: column; gap: 8px; } .job-list { display: flex; flex-direction: column; gap: 8px; }
.job-row { .job-row {
@@ -2790,15 +2822,41 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
/* 下拉里放的是 Semi 的**内联**日历面板,去掉它自己的浮层阴影/描边避免双层框 */ /* 下拉里放的是 Semi 的**内联**日历面板,去掉它自己的浮层阴影/描边避免双层框 */
} }
.cal-jump-drop[hidden] { display: none; } .cal-jump-drop[hidden] { display: none; }
.cal-jump-drop .semi-datepicker, /* ---- v24 第 14 批 #1:下拉里是**自绘**的日历(不再挂 Semi 的浮层面板)----
.cal-jump-drop .semi-popover { 「上一月/上一年」只重画面板、绝不提交(这正是不引第三方控件的原因:
box-shadow: none !important; 它把"翻月"与"选中"混在同一个 onChange 里,于是翻月也会跳走)。 */
border: 0 !important; .cjd-head {
background: var(--surface) !important; display: flex; align-items: center; gap: 4px; margin-bottom: 6px;
} }
.cal-jump-drop .semi-datepicker { .cjd-title { flex: 1; text-align: center; font-weight: 600; font-size: 14px; }
display: block; .cjd-nav {
width: 28px; height: 28px; flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid var(--line); border-radius: 8px;
background: var(--surface); color: var(--ink); font-size: 14px; line-height: 1;
cursor: pointer;
} }
.cjd-nav:hover { background: var(--line); }
.cjd-grid {
display: grid; grid-template-columns: repeat(7, 32px); gap: 2px;
}
.cjd-wd {
text-align: center; font-size: 11px; color: var(--muted); padding: 2px 0 4px;
}
.cjd-day {
width: 32px; height: 30px; padding: 0;
border: 0; border-radius: 8px; background: transparent; color: var(--ink);
font-size: 13px; cursor: pointer;
}
.cjd-day:hover { background: var(--line); }
.cjd-day.is-out { color: var(--muted); opacity: .55; }
.cjd-day.is-today { font-weight: 700; box-shadow: inset 0 0 0 1px var(--line); }
.cjd-day.is-on { background: var(--brand); color: #fff; }
.cjd-foot {
display: flex; align-items: center; gap: 8px;
margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line);
}
.cjd-foot .cjd-sel { flex: 1; }
/* ==================== 周 / 天:按小时铺的时间网格 ==================== */ /* ==================== 周 / 天:按小时铺的时间网格 ==================== */
/* 结构:左边一条"小时刻度"沟槽(tg-gutter),右边 N 个日期列(tg-col)。 /* 结构:左边一条"小时刻度"沟槽(tg-gutter),右边 N 个日期列(tg-col)。
@@ -2960,3 +3018,192 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
padding: 9px 11px; border: 1px dashed var(--line); border-radius: var(--radius-s); padding: 9px 11px; border: 1px dashed var(--line); border-radius: var(--radius-s);
background: var(--line-2); font-size: 13px; background: var(--line-2); font-size: 13px;
} }
/* ============================================================
v24 第 14 批:读书模块(用户第 5 条)
卡片骨架 / 统计图表一律复用现成的(.card.card-pad / .stats-grid /
.stat-card / .chart-card / .bar-chart / .vgrid),这里只写读书**特有**的部件。
图表仍然是纯 CSS:不引图表库,服务端画出来、没 JS 也看得见。
============================================================ */
/* ---- 书架:状态筛选条 ---- */
.book-filter { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.bf-btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 5px 14px; border-radius: 999px;
border: 1px solid var(--line); background: var(--surface);
font-size: 13px; color: var(--ink); text-decoration: none;
transition: border-color .15s ease, background .15s ease;
}
.bf-btn:hover { border-color: var(--brand); text-decoration: none; }
.bf-btn.is-on { background: var(--brand); border-color: var(--brand); color: #fff; }
.bf-n { font-size: 11px; opacity: .72; font-variant-numeric: tabular-nums; }
/* ---- 书架:卡片网格 ----
封面按 2:3(书的形状),宽度自适应;窄屏自动减列。 */
.book-grid {
display: grid; gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.book-card {
display: flex; flex-direction: column; gap: 8px;
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 12px; box-shadow: var(--shadow);
}
/* ⚠️ 封面是 <a>(整块可点):居中靠 flex,图自己铺满 —— 只写一次 display,
写两遍后者会把前者悄悄改掉(这类"重复声明"最容易在改版时留成陷阱)。 */
.book-cover {
display: flex; align-items: center; justify-content: center;
aspect-ratio: 2 / 3; border-radius: var(--radius-s);
overflow: hidden; background: #F1E7D6;
}
.book-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.book-cover-empty { font-size: 32px; opacity: .5; }
.book-body { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.book-title {
font-size: 14px; font-weight: 600; color: var(--ink); line-height: 1.45;
text-decoration: none;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.book-title:hover { color: var(--brand); }
.book-author { font-size: 12px; color: var(--muted); }
.book-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; }
.book-rate { font-size: 11px; color: var(--brand-ink); }
.book-note-n { font-size: 11px; color: var(--muted); }
/* 读到第几章的细进度条 */
.bk-bar {
height: 4px; border-radius: 999px; background: var(--line-2); overflow: hidden; margin: 2px 0;
}
.bk-bar i { display: block; height: 100%; background: var(--brand); }
.book-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: auto; }
/* ---- 一本书 ---- */
.book-hero { display: flex; gap: 20px; flex-wrap: wrap; }
.book-hero-cover {
width: 150px; max-width: 46%; flex: 0 0 auto;
aspect-ratio: 2 / 3; border-radius: var(--radius-s);
overflow: hidden; background: #F1E7D6;
display: flex; align-items: center; justify-content: center;
}
.book-hero-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.book-hero-body { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.book-sub { margin: 0; font-size: 14px; color: var(--muted); }
.book-states { display: flex; flex-wrap: wrap; gap: 6px; }
.book-sum { margin-top: 4px; }
.book-toc-box { margin-top: 4px; }
.book-toc { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 3px; }
.book-toc li { font-size: 13.5px; display: flex; align-items: baseline; gap: 8px; }
.book-toc li.is-cur > a { color: var(--brand); font-weight: 600; }
.bk-file-now { margin: 0 0 6px; font-size: 13px; color: var(--muted); }
/* ---- 笔记 ---- */
.book-note-form { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; }
.bn-row { display: flex; gap: 8px; flex-wrap: wrap; }
.bn-row .select { flex: 0 0 auto; width: auto; min-width: 96px; }
.bn-row .input { flex: 1 1 160px; min-width: 0; }
.book-notes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.book-note {
border: 1px solid var(--line-2); border-radius: var(--radius-s);
padding: 9px 12px; background: #FDFBF7;
}
.book-note.is-quote { border-left: 3px solid var(--brand-2); }
.book-note.is-review { border-left: 3px solid var(--green); }
.bn-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 5px; }
.bn-kind {
font-size: 11px; padding: 1px 7px; border-radius: 999px;
background: var(--line-2); color: var(--brand-ink);
}
.bn-ch, .bn-src, .bn-who { font-size: 11.5px; color: var(--muted); }
.bn-del { margin-left: auto; }
.bn-body { font-size: 14px; line-height: 1.75; color: var(--ink); white-space: normal; }
/* ---- 阅读器 ----
目录在侧栏、正文在右边;窄屏侧栏折到上面(而不是横向挤成一条)。 */
.book-read { display: flex; gap: 18px; align-items: flex-start; }
.br-side {
flex: 0 0 210px; max-height: 72vh; overflow-y: auto;
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 12px 14px;
}
.br-side-h { font-size: 13px; font-weight: 600; color: var(--muted); margin-bottom: 8px; }
.br-toc { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.br-toc li { font-size: 13px; }
.br-toc li a {
display: block; padding: 4px 8px; border-radius: 6px; color: var(--ink); text-decoration: none;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.br-toc li a:hover { background: var(--line-2); }
.br-toc li.is-cur a { background: rgba(168, 74, 40, .12); color: var(--brand-ink); font-weight: 600; }
.br-main {
flex: 1 1 auto; min-width: 0;
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 22px 26px; box-shadow: var(--shadow);
}
.br-ch-title { font-size: 19px; margin: 0 0 14px; }
.br-body { font-size: 16px; line-height: 1.95; color: var(--ink); }
.br-nav { display: flex; align-items: center; gap: 12px; margin-top: 22px; flex-wrap: wrap; }
.br-pos { margin-left: auto; }
.br-pdf object { width: 100%; height: 78vh; border: 0; display: block; }
@media (max-width: 860px) {
.book-read { flex-direction: column; }
.br-side { flex: 1 1 auto; width: 100%; max-height: 260px; }
.br-main { padding: 16px 18px; }
.br-body { font-size: 15.5px; line-height: 1.9; }
}
/* ---- 封面裁剪(书的封面是 2:3,与旅程题图的 16:9 不同)----
与 .cover-picker 同一套结构,只是预览框换成竖的。 */
.book-cover-picker #book-cover-preview {
width: 150px; aspect-ratio: 2 / 3;
}
/* ---- 导入读书笔记 ---- */
.imp-how { margin: 0 0 10px; padding-left: 20px; display: flex; flex-direction: column; gap: 5px; }
.imp-how li { font-size: 13.5px; line-height: 1.7; }
.imp-list { list-style: none; margin: 0 0 14px; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.imp-book {
border: 1px solid var(--line-2); border-radius: var(--radius-s);
padding: 10px 12px; background: #FDFBF7;
}
.imp-bh { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
.imp-t { font-size: 14.5px; font-weight: 600; }
.imp-n { margin-left: auto; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
.imp-notes { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 5px; }
.imp-notes li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; font-size: 13px; }
.imp-notes li.is-quote .imp-txt { color: var(--muted); }
.imp-txt { flex: 1 1 240px; min-width: 0; line-height: 1.6; }
.imp-confirm { margin-top: 6px; }
/* ---- 柱状图:列数不定的(每年的本数 / 评分档位)----
观影那套 .vgrid 是写死 12 列的(12 个月),这里列数随数据变。 */
.vgrid-auto { grid-template-columns: repeat(auto-fit, minmax(26px, 1fr)); }
/* ============================================================
v24 第 15 批:活动(容器)/ 旅程(行程)两级语义
术语改了(用户第 1 条),活动表单砍到三项(用户第 4 条)。
============================================================ */
/* ---- 活动表单里的"说明一句"(时间/城市从哪来)----
用左边一条竖线而不是整块底色:它是**解释**、不是输入项,
染成和输入区一样的底色会让人以为这里要填东西。 */
.form-note {
margin: 0 0 14px; padding: 8px 0 8px 12px;
border-left: 3px solid var(--line);
font-size: 13px; line-height: 1.75; color: var(--muted);
}
.form-note b { color: var(--ink); font-weight: 600; }
/* ---- 「更多设置」折叠区:主流程只留三项,其余收在这里 ---- */
.more-set {
margin: 4px 0 14px; border: 1px solid var(--line);
border-radius: var(--radius-s); background: #FDFBF7;
}
.more-set > summary {
cursor: pointer; padding: 10px 14px;
font-size: 13.5px; color: var(--muted); font-weight: 500;
}
.more-set > summary:hover { color: var(--brand); }
.more-set[open] > summary { border-bottom: 1px solid var(--line-2); }
.more-set .form-grid { padding: 14px; margin: 0; }
/* 折叠区里的字段少一点内边距,看起来是"附带的"而不是主流程 */
.more-set .field { margin-bottom: 0; }
+28
View File
@@ -300,6 +300,34 @@
} }
})(); })();
/**
* 通用「点预览图 = 打开文件选择框」。
*
* ⚠️ 为什么必须做成通用的(2026-10-05 第 15 批 用户第 3 条实测踩到):
* 原来这句 `prev.addEventListener('click', () => file.click())` 只写在
* **templates/people/form.php 的内页脚本**里(那是"头像"专用的)。
* 后来加的「旅程题图」「图书封面」照抄了同一套 DOM(.cover-picker +
* #cover-preview + 隐藏的 file input),却**没有对应的 JS** ——
* 于是图块看着能点(CSS 给了 cursor:pointer 与 hover 高亮),
* 点下去什么都不会发生,用户以为功能坏了(原话:"点击哪里都没反应")。
*
* 做法:谁想"点图选文件",就在图上写 `data-pick-input="#文件框id"`,
* 这里统一接管(一个页面有多个也各绑各的)。声明式,模板里不再各写一段 JS。
*/
Array.prototype.forEach.call(
document.querySelectorAll('[data-pick-input]'),
function (pv) {
var sel = pv.getAttribute('data-pick-input');
var file = sel ? document.querySelector(sel) : null;
if (!file) { return; }
// ⚠️ 用 <label for> 包住图片也能触发文件框,但那样 hover/光标语义会变、
// 且部分浏览器在 label 里点 <img> 不会激活控件(要额外 CSS 才能稳)。
// 直接 click() 最省事,也与原来头像那一套保持一致。
pv.addEventListener('click', function () { file.click(); });
pv.style.cursor = 'pointer';
}
);
// 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架) // 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架)
var fileInput = document.getElementById('avatar'); var fileInput = document.getElementById('avatar');
if (fileInput && !fileInput.hasAttribute('data-crop-ratio') && window.FileReader) { if (fileInput && !fileInput.hasAttribute('data-crop-ratio') && window.FileReader) {
+489 -38
View File
@@ -14,6 +14,9 @@ use App\Account;
use App\Attachment; use App\Attachment;
use App\Auth; use App\Auth;
use App\Birthday; use App\Birthday;
use App\Book;
use App\BookNoteImport;
use App\BookParser;
use App\Calendar; use App\Calendar;
use App\Cinema; use App\Cinema;
use App\ContactImport; use App\ContactImport;
@@ -688,9 +691,13 @@ switch ($r) {
})); }));
// 进行中的任务 + 已经过期的待办(**只有这些才值得占首页**; // 进行中的任务 + 已经过期的待办(**只有这些才值得占首页**;
// 没截止日期的待办放任务看板里看,堆到首页会变成一张长长的清单) // 没截止日期的待办放任务看板里看,堆到首页会变成一张长长的清单)
$board = Task::board($familyId, $me); //
$tasks = $board['doing']; // ⚠️ v24 第 14 批 用户第 2 条:**按状态取,不看板取**。
foreach ($board['todo'] as $t) { // 原话:「首页上显示的任务,是和任务的状态有关,而不是和它在那个看板有关」。
// 原来调的是 Task::board() —— 它只取**默认看板**,于是建在别的看板上的
// 「进行中」永远上不了首页(用户看到的就是"我明明在做,首页却没有")。
$tasks = Task::byStatus($familyId, $me, 'doing');
foreach (Task::byStatus($familyId, $me, 'todo') as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) { if (Task::isOverdue($t['due_at'] ?? null)) {
$tasks[] = $t; $tasks[] = $t;
} }
@@ -714,13 +721,14 @@ switch ($r) {
); );
$st7->execute([$familyId, $today, $weekEnd, $me, $me]); $st7->execute([$familyId, $today, $weekEnd, $me, $me]);
$ev7 = (int)$st7->fetchColumn(); $ev7 = (int)$st7->fetchColumn();
$todoN = count($board['todo']); // ⚠️ 同上(用户第 2 条):这两个数字也是**按状态、跨看板**算的。
// $board 在首页已经不再使用(它只取默认看板)—— 留着它只会让统计与
// 上面的清单对不上("清单上有 3 件,卡片却说 1 件在做")。
$todoN = count(Task::byStatus($familyId, $me, 'todo'));
$lateN = 0; $lateN = 0;
foreach ([$board['todo'], $board['doing']] as $col) { foreach ($tasks as $t) {
foreach ($col as $t) { if (Task::isOverdue($t['due_at'] ?? null)) {
if (Task::isOverdue($t['due_at'] ?? null)) { $lateN++;
$lateN++;
}
} }
} }
$meName = Nickname::display($me, $people[$me] ?? null) ?: (string)($people[$me]['name'] ?? ''); $meName = Nickname::display($me, $people[$me] ?? null) ?: (string)($people[$me]['name'] ?? '');
@@ -2756,7 +2764,7 @@ switch ($r) {
case 'trips/new': case 'trips/new':
Auth::requireWrite(); Auth::requireWrite();
View::render('trips/form', [ View::render('trips/form', [
'title' => '记一趟旅程', 'title' => '记一次活动',
't' => null, 't' => null,
// 新建时默认勾上「我自己」—— 一趟旅程里几乎总有我,少点一下 // 新建时默认勾上「我自己」—— 一趟旅程里几乎总有我,少点一下
'presetAttendees' => [(int)Auth::personId()], 'presetAttendees' => [(int)Auth::personId()],
@@ -2770,7 +2778,7 @@ switch ($r) {
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
$t = Trip::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId()); $t = Trip::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId());
if (!$t) { if (!$t) {
flash('err', '找不到这趟旅程。'); flash('err', '找不到这次活动。');
redirect('trips'); redirect('trips');
} }
View::render('trips/form', [ View::render('trips/form', [
@@ -2790,16 +2798,17 @@ switch ($r) {
$id = $isEdit ? (int)($_POST['id'] ?? 0) : 0; $id = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
$t = $isEdit ? Trip::find($familyId, $id) : null; $t = $isEdit ? Trip::find($familyId, $id) : null;
if ($isEdit && !$t) { if ($isEdit && !$t) {
flash('err', '找不到这趟旅程。'); flash('err', '找不到这次活动。');
redirect('trips'); redirect('trips');
} }
$startsAt = read_dt_input('starts_at');
$endsAt = read_dt_input('ends_at');
$d = [ $d = [
'title' => trim((string)($_POST['title'] ?? '')), 'title' => trim((string)($_POST['title'] ?? '')),
'starts_at' => (string)($startsAt ?? ''), // ⚠️ 用户第 15 批 第 2 条:活动**不收**起止时间与目的地 ——
'ends_at' => (string)($endsAt ?? ''), // 时间由各段旅程的起止算出来、城市由各段旅程的 place 去重得到。
'location' => trim((string)($_POST['location'] ?? '')), // 表单里已经没有这些字段;这里不是"忘了读",是**故意不读**。
// ($startsAt / $endsAt 的 read_dt_input 一并删掉了,
// 否则"表单没这两个字段 → 读出 null → 把库里老值冲成 NULL",
// 会静默毁掉迁移前的数据。)
'note' => trim((string)($_POST['note'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')),
'summary' => trim((string)($_POST['summary'] ?? '')), 'summary' => trim((string)($_POST['summary'] ?? '')),
// 用户 2026-10-03 第 4 条:旅程是否公开(只影响列表与日历,不影响能否直接打开) // 用户 2026-10-03 第 4 条:旅程是否公开(只影响列表与日历,不影响能否直接打开)
@@ -2818,16 +2827,19 @@ switch ($r) {
} }
$errors = []; $errors = [];
if ($d['title'] === '') { if ($d['title'] === '') {
$errors['title'] = '旅程名字不能为空'; $errors['title'] = '活动名字不能为空';
} elseif ($d['starts_at'] !== '' && $d['ends_at'] !== ''
&& strcmp($d['ends_at'], $d['starts_at']) < 0) {
$errors['ends_at'] = '回来的时间不能早于出发';
} }
// 「回来不能早于出发」的校验一并删掉:时间不再由这里填,
// 而是从各段旅程算 max/min —— 二者反了也不可能(min ≤ max 恒成立)。
// 封面图(编辑时换新图才动它;失败不挡保存,与观影海报同一个口径) // 封面图(编辑时换新图才动它;失败不挡保存,与观影海报同一个口径)
$oldCover = $isEdit ? (string)($t['cover_key'] ?? '') : ''; $oldCover = $isEdit ? (string)($t['cover_key'] ?? '') : '';
$d['cover_key'] = $oldCover; $d['cover_key'] = $oldCover;
$warns = []; $warns = [];
$up = Upload::image($familyId, $_FILES['cover'] ?? [], 'photo'); // v24 第 14 批 用户第 3 条:题图按 **16:9 裁剪**(走 'cover' 槽位,不再是
// 'photo' 那种"只缩不裁"的自由槽位)。裁剪矩形由前端 Cropper 写成归一化
// "x,y,w,h";没裁(空串)时服务端按 16:9 居中裁。
$up = Upload::image($familyId, $_FILES['cover'] ?? [], 'cover',
(string)($_POST['cover_crop'] ?? ''));
if ($up['ok']) { if ($up['ok']) {
$d['cover_key'] = (string)$up['file']; $d['cover_key'] = (string)$up['file'];
if ($oldCover !== '' && $oldCover !== $d['cover_key']) { if ($oldCover !== '' && $oldCover !== $d['cover_key']) {
@@ -2839,7 +2851,7 @@ switch ($r) {
if ($errors) { if ($errors) {
set_old($_POST); set_old($_POST);
View::render('trips/form', [ View::render('trips/form', [
'title' => $isEdit ? '编辑旅程' : '记一趟旅程', 'title' => $isEdit ? '编辑活动' : '记一次活动',
't' => $isEdit ? array_merge($t, $d) : null, 't' => $isEdit ? array_merge($t, $d) : null,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => $errors, 'errors' => $errors,
@@ -2854,7 +2866,7 @@ switch ($r) {
} }
$newId = Trip::create($familyId, (int)Auth::personId(), $d); $newId = Trip::create($familyId, (int)Auth::personId(), $d);
clear_old(); clear_old();
flash('ok', '这趟旅程记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); flash('ok', '这次活动记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('trips/show', ['id' => $newId]); redirect('trips/show', ['id' => $newId]);
break; break;
@@ -2863,7 +2875,7 @@ switch ($r) {
$meId = (int)Auth::personId(); $meId = (int)Auth::personId();
$t = Trip::find($familyId, (int)($_GET['id'] ?? 0), $meId); $t = Trip::find($familyId, (int)($_GET['id'] ?? 0), $meId);
if (!$t) { if (!$t) {
flash('err', '找不到这趟旅程。'); flash('err', '找不到这次活动。');
redirect('trips'); redirect('trips');
} }
View::render('trips/show', [ View::render('trips/show', [
@@ -2881,11 +2893,11 @@ switch ($r) {
csrf_check(); csrf_check();
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) { if (!Trip::find($familyId, $id)) {
flash('err', '找不到这趟旅程。'); flash('err', '找不到这次活动。');
redirect('trips'); redirect('trips');
} }
Trip::destroy($familyId, $id); Trip::destroy($familyId, $id);
flash('ok', '这趟旅程已删除。'); flash('ok', '这次活动已删除。');
redirect('trips'); redirect('trips');
break; break;
@@ -2895,7 +2907,7 @@ switch ($r) {
csrf_check(); csrf_check();
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) { if (!Trip::find($familyId, $id)) {
flash('err', '找不到这趟旅程。'); flash('err', '找不到这次活动。');
redirect('trips'); redirect('trips');
} }
$warns = attach_save_files($familyId, 'trip', $id, 'photo', (int)Auth::personId()); $warns = attach_save_files($familyId, 'trip', $id, 'photo', (int)Auth::personId());
@@ -2913,7 +2925,7 @@ switch ($r) {
csrf_check(); csrf_check();
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) { if (!Trip::find($familyId, $id)) {
flash('err', '找不到这趟旅程。'); flash('err', '找不到这次活动。');
redirect('trips'); redirect('trips');
} }
\App\Attachment::remove((int)($_POST['photo_id'] ?? 0)); \App\Attachment::remove((int)($_POST['photo_id'] ?? 0));
@@ -2931,7 +2943,7 @@ switch ($r) {
$tripId = (int)($_POST['trip_id'] ?? 0); $tripId = (int)($_POST['trip_id'] ?? 0);
$t = Trip::find($familyId, $tripId); $t = Trip::find($familyId, $tripId);
if (!$t) { if (!$t) {
flash('err', '找不到这趟旅程。'); flash('err', '找不到这次活动。');
redirect('trips'); redirect('trips');
} }
$isEdit = $r === 'trips/node_update'; $isEdit = $r === 'trips/node_update';
@@ -2967,7 +2979,7 @@ switch ($r) {
csrf_check(); csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0); $tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) { if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。'); flash('err', '找不到这次活动。');
redirect('trips'); redirect('trips');
} }
Trip::deleteNode((int)($_POST['id'] ?? 0)); Trip::deleteNode((int)($_POST['id'] ?? 0));
@@ -2990,7 +3002,7 @@ switch ($r) {
csrf_check(); csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0); $tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) { if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。'); flash('err', '找不到这次活动。');
redirect('trips'); redirect('trips');
} }
$nid = (int)($_POST['id'] ?? 0); $nid = (int)($_POST['id'] ?? 0);
@@ -3013,7 +3025,7 @@ switch ($r) {
csrf_check(); csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0); $tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) { if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。'); flash('err', '找不到这次活动。');
redirect('trips'); redirect('trips');
} }
Trip::markActual((int)($_POST['id'] ?? 0)); Trip::markActual((int)($_POST['id'] ?? 0));
@@ -3028,7 +3040,7 @@ switch ($r) {
csrf_check(); csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0); $tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) { if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。'); flash('err', '找不到这次活动。');
redirect('trips'); redirect('trips');
} }
Trip::completeNode((int)($_POST['id'] ?? 0)); Trip::completeNode((int)($_POST['id'] ?? 0));
@@ -3043,11 +3055,11 @@ switch ($r) {
csrf_check(); csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0); $tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) { if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。'); flash('err', '找不到这次活动。');
redirect('trips'); redirect('trips');
} }
Trip::skipNode((int)($_POST['id'] ?? 0)); Trip::skipNode((int)($_POST['id'] ?? 0));
flash('ok', '已标记为**未成行**。它不再出现在日程与往年今日,但在这趟旅程里仍然看得到。'); flash('ok', '已标记为未成行。它不再出现在日程与往年今日,但在这段旅程里仍然看得到。');
redirect('trips/show', ['id' => $tripId]); redirect('trips/show', ['id' => $tripId]);
break; break;
@@ -3058,7 +3070,7 @@ switch ($r) {
csrf_check(); csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0); $tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) { if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。'); flash('err', '找不到这次活动。');
redirect('trips'); redirect('trips');
} }
Trip::restoreNode((int)($_POST['id'] ?? 0)); Trip::restoreNode((int)($_POST['id'] ?? 0));
@@ -4250,7 +4262,11 @@ switch ($r) {
// v24 第 9、10 条:分类与看板。Task 侧会校验归属(不认别的家庭的 id)。 // v24 第 9、10 条:分类与看板。Task 侧会校验归属(不认别的家庭的 id)。
'category_id' => (int)($_POST['category_id'] ?? 0), 'category_id' => (int)($_POST['category_id'] ?? 0),
'board_id' => (int)($_POST['board_id'] ?? 0), 'board_id' => (int)($_POST['board_id'] ?? 0),
'status' => $isEdit ? (string)$old['status'] : 'todo', // v24 第 14 批 用户第 2 条:新建时可以**直接定状态**(表单里有这一项)。
// ⚠️ 编辑时**不**在这里改状态:状态由专门的 tasks/status 路由管
// (那个路由还要处理循环任务的"下一次也排上"),这里保持原值 ——
// 否则"只改个标题"会把进行中的任务悄悄打回待办。
'status' => $isEdit ? (string)$old['status'] : (string)($_POST['status'] ?? 'todo'),
]; ];
if ($d['assignee_id'] > 0 && !isset(Person::map($familyId)[$d['assignee_id']])) { if ($d['assignee_id'] > 0 && !isset(Person::map($familyId)[$d['assignee_id']])) {
$d['assignee_id'] = 0; $d['assignee_id'] = 0;
@@ -4310,6 +4326,39 @@ switch ($r) {
redirect('tasks/show', ['id' => $id]); redirect('tasks/show', ['id' => $id]);
break; break;
/**
* 改一条任务的**状态**(v24 第 14 批 用户第 2 条)。
*
* 用户原话:「任务模块,每个任务都应该有不同的状态,例如正在进行、已完成等。」
* → 看板卡片上的状态徽标点一下就走这里:待办 → 进行中 → 已完成 → 待办。
*
* ⚠️ 与 tasks/move 的分工:move 管"搬到哪个看板/哪一列"(改 board_id 或带定位的
* status),这里只管"这件事做到哪一步"(改 status),不带位置信息 ——
* 在看板上改状态不该顺手把卡片挪到列尾。
*/
case 'tasks/status':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$status = (string)($_POST['status'] ?? '');
$t = Task::find($familyId, $id, $me);
if (!$t || !Task::canWrite($t, $me)) {
flash('err', '这项任务不能改。');
redirect('tasks');
}
Task::setStatus($familyId, $id, $status);
$word = Task::statusWord(Task::normalizeStatus($status));
if ($status === 'done' && !empty($t['repeat_rule']) && ($t['due_at'] ?? '') !== '') {
flash('ok', '已标成「' . $word . '」,下一次的也排上了。');
} else {
flash('ok', '已标成「' . $word . '」。');
}
redirect((string)($_POST['back'] ?? '') === 'show' ? 'tasks/show' : 'tasks',
(string)($_POST['back'] ?? '') === 'show' ? ['id' => $id] : []);
break;
case 'tasks/comment_add': case 'tasks/comment_add':
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
$me = (int)Auth::personId(); $me = (int)Auth::personId();
@@ -4426,6 +4475,408 @@ switch ($r) {
redirect('tasks'); redirect('tasks');
break; break;
// ==================== 读书(v24 第 14 批 用户第 5 条) ====================
// ⚠️ 与观影 / 旅程不同,读书**不走 events 骨架**:一本书是"一段持续几个月的状态",
// 不是"某一天发生的事"(详见 app/Book.php 的头注释)。真要上日历的那天是"读完那天"。
//
// 入口就两个:书架(books)与一本书(books/show);阅读器(books/read)从书进去。
// 统计与笔记导入挂在读书页右上角的「☰」里,与观影同一套规矩。
case 'books':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$f = [
'status' => (string)($_GET['status'] ?? ''),
'category' => (string)($_GET['cat'] ?? ''),
'q' => (string)($_GET['q'] ?? ''),
'owner' => (int)($_GET['owner'] ?? 0),
];
View::render('books/index', [
'title' => '读书',
'rows' => Book::all($familyId, $f),
// ⚠️ 计数**不带筛选条件**(见 Book::statusCounts 的注释):
// 否则点进某个状态后其它状态的数字全变 0,筛选条成了死路。
'counts' => Book::statusCounts($familyId),
'cats' => Book::categories($familyId),
'status' => $f['status'],
'cat' => $f['category'],
'q' => $f['q'],
'owner' => $f['owner'],
'people' => Person::roster($familyId, $me, 'member', ''),
], 'books');
break;
case 'books/new':
Auth::requireWrite();
View::render('books/form', [
'title' => '添加图书',
'b' => null,
'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''),
'errors' => [],
], 'books');
break;
case 'books/edit':
Auth::requireWrite();
$familyId = (int)Auth::familyId();
$b = Book::find($familyId, (int)($_GET['id'] ?? 0));
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
View::render('books/form', [
'title' => '编辑图书',
'b' => $b,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => [],
], 'books');
break;
case 'books/create':
case 'books/update':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$isEdit = $r === 'books/update';
$id = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
$b = $isEdit ? Book::find($familyId, $id) : null;
if ($isEdit && !$b) {
flash('err', '找不到这本书。');
redirect('books');
}
$errors = [];
$warns = [];
// ---- ① 图书文件:先存原文件,再解析出元数据与正文 ----
// ⚠️ 顺序不能反:文件是"本来就想要的"(下载 / 浏览器内嵌看 PDF 都靠它),
// 解析只是锦上添花。解析失败也必须有一句人能看懂的话,不能静默。
$fileKey = $isEdit ? (string)($b['file_key'] ?? '') : '';
$fileName = $isEdit ? (string)($b['file_name'] ?? '') : '';
$fileFormat = $isEdit ? (string)($b['file_format'] ?? '') : '';
$fileSize = $isEdit ? (int)($b['file_size'] ?? 0) : 0;
$parsed = null;
$upFile = $_FILES['bookfile'] ?? [];
if (isset($upFile['error']) && !is_array($upFile['error'])
&& (int)$upFile['error'] === UPLOAD_ERR_OK) {
$origName = (string)($upFile['name'] ?? '');
$tmp = (string)($upFile['tmp_name'] ?? '');
$ext = strtolower((string)pathinfo($origName, PATHINFO_EXTENSION));
if (!in_array($ext, Book::FILE_EXT, true)) {
$warns[] = '这个文件的格式不在允许范围内(' . implode(' / ', Book::FILE_EXT)
. '),没有保存。';
} elseif ($tmp === '' || !is_file($tmp)) {
$warns[] = '文件没有传上来(可能是太大了),其他信息已保存。';
} else {
$safeExt = preg_replace('/[^a-z0-9]/', '', $ext) ?? '';
$newKey = 'books/' . bin2hex(random_bytes(12)) . ($safeExt !== '' ? '.' . $safeExt : '');
$fino = new \finfo(FILEINFO_MIME_TYPE);
$mime = (string)$fino->file($tmp);
$res = \App\Storage\Storage::putFile(
$familyId, $tmp, $newKey,
$mime !== '' ? $mime : 'application/octet-stream'
);
if (!empty($res['ok'])) {
if ($fileKey !== '' && $fileKey !== $newKey) {
\App\Storage\Storage::delete($familyId, $fileKey);
}
$fileKey = $newKey;
$fileName = $origName;
$fileFormat = $ext;
$fileSize = (int)@filesize($tmp);
$p = BookParser::parse($tmp, $ext, $origName);
if (empty($p['ok'])) {
// 文件已经存好了 —— 解析失败不等于白传,如实说清楚
$warns[] = '文件存好了,但读不出内容:' . (string)($p['error'] ?? '未知原因');
} else {
$parsed = $p;
if (!empty($p['note'])) {
$warns[] = (string)$p['note'];
}
}
} else {
$warns[] = '文件没有保存:' . (string)($res['error'] ?? '未知错误');
}
}
}
// ---- ② 字段:手填优先,空着的才用解析出来的值补 ----
// ("解析出来的覆盖手填的"是最招人烦的一种自作聪明 —— 用户改了书名,
// 再传一次文件又变回去,他会以为保存坏了。)
$meta = is_array($parsed['meta'] ?? null) ? $parsed['meta'] : [];
$mv = static function (string $k) use ($meta): string {
return trim((string)($meta[$k] ?? ''));
};
$titleIn = trim((string)($_POST['title'] ?? ''));
if ($titleIn === '') {
$titleIn = $mv('title');
}
if ($titleIn === '') {
$errors['title'] = '书名不能为空(上传 EPUB / TXT 会自动读出来)';
}
$d = [
'title' => $titleIn,
'subtitle' => trim((string)($_POST['subtitle'] ?? '')) ?: $mv('subtitle'),
'author' => trim((string)($_POST['author'] ?? '')) ?: $mv('author'),
'publisher' => trim((string)($_POST['publisher'] ?? '')) ?: $mv('publisher'),
'published' => trim((string)($_POST['published'] ?? '')) ?: $mv('published'),
'isbn' => trim((string)($_POST['isbn'] ?? '')) ?: $mv('isbn'),
'language' => trim((string)($_POST['language'] ?? '')) ?: $mv('language'),
'summary' => trim((string)($_POST['summary'] ?? '')) ?: $mv('summary'),
'translator' => trim((string)($_POST['translator'] ?? '')),
'category' => trim((string)($_POST['category'] ?? '')),
'tags' => trim((string)($_POST['tags'] ?? '')),
'pages' => (int)($_POST['pages'] ?? 0),
'status' => (string)($_POST['status'] ?? 'wish'),
'rating' => (string)($_POST['rating'] ?? ''),
'owner_id' => (int)($_POST['owner_id'] ?? 0),
'file_key' => $fileKey,
'file_name' => $fileName,
'file_format' => $fileFormat,
'file_size' => $fileSize,
];
// ---- ③ 封面:用户选的优先,其次用 EPUB 自带的 ----
$oldCover = $isEdit ? (string)($b['cover_key'] ?? '') : '';
$d['cover_key'] = $oldCover;
$up = Upload::image($familyId, $_FILES['cover'] ?? [], 'bookcover',
(string)($_POST['cover_crop'] ?? ''));
if ($up['ok']) {
$d['cover_key'] = (string)$up['file'];
if ($oldCover !== '' && $oldCover !== $d['cover_key']) {
\App\Storage\Storage::delete($familyId, $oldCover);
}
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = '封面没有保存:' . $up['error'];
} elseif ($parsed !== null && !empty($parsed['cover']['tmp'])) {
// 用户没另外选图 → 用书里自带的封面(Upload::image 只认 $_FILES 结构,
// 这里按同样的形状喂一个进去,就能复用同一条"缩放 + 转 JPG"的链路)。
$ctmp = (string)$parsed['cover']['tmp'];
$fake = [
'name' => 'cover',
'type' => (string)($parsed['cover']['mime'] ?? 'image/jpeg'),
'tmp_name' => $ctmp,
'error' => UPLOAD_ERR_OK,
'size' => (int)@filesize($ctmp),
];
$up2 = Upload::image($familyId, $fake, 'bookcover', '');
if ($up2['ok']) {
$d['cover_key'] = (string)$up2['file'];
if ($oldCover !== '' && $oldCover !== $d['cover_key']) {
\App\Storage\Storage::delete($familyId, $oldCover);
}
}
}
// 解析产生的临时封面文件用完即删(它是 BookParser 在 temp 下生成的)
if ($parsed !== null && !empty($parsed['cover']['tmp'])) {
@unlink((string)$parsed['cover']['tmp']);
}
if ($errors) {
set_old($_POST);
View::render('books/form', [
'title' => $isEdit ? '编辑图书' : '添加图书',
'b' => $isEdit ? array_merge($b, $d) : null,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => $errors,
], 'books');
exit;
}
if ($isEdit) {
Book::update($familyId, $id, $d);
// 重新上传了文件 → 章节按新文件整组替换(旧章节留着就是"两本书混在一起")
if ($parsed !== null && is_array($parsed['chapters'] ?? null)) {
Book::replaceChapters($id, $parsed['chapters']);
}
clear_old();
flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('books/show', ['id' => $id]);
}
$newId = Book::create($familyId, (int)Auth::personId(), $d);
if ($parsed !== null && is_array($parsed['chapters'] ?? null)) {
Book::replaceChapters($newId, $parsed['chapters']);
}
clear_old();
flash('ok', '这本书进书架了。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('books/show', ['id' => $newId]);
break;
case 'books/show':
$familyId = (int)Auth::familyId();
$id = (int)($_GET['id'] ?? 0);
$b = Book::find($familyId, $id);
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
View::render('books/show', [
'title' => (string)$b['title'],
'b' => $b,
'chapters' => Book::chapters($id),
'notes' => Book::notes($id, (int)Auth::personId()),
], 'books');
break;
case 'books/read':
$familyId = (int)Auth::familyId();
$id = (int)($_GET['id'] ?? 0);
$b = Book::find($familyId, $id);
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
if (empty($b['file_key'])) {
flash('err', '这本书还没有上传文件,先到「编辑」里传一个。');
redirect('books/show', ['id' => $id]);
}
$chapters = Book::chapters($id);
$total = count($chapters);
$seq = (int)($_GET['seq'] ?? 0);
if ($seq <= 0) {
// 没指定 → 接着上次读到的地方(这就是"继续读"按钮能直接回原位的原因)
$seq = max(1, (int)($b['progress'] ?? 0));
}
$seq = max(1, min($seq, max(1, $total)));
// ⚠️ 翻章就顺手记进度:让人手动记"我读到第几章"的阅读器没人会用。
if (Auth::canWrite() && $total > 0 && (int)($b['progress'] ?? 0) !== $seq) {
Book::setProgress($familyId, $id, $seq);
$b['progress'] = $seq;
}
View::render('books/read', [
'title' => (string)$b['title'],
'b' => $b,
'chapters' => $chapters,
'chapter' => Book::chapter($id, $seq),
'seq' => $seq,
], 'books');
break;
case 'books/status':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$b = Book::find($familyId, $id);
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
$st = Book::normalizeStatus((string)($_POST['status'] ?? ''));
Book::setStatus($familyId, $id, $st);
flash('ok', '《' . (string)$b['title'] . '》标成「' . Book::statusWord($st) . '」了。');
// 列表上的一键 → 回列表;详情页 / 阅读页 → 回这本书
redirect((string)($_POST['back'] ?? '') === 'list' ? 'books' : 'books/show', ['id' => $id]);
break;
case 'books/destroy':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$b = Book::find($familyId, $id);
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
Book::destroy($familyId, $id);
flash('ok', '《' . (string)$b['title'] . '》已从书架移走。');
redirect('books');
break;
case 'books/note_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$b = Book::find($familyId, $id);
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
$nid = Book::addNote($familyId, $id, $me, [
'kind' => (string)($_POST['kind'] ?? 'note'),
'chapter' => (string)($_POST['chapter'] ?? ''),
'content' => (string)($_POST['content'] ?? ''),
'source' => 'manual',
]);
flash($nid > 0 ? 'ok' : 'err', $nid > 0 ? '记下了。' : '笔记是空的,没记。');
redirect('books/show', ['id' => $id], 'notes');
break;
case 'books/note_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$noteId = (int)($_POST['note_id'] ?? 0);
// 只能删自己的或无主的(导入进来的笔记没有归属,谁都能删)
$st = Database::get()->prepare(
'SELECT n.book_id FROM book_notes n JOIN books b ON b.id = n.book_id
WHERE n.id = ? AND b.family_id = ?'
);
$st->execute([$noteId, $familyId]);
$bid = (int)$st->fetchColumn();
Book::deleteNote($familyId, $noteId, $me);
flash('ok', '这条笔记删掉了。');
if ($bid > 0) {
redirect('books/show', ['id' => $bid], 'notes');
}
redirect('books');
break;
case 'books/stats':
$familyId = (int)Auth::familyId();
View::render('books/stats', [
'title' => '读书统计',
's' => Book::stats($familyId),
], 'books');
break;
case 'books/notes_import':
Auth::requireWrite();
// 同一条路由干两件事:GET 给粘贴框,POST 先解析出**预览**。
// ⚠️ 必须有预览这一步:解析是宽容的(宁可归类错也不丢字),
// 直接落库的话,一段没书名的文字会变成一本"未命名的一本"——
// 看不见结果就写进去,是最容易让人以后再也不敢用这个功能的做法。
$raw = '';
$parsed = [];
$source = 'weread';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
csrf_check();
$raw = (string)($_POST['raw'] ?? '');
$source = BookNoteImport::sourceOf((string)($_POST['source'] ?? ''));
$parsed = BookNoteImport::parse($raw);
}
View::render('books/notes_import', [
'title' => '导入读书笔记',
'raw' => $raw,
'parsed' => $parsed,
'source' => $source,
], 'books');
break;
case 'books/notes_import_save':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$raw = (string)($_POST['raw'] ?? '');
$source = BookNoteImport::sourceOf((string)($_POST['source'] ?? ''));
// ⚠️ **重新解析一遍**,不接前端传回来的"解析结果"——
// 表单可以改,信它就是让人往库里塞任意结构。
$parsed = BookNoteImport::parse($raw);
$res = BookNoteImport::import($familyId, $me, $parsed, $source);
if ((int)$res['added'] === 0 && (int)$res['created'] === 0) {
flash('err', '这段里没认出能导入的笔记。检查一下是不是把书名行也贴上了。');
redirect('books/notes_import');
}
flash('ok', '导入完成:新增 ' . (int)$res['created'] . ' 本书、'
. (int)$res['added'] . ' 条笔记。');
redirect('books');
break;
default: default:
http_response_code(404); http_response_code(404);
View::render('error', [ View::render('error', [
+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:带上**当前视图**(月/周/日各自跳到对应跨度); → ① 不再固定 view=day:带上**当前视图**(月/周/日各自跳到对应跨度);
② 日期选择从"居中弹窗"改成**贴着按钮下方展开的下拉日历**。 */ ?> ② 日期选择从"居中弹窗"改成**贴着按钮下方展开的下拉日历**。
🔴 2026-10-05 第 14 批 #1(用户报严重 bug):「点上一月,页面直接跳转,
我还没选择日期呢」。根因是这一版下拉里挂的是 Semi 的 DatePicker ——
它的日历面板**渲染在 body 的 portal 里**(不在下拉容器内),
而它在面板上翻月时会把"面板当前显示的日期"当成新值回调 onChange,
于是"翻月"被当成了"选中"。第三方控件把"翻页"与"选中"混在一个回调里,
这个语义我们改不了。
→ 改成**自绘内联日历**(几十行、零依赖):翻月/翻年只重绘面板、绝不提交,
只有点**某一天**才跳转,语义 100% 由我们自己定。 */ ?>
<form class="cal-jump" method="get" action="index.php"> <form class="cal-jump" method="get" action="index.php">
<input type="hidden" name="r" value="calendar"> <input type="hidden" name="r" value="calendar">
<?php /* 保留当前视图:月→该月 / 周→那一周 / 日→那一天。 <?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"/> <path d="M2 6.6h12M5.2 1.8v2.6M10.8 1.8v2.6"/>
</svg> </svg>
</button> </button>
<?php /* 下拉日历的宿主:紧贴按钮下方展开(不是弹窗)。JS 就绪后把 Semi <?php /* 下拉日历的宿主:紧贴按钮下方展开(不是弹窗)。内容由下面那段 JS 画;
日期选择器挂进来;JS 不可用时它是空的、退到下面的 noscript。 */ ?> JS 不可用时它是空的、退到下面的 noscript(原生日期框 + 跳转按钮)。 */ ?>
<div class="cal-jump-drop" id="calJumpDrop" hidden></div> <div class="cal-jump-drop" id="calJumpDrop" hidden></div>
<noscript> <noscript>
<input class="input" type="date" name="d" value="<?= e($anchor) ?>" aria-label="选择日期"> <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 里(两个页面共用一份) */ /* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
})(); })();
/* ---- v24 第 12 批 #3:「跳到某一天」—— 单个图标按钮 + **按钮下方的下拉日历** ---- /* ---- v24 第 14 批 #1:「跳到某一天」—— 图标按钮 + **按钮下方自绘的下拉日历** ----
用户原话:「选择这个日期,不用弹窗,直接把日历选择,弹出在这个按钮下面就可以了。」 原话(两条,都要满足):
所以从"居中弹窗(cal-jump-host 全屏遮罩)"改成"贴着按钮下方展开的下拉"。 · 「选择这个日期,不用弹窗,直接把日历选择,弹出在这个按钮下面就可以了」
⚠️ 中途会经过"保留视图"那一关:jump() 提交时带上当前 view(月的 ym / 周的 d / 日的 d), · 「我点击上一个月,页面直接跳转,我还没选择日期呢」(严重 bug)
🔴 为什么**不用** Semi 的 DatePicker(上一版就是它):
Semi 的日历面板渲染在 **body 的 portal** 里(不在我们的下拉容器内),
而且它把"翻月/翻年"与"选中某天"混在同一个 onChange 回调里 ——
于是点"上一月"也被当成"选中了这一天",页面立刻提交跳转。
第三方控件的这个语义我们改不了;而这个交互又是"翻月不跳、点天才跳",
必须 100% 由我们自己定。所以这里自绘:
· « ‹ 年/月 › » → **只重绘面板**,绝不提交;
· 点某一天 → 立刻提交跳转(保留当前视图);
· 底部「跳到这天」→ 明确的确认入口(键盘/读屏用户也能用)。
⚠️ jump() 提交时带上当前 view(月的 ym / 周、日的 d),
由服务端决定该视图下这个日期落在哪一屏 —— 见 index.php 的 calendar 路由。 */ 由服务端决定该视图下这个日期落在哪一屏 —— 见 index.php 的 calendar 路由。 */
(function () { (function () {
var form = document.querySelector('.cal-jump'); var form = document.querySelector('.cal-jump');
@@ -363,8 +383,14 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
var hid = document.getElementById('calJumpD'); var hid = document.getElementById('calJumpD');
var drop = document.getElementById('calJumpDrop'); var drop = document.getElementById('calJumpDrop');
if (!form || !btn || !hid) { return; } 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) { function jump(v) {
if (!v) { return; } if (!v) { return; }
hid.value = v; hid.value = v;
@@ -373,54 +399,115 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
if (ym) { ym.value = String(v).slice(0, 7); } if (ym) { ym.value = String(v).slice(0, 7); }
form.submit(); form.submit();
} }
function closeDrop() { function closeDrop() {
if (!drop) { return; } if (!drop) { return; }
drop.hidden = true; drop.hidden = true;
btn.setAttribute('aria-expanded', 'false'); btn.setAttribute('aria-expanded', 'false');
if (root) { root.unmount(); root = null; }
drop.innerHTML = ''; drop.innerHTML = '';
} }
// 原生兜底:Semi 没就绪时用临时原生日期框(与无 JS 时行为一致) /** 画一个月:**只动面板内容**,不做任何跳转 */
function fallback() { function paint() {
var t = document.createElement('input'); var head = document.createElement('div');
t.type = 'date'; head.className = 'cjd-head';
t.value = hid.value; head.innerHTML =
t.style.position = 'fixed'; '<button type="button" class="cjd-nav" data-step="-12" title="上一年" aria-label="上一年">«</button>' +
t.style.left = '-9999px'; '<button type="button" class="cjd-nav" data-step="-1" title="上一月" aria-label="上一月">‹</button>' +
document.body.appendChild(t); '<span class="cjd-title"></span>' +
t.addEventListener('change', function () { jump(t.value); }); '<button type="button" class="cjd-nav" data-step="1" title="下一月" aria-label="下一月">›</button>' +
if (typeof t.showPicker === 'function') { try { t.showPicker(); } catch (e2) {} } '<button type="button" class="cjd-nav" data-step="12" title="下一年" aria-label="下一年">»</button>';
else { t.focus(); t.click(); } 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) { btn.addEventListener('click', function (e) {
e.stopPropagation(); e.stopPropagation();
if (!drop) { fallback(); return; } if (!drop) { return; }
if (!drop.hidden) { closeDrop(); return; } if (!drop.hidden) { closeDrop(); return; }
var seed = String(hid.value || '').slice(0, 10);
if (!window.SemiUI || !window.SemiUI.DatePicker || !window.ReactDOM) { fallback(); return; } 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; drop.hidden = false;
btn.setAttribute('aria-expanded', 'true'); btn.setAttribute('aria-expanded', 'true');
var inner = document.createElement('div'); paint();
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); }
}
}));
}); });
// 点外面 / Esc 收起(下拉的常规行为) // 点外面 / 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']])) ?>"> <a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>">
<?= e((string)$t['title']) ?> <?= e((string)$t['title']) ?>
</a> </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): ?> <?php if ((int)($t['child_total'] ?? 0) > 0): ?>
<span class="dash-tag"><?= (int)$t['child_done'] ?>/<?= (int)$t['child_total'] ?></span> <span class="dash-tag"><?= (int)$t['child_done'] ?>/<?= (int)$t['child_total'] ?></span>
<?php endif; ?> <?php endif; ?>
+7 -12
View File
@@ -86,9 +86,10 @@ $ctHints = [
<div class="avatar-picker"> <div class="avatar-picker">
<img id="avatar-preview" src="<?= e($avatarUrl) ?>" alt="" <img id="avatar-preview" src="<?= e($avatarUrl) ?>" alt=""
title="点击更换头像" title="点击更换头像"
data-pick-input="#avatar"
data-has-avatar="<?= trim((string)($person['avatar'] ?? '')) !== '' ? '1' : '0' ?>"> data-has-avatar="<?= trim((string)($person['avatar'] ?? '')) !== '' ? '1' : '0' ?>">
<?php /* 真正吃文件的 input:视觉上藏起来,由上面的图片代它触发(label/JS 都行, <?php /* 真正吃文件的 input:视觉上藏起来,由上面的图片代它触发
这里用 JS 点它,兼容性最好,也不依赖 for/id 配对)。 */ ?> (app.js 的通用绑定认 data-pick-input,不依赖 for/id 配对)。 */ ?>
<input class="sr-only" id="avatar" name="avatar" type="file" accept="image/*" <input class="sr-only" id="avatar" name="avatar" type="file" accept="image/*"
data-crop-ratio="<?= e((string)$avSlot['ratio']) ?>" data-crop-ratio="<?= e((string)$avSlot['ratio']) ?>"
data-crop-w="<?= (int)$avSlot['w'] ?>" data-crop-w="<?= (int)$avSlot['w'] ?>"
@@ -451,16 +452,10 @@ $ctHints = [
<script> <script>
(function () { (function () {
/* ---- 头像:点图即选文件(用户 2026-10-03 第 6 条)---- /* ---- 头像:点图即选文件(用户 2026-10-03 第 6 条)----
真 input[type=file] 视觉上藏起来了(.sr-only),点图代它触发。 ⚠️ 2026-10-05 起这段**已搬到 app.js 的通用绑定**(模板上写
⚠️ 不要用 <label for> 包住图片来做这件事:一旦图被包进 label, `data-pick-input="#avatar"` 即可)。搬走的原因:只写在"头像"这一页,
.avatar-picker 的 hover/光标语义会变,而且有的浏览器在 label 里 别的页面照抄同一套 DOM 却没有 JS —— 题图/封面点了没反应。
点 <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';
}
/* ---- 工作经历:+/× 增删一段(用户第 4 条)---- /* ---- 工作经历:+/× 增删一段(用户第 4 条)----
⚠️ 模板里的行是**服务端渲染**的(首屏没有 JS 也能填、也能提交), ⚠️ 模板里的行是**服务端渲染**的(首屏没有 JS 也能填、也能提交),
+56
View File
@@ -90,6 +90,19 @@ $lastColIdx = count($colList) - 1;
require __DIR__ . '/../_person_picker.php'; require __DIR__ . '/../_person_picker.php';
?> ?>
</div> </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"> <div class="field">
<label for="tk-repeat">循环</label> <label for="tk-repeat">循环</label>
<select class="select" id="tk-repeat" name="repeat_rule"> <select class="select" id="tk-repeat" name="repeat_rule">
@@ -154,6 +167,16 @@ $lastColIdx = count($colList) - 1;
<input type="hidden" name="before" id="mvBefore"> <input type="hidden" name="before" id="mvBefore">
</form> </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) ?>"> <div class="kanban" data-cols="<?= count($colList) ?>">
<?php foreach ($colList as $ci => $col): $cid = (int)$col['id']; ?> <?php foreach ($colList as $ci => $col): $cid = (int)$col['id']; ?>
<section class="kb-col" data-board="<?= $cid ?>"> <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])) ?>"> <a class="kb-title" href="<?= e(url('tasks/show', ['id' => $tid])) ?>">
<?= e((string)$t['title']) ?> <?= e((string)$t['title']) ?>
</a> </a>
<?php /* 状态徽标:每一张卡都有(用户第 2 条「每个任务都应该有不同的状态」)。
点一下推进到下一个状态:待办 → 进行中 → 已完成 → 回到待办
(最后一档做成可回头,是"点错了能改回来",完成本身另有详情页的按钮)。
⚠️ 只读账号给的是**静态**徽标(span),点了没反应还容易误会。 */ ?>
<?php
$stNow = (string)($t['status'] ?? 'todo');
$stNext = ['todo' => 'doing', 'doing' => 'done', 'done' => 'todo'][$stNow] ?? 'doing';
?>
<div class="kb-meta"> <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'] ?? '') !== ''): ?> <?php if (($t['due_at'] ?? '') !== ''): ?>
<span class="kb-due<?= $overdue ? ' is-late' : '' ?>"> <span class="kb-due<?= $overdue ? ' is-late' : '' ?>">
<?= e(\App\Task::dueWord($t['due_at'] ?? null)) ?> <?= e(\App\Task::dueWord($t['due_at'] ?? null)) ?>
@@ -258,6 +298,22 @@ $lastColIdx = count($colList) - 1;
document.getElementById('mvBefore').value = before || ''; document.getElementById('mvBefore').value = before || '';
form.submit(); 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) { Array.prototype.forEach.call(document.querySelectorAll('[data-move]'), function (btn) {
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
var card = btn.closest('.kb-card'); var card = btn.closest('.kb-card');
+26 -1
View File
@@ -45,7 +45,32 @@ $isChild = !empty($t['parent_id']);
<div class="card card-pad"> <div class="card card-pad">
<dl class="kv"> <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'] ?? '') !== ''): ?> <?php if (($t['due_at'] ?? '') !== ''): ?>
<div class="kv-row"><dt>截止</dt> <div class="kv-row"><dt>截止</dt>
<dd class="<?= \App\Task::isOverdue($t['due_at'] ?? null) && $t['status'] !== 'done' ? 'kb-late' : '' ?>"> <dd class="<?= \App\Task::isOverdue($t['due_at'] ?? null) && $t['status'] !== 'done' ? 'kb-late' : '' ?>">
+16 -14
View File
@@ -1,17 +1,19 @@
<?php <?php
/** /**
* 行程节点表单(新增 / 编辑共用;被 show.php 循环 require)。 * 一段旅程的表单(新增 / 编辑共用;被 show.php 循环 require)。
* *
* v24 第 13 批:旅程重构后,**行程不再分两条轨**,而是**一条记录 + 状态机**。 * ⚠️ 术语(用户第 15 批 第 1 条):这是**一段旅程**;「计划 / 实际」是它的阶段。
*
* v24 第 13 批:旅程重构后,**不再分两条轨**,而是**一条记录 + 状态机**。
* 用户原话:「这个实际的行程和计划行程,是一条记录,不是两条」。 * 用户原话:「这个实际的行程和计划行程,是一条记录,不是两条」。
* 所以这里加一个「这是哪一类」的下拉:计划行程 / 实际行程 * 所以这里有「这是哪一类」的下拉:计划 / 实际
* (新建时由用户选;编辑时是当前状态)。 * (新建时由用户选;编辑时是当前状态)。
* 「修正」「标记为实际」「完成」「标记未成行」是**另外三个按钮**(在 show.php 上), * 「修正」「标记为实际」「完成」「标记未成行」是**另外几个按钮**(在 show.php 上),
* 不走这张表单 —— 它们的语义是"对既有行程的操作",不是"填一遍字段"。 * 不走这张表单 —— 它们的语义是"对既有旅程的操作",不是"填一遍字段"。
* *
* 变量(require 前设好): * 变量(require 前设好):
* $nfNode 被编辑的节点数组,或 null(新增) * $nfNode 被编辑的节点数组,或 null(新增)
* $nfTripId 旅程 id(events.id) * $nfTripId 活动 id(events.id)
* $nfCategory 【兼容保留】'plan' | 'reality'(新版只用来定默认值) * $nfCategory 【兼容保留】'plan' | 'reality'(新版只用来定默认值)
* $nfPeople 可选人名册(选参与人用),为空就不显示成员多选 * $nfPeople 可选人名册(选参与人用),为空就不显示成员多选
* $nfMembers 已选成员 id 列表 * $nfMembers 已选成员 id 列表
@@ -35,7 +37,7 @@ $nfStage = $nfIsEdit
<div class="nf-grid"> <div class="nf-grid">
<div class="field"> <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 <input class="input" id="<?= e($nfPrefix) ?>-title" name="title" required
value="<?= e($nfNode['title'] ?? '') ?>" placeholder="如:栈桥看海"> value="<?= e($nfNode['title'] ?? '') ?>" placeholder="如:栈桥看海">
</div> </div>
@@ -51,12 +53,12 @@ $nfStage = $nfIsEdit
</div> </div>
<?php if (!$nfIsEdit): ?> <?php if (!$nfIsEdit): ?>
<div class="field"> <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"> <select class="select" id="<?= e($nfPrefix) ?>-stage" name="stage_pick">
<option value="plan"<?= $nfStage === 'plan' ? ' selected' : '' ?>>计划行程(还没走)</option> <option value="plan"<?= $nfStage === 'plan' ? ' selected' : '' ?>>计划(还没走)</option>
<option value="actual"<?= $nfStage === 'actual' ? ' selected' : '' ?>>实际行程(已经走过)</option> <option value="actual"<?= $nfStage === 'actual' ? ' selected' : '' ?>>实际(已经走过)</option>
</select> </select>
<span class="tip">计划行程走完后可用「修正 / 完成」转成实际行程。</span> <span class="tip">计划走完后可用「修正 / 完成」把它转成实际。</span>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="field"> <div class="field">
@@ -93,11 +95,11 @@ $nfStage = $nfIsEdit
<div class="field full"> <div class="field full">
<label for="<?= e($nfPrefix) ?>-detail">记录</label> <label for="<?= e($nfPrefix) ?>-detail">记录</label>
<textarea class="input" id="<?= e($nfPrefix) ?>-detail" name="detail" rows="2" <textarea class="input" id="<?= e($nfPrefix) ?>-detail" name="detail" rows="2"
placeholder="这一段发生了什么"><?= e($nfNode['detail'] ?? '') ?></textarea> placeholder="这段旅程发生了什么"><?= e($nfNode['detail'] ?? '') ?></textarea>
</div> </div>
<?php if (!empty($nfPeople)): ?> <?php if (!empty($nfPeople)): ?>
<div class="field full"> <div class="field full">
<label>这一段里有谁(不勾 = 全趟同行的人)</label> <label>这段旅程里有谁(不勾 = 整个活动同行的人)</label>
<div class="nf-members"> <div class="nf-members">
<?php foreach ($nfPeople as $p): ?> <?php foreach ($nfPeople as $p): ?>
<label class="check-item"> <label class="check-item">
@@ -111,6 +113,6 @@ $nfStage = $nfIsEdit
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<button class="btn btn-sm" type="submit"><?= $nfIsEdit ? '保存' : '加一段' ?></button> <button class="btn btn-sm" type="submit"><?= $nfIsEdit ? '保存' : '加这段旅程' ?></button>
</div> </div>
</form> </form>
+3 -3
View File
@@ -1,6 +1,6 @@
<?php <?php
/** /**
* 「修正」表单(v24 第 13 批):记录一条计划行程的**实际**执行情况。 * 「修正」表单(v24 第 13 批):记录一段计划旅程的**实际**执行情况。
* *
* 用户原话:「另外一个按钮,用来记录实际的情况,按钮名字就叫修正,但是记住, * 用户原话:「另外一个按钮,用来记录实际的情况,按钮名字就叫修正,但是记住,
* 这个实际的行程和计划行程,是一条记录,不是两条,点击这个按钮, * 这个实际的行程和计划行程,是一条记录,不是两条,点击这个按钮,
@@ -14,8 +14,8 @@
* 两者都作用在同一条 trip_nodes 记录上(用户明确"是一条记录,不是两条")。 * 两者都作用在同一条 trip_nodes 记录上(用户明确"是一条记录,不是两条")。
* *
* 变量(require 前设好): * 变量(require 前设好):
* $rvNode 被修正的行程 * $rvNode 被修正的这段旅程
* $rvTripId 旅程 id * $rvTripId 活动 id
* $rvPeople 人名册(选实际参与人) * $rvPeople 人名册(选实际参与人)
* $rvMembers 当前参与人 id * $rvMembers 当前参与人 id
*/ */
+142 -96
View File
@@ -1,13 +1,26 @@
<?php <?php
/** /**
* 记一趟旅程 / 编辑旅程(v12)。 * 记一次活动 / 编辑活动(v24 第 15 批重排)。
* *
* 字段分两组: * ⚠️ 用户原话(2026-10-05 第 15 批):
* · 骨架(落在 events 上):标题 / 出发 / 回来 / 目的地 / 备注 —— 与观影同一套原语; * 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
* · 旅程特有(落在 trip_details 上):封面图 + 一段话小结。 * 「活动不需要开始和结束时间,是根据活动中的旅程…计算出来的。目的地也不需要…
* 去的城市,也是根据旅程,得到的。」
* 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」
* *
* ⚠️ 同行的人用多选框(人不多,一次性列全比"搜索+联想"快); * 所以表单**主区就三样**(顺序就是用户说的顺序):
* 显示名一律是「我叫他什么」(person_name),提交的是 person_id。 * ① 标题 ② 题图(上传 + 16:9 裁剪)
* ③ 简介
* 时间 / 城市**不在表单里** —— 它们是"旅程算出来的",不是"这里填进去的"。
* 页面上只做一句说明,让人知道这两个东西从哪来(不然会去找"怎么没地方填")。
*
* 「备注 / 公开 / 同行的人」功能没丢,收进「更多设置」折叠区:
* 用户说的是"就可以了"(主流程够短),不是"把这些删掉"。
* ⚠️ 但它们在同一个 <form> 里、只是视觉上折叠 —— 少了哪个字段都会静默丢数据。
*
* ⚠️ 同行的人:活动上登记的这份名单**仍然有用**(参与人 = 活动登记 ∪ 各段旅程成员)。
* 但既然活动现在"没有时间也没有地点",多数人会直接在每段旅程里填成员;
* 所以它退到折叠区,默认不占地方。
*/ */
$isEdit = !empty($t); $isEdit = !empty($t);
$canWrite = \App\Auth::canWrite(); $canWrite = \App\Auth::canWrite();
@@ -19,12 +32,20 @@ if ($isEdit) {
} else { } else {
$selIds = array_map('intval', (array)($presetAttendees ?? [])); $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"> <div class="page-head">
<h1><?= $isEdit ? '编辑旅程' : '记一趟旅程' ?></h1> <h1><?= $isEdit ? '编辑活动' : '记一次活动' ?></h1>
<span class="sub">活动是容器;里面一段一段的旅程才是日程</span>
<div class="actions"> <div class="actions">
<a class="btn btn-ghost" <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>
</div> </div>
@@ -36,101 +57,126 @@ if ($isEdit) {
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<?php endif; ?> <?php endif; ?>
<div class="form-grid"> <?php /* ==================== ① 标题 ==================== */ ?>
<div class="field full"> <div class="field full">
<label for="t-title">这趟旅程叫什么 *</label> <label for="t-title">这次活动叫什么 *</label>
<input class="input" id="t-title" name="title" required <input class="input" id="t-title" name="title" required autofocus
value="<?= e(old('title', $t['title'] ?? '')) ?>" placeholder="如:青岛三日 · 暑假"> value="<?= e(old('title', $t['title'] ?? '')) ?>" placeholder="如:青岛之行 · 2026 暑假">
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?> <?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div> </div>
<?php /* 用户 2026-10-04 第 7 条:出发/回来是一个**时间范围**, <?php /* ==================== ② 题图(上传 + 裁剪)====================
用一个范围选择器一次选完(不再是并列两个控件)。 */ ?> ⚠️ 用声明式绑定:`data-pick-input="#t-cover"` 让 app.js 的通用逻辑接管
<div class="field dt-range"> "点图 = 打开文件选择框"。上一批这里**没有**这段绑定(JS 只写在
<label for="t-start">出发 / 回来</label> people/form.php 里),于是点图毫无反应 —— 用户当轮就报了这个问题。 */ ?>
<?php $dtName = 'starts_at'; $dtValue = $t['starts_at'] ?? ''; <div class="field full">
$dtId = 't-start'; $dtSeconds = true; $dtRange = 'start'; <label>题图</label>
require __DIR__ . '/../_datetime.php'; ?> <div class="cover-picker">
<?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?> <img id="cover-preview"
<?php $dtName = 'ends_at'; $dtValue = $t['ends_at'] ?? ''; $dtId = 't-end'; $dtSeconds = true; src="<?= e($hasCover ? media_url((string)$t['cover_key']) : '') ?>"
$dtRange = 'end'; alt="" title="点击选择题图"
require __DIR__ . '/../_datetime.php'; ?> data-pick-input="#t-cover"
<?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?> data-has-cover="<?= $hasCover ? '1' : '0' ?>">
<span class="tip">回来的时间可不填。</span> <input class="sr-only" id="t-cover" name="cover" type="file" accept="image/*"
</div> data-crop-ratio="<?= e((string)$cvSlot['ratio']) ?>"
data-crop-w="<?= (int)$cvSlot['w'] ?>"
<div class="field full"> data-crop-h="<?= (int)$cvSlot['h'] ?>"
<label for="t-loc">目的地</label> data-crop-label="<?= e((string)$cvSlot['label']) ?>"
<input class="input" id="t-loc" name="location" data-crop-hint="<?= e((string)$cvSlot['hint']) ?>"
value="<?= e(old('location', $t['location'] ?? '')) ?>" placeholder="如:青岛"> data-crop-preview="#cover-preview">
</div> <?php /* 用户挑的裁剪框(归一化 0..1);没裁就留空 = 服务端按 16:9 居中裁 */ ?>
<input type="hidden" name="cover_crop" value="">
<div class="field full"> <span class="avatar-hint small muted">
<label for="t-sum">一句话小结</label> 点左边那块选题图,选完弹裁剪框。<br>
<textarea class="input" id="t-sum" name="summary" rows="3" 题图是 16:9 的宽幅横图,多大都能传,保存时自动缩成
placeholder="回看时最能勾起记忆的那一句"><?= e(old('summary', $t['summary'] ?? '')) ?></textarea> <?= (int)$cvSlot['w'] ?>×<?= (int)$cvSlot['h'] ?>。
</div> <?php if ($hasCover): ?>不换图就不用管这里。<?php endif; ?>
</span>
<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/*">
</div> </div>
</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"> <div class="btn-row">
<button class="btn" type="submit"><?= $isEdit ? '保存' : '记下来' ?></button> <button class="btn" type="submit"><?= $isEdit ? '保存' : '记下来' ?></button>
<a class="btn btn-ghost" <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>
</form> </form>
</div> </div>
+23 -13
View File
@@ -1,18 +1,26 @@
<?php <?php
/** /**
* 旅程列表(v12,TripLog activities/index 迁来,但按家书的统一事件原语重写)。 * 活动列表(v12 建立,v24 第 15 批把术语理清)。
* *
* 与观影列表同一个骨架:页头 + 搜索 + 卡片列表。 * ⚠️ 术语(用户 2026-10-05 第 15 批 第 1 条原话):
* ⚠️ 卡片上的人是「我叫他什么」(person_chip),不是姓名(用户第 3 条)。 * 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
* 所以:这一页是**活动**的列表,一条活动里头才是若干段**旅程**。
* 页面上的"趟"一律改成"次"(一次活动);"段行程"改成"段旅程"。
*
* 时间与城市都是从各段旅程**派生**的(用户第 2 条)——
* 列表里读的是 Trip::attachDerived() 挂上来的值,不是活动自己的字段。
*/ */
$canWrite = \App\Auth::canWrite(); $canWrite = \App\Auth::canWrite();
?> ?>
<div class="page-head"> <div class="page-head">
<h1>旅程</h1> <h1>活动</h1>
<span class="sub">共 <?= (int)$total ?> 趟</span> <?php /* 导航条上这个模块仍叫「旅程」(用户就是这么称呼它的),
但这一页列的顶层单位是**活动** —— 一句副标题把两级关系说清楚,
否则"导航写旅程、页面写活动"会让人以为点错了地方。 */ ?>
<span class="sub">共 <?= (int)$total ?> 次 · 每次活动里可以有若干段旅程</span>
<div class="actions"> <div class="actions">
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<a class="btn" href="<?= e(url('trips/new')) ?>">记一趟旅程</a> <a class="btn" href="<?= e(url('trips/new')) ?>">记一次活动</a>
<?php else: ?> <?php else: ?>
<span class="link-quiet">只读账号</span> <span class="link-quiet">只读账号</span>
<?php endif; ?> <?php endif; ?>
@@ -21,7 +29,7 @@ $canWrite = \App\Auth::canWrite();
<form class="search" method="get" action="index.php"> <form class="search" method="get" action="index.php">
<input type="hidden" name="r" value="trips"> <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> <button class="btn" type="submit">搜索</button>
<?php if ($keyword !== ''): ?> <?php if ($keyword !== ''): ?>
<a class="search-clear" href="<?= e(url('trips')) ?>">清除</a> <a class="search-clear" href="<?= e(url('trips')) ?>">清除</a>
@@ -30,9 +38,10 @@ $canWrite = \App\Auth::canWrite();
<?php if (!$rows): ?> <?php if (!$rows): ?>
<div class="card card-pad empty"> <div class="card card-pad empty">
<b><?= $keyword !== '' ? '没有匹配的旅程' : '还没有旅程记录' ?></b> <b><?= $keyword !== '' ? '没有匹配的活动' : '还没有活动记录' ?></b>
<?= $keyword !== '' ? '换个关键词试试,或清除搜索看全部。' <?= $keyword !== '' ? '换个关键词试试,或清除搜索看全部。'
: ($canWrite ? '点右上角「记一趟旅程」,把一次出行记下来。' : '家人还没有记录旅程。') ?> : ($canWrite ? '点右上角「记一次活动」,把一次出行记下来;进去之后再加一段段旅程。'
: '家人还没有记录活动。') ?>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="trip-list"> <div class="trip-list">
@@ -48,11 +57,12 @@ $canWrite = \App\Auth::canWrite();
<div class="trip-main"> <div class="trip-main">
<h2 class="trip-title"><?= e((string)$t['title']) ?></h2> <h2 class="trip-title"><?= e((string)$t['title']) ?></h2>
<div class="trip-meta"> <div class="trip-meta">
<span><?= e(\App\Trip::formatSpan($t['starts_at'] ?? null, $t['ends_at'] ?? null)) ?></span> <?php /* 时间是从各段旅程算的;一段都没有时如实说"还没有旅程" */ ?>
<?php if (!empty($t['location'])): ?> <span><?= e(\App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null)) ?></span>
<span>📍 <?= e((string)$t['location']) ?></span> <?php if (!empty($t['city'])): ?>
<span>📍 <?= e((string)$t['city']) ?></span>
<?php endif; ?> <?php endif; ?>
<span><?= (int)($t['node_count'] ?? 0) ?> 段行程</span> <span><?= (int)($t['node_count'] ?? 0) ?> 段旅程</span>
</div> </div>
<?php if (!empty($t['summary'])): ?> <?php if (!empty($t['summary'])): ?>
<p class="trip-sum"><?= e(mb_substr((string)$t['summary'], 0, 80, 'UTF-8')) ?></p> <p class="trip-sum"><?= e(mb_substr((string)$t['summary'], 0, 80, 'UTF-8')) ?></p>
+52 -28
View File
@@ -1,9 +1,15 @@
<?php <?php
/** /**
* 旅程详情(v12):封面 + 小结 + 同行的人 + 照片 + 行程节点(计划 / 执行双轨)。 * 活动详情(v12 建立,v24 第 15 批整理术语与派生值)。
* *
* ⚠️ 双轨的意义:出发前排的是「打算怎么走」,走完记的是「实际怎么走的」。 * 页面结构:封面 + 简介 + 同行的人 + 照片 + 下面的若干段**旅程**。
* 两轨并存才能回看"本来想去哪、后来去了哪"—— 这是 TripLog 最有价值的一点,搬过来时保留了。 *
* ⚠️ 术语(用户 2026-10-05 第 15 批 第 1 条):这里是**一次活动**,
* 里头一段一段的才是**旅程**。所以「+ 加一段计划行程」这类按钮
* 统一改成「+ 加一段旅程」(阶段用「计划 / 实际」修饰)。
*
* ⚠️ 时间与城市都来自 $t 上挂的**派生值**(Trip::derived(),用户第 2 条)——
* 页面不再读 events.starts_at / location。
*/ */
$canWrite = \App\Auth::canWrite(); $canWrite = \App\Auth::canWrite();
// v24 第 13 批:不再按 category 双轨分,按**状态机 stage** 分三组展示。 // v24 第 13 批:不再按 category 双轨分,按**状态机 stage** 分三组展示。
@@ -26,18 +32,19 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
?> ?>
<div class="page-head"> <div class="page-head">
<h1><?= e((string)$t['title']) ?></h1> <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"> <div class="actions">
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<a class="btn btn-ghost" href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">编辑</a> <a class="btn btn-ghost" href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">编辑</a>
<form method="post" action="<?= e(url('trips/destroy')) ?>" <form method="post" action="<?= e(url('trips/destroy')) ?>"
onsubmit="return confirm('确定删除「<?= e((string)$t['title']) ?>」吗?照片与行程节点会一起删掉,不可恢复。')"> onsubmit="return confirm('确定删除「<?= e((string)$t['title']) ?>」吗?里面的旅程、照片会一起删掉,不可恢复。')">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-danger" type="submit">删除</button> <button class="btn btn-danger" type="submit">删除</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回旅程</a> <a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回活动</a>
</div> </div>
</div> </div>
@@ -50,17 +57,29 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="trip-hero-body"> <div class="trip-hero-body">
<?php if (!empty($t['location'])): ?> <?php /* 地名可能是好几个城市 → 用派生出来的列表逐颗画(用户第 2 条) */ ?>
<p class="trip-loc">📍 <?= e((string)$t['location']) ?></p> <?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 endif; ?>
<?php if (!empty($t['summary'])): ?> <?php if (!empty($t['summary'])): ?>
<p class="info-text"><?= nl2br(e((string)$t['summary'])) ?></p> <p class="info-text"><?= nl2br(e((string)$t['summary'])) ?></p>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($t['attendees'])): ?> <?php if (!empty($t['attendees'])): ?>
<div class="viewers-list"> <?php /* v24 第 14 批 用户第 4 条:「一个活动的参与人,应该是这个活动下的
<?php foreach ($t['attendees'] as $a): ?> 所有旅程的参与人的合集」—— 这上面的名单是**每段行程参与人的并集**,
<?= person_chip($a, url('people/show', ['id' => (int)$a['id']])) ?> 在行程里加了人,这里自动出现,不用再到活动上登记一遍。 */ ?>
<?php endforeach; ?> <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> </div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($t['note'])): ?> <?php if (!empty($t['note'])): ?>
@@ -110,19 +129,22 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php /* ==================== 行程(v24 第 13 批重构)==================== <?php /* ==================== 这个活动里的旅程(v24 第 13 批重构)====================
用户 2026-10-05 原话要点: ⚠️ 术语(用户第 15 批 第 1 条):这一块里每一条都是**一段旅程**,
· 刚开始添加时选「计划行程 / 实际行程」 不是"行程";分三组是按它的**阶段**(计划 / 实际 / 未成行)。
· 计划行程有三个按钮:删除(**标记未成行,不真删**)、修正(记实际执行,可无限次)、
用户 2026-10-05(第 13 批)原话要点:
· 刚开始添加时选「计划 / 实际」
· 计划的三段有三个按钮:删除(**标记未成行,不真删**)、修正(记实际执行,可无限次)、
完成(实际=计划,直接变实际) 完成(实际=计划,直接变实际)
· 未成行的仍显示、但带标记;不上日程、不进往年今日 · 未成行的仍显示、但带标记;不上日程、不进往年今日
· 实际行程上日程 + 往年今日 · 实际的会同时上日程 + 往年今日
分三组渲染:计划 / 实际 / 未成行(未成行放最后,用灰调)。 */ ?> 分三组渲染(未成行放最后,用灰调)。 */ ?>
<?php <?php
$stageMeta = [ $stageMeta = [
'plan' => ['计划行程', '还没走,下面的「修正 / 完成 / 标记未成行」都能用'], 'plan' => ['计划的旅程', '还没走。每段下面的「修正 / 完成 / 标记未成行」都能用'],
'actual' => ['实际行程', '已走过 —— 会出现在日历与首页「往年今日」'], 'actual' => ['走过的旅程', '已走过 —— 会出现在日历与首页「往年今日」'],
'skipped' => ['未成行', '这次计划没成行;不再出现在日程与「往年今日」,但在这里保留可查'], 'skipped' => ['没成行的', '这次没去成;不再出现在日程与「往年今日」,但在这里保留可查'],
]; ];
foreach (['plan', 'actual', 'skipped'] as $sg): foreach (['plan', 'actual', 'skipped'] as $sg):
$list = $bysStage[$sg]; $list = $bysStage[$sg];
@@ -136,7 +158,8 @@ foreach (['plan', 'actual', 'skipped'] as $sg):
<?php if (!$list): ?> <?php if (!$list): ?>
<p class="empty-hint"> <p class="empty-hint">
<?= $sg === 'plan' ? '还没有排计划。' : ($sg === 'actual' ? '还没有走过的行程。' : '没有未成行的计划。') ?> <?= $sg === 'plan' ? '还没有排计划。点下面的「+ 加一段旅程」,把打算走的地方排进来。'
: ($sg === 'actual' ? '还没有走过的旅程。' : '没有未成行的。') ?>
</p> </p>
<?php else: ?> <?php else: ?>
<ol class="node-list"> <ol class="node-list">
@@ -212,9 +235,9 @@ foreach (['plan', 'actual', 'skipped'] as $sg):
<summary class="btn btn-ghost btn-sm">修正</summary> <summary class="btn btn-ghost btn-sm">修正</summary>
<div class="rev-panel"> <div class="rev-panel">
<p class="rev-tip"> <p class="rev-tip">
记录**实际**执行情况。这仍与上面那条计划是<b>同一条记录</b>, 记录实际执行情况。这仍与上面那条计划是<b>同一条记录</b>,
原来的计划数据会保留在「修正记录」里,方便日后对比。 原来的计划数据会保留在「修正记录」里,方便日后对比。
点完这里<b>行程仍是计划</b>;确认无误后再点「标记为实际」。 点完这里<b>这段旅程仍是计划</b>;确认无误后再点「标记为实际」。
</p> </p>
<?php <?php
$rvNode = $n; $rvTripId = (int)$t['id']; $rvNode = $n; $rvTripId = (int)$t['id'];
@@ -225,14 +248,14 @@ foreach (['plan', 'actual', 'skipped'] as $sg):
</div> </div>
</details> </details>
<form method="post" action="<?= e(url('trips/node_done')) ?>" <form method="post" action="<?= e(url('trips/node_done')) ?>"
onsubmit="return confirm('实际执行与计划完全相同,直接转为实际行程?')"> onsubmit="return confirm('实际执行与计划完全相同,直接转为实际?')">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>"> <input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-sm" type="submit">完成</button> <button class="btn btn-sm" type="submit">完成</button>
</form> </form>
<form method="post" action="<?= e(url('trips/node_skip')) ?>" <form method="post" action="<?= e(url('trips/node_skip')) ?>"
onsubmit="return confirm('标记为「未成行」?它不会再出现在日程与往年今日,但在这趟旅程里仍然看得到。')"> onsubmit="return confirm('标记为「未成行」?它不会再出现在日程与往年今日,但在这个活动里仍然看得到。')">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>"> <input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
@@ -265,7 +288,7 @@ foreach (['plan', 'actual', 'skipped'] as $sg):
?> ?>
</details> </details>
<form method="post" action="<?= e(url('trips/node_del')) ?>" <form method="post" action="<?= e(url('trips/node_del')) ?>"
onsubmit="return confirm('**彻底删除**这一段?连同它的修正记录一起消失,不可恢复。\n\n如果你只是想表达"这次没去成",请用「标记未成行」。')"> onsubmit="return confirm('彻底删除这一段旅程?连同它的修正记录一起消失,不可恢复。\n\n如果你只是想表达「这次没去成」,请用「标记未成行」。')">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['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'): ?> <?php if ($canWrite && $sg !== 'skipped'): ?>
<details class="node-add"> <details class="node-add">
<summary class="link-quiet">+ 加一段<?= e($stageMeta[$sg][0]) ?></summary> <?php /* 术语:加的是"一段旅程",阶段只是它的属性 */ ?>
<summary class="link-quiet">+ 加一段旅程(<?= $sg === 'actual' ? '实际' : '计划' ?>)</summary>
<?php <?php
$nfNode = null; $nfTripId = (int)$t['id']; $nfCategory = $sg; $nfNode = null; $nfTripId = (int)$t['id']; $nfCategory = $sg;
$nfPeople = $people ?? []; $nfMembers = $memberIds; $nfPeople = $people ?? []; $nfMembers = $memberIds;