diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..04832f0 --- /dev/null +++ b/.htaccess @@ -0,0 +1,32 @@ +# 家书 FamilyBook · Apache 重写规则 +# +# ⚠️ 这个文件**不随包上线**(familybook_push.sh 的打包白名单里没有它)。 +# 原因:线上站点根那份 .htaccess 是**宝塔面板生成的**(PHP 版本段、防盗链、 +# 缓存段、伪静态…),整份覆盖会把面板的东西清空,而且面板下次重写还会改回来, +# 变成"时好时坏"。 +# /dav/ 的重写规则由 `familybook_deploy.sh` 第 5c 段**追加**到线上那份里 +# (带 `# >>> FamilyBook CalDAV` 标记,幂等)。 +# +# 那这份文件是干什么用的? +# · 新建站点(没有宝塔那层)时,可直接把它作为站点根的 .htaccess; +# · 它是那段规则的**唯一书面来源**,push 脚本会断言部署脚本里的规则串 +# 与它完全一致(防两处漂移)。 +# +# 只做一件事:把 /dav/ 开头的 URL 交给 dav.php。 +# 其余一律不动 —— index.php 那套 ?r=xxx 路由不依赖重写, +# 少写规则就少一处"上线才发现 404"的地方。 + + + RewriteEngine On + + # CalDAV:/dav/、/dav/3.ics/、/dav/3.ics/12.ics 全部交给 dav.php + RewriteRule ^dav(/.*)?$ dav.php [L,QSA] + + +# 备份/临时文件永远不给出去(这条线上很要紧:*.php.bak 曾经可被直接下载) + + Require all denied + + + Require all denied + diff --git a/app/Anniversary.php b/app/Anniversary.php new file mode 100644 index 0000000..4c70850 --- /dev/null +++ b/app/Anniversary.php @@ -0,0 +1,275 @@ + '每年(阳历)', + 'lunar' => '每年(农历)', + 'once' => '只此一次', + ]; + + /** 庆典类纪念日大致的分类,给个默认值方便挑选(用户可自由填) */ + public const PRESET_KINDS = ['生日', '结婚纪念日', '相识纪念日', '忌日', '节日', '其它']; + + public const ATTACH = 'anniversary'; + + // ---------------- ① 用户自建的纪念日 ---------------- + + public static function list(int $familyId): array + { + $st = Database::get()->prepare( + 'SELECT * FROM anniversaries WHERE family_id = ? ORDER BY month, day, id' + ); + $st->execute([$familyId]); + return $st->fetchAll(); + } + + public static function find(int $familyId, int $id): ?array + { + $st = Database::get()->prepare('SELECT * FROM anniversaries WHERE id = ? AND family_id = ?'); + $st->execute([$id, $familyId]); + $r = $st->fetch(); + return $r ?: null; + } + + /** + * 新建 / 修改一条纪念日。 + * + * @param array $d title / repeat_type / month / day / kind / note / person_id / leap + * @return int 记录 id + */ + public static function save(int $familyId, array $d, int $id = 0): int + { + $db = Database::get(); + $title = trim((string)($d['title'] ?? '')); + if ($title === '') { + $title = '纪念日'; + } + $repeat = (string)($d['repeat_type'] ?? 'solar'); + if (!isset(self::REPEAT[$repeat])) { + $repeat = 'solar'; + } + $month = max(1, min(12, (int)($d['month'] ?? 1))); + $day = max(1, min(31, (int)($d['day'] ?? 1))); + $kind = mb_substr(trim((string)($d['kind'] ?? '')), 0, 40, 'UTF-8'); + $note = trim((string)($d['note'] ?? '')); + $pid = (int)($d['person_id'] ?? 0) ?: null; + $leap = !empty($d['leap']) ? 1 : 0; + + if ($id > 0) { + $db->prepare( + 'UPDATE anniversaries SET title=?, repeat_type=?, month=?, day=?, is_leap=?, + kind=?, note=?, person_id=? WHERE id=? AND family_id=?' + )->execute([$title, $repeat, $month, $day, $leap, $kind !== '' ? $kind : null, + $note !== '' ? $note : null, $pid, $id, $familyId]); + return $id; + } + $db->prepare( + 'INSERT INTO anniversaries (family_id, title, repeat_type, month, day, is_leap, + kind, note, person_id) + VALUES (?,?,?,?,?,?,?,?,?)' + )->execute([$familyId, $title, $repeat, $month, $day, $leap, + $kind !== '' ? $kind : null, $note !== '' ? $note : null, $pid]); + return (int)$db->lastInsertId(); + } + + public static function remove(int $familyId, int $id): void + { + Attachment::removeAll(self::ATTACH, $id); + Database::get()->prepare('DELETE FROM anniversaries WHERE id = ? AND family_id = ?') + ->execute([$id, $familyId]); + } + + // ---------------- ② 派生:名册里每个人的生日 ---------------- + + /** + * 把名册里所有人的生日,当成"纪念日"派生出来。 + * + * 用户原话:「生日本身就是纪念日的一种」——所以这里不去 persons 里写任何东西, + * 只读出生日、包成与自建纪念日**同一个形状**的行,上层(列表页 / 日历)一视同仁。 + * + * @return array 每项含 title/kind/month/day/repeat_type/person_id/source/… + */ + public static function birthdays(int $familyId, int $viewerId): array + { + $people = Person::map($familyId); + $out = []; + foreach ($people as $pid => $p) { + $pid = (int)$pid; + // 阳历生日(列名叫 birthday,读取一律走 Birthday::solarOf) + $solar = Birthday::dateOnly(Birthday::solarOf($p)); + if ($solar !== '') { + $out[] = [ + 'id' => 0, + 'title' => (string)($p['name'] ?? '') . ' 的生日', + 'kind' => '生日', + 'repeat_type' => 'solar', + 'month' => (int)substr($solar, 5, 2), + 'day' => (int)substr($solar, 8, 2), + 'is_leap' => 0, + 'person_id' => $pid, + 'person_name' => (string)($p['name'] ?? ''), + 'source' => 'birthday_solar', + 'born' => $solar, + ]; + } + // 农历生日(**日期部分是农历**,所以月/日是农历的月日) + $lunar = Birthday::dateOnly((string)($p['birthday_lunar'] ?? '')); + if ($lunar !== '') { + $out[] = [ + 'id' => 0, + 'title' => (string)($p['name'] ?? '') . ' 的农历生日', + 'kind' => '生日', + 'repeat_type' => 'lunar', + 'month' => (int)substr($lunar, 5, 2), + 'day' => (int)substr($lunar, 8, 2), + 'is_leap' => (int)($p['birthday_lunar_leap'] ?? 0), + 'person_id' => $pid, + 'person_name' => (string)($p['name'] ?? ''), + 'source' => 'birthday_lunar', + 'born' => $lunar, + ]; + } + } + unset($viewerId); + return $out; + } + + /** 自建纪念日 + 派生生日,合成一份"全部纪念日" */ + public static function all(int $familyId, int $viewerId): array + { + $out = self::birthdays($familyId, $viewerId); + foreach (self::list($familyId) as $r) { + $r['source'] = 'custom'; + $r['person_name'] = ''; + $out[] = $r; + } + return $out; + } + + // ---------------- ③ 这一年会落在哪几天(给日历用) ---------------- + + /** + * 某一年里,这些纪念日各落在哪个公历日期。 + * + * ⚠️ 农历那一路必须**逐年换算**:农历新年在公历 1~2 月, + * 所以同一条"农历冬月初一"在不同年份对应的公历日期完全不同, + * 不存在"存一个公历日期年年用"的可能。 + * + * @return array + */ + public static function occurrences(int $familyId, int $viewerId, int $year): array + { + $out = []; + foreach (self::all($familyId, $viewerId) as $a) { + $repeat = (string)($a['repeat_type'] ?? 'solar'); + $m = (int)($a['month'] ?? 0); + $d = (int)($a['day'] ?? 0); + if ($m < 1 || $m > 12 || $d < 1 || $d > 31) { + continue; + } + $date = null; + if ($repeat === 'lunar') { + $leap = !empty($a['is_leap']) && Lunar::leapMonth($year) === $m; + $sol = Lunar::lunarToSolar($year, $m, $d, $leap); + if ($sol === null) { + // 这一年这个月没有这一天(如腊月三十遇上 29 天的腊月)→ 退到当月最后一天 + $max = $leap ? Lunar::leapDays($year) : Lunar::monthDays($year, $m); + $sol = Lunar::lunarToSolar($year, $m, max(1, min($d, $max)), $leap); + } + if ($sol === null) { + continue; + } + $date = sprintf('%04d-%02d-%02d', $sol['year'], $sol['month'], $sol['day']); + } else { + // 公历(含"只此一次"):2/29 落在平年时退到 2/28 + $dd = $d; + if (!checkdate($m, $dd, $year)) { + $dd = min($dd, (int)date('t', mktime(0, 0, 0, $m, 1, $year))); + } + $date = sprintf('%04d-%02d-%02d', $year, $m, $dd); + } + $age = null; + if (($a['source'] ?? '') !== 'custom' && !empty($a['born'])) { + $age = Birthday::ageOn((string)$a['born'], $date); + } + $out[] = [ + 'date' => $date, + 'title' => (string)($a['title'] ?? ''), + 'kind' => (string)($a['kind'] ?? ''), + 'person_id' => (int)($a['person_id'] ?? 0), + 'source' => (string)($a['source'] ?? 'custom'), + 'note' => (string)($a['note'] ?? ''), + 'id' => (int)($a['id'] ?? 0), + 'age' => $age, + 'repeat' => $repeat, + ]; + } + return $out; + } + + /** + * 一段时间内的纪念日(日历画格子用)。 + * @return array 按日期升序 + */ + public static function between(int $familyId, int $viewerId, string $from, string $to): array + { + $y1 = (int)substr($from, 0, 4); + $y2 = (int)substr($to, 0, 4); + $out = []; + for ($y = $y1; $y <= $y2; $y++) { + foreach (self::occurrences($familyId, $viewerId, $y) as $o) { + if ($o['date'] >= $from && $o['date'] <= $to) { + $out[] = $o; + } + } + } + usort($out, static fn($a, $b) => strcmp($a['date'], $b['date'])); + return $out; + } + + /** 接下来 n 天内的纪念日(列表页头部"临近"用) */ + public static function upcoming(int $familyId, int $viewerId, int $withinDays = 60): array + { + $today = date('Y-m-d'); + $to = date('Y-m-d', strtotime('+' . max(1, $withinDays) . ' days')); + $rows = self::between($familyId, $viewerId, $today, $to); + foreach ($rows as &$r) { + $r['days'] = (int)((strtotime($r['date']) - strtotime($today)) / 86400); + } + unset($r); + return $rows; + } + + public static function repeatWord(string $k): string + { + return self::REPEAT[$k] ?? $k; + } +} \ No newline at end of file diff --git a/app/Attachment.php b/app/Attachment.php index a7c3f9c..d7cfb37 100644 --- a/app/Attachment.php +++ b/app/Attachment.php @@ -128,13 +128,98 @@ final class Attachment } } - /** 能在浏览器里直接预览吗(图片 / PDF) */ - public static function previewable(array $a): bool + /** + * 这个附件是「哪一类」—— 决定页面上画缩略图还是画图标、点开是预览还是下载。 + * + * 返回:image / video / pdf / doc / xls / ppt / text / file + * + * ⚠️ 判据是**库里的 mime**(上传时由服务端嗅探内容得来),不看文件名后缀: + * 改个后缀骗不过它,而"文档其实是张图"这种也认得出来。 + */ + public static function kind(array $a): string { if ((int)($a['is_image'] ?? 0) === 1) { - return true; + return 'image'; } - return strpos((string)($a['mime'] ?? ''), 'pdf') !== false; + $mime = strtolower((string)($a['mime'] ?? '')); + $name = strtolower((string)($a['name'] ?? '')); + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + if ($mime === '' && $ext !== '') { + $mime = self::EXT_MIME[$ext] ?? ''; + } + if (strpos($mime, 'video/') === 0) { + return 'video'; + } + if (strpos($mime, 'audio/') === 0) { + return 'video'; // 音频用播放器放,与视频同一条路 + } + if (strpos($mime, 'pdf') !== false || $ext === 'pdf') { + return 'pdf'; + } + if (strpos($mime, 'text/') === 0 || in_array($ext, ['txt', 'md', 'csv', 'log', 'json'], true)) { + return 'text'; + } + // Office 的 MIME 一长串且各家写法不一(含 -msword / officedocument / opendocument), + // 扩展名反而更稳,所以这里两条路都认。 + if (self::inAny($mime . ' ' . $ext, ['word', 'doc', 'rtf', 'odt', 'wps'])) { + return 'doc'; + } + if (self::inAny($mime . ' ' . $ext, ['excel', 'sheet', 'xls', 'xlsx', 'csv', 'ods', 'et'])) { + return 'xls'; + } + if (self::inAny($mime . ' ' . $ext, ['powerpoint', 'presentation', 'ppt', 'pps', 'odp', 'dps'])) { + return 'ppt'; + } + return 'file'; + } + + /** + * 能在**本页**直接预览吗。 + * + * 图片与视频不用说;PDF 浏览器自带阅读器,也能直接看。 + * Word / Excel 之类浏览器渲染不了 —— 点了就下载(不装"点了没反应"的预览)。 + */ + public static function previewable(array $a): bool + { + return in_array(self::kind($a), ['image', 'video', 'pdf'], true); + } + + /** 这一类在人话里叫什么(图标上的角标、预览标题都用它) */ + public static function kindWord(string $kind): string + { + return self::KIND_WORDS[$kind] ?? '文件'; + } + + private const KIND_WORDS = [ + 'image' => '图片', 'video' => '视频', 'pdf' => 'PDF', + 'doc' => 'Word', 'xls' => 'Excel', 'ppt' => 'PPT', + 'text' => '文本', 'file' => '文件', + ]; + + /** 扩展名兜底用的 MIME 表(库里 mime 为空时) */ + private const EXT_MIME = [ + 'pdf' => 'application/pdf', 'txt' => 'text/plain', 'md' => 'text/markdown', + 'csv' => 'text/csv', 'json' => 'application/json', + 'mp4' => 'video/mp4', 'm4v' => 'video/mp4', 'webm' => 'video/webm', + 'mov' => 'video/quicktime', 'avi' => 'video/x-msvideo', 'mkv' => 'video/x-matroska', + 'mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'wav' => 'audio/wav', + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt' => 'application/vnd.ms-powerpoint', + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'zip' => 'application/zip', 'rar' => 'application/x-rar-compressed', + ]; + + private static function inAny(string $hay, array $needles): bool + { + foreach ($needles as $n) { + if (strpos($hay, $n) !== false) { + return true; + } + } + return false; } /** 人话的大小 */ diff --git a/app/Auth.php b/app/Auth.php index a3e0b4d..c8c7eb5 100644 --- a/app/Auth.php +++ b/app/Auth.php @@ -38,6 +38,109 @@ final class Auth return $_SESSION['name'] ?? ''; } + // ---------------- CalDAV 专用口令(v24 第 7 条后半) ---------------- + + /** + * 这个人有没有配过 CalDAV 口令。 + * + * ⚠️ 为什么不用登录口令:CalDAV 客户端(iOS 日历 / DAVx⁵ / Thunderbird) + * 会把这串凭据**长期存在设备上**并反复自动发送。让它是登录口令, + * 等于把主密码复制到每一台设备、且改密码就要每台重配。 + * 所以另发一个只用于日历订阅的口令,随时可换、与登录互不影响。 + */ + public static function hasCalDav(int $personId): bool + { + $st = Database::get()->prepare('SELECT COUNT(*) FROM caldav_credentials WHERE person_id = ?'); + $st->execute([$personId]); + return (int)$st->fetchColumn() > 0; + } + + /** + * 生成(或重置)CalDAV 口令,**返回明文**(只在这一刻能看到,库里只有哈希)。 + */ + public static function caldavIssue(int $personId): string + { + // 去掉容易读错的字符(0/O、1/l/I),因为用户要手打进手机 + $alphabet = 'abcdefghjkmnpqrstuvwxyz23456789'; + $plain = ''; + for ($i = 0; $i < 24; $i++) { + $plain .= $alphabet[random_int(0, strlen($alphabet) - 1)]; + } + Database::get()->prepare( + 'INSERT INTO caldav_credentials (person_id, token_hash) VALUES (?,?) + ON DUPLICATE KEY UPDATE token_hash = VALUES(token_hash), created_at = NOW()' + )->execute([$personId, hash('sha256', $plain)]); + return $plain; + } + + public static function caldavRevoke(int $personId): void + { + Database::get()->prepare('DELETE FROM caldav_credentials WHERE person_id = ?') + ->execute([$personId]); + } + + /** + * 用 Basic 凭据换一个「登录者」。成功返回 [personId, familyId, name],失败 null。 + * + * 用户名可以是手机号,也可以是姓名 —— 手机上打姓名比打 11 位数字省事得多。 + * 两者都走同一套节流(失败计数保存在会话里,与登录共用)。 + */ + public static function caldavAuth(?string $user, ?string $pass): ?array + { + $user = trim((string)$user); + $pass = (string)$pass; + if ($user === '' || $pass === '') { + return null; + } + + $fail = $_SESSION['cd_fail'] ?? ['n' => 0, 't' => 0]; + if ($fail['n'] >= 8 && (time() - $fail['t']) < 60) { + usleep(500000); + return null; + } + + $db = Database::get(); + // 先按手机号(哈希)找,再按姓名找 —— 都限定 status=active + $row = null; + $st = $db->prepare( + 'SELECT a.id AS account_id, p.id AS person_id, p.family_id, p.name, cd.token_hash + FROM accounts a + JOIN persons p ON p.id = a.person_id + LEFT JOIN caldav_credentials cd ON cd.person_id = p.id + WHERE a.status = "active" AND p.phone_hash = ? LIMIT 1' + ); + $st->execute([Crypto::hash($user)]); + $row = $st->fetch(); + if (!$row) { + $st = $db->prepare( + 'SELECT a.id AS account_id, p.id AS person_id, p.family_id, p.name, cd.token_hash + FROM accounts a + JOIN persons p ON p.id = a.person_id + LEFT JOIN caldav_credentials cd ON cd.person_id = p.id + WHERE a.status = "active" AND p.name = ? + ORDER BY p.id LIMIT 1' + ); + $st->execute([$user]); + $row = $st->fetch(); + } + + $ok = $row && !empty($row['token_hash']) + && hash_equals((string)$row['token_hash'], hash('sha256', $pass)); + if (!$ok) { + $_SESSION['cd_fail'] = ['n' => $fail['n'] + 1, 't' => time()]; + return null; + } + + unset($_SESSION['cd_fail']); + $db->prepare('UPDATE caldav_credentials SET last_used_at = NOW() WHERE person_id = ?') + ->execute([(int)$row['person_id']]); + return [ + 'person_id' => (int)$row['person_id'], + 'family_id' => (int)$row['family_id'], + 'name' => (string)$row['name'], + ]; + } + public static function isAdmin(): bool { return ($_SESSION['role'] ?? '') === 'admin'; diff --git a/app/Birthday.php b/app/Birthday.php index aeb3d55..9a50180 100644 --- a/app/Birthday.php +++ b/app/Birthday.php @@ -1,8 +1,12 @@ 人话名字 */ + /** + * 会**往外显示**的生日种类 => 人话名字。 + * + * ⚠️ v24(用户 2026-10-02 第 16 条):这里原本有三档,含身份证生日。 + * 现在只剩阳历与农历两种 —— 身份证生日仍然照常存库 + * (`birthday_id_card`,只读、永不变),但**任何地方都不再显示它**: + * 它常常和真实生日不是同一天,两个日期并列摆着反而让人分不清"到底哪天过生日"。 + */ public const KINDS = [ - 'id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '农历生日', ]; @@ -259,10 +269,8 @@ final class Birthday $lunarBd = self::dateOnly($person['birthday_lunar'] ?? ''); $lunarLeap = !empty($person['birthday_lunar_leap']); - if ($idBd !== '') { - $d = self::nextSolarDate($idBd, $today); - $buckets[$d]['kinds'][] = 'id'; - } + // v24 第 16 条:身份证生日不再参与"接下来要过哪个生日"(理由见 describe())。 + // 它仍然会照常存库,只是不再产出提醒与日历条目。 if ($solarBd !== '') { $d = self::nextSolarDate($solarBd, $today); $buckets[$d]['kinds'][] = 'solar'; @@ -339,9 +347,10 @@ final class Birthday $solarBd = self::dateOnly($solarRaw); $lunarBd = self::dateOnly($person['birthday_lunar'] ?? ''); - if ($idBd !== '') { - $rows[] = ['kind' => 'id', 'text' => $idBd, 'note' => '由身份证推导,不可修改', 'date' => $idBd]; - } + // ⚠️ v24(用户 2026-10-02 第 16 条):**身份证生日默认不显示**。 + // 它只是"证件上的那一天",常常和真实生日不是同一天,两个日期并列摆在详情页 + // 反而让人分不清"到底哪天过生日"。现在只显示阳历与农历两种真实生日。 + // 身份证生日仍然照常在库里(birthday_id_card 只读、永不变),只是不往外显示。 if ($solarBd !== '') { $rows[] = ['kind' => 'solar', 'text' => self::pretty($solarRaw), 'note' => '', 'date' => $solarBd]; } diff --git a/app/CalDav.php b/app/CalDav.php new file mode 100644 index 0000000..311c8fc --- /dev/null +++ b/app/CalDav.php @@ -0,0 +1,142 @@ +.ics/ (0) → 某一本的属性(displayname / color / 组件集) + * PROPFIND /dav/.ics/ (1) → 这本里所有日程的资源清单(getetag) + * REPORT /dav/.ics/ → calendar-query / calendar-multiget,取正文 + * GET /dav/.ics/ → 整本导出成一个 ics(很多客户端走这条) + * + * 鉴权用 **HTTP Basic**(DAVx⁵ / iOS 都原生支持),口令不是登录口令: + * 见 Auth::caldavToken() —— 登录口令绝不该在客户端里被反复存着。 + * + * ⚠️ 只读:所有写方法(PUT/DELETE/PROPPATCH)一律 403。 + * 与其"半支持"(写成功但客户端状态与服务端不一致),不如明确拒绝。 + */ + +declare(strict_types=1); + +namespace App; + +final class CalDav +{ + /** 资源前缀(URL 里的路径) */ + public const ROOT = '/dav/'; + + /** + * 当前用户可见的日历本。与「日程」模块里看到的是同一批 + * (我建的 + 共享给我的),口径必须一致,否则手机上少一本、网页上多一本。 + */ + public static function books(int $familyId, int $personId): array + { + return Calendar::mine($familyId, $personId); + } + + /** + * 日历本 → URL 段。用 id 而不是名字:名字里有中文/空格/斜杠时, + * 客户端与服务端对"怎么编码"的理解常常不一致(实测 DAVx⁵ 会二次编码)。 + */ + public static function hrefOf(array $cal): string + { + return self::ROOT . (int)$cal['id'] . '.ics/'; + } + + public static function bookHref(int $calId): string + { + return self::ROOT . $calId . '.ics/'; + } + + /** 从 URL 段(如 /dav/3.ics/)解出日历本 id 与"是不是要单个日程" */ + public static function parsePath(string $path): array + { + $p = (string)parse_url($path, PHP_URL_PATH); + if (strpos($p, self::ROOT) !== 0) { + return ['book' => 0, 'event' => 0]; + } + $rest = substr($p, strlen(self::ROOT)); + $rest = trim($rest, '/'); + if ($rest === '') { + return ['book' => 0, 'event' => 0]; + } + // 形如 3.ics 或 3.ics/.ics + $parts = explode('/', $rest); + $bookSeg = $parts[0]; + $book = (int)preg_replace('/\.ics$/', '', $bookSeg); + $event = 0; + if (isset($parts[1]) && $parts[1] !== '') { + // 单条日程:.ics —— uid 里可能含点,所以只去掉结尾的 .ics + $evSeg = preg_replace('/\.ics$/', '', $parts[1]); + $event = (int)$evSeg; // 我们用的是 calendar_events.id + } + return ['book' => $book, 'event' => $event]; + } + + // ---------------- 应答 ---------------- + + public static function xmlHeader(): void + { + header('Content-Type: application/xml; charset=utf-8'); + } + + /** 多状态应答(207)—— PROPFIND / REPORT 都用它 */ + public static function multis(int $code = 207): void + { + http_response_code($code); + self::xmlHeader(); + } + + /** 把一个 xml 文本片段做最省事的转义(我们只回显自己库里的字符串) */ + public static function x(string $s): string + { + return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + } + + /** + * 某个日历本里"能列出来"的日程。 + * + * ⚠️ 与网页日历同源:导出/订阅的内容必须与页面上看到的一致。 + * 派生本(观影 / 旅程)走 Calendar::exportRows,自定义本走 calendar_events。 + */ + public static function rows(array $cal, int $familyId, int $me): array + { + return Calendar::exportRows($cal, $familyId, $me); + } + + /** 单条日程(按 calendar_events.id)—— 只允许自定义本,派生本没有"单条资源" */ + public static function oneEvent(int $calId, int $eventId, array $familyIdAndMe): ?array + { + [$familyId, $me] = $familyIdAndMe; + $st = Database::get()->prepare( + 'SELECT e.* FROM calendar_events e + JOIN calendars c ON c.id = e.calendar_id + WHERE e.id = ? AND e.calendar_id = ? AND c.family_id = ?' + ); + $st->execute([$eventId, $calId, $familyId]); + $r = $st->fetch(); + if (!$r) { + return null; + } + // 共享只读的人也要能取到(能看见这本就能看见里面的条目) + $cal = Calendar::mineOne($familyId, $calId, $me); + if (!$cal) { + return null; + } + return $r; + } + + /** 一整本导出成 ics 文本(GET 与 REPORT 都用它) */ + public static function ics(array $cal, int $familyId, int $me): string + { + return Ics::export(self::rows($cal, $familyId, $me), (string)$cal['name']); + } +} \ No newline at end of file diff --git a/app/Calendar.php b/app/Calendar.php index 6d110dd..3df7bd3 100644 --- a/app/Calendar.php +++ b/app/Calendar.php @@ -19,6 +19,9 @@ namespace App; final class Calendar { + /** 日程附件的宿主类型(统一附件表用;v24 第 6 条:日程也能挂附件) */ + public const ATTACH = 'calendar_event'; + /** 系统预置的两本默认日历(kind 与名字都写死,用户的自定义本一律 kind='custom') */ public const DEFAULT_KINDS = [ 'movie' => ['观影', '#7A4E8C'], @@ -32,9 +35,30 @@ final class Calendar * 太浅看不清字,太艳会盖过整个月视图。 */ public const PRESET_CAL_COLORS = [ - '#4A7C82', '#7A4E8C', '#3F7D5A', '#B0763A', '#5566A8', - '#A8574F', '#8A6B4B', '#5C8A48', '#9A5C7A', '#3D6B8E', - '#8C7A3F', '#6B5B95', + '#A63030', '#A65C30', '#A68930', '#97A630', + '#6BA630', '#3FA630', '#30A64E', '#30A67A', + '#30A6A6', '#307AA6', '#304EA6', '#3F30A6', + '#6B30A6', '#9730A6', '#A63089', '#A6305C', + ]; + + /** + * 提醒档位:提前多久(分钟)—— 对应 RFC 5545 的 VALARM / TRIGGER。 + * + * ⚠️ 顺序就是用户 2026-10-02 第 6 条原话的顺序,别按数值大小重排: + * 下拉里"5 分钟、10 分钟、15 分钟…1 天"读起来是递进的, + * 按数值排也一样,但一改动顺序用户就会以为自己选错了。 + */ + public const REMINDERS = [ + 5 => '提前 5 分钟', + 10 => '提前 10 分钟', + 15 => '提前 15 分钟', + 30 => '提前 30 分钟', + 60 => '提前 1 小时', + 120 => '提前 2 小时', + 240 => '提前 4 小时', + 480 => '提前 8 小时', + 720 => '提前 12 小时', + 1440 => '提前 1 天', ]; /** 新建一本 */ @@ -120,6 +144,23 @@ final class Calendar return ($cal['perm'] ?? '') === 'write'; } + /** + * 按 id 取一本「我能看到的」日历(带 perm / shared)。 + * + * ⚠️ 判权限时**必须**走这里而不是 Calendar::find():find() 返回的是 calendars + * 表原行,里面没有 perm —— 于是「共享给我的、可读写的本子」一律被判成不能写, + * 被共享的人建了日程也改不了(蹲在 calendar/event 页上看只读提示)。 + */ + public static function mineOne(int $familyId, int $personId, int $calendarId): ?array + { + foreach (self::mine($familyId, $personId) as $c) { + if ((int)$c['id'] === $calendarId) { + return $c; + } + } + return null; + } + /** 给某人补建两本默认日历(幂等) */ public static function seedDefaults(int $familyId, int $personId): void { @@ -176,39 +217,63 @@ final class Calendar // ---------------- 日程 ---------------- + /** + * 新建一条日程。 + * + * ⚠️ v24(第 5、6 条):起止是**完整的 DATETIME(精确到秒)**,不再分成 + * "日期一列 + 时刻一列" —— 分开存就表达不了"今天 22:30 到明天 01:15", + * 而且用户填的时候也得分两处。全天日程仍然写 DATETIME,只是时间部分是 00:00:00。 + * ⚠️ uid 一定要在**插入时**生成:CalDAV 客户端靠 UID 认日程, + * 没有 UID 的日程每同步一次就被当成新日程再建一条(无限重复)。 + */ public static function addEvent(int $calendarId, array $d): int { $db = Database::get(); $db->prepare( 'INSERT INTO calendar_events - (calendar_id, title, starts_at, ends_at, start_time, end_time, all_day, note, created_by) - VALUES (?,?,?,?,?,?,?,?,?)' + (calendar_id, uid, title, location, starts_at, ends_at, all_day, + reminder_minutes, status, transp, rrule, note, created_by) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)' )->execute([ $calendarId, + self::newUid(), mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'), + self::textOrNull((string)($d['location'] ?? ''), 255), $d['starts_at'], $d['ends_at'] ?: null, - $d['start_time'] ?: null, - $d['end_time'] ?: null, !empty($d['all_day']) ? 1 : 0, + self::reminderMinutes($d['reminder_minutes'] ?? null), + self::statusOf($d['status'] ?? null), + !empty($d['transp']) ? 1 : 0, + self::textOrNull((string)($d['rrule'] ?? ''), 255), self::textOrNull($d['note'] ?? ''), $d['created_by'] ?: null, ]); return (int)$db->lastInsertId(); } + /** + * 改一条日程。 + * + * ⚠️ SEQUENCE 每次 +1(RFC 5545):CalDAV 客户端拿它判断"服务端这版比我新吗"。 + * 不加的话,改完日程客户端可能还抱着旧的不放。 + */ public static function updateEvent(int $id, array $d): void { Database::get()->prepare( - 'UPDATE calendar_events SET title=?, starts_at=?, ends_at=?, start_time=?, end_time=?, - all_day=?, note=? WHERE id=?' + 'UPDATE calendar_events SET title=?, location=?, starts_at=?, ends_at=?, all_day=?, + reminder_minutes=?, status=?, transp=?, rrule=?, note=?, sequence = sequence + 1 + WHERE id=?' )->execute([ mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'), + self::textOrNull((string)($d['location'] ?? ''), 255), $d['starts_at'], $d['ends_at'] ?: null, - $d['start_time'] ?: null, - $d['end_time'] ?: null, !empty($d['all_day']) ? 1 : 0, + self::reminderMinutes($d['reminder_minutes'] ?? null), + self::statusOf($d['status'] ?? null), + !empty($d['transp']) ? 1 : 0, + self::textOrNull((string)($d['rrule'] ?? ''), 255), self::textOrNull($d['note'] ?? ''), $id, ]); @@ -216,9 +281,165 @@ final class Calendar public static function deleteEvent(int $id): void { + Attachment::removeAll(self::ATTACH, $id); Database::get()->prepare('DELETE FROM calendar_events WHERE id = ?')->execute([$id]); } + /** 一条日程(含所属本子;用来判权限) */ + public static function event(int $id): ?array + { + $st = Database::get()->prepare( + 'SELECT e.*, c.owner_id, c.name AS cal_name, c.color AS cal_color, c.kind AS cal_kind + FROM calendar_events e JOIN calendars c ON c.id = e.calendar_id + WHERE e.id = ?' + ); + $st->execute([$id]); + $r = $st->fetch(); + return $r ?: null; // 「能不能写」另走 canWriteEvent()(要按人查共享表,塞不进这一行) + } + + /** + * 这条日程我能写吗(按它所在本子的归属与共享权限判)。 + * + * ⚠️ 单独开一个方法而不是让调用方自己拼数组给 canWrite(): + * canWrite() 吃的是「日历本」那一行(要带 perm),拿日程行直接喂进去 + * 会因为没有 perm 而恒判成不能写。 + */ + public static function canWriteEvent(array $event, int $personId): bool + { + if ((int)($event['owner_id'] ?? 0) === $personId) { + return true; + } + $st = Database::get()->prepare( + 'SELECT perm FROM calendar_shares WHERE calendar_id = ? AND person_id = ?' + ); + $st->execute([(int)$event['calendar_id'], $personId]); + return (string)$st->fetchColumn() === 'write'; + } + + /** 一条日程的 UID(CalDAV 那边按 UID 取) */ + public static function newUid(): string + { + return bin2hex(random_bytes(12)) . '@familybook'; + } + + /** + * 导出 ics 用的行集(用户 2026-10-02 第 7 条)。 + * + * 三本日历的内容来源不一样,但**导出时都要有东西**: + * · custom → calendar_events 里真存着的自定义日程; + * · movie → events(kind=movie) 派生; + * · trip → trip_nodes 派生(与日历视图同一口径:出节点,不出整趟旅程)。 + * 统一成 calendar_events 那一套字段名(starts_at/ends_at/title/...), + * Ics 那边就不必分辨来源是哪种。 + * + * @return array + */ + public static function exportRows(array $cal, int $familyId, int $me): array + { + $kind = (string)$cal['kind']; + if ($kind === 'movie' || $kind === 'trip') { + // 派生本:先按一个足够宽的范围取日历视图,再把每一条映射成可导出的行 + $from = '1970-01-01'; + $to = '2100-01-01'; + $out = []; + $seen = []; + foreach (self::month($familyId, [$cal], $me, $from, $to) as $e) { + $key = $e['kind'] . '-' . $e['ref_id'] . '-' . $e['date']; + if (isset($seen[$key])) { + continue; // 跨天日程会逐天出一条,导出时一条就够 + } + $seen[$key] = true; + $day = (string)$e['date']; + $out[] = [ + 'uid' => $e['kind'] . '-' . (int)$e['ref_id'] . '@familybook', + 'title' => (string)$e['title'], + 'starts_at' => $day . ' ' . ($e['time'] !== '' ? $e['time'] . ':00' : '00:00:00'), + 'ends_at' => null, + 'all_day' => empty($e['time']) ? 1 : 0, + 'location' => null, + 'note' => null, + 'status' => 'confirmed', + 'transp' => 0, + 'sequence' => 0, + 'created_at' => null, + 'updated_at' => null, + 'reminder_minutes' => null, + 'rrule' => null, + ]; + } + return $out; + } + $st = Database::get()->prepare( + 'SELECT * FROM calendar_events WHERE calendar_id = ? ORDER BY starts_at' + ); + $st->execute([(int)$cal['id']]); + return $st->fetchAll(); + } + + /** + * 导入一条 ics 里的 VEVENT。 + * + * ⚠️ 判重用 UID + 日历本:同一本日历里 UID 已存在就**更新**而不是再插一条 —— + * 否则同一个 .ics 导入两次,日历里就是两份一模一样的日程(用户最常见的操作 + * "导一次没看清,再导一次"会立刻翻车)。 + * + * @return string 'added' | 'updated' + */ + public static function importEvent(int $calendarId, array $v, int $createdBy): string + { + $db = Database::get(); + $uid = (string)($v['uid'] ?? ''); + if ($uid !== '') { + $st = $db->prepare('SELECT id FROM calendar_events WHERE calendar_id = ? AND uid = ?'); + $st->execute([$calendarId, $uid]); + $old = (int)$st->fetchColumn(); + if ($old > 0) { + self::updateEvent($old, $v); + return 'updated'; + } + } else { + $uid = self::newUid(); + } + $db->prepare( + 'INSERT INTO calendar_events + (calendar_id, uid, title, location, starts_at, ends_at, all_day, + reminder_minutes, status, transp, rrule, note, created_by) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)' + )->execute([ + $calendarId, + $uid, + mb_substr(trim((string)($v['title'] ?? '')), 0, 200, 'UTF-8'), + self::textOrNull((string)($v['location'] ?? ''), 255), + $v['starts_at'], + $v['ends_at'] ?: null, + !empty($v['all_day']) ? 1 : 0, + self::reminderMinutes($v['reminder_minutes'] ?? null), + self::statusOf($v['status'] ?? null), + 0, + self::textOrNull((string)($v['rrule'] ?? ''), 255), + self::textOrNull((string)($v['note'] ?? '')), + $createdBy ?: null, + ]); + return 'added'; + } + + /** 提醒分钟数:只认预设档位与 0/NULL,乱传的落 NULL(不提醒) */ + private static function reminderMinutes($v): ?int + { + if ($v === null || $v === '' || $v === false) { + return null; + } + $n = (int)$v; + return isset(self::REMINDERS[$n]) ? $n : null; + } + + private static function statusOf($v): string + { + $v = (string)$v; + return in_array($v, ['confirmed', 'tentative', 'cancelled'], true) ? $v : 'confirmed'; + } + /** * 某个月(或某段日期)里**全部**日程: * 自定义日程(calendar_events)+ 观影(events kind=movie)+ 旅行(events kind=trip) @@ -249,8 +470,9 @@ final class Calendar $in = implode(',', array_fill(0, count($ids), '?')); $st = Database::get()->prepare( "SELECT * FROM calendar_events - WHERE calendar_id IN ($in) AND starts_at <= ? AND COALESCE(ends_at, starts_at) >= ? - ORDER BY starts_at, start_time" + WHERE calendar_id IN ($in) + AND DATE(starts_at) <= ? AND DATE(COALESCE(ends_at, starts_at)) >= ? + ORDER BY starts_at" ); $st->execute(array_merge($ids, [$to, $from])); $calById = []; @@ -259,21 +481,26 @@ final class Calendar } foreach ($st->fetchAll() as $e) { $c = $calById[(int)$e['calendar_id']] ?? null; - $end = $e['ends_at'] ?: $e['starts_at']; + $sd = substr((string)$e['starts_at'], 0, 10); + $ed = substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10); // 跨天日程:在范围内**每天**都出一条(群晖日历就是这么画的) - foreach (self::eachDay((string)$e['starts_at'], (string)$end, $from, $to) as $day) { + $first = true; + foreach (self::eachDay($sd, $ed, $from, $to) as $day) { + $tm = substr((string)$e['starts_at'], 11, 5); $out[] = [ 'cal' => (int)$e['calendar_id'], 'color' => (string)($c['color'] ?? '#4A7C82'), 'calname' => (string)($c['name'] ?? ''), 'title' => (string)$e['title'], 'date' => $day, - 'time' => empty($e['all_day']) ? substr((string)$e['start_time'], 0, 5) : '', + // 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻 + 'time' => ($first && empty($e['all_day'])) ? $tm : '', 'all_day' => !empty($e['all_day']), 'kind' => 'custom', 'ref_id' => (int)$e['id'], 'href' => url('calendar/event', ['id' => (int)$e['id']]), ]; + $first = false; } } } @@ -306,32 +533,46 @@ final class Calendar } } - // ---- 旅行(events kind=trip)---- + // ---- 旅程(trip_nodes,不是 events)---- + // ⚠️ v24(用户 2026-10-02 第 2 条):日历里出的是**行程节点**,不是「这趟旅程」本身。 + // 原来把整趟旅程摊成一条跨天全天日程,于是日历上一整周都被"云南之行"占满, + // 真正要看的"几点出发 / 几点到景点"全都看不见 —— 旅程是个容器,节点才是日程。 + // 现在按 trip_nodes.starts_at 出条目,跨天的节点同样逐天铺开。 if (isset($byKind['trip'])) { $c = $byKind['trip'][0]; $st = Database::get()->prepare( - "SELECT id, title, starts_at, ends_at FROM events - WHERE family_id = ? AND kind = 'trip' AND starts_at IS NOT NULL - AND DATE(starts_at) <= ? AND COALESCE(DATE(ends_at), DATE(starts_at)) >= ? - ORDER BY starts_at" + "SELECT n.id AS node_id, n.title AS node_title, n.starts_at, n.ends_at, + n.node_type, n.status AS node_status, + e.id AS event_id, e.title AS trip_title + FROM trip_nodes n + JOIN events e ON e.id = n.event_id AND e.kind = 'trip' + WHERE n.family_id = ? AND n.starts_at IS NOT NULL + AND n.status <> 'cancelled' + AND DATE(n.starts_at) <= ? AND COALESCE(DATE(n.ends_at), DATE(n.starts_at)) >= ? + ORDER BY n.starts_at" ); $st->execute([$familyId, $to, $from]); - foreach ($st->fetchAll() as $e) { - foreach (self::eachDay(substr((string)$e['starts_at'], 0, 10), - substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10), - $from, $to) as $day) { + foreach ($st->fetchAll() as $n) { + $start = substr((string)$n['starts_at'], 0, 10); + $end = substr((string)($n['ends_at'] ?: $n['starts_at']), 0, 10); + // 节点跨天时只在**第一天**显示时间(后面的天是"还在进行",不是"又开始了") + $tm = substr((string)$n['starts_at'], 11, 5); + $first = true; + foreach (self::eachDay($start, $end, $from, $to) as $day) { $out[] = [ 'cal' => (int)$c['id'], 'color' => (string)$c['color'], 'calname' => (string)$c['name'], - 'title' => '🧳 ' . (string)$e['title'], + 'title' => Trip::typeIcon((string)$n['node_type']) . ' ' . (string)$n['node_title'], 'date' => $day, - 'time' => '', - 'all_day' => true, + 'time' => $first ? ($tm === '00:00' ? '' : $tm) : '', + 'all_day' => $first && $tm === '00:00', 'kind' => 'trip', - 'ref_id' => (int)$e['id'], - 'href' => url('trips/show', ['id' => (int)$e['id']]), + 'ref_id' => (int)$n['node_id'], + 'href' => url('trips/show', ['id' => (int)$n['event_id']]) + . '#node-' . (int)$n['node_id'], ]; + $first = false; } } } @@ -364,6 +605,25 @@ final class Calendar ]; } + // ---- 纪念日(v24 第 13 条)---- + // 用户明确要求:纪念日一律作为**全天日程**出现在日历里。 + // 数据是**实时派生**的(生日读 persons、农历逐年换算),所以这里不查表、直接算。 + // ⚠️ 农历那一路必须按"这一年"换算 —— 同一条农历生日在不同年份落在不同公历日期。 + foreach (Anniversary::between($familyId, $personId, $from, $to) as $a) { + $out[] = [ + 'cal' => 0, + 'color' => '#B4633C', + 'calname' => '纪念日', + 'title' => '🎂 ' . (string)$a['title'], + 'date' => (string)$a['date'], + 'time' => '', + 'all_day' => true, // 全天(用户点名要求) + 'kind' => 'anniv', + 'ref_id' => (int)($a['id'] ?? 0), + 'href' => $a['id'] > 0 ? url('days/edit', ['id' => (int)$a['id']]) : url('days'), + ]; + } + usort($out, static function ($a, $b) { return strcmp($a['date'], $b['date']) ?: strcmp((string)$a['time'], (string)$b['time']) diff --git a/app/Ics.php b/app/Ics.php new file mode 100644 index 0000000..0c6e6dd --- /dev/null +++ b/app/Ics.php @@ -0,0 +1,402 @@ + 0) { + $out = array_merge($out, [ + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:' . self::escapeText((string)($e['title'] ?? '提醒')), + 'TRIGGER:-' . self::duration((int)$e['reminder_minutes']), + 'END:VALARM', + ]); + } + $out[] = 'END:VEVENT'; + return $out; + } + + /** + * 解析一个 .ics 文本,返回里面的 VEVENT。 + * + * @return array + */ + public static function parse(string $text): array + { + $text = str_replace(["\r\n", "\r"], "\n", $text); + // ① 反折行:以空格或制表符开头的行是上一行的续行 + $text = preg_replace("/\n[ \t]/", '', $text) ?? $text; + $lines = explode("\n", $text); + + $out = []; + $cur = null; + $inAlarm = false; + foreach ($lines as $raw) { + $line = trim($raw); + if ($line === '') { + continue; + } + if ($line === 'BEGIN:VEVENT') { + $cur = []; + $inAlarm = false; + continue; + } + if ($line === 'END:VEVENT') { + if ($cur !== null) { + $out[] = self::normalizeVevent($cur); + } + $cur = null; + $inAlarm = false; + continue; + } + if ($cur === null) { + continue; // VCALENDAR / VTIMEZONE 里的东西一律跳过 + } + if ($line === 'BEGIN:VALARM') { $inAlarm = true; continue; } + if ($line === 'END:VALARM') { $inAlarm = false; continue; } + + [$name, $params, $value] = self::splitProp($line); + if ($name === '') { + continue; + } + // VALARM 里只认 TRIGGER(提前多久);DESCRIPTION 那些是给闹钟弹窗用的, + // 混进来会覆盖日程本身的备注(实测:iOS 导出的 VALARM 里也带 DESCRIPTION)。 + if ($inAlarm) { + if ($name === 'TRIGGER') { + $cur['_trigger'] = $value; + } + continue; + } + // 同名属性出现多次时保留第一个(RFC 里多数属性都是单值的) + if (!array_key_exists($name, $cur)) { + $cur[$name] = $value; + } + if (!array_key_exists($name . '_PARAMS', $cur)) { + $cur[$name . '_PARAMS'] = $params; + } + } + return $out; + } + + /** 把解析出来的属性归一成我们要落库的形状 */ + private static function normalizeVevent(array $p): array + { + $startRaw = (string)($p['DTSTART'] ?? ''); + $endRaw = (string)($p['DTEND'] ?? ''); + $params = (string)($p['DTSTART_PARAMS'] ?? ''); + // 全天:VALUE=DATE,或者值本身就是 8 位的 YYYYMMDD + $allDay = stripos($params, 'VALUE=DATE') !== false + || preg_match('/^\d{8}$/', $startRaw) === 1; + + $starts = $allDay ? self::fromDateValue($startRaw) : self::fromDateTimeValue($startRaw); + $ends = $endRaw !== '' + ? ($allDay ? self::fromDateValue($endRaw) : self::fromDateTimeValue($endRaw)) + : null; + // 全天日程的 DTEND 是**排他**的第二天,减回一天才对得上"到哪天为止" + if ($allDay && $ends !== null && $starts !== null) { + $d = strtotime($ends . ' -1 day'); + $ends = $d ? date('Y-m-d', $d) . ' 00:00:00' : null; + } + + return [ + 'uid' => (string)($p['UID'] ?? ''), + 'title' => self::unescapeText((string)($p['SUMMARY'] ?? '')), + 'starts_at' => $starts, + 'ends_at' => $ends, + 'all_day' => $allDay, + 'location' => self::nullOr(self::unescapeText((string)($p['LOCATION'] ?? ''))), + 'note' => self::nullOr(self::unescapeText((string)($p['DESCRIPTION'] ?? ''))), + 'rrule' => self::nullOr((string)($p['RRULE'] ?? '')), + 'status' => strtolower((string)($p['STATUS'] ?? '')) ?: 'confirmed', + 'reminder_minutes' => self::triggerMinutes((string)($p['_trigger'] ?? '')), + ]; + } + + /** TRIGGER:-PT15M / TRIGGER:-P1D / TRIGGER;VALUE=DATE-TIME:... → 分钟数 */ + private static function triggerMinutes(string $v): ?int + { + $v = trim($v); + if ($v === '') { + return null; + } + // 绝大多数客户端导出的是"提前多久"的持续时间(负号开头) + if (preg_match('/^-?P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/', $v, $m)) { + $mins = (int)($m[1] ?? 0) * 1440; + $mins += (int)($m[2] ?? 0) * 60; + $mins += (int)($m[3] ?? 0); + $mins += (int)($m[4] ?? 0) > 0 ? 1 : 0; // 有秒就进位到 1 分钟,别归零 + return $mins > 0 ? $mins : null; + } + return null; + } + + /** 分钟 → RFC 5545 的持续时间,如 90 → PT1H30M */ + private static function duration(int $mins): string + { + $d = intdiv($mins, 1440); + $h = intdiv($mins % 1440, 60); + $m = $mins % 60; + $s = 'P'; + if ($d > 0) { + $s .= $d . 'D'; + } + if ($h > 0 || $m > 0) { + $s .= 'T'; + if ($h > 0) { + $s .= $h . 'H'; + } + if ($m > 0) { + $s .= $m . 'M'; + } + } + return $s === 'P' ? 'PT0S' : $s; + } + + /** '2026-10-16 14:30:00' → '20261016T143000' */ + private static function dateTimeValue(string $dt): string + { + $t = strtotime($dt); + if ($t === false) { + return '19700101T000000'; + } + return date('Ymd\THis', $t); + } + + /** '2026-10-16 14:30:00' → '20261016' */ + private static function dateValue(string $dt): string + { + $t = strtotime($dt); + if ($t === false) { + return '19700101'; + } + return date('Ymd', $t); + } + + /** '20261016T143000' / '2026-10-16 14:30:00' → '2026-10-16 14:30:00' */ + private static function fromDateTimeValue(string $v): ?string + { + $v = trim($v); + if (preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/', $v, $m)) { + return sprintf('%s-%s-%s %s:%s:%s', $m[1], $m[2], $m[3], $m[4], $m[5], $m[6]); + } + $t = strtotime($v); + return $t === false ? null : date('Y-m-d H:i:s', $t); + } + + /** '20261016' → '2026-10-16 00:00:00' */ + private static function fromDateValue(string $v): ?string + { + if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', trim($v), $m)) { + return sprintf('%s-%s-%s 00:00:00', $m[1], $m[2], $m[3]); + } + $t = strtotime($v); + return $t === false ? null : date('Y-m-d 00:00:00', $t); + } + + /** '20261016' → '20261017' */ + private static function nextDay(string $ymd): string + { + $t = strtotime(substr($ymd, 0, 4) . '-' . substr($ymd, 4, 2) . '-' . substr($ymd, 6, 2) . ' +1 day'); + return $t === false ? $ymd : date('Ymd', $t); + } + + /** 属性行 → [名, 参数串, 值](只在**第一个**未加引号的冒号处切) */ + private static function splitProp(string $line): array + { + $pos = strpos($line, ':'); + if ($pos === false) { + return ['', '', '']; + } + $left = substr($line, 0, $pos); + $value = substr($line, $pos + 1); + if (strpos($left, ';') === false) { + return [strtoupper($left), '', $value]; + } + [$name, $params] = explode(';', $left, 2); + return [strtoupper($name), $params, $value]; + } + + /** DTSTAMP / CREATED 一律写成 UTC(RFC 要求带 Z) */ + private static function stampOf($dt): string + { + $t = strtotime((string)$dt); + if ($t === false) { + $t = time(); + } + return gmdate('Ymd\THis\Z', $t); + } + + /** 文本转义(RFC 5545 3.3.11) */ + private static function escapeText(string $v): string + { + $v = str_replace('\\', '\\\\', $v); + $v = str_replace(';', '\;', $v); + $v = str_replace(',', '\,', $v); + $v = str_replace(["\r\n", "\r", "\n"], '\n', $v); + return $v; + } + + private static function unescapeText(string $v): string + { + $v = str_replace('\n', "\n", $v); + $v = str_replace('\N', "\n", $v); + $v = str_replace('\,', ',', $v); + $v = str_replace('\;', ';', $v); + return str_replace('\\\\', '\\', $v); + } + + private static function nullOr(string $v): ?string + { + $v = trim($v); + return $v === '' ? null : $v; + } + + /** + * 折行:一行最多 75 **字节**,且不能在多字节字符中间切断。 + * + * ⚠️ 按字节而不是按字符数:中文一个字 3 字节,按字符数切会切出半个 UTF-8 字, + * 客户端解出来就是乱码。续行以**一个空格**开头。 + */ + private static function fold(string $line): array + { + if (strlen($line) <= 75) { + return [$line]; + } + $out = []; + $cur = ''; + $len = 0; + $limit = 75; + $bytes = strlen($line); + $i = 0; + while ($i < $bytes) { + // 一次取一个完整 UTF-8 字符 + $ch = $line[$i]; + $n = 1; + $o = ord($ch); + if ($o >= 0xF0) { $n = 4; } + elseif ($o >= 0xE0) { $n = 3; } + elseif ($o >= 0xC0) { $n = 2; } + $tok = substr($line, $i, $n); + $i += $n; + // 续行要以空格开头,所以后续每行实际只能放 74 字节 + $room = $out === [] ? $limit : $limit - 1; + if ($len + $n > $room) { + $out[] = $cur; + $cur = ''; + $len = 0; + } + $cur .= $tok; + $len += $n; + } + if ($cur !== '') { + $out[] = $cur; + } + // 续行补前导空格 + $res = []; + foreach ($out as $k => $v) { + $res[] = $k === 0 ? $v : ' ' . $v; + } + return $res; + } +} diff --git a/app/Kinship.php b/app/Kinship.php index 67040d1..0719d51 100644 --- a/app/Kinship.php +++ b/app/Kinship.php @@ -246,6 +246,10 @@ final class Kinship return ['gen' => [], 'units' => [], 'unit_of' => [], 'me' => $me]; } + // ⚠️ $up / $down 是**代数标号的上下限**(clamp),不是遍历深度 —— + // 走是走遍整个连通图的。所以同一个人可能通过两条路算出不同代数, + // 下面取「离我最近」的那一档。 + // ---- ① 代数:以「我」为 0 代,反复贯穿直到不再有人可加 ---- $gen = [$me => 0]; $seen = [$me => true]; @@ -312,6 +316,145 @@ final class Kinship return ['gen' => $gen, 'units' => $units, 'unit_of' => $unitOf, 'me' => $me]; } + /** + * v24 用户第 12 条:谁的姻亲「没画在家谱上」。 + * + * 家谱画的是「同一位祖先的血脉后代 + 各人配偶」,所以**配偶那一侧的 + * 父母/兄弟姐妹**天然不在图内(洪博为的父母不会出现在以杨家为根的树上)。 + * 用户要的是:这些人的卡片上给一个入口,点一下就以 TA 为中心重画一张, + * 把 TA 那侧的姻亲露出来,再给个「返回」回到原图。 + * + * 判据:**这个人有父母,但父母不在"库实际画出来的那批人"里** → + * TA 是有独立亲缘家族的人,点一下就以 TA 为中心重画。 + * + * ⚠️ 这里的关键是"实际画出来的" ≠ "我们遍历到的": + * familyMap 为了算称呼会走遍**整个连通图**(连配偶的父母都拉进来), + * 但 family-chart 只画「根的祖先链 + 根的后代 + 根的兄弟姐妹 + 这些人的配偶」—— + * **配偶的父母不在画布上**。拿遍历集合当判据,就会得出"岳父母已经在家谱里了" + * 这种与眼睛看到的相反的结论(实测踩到)。 + * + * @param array $onTree 遍历到的人(用于确定根与限制范围) + * @return array 人 id => 提示文案 + */ + public static function branchOuts(array $g, array $people, array $onTree): array + { + // 本图的根 = 辈分最高的那个人(与 kin/tree 的取法一致) + $rootId = 0; + foreach (array_keys($onTree) as $id) { + $id = (int)$id; + if (!isset($people[$id])) { + continue; + } + $hasParentOnTree = false; + foreach (self::parentIds($g, $id) as $p) { + if (isset($onTree[(int)$p])) { + $hasParentOnTree = true; + break; + } + } + if (!$hasParentOnTree) { + $rootId = $id; + break; + } + } + if ($rootId === 0) { + return []; + } + + $drawn = self::drawnOf($g, $rootId, $onTree); + + $out = []; + foreach (array_keys($drawn) as $id) { + $id = (int)$id; + if ($id === $rootId || !isset($people[$id])) { + continue; + } + $parents = self::parentIds($g, $id); + if (!$parents) { + continue; // 不知道父母的(根、待确认的占位人)没什么可展开 + } + $outside = 0; + foreach ($parents as $p) { + if (!isset($drawn[(int)$p])) { + $outside++; + } + } + if ($outside === 0) { + continue; // 父母都在画布上,这一侧已经露出来了 + } + $out[$id] = [ + 'name' => (string)($people[$id]['name'] ?? ''), + 'count' => $outside, + ]; + } + return $out; + } + + /** + * family-chart 实际会画出来的人(近似复刻它的选择规则)。 + * + * 规则(按库的行为): + * ① 根的**祖先链**(一直往上); + * ② 根的**后代**(一直往下 —— 库在未设上限时不截断); + * ③ 根的**兄弟姐妹**(与根共享父母的人); + * ④ 以上所有人的**配偶**(夫妻并排是库的基本单位)。 + * + * ⚠️ **配偶的父母不在其中** —— 这正是"姻亲看不到"的根源,也是本函数存在的理由。 + * + * @param array $inScope 只在遍历到的人里挑(脏数据不越界) + * @return array + */ + private static function drawnOf(array $g, int $rootId, array $inScope): array + { + $drawn = []; + $mark = static function (int $id) use (&$drawn, $inScope): bool { + if (!isset($inScope[$id]) || isset($drawn[$id])) { + return false; + } + $drawn[$id] = true; + return true; + }; + $mark($rootId); + + // ① 祖先链 + $cur = $rootId; + for ($i = 0; $i < 12; $i++) { + $next = 0; + foreach (self::parentIds($g, $cur) as $p) { + if (isset($inScope[(int)$p])) { $next = (int)$p; break; } + } + if ($next === 0) { break; } + $mark($next); + $cur = $next; + } + + // ③ 根的兄弟姐妹(共享父母) + foreach (self::parentIds($g, $rootId) as $p) { + foreach (self::childIds($g, (int)$p) as $sib) { + $mark((int)$sib); + } + } + + // ② 后代:从根出发逐层往下;④ 每加进一个人,配偶跟着进来 + $queue = array_keys($drawn); + while ($queue) { + $id = (int)array_shift($queue); + foreach (self::spouseIds($g, $id) as $s) { + if ($mark((int)$s)) { $queue[] = (int)$s; } + } + foreach (self::childIds($g, $id) as $c) { + if ($mark((int)$c)) { $queue[] = (int)$c; } + } + } + // 收尾再来一轮配偶(新进来的孩子要带上他们的配偶) + foreach (array_keys($drawn) as $id) { + foreach (self::spouseIds($g, (int)$id) as $s) { + $mark((int)$s); + } + } + return $drawn; + } + /** * v15:把「孩子」按**父母所在的户**归堆。 * @@ -566,7 +709,13 @@ final class Kinship return $isKnown ? ($isMale ? '表兄弟' : '表姐妹') : '表亲'; } - return '亲属'; + // ⚠️ v24(用户 2026-10-02 第 1 条):这里原来兜底返回「亲属」。 + // 后果是名册里**每个没有血缘关系的人**(同事、朋友、老师…)都被贴上「亲属」—— + // 人家明明只是个联系人,叫"亲属"是错的,而且人一多满屏都是同一个词, + // 看不出任何信息。「亲属」这个词本身也没有定义:关系推导不出来就是没关系, + // 硬套一个称呼反而是编造。 + // 现在改为**返回空串**:没关系就不显示标签。调用方一律用 empty() 判断。 + return ''; } /** 配偶的称呼:按「我的性别」定,因为中文里丈夫/妻子是从自己视角说的 */ diff --git a/app/Ledger.php b/app/Ledger.php new file mode 100644 index 0000000..335b218 --- /dev/null +++ b/app/Ledger.php @@ -0,0 +1,314 @@ + [名字, 是资产还是负债, 图标] */ + public const KINDS = [ + 'deposit' => ['存款', 'asset', '🏦'], + 'fund' => ['基金', 'asset', '📈'], + 'stock' => ['股票', 'asset', '📊'], + 'insurance'=> ['保险', 'asset', '🛡'], + 'house' => ['房子', 'asset', '🏠'], + 'car' => ['车子', 'asset', '🚗'], + 'cash' => ['现金', 'asset', '💵'], + 'loan' => ['贷款', 'debt', '📉'], + 'credit' => ['信用卡', 'debt', '💳'], + 'other' => ['其它', 'asset', '📦'], + ]; + + public const PERM = ['read' => '只读', 'write' => '可写']; + + /** + * 我能看到的账本 = 我建的 + 共享给我的。 + * 全都带上 my_perm(我在这本账上能做什么),上层不必再查一次。 + */ + public static function mine(int $familyId, int $personId): array + { + $st = Database::get()->prepare( + 'SELECT l.*, s.perm AS share_perm, + (SELECT COUNT(*) FROM ledger_entries e WHERE e.ledger_id = l.id) AS entry_count + FROM ledgers l + LEFT JOIN ledger_shares s ON s.ledger_id = l.id AND s.person_id = ? + WHERE l.family_id = ? AND (l.owner_id = ? OR s.person_id IS NOT NULL) + ORDER BY l.id' + ); + $st->execute([$personId, $familyId, $personId]); + $out = []; + foreach ($st->fetchAll() as $r) { + $r['my_perm'] = (int)$r['owner_id'] === $personId + ? 'write' + : (string)($r['share_perm'] ?? 'read'); + $out[] = $r; + } + return $out; + } + + public static function find(int $familyId, int $id, int $personId): ?array + { + $st = Database::get()->prepare( + 'SELECT l.*, s.perm AS share_perm FROM ledgers l + LEFT JOIN ledger_shares s ON s.ledger_id = l.id AND s.person_id = ? + WHERE l.id = ? AND l.family_id = ? AND (l.owner_id = ? OR s.person_id IS NOT NULL)' + ); + $st->execute([$personId, $id, $familyId, $personId]); + $r = $st->fetch(); + if (!$r) { + return null; + } + $r['my_perm'] = (int)$r['owner_id'] === $personId ? 'write' : (string)($r['share_perm'] ?? 'read'); + return $r; + } + + public static function canWrite(array $ledger): bool + { + return ($ledger['my_perm'] ?? '') === 'write'; + } + + /** 账本里的全部账目 */ + public static function entries(int $ledgerId): array + { + $st = Database::get()->prepare( + 'SELECT e.*, p.name AS person_name, p.avatar, p.avatar_color, + n.nickname AS person_nick + FROM ledger_entries e + LEFT JOIN persons p ON p.id = e.person_id + LEFT JOIN nicknames n ON n.target_id = e.person_id AND n.viewer_id = ? + WHERE e.ledger_id = ? ORDER BY e.id' + ); + $st->execute([0, $ledgerId]); + return $st->fetchAll(); + } + + /** + * 这本账的汇总。 + * @return array{asset:int,debt:int,net:int,by_kind:array,count:int} + */ + public static function summary(array $entries): array + { + $asset = 0; + $debt = 0; + $byKind = []; + foreach ($entries as $e) { + $amt = (int)($e['amount_cents'] ?? 0); + $kind = (string)($e['kind'] ?? 'other'); + $isDebt = (self::KINDS[$kind][1] ?? 'asset') === 'debt'; + if ($isDebt) { + $debt += $amt; + } else { + $asset += $amt; + } + $byKind[$kind] = ($byKind[$kind] ?? 0) + $amt; + } + return [ + 'asset' => $asset, + 'debt' => $debt, + 'net' => $asset - $debt, + 'by_kind' => $byKind, + 'count' => count($entries), + ]; + } + + // ---------------- 写 ---------------- + + /** @return int 新账本 id */ + public static function create(int $familyId, int $ownerId, string $name): int + { + $db = Database::get(); + $name = trim($name); + if ($name === '') { + $name = '家庭账本'; + } + $db->prepare('INSERT INTO ledgers (family_id, owner_id, name) VALUES (?,?,?)') + ->execute([$familyId, $ownerId ?: null, mb_substr($name, 0, 60, 'UTF-8')]); + return (int)$db->lastInsertId(); + } + + /** + * 这个家庭至少有**一本我能看到的**账本;没有就建一本并给我共享。 + * @return int 账本 id + */ + public static function ensureDefault(int $familyId, int $personId): int + { + $mine = self::mine($familyId, $personId); + if ($mine) { + return (int)$mine[0]['id']; + } + $id = self::create($familyId, $personId, '家庭账本'); + return $id; // 建的人就是 owner,天然能写 + } + + public static function rename(int $familyId, int $id, string $name): void + { + Database::get()->prepare('UPDATE ledgers SET name = ? WHERE id = ? AND family_id = ?') + ->execute([mb_substr(trim($name), 0, 60, 'UTF-8'), $id, $familyId]); + } + + public static function remove(int $familyId, int $id): void + { + // 账目与共享行靠外键 ON DELETE CASCADE 一起走 + Database::get()->prepare('DELETE FROM ledgers WHERE id = ? AND family_id = ?') + ->execute([$id, $familyId]); + } + + /** 共享给某人(重复共享 = 改权限) */ + public static function setShare(int $ledgerId, int $personId, string $perm = 'read'): void + { + $perm = isset(self::PERM[$perm]) ? $perm : 'read'; + Database::get()->prepare( + 'INSERT INTO ledger_shares (ledger_id, person_id, perm) VALUES (?,?,?) + ON DUPLICATE KEY UPDATE perm = VALUES(perm)' + )->execute([$ledgerId, $personId, $perm]); + } + + public static function unshare(int $ledgerId, int $personId): void + { + Database::get()->prepare('DELETE FROM ledger_shares WHERE ledger_id = ? AND person_id = ?') + ->execute([$ledgerId, $personId]); + } + + public static function shares(int $ledgerId): array + { + $st = Database::get()->prepare( + 'SELECT s.*, p.name, p.avatar, p.avatar_color + FROM ledger_shares s JOIN persons p ON p.id = s.person_id + WHERE s.ledger_id = ? ORDER BY s.id' + ); + $st->execute([$ledgerId]); + return $st->fetchAll(); + } + + /** @return int 新增账目 id */ + public static function addEntry(int $ledgerId, array $d, int $byId = 0): int + { + $db = Database::get(); + $db->prepare( + 'INSERT INTO ledger_entries (ledger_id, kind, title, amount_cents, note, person_id, created_by) + VALUES (?,?,?,?,?,?,?)' + )->execute([ + $ledgerId, + self::kind($d['kind'] ?? ''), + mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'), + self::toCents($d['amount'] ?? ''), + trim((string)($d['note'] ?? '')) ?: null, + (int)($d['person_id'] ?? 0) ?: null, + $byId ?: null, + ]); + return (int)$db->lastInsertId(); + } + + public static function updateEntry(int $ledgerId, int $id, array $d): void + { + Database::get()->prepare( + 'UPDATE ledger_entries SET kind=?, title=?, amount_cents=?, note=?, person_id=? + WHERE id=? AND ledger_id=?' + )->execute([ + self::kind($d['kind'] ?? ''), + mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'), + self::toCents($d['amount'] ?? ''), + trim((string)($d['note'] ?? '')) ?: null, + (int)($d['person_id'] ?? 0) ?: null, + $id, $ledgerId, + ]); + } + + public static function removeEntry(int $ledgerId, int $id): void + { + Database::get()->prepare('DELETE FROM ledger_entries WHERE id = ? AND ledger_id = ?') + ->execute([$id, $ledgerId]); + } + + // ---------------- 钱 ---------------- + + /** + * 「元」→「分」。 + * + * ⚠️ 不能用 (int)($yuan * 100):浮点乘法会掉一分(19.99 * 100 = 1998.9999…)。 + * 一律走字符串:拆出整数部与小数部,各自转整数再拼。 + */ + public static function toCents($yuan): int + { + $s = trim((string)$yuan); + $s = str_replace([',', ',', ' '], '', $s); + if ($s === '') { + return 0; + } + $neg = false; + if ($s[0] === '-') { + $neg = true; + $s = substr($s, 1); + } + if (!preg_match('/^(\d*)(?:\.(\d*))?$/', $s, $m)) { + return 0; + } + $intPart = $m[1] !== '' ? $m[1] : '0'; + $dec = $m[2] ?? ''; + $dec = str_pad(substr($dec, 0, 2), 2, '0'); // 超过两位小数直接截断(不做四舍五入到厘) + $cents = (int)$intPart * 100 + (int)$dec; + return $neg ? -$cents : $cents; + } + + /** 「分」→ 显示用的「元」字符串(总是两位小数) */ + public static function amountYuan($cents): string + { + $c = (int)$cents; + $neg = $c < 0; + $c = abs($c); + $s = sprintf('%d.%02d', intdiv($c, 100), $c % 100); + return ($neg ? '-' : '') . $s; + } + + /** 带千分位、带 ¥ 的显示(列表/汇总用) */ + public static function money($cents): string + { + $c = (int)$cents; + $neg = $c < 0; + $c = abs($c); + $s = number_format(intdiv($c, 100)) . '.' . sprintf('%02d', $c % 100); + return ($neg ? '-' : '') . '¥' . $s; + } + + public static function kind(string $k): string + { + return isset(self::KINDS[$k]) ? $k : 'other'; + } + + public static function kindWord(string $k): string + { + return self::KINDS[$k][0] ?? '其它'; + } + + public static function kindIcon(string $k): string + { + return self::KINDS[$k][2] ?? '📦'; + } + + public static function isDebt(string $k): bool + { + return (self::KINDS[$k][1] ?? 'asset') === 'debt'; + } +} \ No newline at end of file diff --git a/app/Module.php b/app/Module.php index 6f8b67e..f57d022 100644 --- a/app/Module.php +++ b/app/Module.php @@ -41,10 +41,23 @@ final class Module 'calendar' => ['日程', '#B0763A', 'calendar'], 'resources' => ['资源', '#5566A8', 'resources'], 'tasks' => ['任务', '#A8574F', 'tasks'], + // v24 第 13 条:纪念日(含名册里每个人的生日) + 'days' => ['纪念日', '#B4633C', 'days'], + // v24 第 15 条:财务(账本 + 账目 + 共享) + 'ledger' => ['财务', '#5E7A3C', 'ledger'], ]; - /** 默认出现在导航条上的(没自定义过就用它)。首页永远在,且不能被拖到最后面也不影响。 */ - public const DEFAULT_NAV = ['home', 'kin', 'people', 'movies', 'trips', 'calendar', 'resources', 'tasks']; + /** + * 默认出现在导航条上的(没自定义过就用它)。首页永远在,且不能被拖到最后面也不影响。 + * + * ⚠️ v24(用户 2026-10-02 第 14 条):**家谱默认不上导航条** —— + * "毕竟,不需要每天都查看家谱"。家谱模块本身还在(个人设置里能勾回来), + * 只是不再占顶栏的位置。 + */ + public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources', 'tasks', 'days', 'ledger']; + + /** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */ + private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24'; /** * 主题色的预设色板(个人设置里点一下就换)。 @@ -106,6 +119,19 @@ final class Module $saved[] = $k; } } + + // v24 一次性迁移:老用户存过配置、里面还留着家谱的,帮他撤下来。 + // ⚠️ 只做一次(记 NAV_KIN_OFF_FLAG)—— 之后他自己在设置里勾回来就该尊重他, + // 否则每次请求都删一遍 = "家谱开关"永远失效。 + if ($personId > 0 + && (string)UserSetting::get($personId, self::NAV_KIN_OFF_FLAG, '') === '') { + UserSetting::set($personId, self::NAV_KIN_OFF_FLAG, '1'); + if (in_array('kin', $saved, true)) { + $saved = array_values(array_filter($saved, static fn($k) => $k !== 'kin')); + UserSetting::set($personId, 'nav_modules', + json_encode($saved, JSON_UNESCAPED_UNICODE)); + } + } // ⚠️ 「新装的模块要自动出现」**不能**用"不在 saved 里就补"来实现 —— // 那样用户**主动关掉**的模块下一次请求就会被补回来, // 「可设置哪些模块出现在导航条」直接失效(实测:关掉观影,刷新它又回来了)。 diff --git a/app/Resource.php b/app/Resource.php index 8eea49a..528d343 100644 --- a/app/Resource.php +++ b/app/Resource.php @@ -123,7 +123,17 @@ final class Resource self::textOrNull($d['content'] ?? ''), $createdBy ?: null, ]); - return (int)$db->lastInsertId(); + $id = (int)$db->lastInsertId(); + + // v24(用户 2026-10-02 第 17 条):**我建的、挂在别人名下的资源,我自己也要看得见**。 + // 以前只有 owner 能看(共享表里不含 owner),于是"我帮孩子录了一张证件"之后, + // 这条资源从我的列表里**消失了** —— 我刚建的东西自己却找不到,看着像数据丢了。 + // 现在建的同时给自己补一条共享(可读写):既能看能改,也不会在"共享给谁" + // 的列表里冒出 owner 自己(owner 那栏由 perm='owner' 单独表示)。 + if ($createdBy > 0 && $createdBy !== $ownerId) { + self::setShare($id, $ownerId, $createdBy, 'write'); + } + return $id; } public static function update(int $familyId, int $id, array $d): void diff --git a/app/Schema.php b/app/Schema.php index 3ba53a2..97ff7b6 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -119,6 +119,19 @@ CREATE TABLE IF NOT EXISTS `accounts` ( UNIQUE KEY `uk_account_person` (`person_id`), CONSTRAINT `fk_accounts_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录账号' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `caldav_credentials` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `person_id` INT UNSIGNED NOT NULL, + `token_hash` CHAR(64) NOT NULL COMMENT 'CalDAV 专用口令的 sha256;口令本身不落库', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_used_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cd_person` (`person_id`), + CONSTRAINT `fk_cd_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='CalDAV 专用口令(与登录口令分开:客户端会把它长期存着,不能是登录口令)' SQL, <<<'SQL' CREATE TABLE IF NOT EXISTS `relations` ( @@ -759,6 +772,179 @@ SQL, } }); + // ---- 日程按 CalDAV / iCalendar 对齐(版本 13)---- + // 用户 2026-10-02 第 5、6 条:起止时刻**精确到秒**,且「日期和时间在一个控件里一起选」。 + // 原来是 starts_at DATE + start_time TIME 两个列,天然就是"分开的"两件事 —— + // 连"跨天又跨时刻"这种最基本的日程都表达不了。现在并成一个 DATETIME。 + // ⚠️ 先 MODIFY 再回填:MODIFY 之后老数据的 DATE 自动变成 'YYYY-MM-DD 00:00:00', + // 紧接着把 start_time 并进去,老日程的时刻才不会一夜之间全变成 00:00。 + $db->exec("ALTER TABLE `calendar_events` MODIFY COLUMN `starts_at` + DATETIME NOT NULL COMMENT '开始时刻(精确到秒;全天日程的时间部分为 00:00:00)'"); + $db->exec("ALTER TABLE `calendar_events` MODIFY COLUMN `ends_at` + DATETIME DEFAULT NULL COMMENT '结束时刻(精确到秒);NULL=与开始同一时刻'"); + self::tryStep('老日程的时刻并进 starts_at/ends_at', static function () use ($db) { + $db->exec("UPDATE `calendar_events` + SET `starts_at` = CONCAT(DATE(`starts_at`), ' ', `start_time`) + WHERE `start_time` IS NOT NULL AND `all_day` = 0"); + $db->exec("UPDATE `calendar_events` + SET `ends_at` = CONCAT(DATE(`ends_at`), ' ', `end_time`) + WHERE `ends_at` IS NOT NULL AND `end_time` IS NOT NULL AND `all_day` = 0"); + }); + // 其余字段按 RFC 5545(iCalendar)/ RFC 4791(CalDAV)的 VEVENT 属性定: + // UID/DTSTAMP/SEQUENCE 是 CalDAV 同步三件套(没有它们客户端每次都当新日程); + // LOCATION / STATUS / TRANSP / RRULE 是用户第 6 条要的地点与忙闲; + // reminder_minutes 对应 VALARM 的 TRIGGER(提前多久提醒)。 + self::addColumn($db, 'calendar_events', 'uid', + "ADD COLUMN `uid` VARCHAR(255) DEFAULT NULL COMMENT 'iCalendar UID(RFC 5545):一条日程全球唯一的身份证,CalDAV 与 ics 导入导出全靠它判重' AFTER `calendar_id`"); + self::addColumn($db, 'calendar_events', 'location', + "ADD COLUMN `location` VARCHAR(255) DEFAULT NULL COMMENT '地点(RFC 5545 LOCATION)' AFTER `title`"); + self::addColumn($db, 'calendar_events', 'reminder_minutes', + "ADD COLUMN `reminder_minutes` INT DEFAULT NULL COMMENT '提前多少分钟提醒(对应 VALARM 的 TRIGGER);NULL=不提醒' AFTER `all_day`"); + self::addColumn($db, 'calendar_events', 'status', + "ADD COLUMN `status` ENUM('confirmed','tentative','cancelled') NOT NULL DEFAULT 'confirmed' COMMENT 'RFC 5545 STATUS:确定 / 待定 / 取消' AFTER `reminder_minutes`"); + self::addColumn($db, 'calendar_events', 'transp', + "ADD COLUMN `transp` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'RFC 5545 TRANSP:0=忙碌(OPAQUE) / 1=空闲(TRANSPARENT)' AFTER `status`"); + self::addColumn($db, 'calendar_events', 'rrule', + "ADD COLUMN `rrule` VARCHAR(255) DEFAULT NULL COMMENT 'RFC 5545 RRULE 重复规则原文(ics 导入导出原样带过,不做解释)' AFTER `transp`"); + self::addColumn($db, 'calendar_events', 'sequence', + "ADD COLUMN `sequence` INT NOT NULL DEFAULT 0 COMMENT 'RFC 5545 SEQUENCE:改一次 +1,CalDAV 客户端据此判断要不要拉新版' AFTER `rrule`"); + self::addColumn($db, 'calendar_events', 'created_at', + "ADD COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'RFC 5545 CREATED' AFTER `created_by`"); + self::addColumn($db, 'calendar_events', 'updated_at', + "ADD COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'RFC 5545 LAST-MODIFIED' AFTER `created_at`"); + // 老日程补 UID(CalDAV 那边没有 UID 就等于"每次都是新日程",客户端会无限重复) + self::tryStep('给老日程补 UID / SEQUENCE', static function () use ($db) { + self::backfillCalendarUid($db); + }); + // 每人的 CalDAV 口令(第 7 条:把我的日历本通过 CalDAV 分享出去)。 + // 存 user_settings,不新建表 —— 与 nav_modules / nav_known 同一套做法。 + + // ---- 资源的创建者默认共享(版本 13)---- + // 历史数据里「我建的、挂在别人名下的资源」没有共享行 → 我在自己的列表里看不到它。 + // 新建时已经会自动补(Resource::create),这里把老的一次补齐(幂等:只补没有的)。 + self::tryStep('回填资源创建者的共享权限', static function () use ($db) { + self::backfillResourceCreatorShare($db); + }); + + // ---- 任务:分类 + 自定义看板(版本 13,用户第 9、10 条)---- + // 看板(board)与状态(status)是两件事: + // status 是这条任务**做到哪一步**(todo / doing / done); + // board 是它**属于哪一块**(家务 / 装修 / 旅行准备 …)。 + // 之前只有 status,于是"想分开看几件事"只能靠给标题加前缀,越用越乱。 + $db->exec( + 'CREATE TABLE IF NOT EXISTS `task_boards` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(80) NOT NULL, + `position` INT NOT NULL DEFAULT 100, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_tb_family` (`family_id`, `position`), + CONSTRAINT `fk_tb_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + $db->exec( + 'CREATE TABLE IF NOT EXISTS `task_categories` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(80) NOT NULL, + `position` INT NOT NULL DEFAULT 100, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_tc_family` (`family_id`, `position`), + CONSTRAINT `fk_tc_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + self::addColumn($db, 'tasks', 'board_id', + "ADD COLUMN `board_id` INT UNSIGNED DEFAULT NULL COMMENT '属于哪个看板(用户自建;NULL=默认看板)' AFTER `family_id`"); + self::addColumn($db, 'tasks', 'category_id', + "ADD COLUMN `category_id` INT UNSIGNED DEFAULT NULL COMMENT '属于哪个分类(用户自建;NULL=未分类)' AFTER `board_id`"); + // 每个家庭至少有一个看板,否则"看板"这个概念在界面上就是空的 + self::tryStep('给每个家庭补一个默认看板', static function () use ($db) { + foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { + \App\Task::ensureDefaultBoard((int)$fam['id']); + } + }); + + // ---- 纪念日(版本 13,用户第 13 条)---- + // ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的 + // (见 Anniversary::birthdays),改了生日纪念日就跟着变。 + // 这里只存"用户自己加的"纪念日(结婚纪念日、忌日、相识纪念日…)。 + // month/day 按**该重复方式的月日**存:repeat_type=lunar 时是农历的月日, + // 不是公历换算结果(换算只对某一年有效,存了明年就错)。 + // ⚠️ 用 heredoc(<<<'SQL')而不是手写单引号字符串:这一段里有 ENUM 的 + // 引号、中文 COMMENT、反引号列名,逐处 `\'` 转义极容易漏一个引号 —— + // 实测就漏过(PHP 报 "unexpected identifier VARCHAR",看着像 SQL 语法错, + // 其实全是 PHP 字符串没闭合)。heredoc 里什么都不用转义。 + $db->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `anniversaries` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `title` VARCHAR(120) NOT NULL, + `repeat_type` ENUM('solar','lunar','once') NOT NULL DEFAULT 'solar' + COMMENT '重复方式:solar=每年阳历同月同日 / lunar=每年农历同月同日 / once=只此一次', + `month` TINYINT UNSIGNED NOT NULL, + `day` TINYINT UNSIGNED NOT NULL, + `is_leap` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '农历:该月是否为闰月', + `kind` VARCHAR(40) DEFAULT NULL COMMENT '分类:生日 / 结婚纪念日 / 忌日 …', + `note` TEXT DEFAULT NULL, + `person_id` INT UNSIGNED DEFAULT NULL COMMENT '与谁相关(可空)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_ann_family` (`family_id`, `month`, `day`), + CONSTRAINT `fk_ann_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_ann_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='纪念日:用户自建(生日不在此表,由 persons 实时派生)' +SQL); + + // ---- 财务 / 账本(版本 13,用户第 15 条)---- + // 两层:账本(可共享给人的一本总账)+ 账目(存款/基金/保险/房子/车子…)。 + // ⚠️ 金额一律存**分**(BIGINT),不存浮点:钱是最不能容忍 + // "0.1+0.2=0.30000000000000004" 的地方(见 Ledger::toCents 的注释)。 + $db->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `ledgers` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `owner_id` INT UNSIGNED DEFAULT NULL COMMENT '创建者', + `name` VARCHAR(60) NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_led_family` (`family_id`), + CONSTRAINT `fk_led_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_led_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账本:一本家庭总账,可共享给人' +SQL); + $db->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `ledger_entries` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `ledger_id` INT UNSIGNED NOT NULL, + `kind` VARCHAR(20) NOT NULL DEFAULT 'other' COMMENT '存款/基金/保险/房子/车子/贷款…', + `title` VARCHAR(120) NOT NULL, + `amount_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '金额(**分**):正数记绝对值,是资产还是负债由 kind 决定', + `note` TEXT DEFAULT NULL, + `person_id` INT UNSIGNED DEFAULT NULL COMMENT '挂在谁名下(可空)', + `created_by` INT UNSIGNED DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_le_ledger` (`ledger_id`), + CONSTRAINT `fk_le_ledger` FOREIGN KEY (`ledger_id`) REFERENCES `ledgers` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_le_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账目:一笔资产或负债' +SQL); + $db->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `ledger_shares` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `ledger_id` INT UNSIGNED NOT NULL, + `person_id` INT UNSIGNED NOT NULL, + `perm` ENUM('read','write') NOT NULL DEFAULT 'read', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_led_person` (`ledger_id`, `person_id`), + CONSTRAINT `fk_ls_ledger` FOREIGN KEY (`ledger_id`) REFERENCES `ledgers` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_ls_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账本共享' +SQL); + // 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 —— // 不然「升级没跑完」这件事只活在日志里,跟没发生一样。 if (self::$stepErrors) { @@ -771,6 +957,47 @@ SQL, } } + /** + * v24:给老日程补 UID(RFC 5545 要求 VEVENT 必须有 UID)。 + * + * 格式沿用「域名倒写 + 随机」的通行写法:`-@familybook`, + * 保证在本系统内唯一即可(跨系统的唯一性靠随机段)。幂等:只补 uid IS NULL 的。 + */ + private static function backfillCalendarUid(\PDO $db): void + { + $rows = $db->query('SELECT id FROM calendar_events WHERE uid IS NULL OR uid = \'\'')->fetchAll(); + if (!$rows) { + return; + } + $st = $db->prepare('UPDATE calendar_events SET uid = ? WHERE id = ?'); + foreach ($rows as $r) { + $st->execute([bin2hex(random_bytes(8)) . '-' . (int)$r['id'] . '@familybook', (int)$r['id']]); + } + } + + /** + * v24(用户 2026-10-02 第 17 条):给「我建的、挂在别人名下的资源」补一条共享。 + * + * 只补 `created_by` 有值且 ≠ owner 的、**共享表里还没有这一对的**。 + * 幂等(NOT EXISTS 判过才插),重复跑不插重。 + */ + private static function backfillResourceCreatorShare(\PDO $db): void + { + if (!self::hasColumn($db, 'resources', 'created_by')) { + return; + } + $db->exec( + "INSERT INTO resource_shares (resource_id, person_id, perm) + SELECT r.id, r.created_by, 'write' + FROM resources r + WHERE r.created_by IS NOT NULL + AND r.created_by <> r.owner_id + AND NOT EXISTS ( + SELECT 1 FROM resource_shares s + WHERE s.resource_id = r.id AND s.person_id = r.created_by)" + ); + } + /** * ⚠️ `birthday_lunar = ''` 这种写法**只在本机的 MariaDB 上能跑**: * MySQL 严格模式(线上是 8.0 + STRICT_TRANS_TABLES)会把右边的 `''` diff --git a/app/Task.php b/app/Task.php index bf3e653..b3c4cfa 100644 --- a/app/Task.php +++ b/app/Task.php @@ -35,6 +35,202 @@ final class Task /** 附件的宿主类型 */ public const ATTACH = 'task'; + // ---------------- 看板与分类(v24 用户第 9、10 条)---------------- + + /** + * 这个家庭的看板列表。 + * + * ⚠️ 「看板」与「状态」是两件事:状态是这条任务做到哪一步,看板是它属于哪一块 + * (家务 / 装修 / 旅行准备)。之前只有状态,想分开看只能给标题加前缀。 + */ + public static function boards(int $familyId): array + { + $st = Database::get()->prepare( + 'SELECT b.*, (SELECT COUNT(*) FROM tasks t WHERE t.board_id = b.id AND t.parent_id IS NULL + AND t.status <> "done") AS open_count + FROM task_boards b WHERE b.family_id = ? ORDER BY b.position, b.id' + ); + $st->execute([$familyId]); + return $st->fetchAll(); + } + + public static function categories(int $familyId): array + { + $st = Database::get()->prepare( + 'SELECT c.*, (SELECT COUNT(*) FROM tasks t WHERE t.category_id = c.id) AS used_count + FROM task_categories c WHERE c.family_id = ? ORDER BY c.position, c.id' + ); + $st->execute([$familyId]); + return $st->fetchAll(); + } + + public static function boardName(int $familyId, ?int $boardId): string + { + if (!$boardId) { + return '默认看板'; + } + $st = Database::get()->prepare('SELECT name FROM task_boards WHERE id = ? AND family_id = ?'); + $st->execute([$boardId, $familyId]); + $v = $st->fetchColumn(); + return $v === false ? '' : (string)$v; + } + + /** + * 这个家庭至少有一个看板(升级时给老家庭补,新家庭在建号时补)。 + * @return int 默认看板的 id + */ + public static function ensureDefaultBoard(int $familyId): int + { + $db = Database::get(); + $st = $db->prepare('SELECT id FROM task_boards WHERE family_id = ? ORDER BY position, id LIMIT 1'); + $st->execute([$familyId]); + $id = (int)$st->fetchColumn(); + if ($id > 0) { + return $id; + } + $db->prepare('INSERT INTO task_boards (family_id, name, position) VALUES (?,?,?)') + ->execute([$familyId, '日常', 100]); + return (int)$db->lastInsertId(); + } + + /** @return int 新建看板的 id */ + public static function createBoard(int $familyId, string $name): int + { + $db = Database::get(); + $db->prepare('INSERT INTO task_boards (family_id, name, position) VALUES (?,?,?)') + ->execute([$familyId, self::shortName($name), self::nextBoardPosition($familyId)]); + return (int)$db->lastInsertId(); + } + + public static function renameBoard(int $familyId, int $id, string $name): void + { + Database::get()->prepare('UPDATE task_boards SET name = ? WHERE id = ? AND family_id = ?') + ->execute([self::shortName($name), $id, $familyId]); + } + + /** + * 删看板:里头的任务**不删**,挪到默认看板上(删看板 ≠ 删任务,这个代价太突然)。 + */ + public static function deleteBoard(int $familyId, int $id): void + { + $db = Database::get(); + $st = $db->prepare('SELECT COUNT(*) FROM task_boards WHERE family_id = ?'); + $st->execute([$familyId]); + if ((int)$st->fetchColumn() <= 1) { + return; // 最后一个看板不许删:删了界面上就没处放任务 + } + $def = self::ensureDefaultBoard($familyId); + $db->prepare('UPDATE tasks SET board_id = ? WHERE board_id = ? AND family_id = ?') + ->execute([$def === $id ? null : $def, $id, $familyId]); + $db->prepare('DELETE FROM task_boards WHERE id = ? AND family_id = ?')->execute([$id, $familyId]); + } + + /** @return int 新建分类的 id */ + public static function createCategory(int $familyId, string $name): int + { + $db = Database::get(); + $db->prepare('INSERT INTO task_categories (family_id, name, position) VALUES (?,?,?)') + ->execute([$familyId, self::shortName($name), self::nextCategoryPosition($familyId)]); + return (int)$db->lastInsertId(); + } + + public static function renameCategory(int $familyId, int $id, string $name): void + { + Database::get()->prepare('UPDATE task_categories SET name = ? WHERE id = ? AND family_id = ?') + ->execute([self::shortName($name), $id, $familyId]); + } + + /** 删分类:用过的任务变成「未分类」,任务本身不动 */ + public static function deleteCategory(int $familyId, int $id): void + { + $db = Database::get(); + $db->prepare('UPDATE tasks SET category_id = NULL WHERE category_id = ? AND family_id = ?') + ->execute([$id, $familyId]); + $db->prepare('DELETE FROM task_categories WHERE id = ? AND family_id = ?')->execute([$id, $familyId]); + } + + /** + * 已完成的任务(v24 第 10 条:从看板上消失,另外一处按分类查看/搜索)。 + * + * @param int $categoryId 0=全部分类 + * @param string $keyword 标题里含这个串(空=不限) + */ + public static function doneList(int $familyId, int $personId, int $categoryId = 0, string $keyword = ''): array + { + $kw = trim($keyword); + $sql = 'SELECT t.*, c.name AS category_name, + a.name AS assignee_name, a.avatar, a.avatar_color, + na.nickname AS assignee_nick + FROM tasks t + LEFT JOIN task_categories c ON c.id = t.category_id + 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 = ? + WHERE t.family_id = ? AND t.status = "done" AND t.parent_id IS NULL + AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)'; + $args = [$personId, $personId, $familyId, $personId, $personId]; + if ($categoryId > 0) { + $sql .= ' AND t.category_id = ?'; + $args[] = $categoryId; + } + if ($kw !== '') { + $sql .= ' AND t.title LIKE ?'; + $args[] = '%' . $kw . '%'; + } + $sql .= ' ORDER BY t.done_at DESC, t.id DESC'; + $st = Database::get()->prepare($sql); + $st->execute($args); + return $st->fetchAll(); + } + + /** + * 看板 id 归一化:0 / 不存在的 id → 默认看板,并且**必须属于这个家庭** + * (不校验的话,改一下表单就能把任务挂到别人家的看板上)。 + * @return int|null NULL 表示"用默认看板"落在库里也存 NULL,读的时候 COALESCE 回来 + */ + private static function boardIdOf(int $familyId, int $id): ?int + { + if ($id <= 0) { + return self::ensureDefaultBoard($familyId); + } + $st = Database::get()->prepare('SELECT id FROM task_boards WHERE id = ? AND family_id = ?'); + $st->execute([$id, $familyId]); + $v = $st->fetchColumn(); + return $v === false ? null : (int)$v; + } + + /** 分类 id 归一化:0 = 未分类(NULL);不属于这个家庭的一律不认 */ + private static function categoryIdOf(int $familyId, int $id): ?int + { + if ($id <= 0) { + return null; + } + $st = Database::get()->prepare('SELECT id FROM task_categories WHERE id = ? AND family_id = ?'); + $st->execute([$id, $familyId]); + $v = $st->fetchColumn(); + return $v === false ? null : (int)$v; + } + + private static function shortName(string $n): string + { + $n = trim(preg_replace('/\s+/u', ' ', $n ?? '')); + return mb_substr($n, 0, 40, 'UTF-8'); + } + + private static function nextBoardPosition(int $familyId): int + { + $st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM task_boards WHERE family_id = ?'); + $st->execute([$familyId]); + return (int)$st->fetchColumn(); + } + + private static function nextCategoryPosition(int $familyId): int + { + $st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM task_categories WHERE family_id = ?'); + $st->execute([$familyId]); + return (int)$st->fetchColumn(); + } + // ---------------- 读 ---------------- /** @@ -43,24 +239,33 @@ final class Task * 一次查出来,内存里按 status 分三堆(看板要的就是这三堆)。 * ⚠️ 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。 */ - public static function board(int $familyId, int $personId): array + public static function board(int $familyId, int $personId, int $boardId = 0): array { + // ⚠️ v24 第 10 条:**已完成不出现在看板里**(用户原话「直接消失」)。 + // 所以这里根本不查 status='done';它们改由 doneList() 单独呈现。 + // 看板上仍然只有 todo / doing 两列 —— 第三列没了,才是"已完成消失"。 + // 老任务的 board_id 是 NULL → 一律算在**默认看板**上(COALESCE 到默认看板 id) + $defId = self::ensureDefaultBoard($familyId); + $boardId = $boardId > 0 ? $boardId : $defId; $st = Database::get()->prepare( 'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color, na.nickname AS assignee_nick, - (SELECT COUNT(*) FROM tasks c WHERE c.parent_id = t.id) AS child_total, - (SELECT COUNT(*) FROM tasks c WHERE c.parent_id = t.id AND c.status = "done") AS child_done, + c.name AS category_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, (SELECT COUNT(*) FROM task_comments m WHERE m.task_id = t.id) AS comment_count, (SELECT COUNT(*) FROM attachments f WHERE f.target_type = "task" AND f.target_id = t.id) AS attach_count 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 = ? - WHERE t.family_id = ? AND t.parent_id IS NULL + LEFT JOIN task_categories c ON c.id = t.category_id + WHERE t.family_id = ? AND t.parent_id IS NULL AND t.status <> "done" + AND COALESCE(t.board_id, ?) = ? AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL) ORDER BY t.position, t.id' ); - $st->execute([$personId, $personId, $familyId, $personId, $personId]); + $st->execute([$personId, $personId, $familyId, $defId, $boardId, $personId, $personId]); $rows = $st->fetchAll(); // 一次把全部子任务取出来挂上去(N+1 会让看板在几十条时明显变慢) @@ -70,6 +275,8 @@ final class Task } unset($r); + // ⚠️ 只有 todo / doing 两列:done 已经从看板上撤走了(v24 第 10 条)。 + // 保留 'done' 这个键(恒空)只为兼容个别直接按 key 取的老调用。 $out = ['todo' => [], 'doing' => [], 'done' => []]; foreach ($rows as $r) { $k = (string)$r['status']; @@ -216,11 +423,13 @@ final class Task { $db = Database::get(); $db->prepare( - 'INSERT INTO tasks (family_id, parent_id, title, note, status, due_at, position, - creator_id, assignee_id, repeat_rule) - VALUES (?,?,?,?,?,?,?,?,?,?)' + 'INSERT INTO tasks (family_id, board_id, category_id, parent_id, title, note, status, due_at, + position, creator_id, assignee_id, repeat_rule) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)' )->execute([ $familyId, + self::boardIdOf($familyId, (int)($d['board_id'] ?? 0)), + self::categoryIdOf($familyId, (int)($d['category_id'] ?? 0)), self::parentId($familyId, (int)($d['parent_id'] ?? 0)), self::title($d['title'] ?? ''), self::textOrNull($d['note'] ?? ''), @@ -247,7 +456,7 @@ final class Task $status = self::status($d['status'] ?? ''); $db->prepare( 'UPDATE tasks SET title=?, note=?, status=?, due_at=?, assignee_id=?, repeat_rule=?, - parent_id=? WHERE id=? AND family_id=?' + parent_id=?, board_id=?, category_id=? WHERE id=? AND family_id=?' )->execute([ self::title($d['title'] ?? ''), self::textOrNull($d['note'] ?? ''), @@ -256,6 +465,10 @@ final class Task (int)($d['assignee_id'] ?? 0) ?: null, self::repeat($d['repeat_rule'] ?? ''), $pid, + array_key_exists('board_id', $d) ? self::boardIdOf($familyId, (int)$d['board_id']) : $old['board_id'], + array_key_exists('category_id', $d) + ? self::categoryIdOf($familyId, (int)$d['category_id']) + : $old['category_id'], $id, $familyId, ]); if ($status === 'done' && (string)$old['status'] !== 'done') { diff --git a/assets/css/app.css b/assets/css/app.css index e4a918c..b37c297 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -521,11 +521,28 @@ textarea.input { min-height: 76px; resize: vertical; } .kin-card:hover { box-shadow: var(--shadow-l); border-color: var(--brand-2); text-decoration: none; } +/* ⚠️ v24 第 12 条:头像是**方形**、不裁切。 + 卡片高度从 120 加到 136,把这块从 70 放大到 74×74 的正方形; + object-fit 用 contain(不是 cover)—— cover 会把非正方形的头像切掉边, + 用户要的正是"所有人的头像都是方形的,别截断"。 */ +.kin-card { + width: 148px; height: 138px; +} .kin-card .kin-ph { - width: 100%; height: 70px; border-radius: 7px; overflow: hidden; + width: 74px; height: 74px; border-radius: 6px; overflow: hidden; background: #F1E7D6; flex: 0 0 auto; } -.kin-card .kin-ph img { width: 100%; height: 100%; object-fit: cover; display: block; } +.kin-card .kin-ph img { + width: 100%; height: 100%; object-fit: contain; display: block; +} +/* 「⇄ 姻亲」:卡片右下角的小按钮(这一侧的人没画在本图上时才有) */ +.kin-card .kin-branch { + position: absolute; right: 3px; bottom: 3px; + font-size: 10px; line-height: 1; padding: 3px 5px; border-radius: 999px; + background: var(--brand); color: #fff; cursor: pointer; + box-shadow: 0 1px 3px rgba(43, 38, 32, .25); +} +.kin-card .kin-branch:hover { background: var(--brand-ink); } .kin-card .kin-nm { font-size: 12.5px; font-weight: 600; line-height: 1.25; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1148,6 +1165,13 @@ textarea.input { min-height: 76px; resize: vertical; } .lb-btn:hover { background: rgba(255, 255, 255, .26); } .lb-btn[hidden] { display: none; } .lb-close { top: 14px; right: 16px; width: 40px; height: 40px; border-radius: 50%; font-size: 18px; } +/* 下载按钮在关闭按钮左边,同高、同一套视觉(v24 第 3 条:预览层右上角要有下载) */ +.lb-dl { + top: 14px; right: 66px; height: 40px; padding: 0 16px; border-radius: 20px; + display: inline-flex; align-items: center; font-size: 13px; font-weight: 600; + background: #fff; color: #1a1a1a; text-decoration: none; +} +.lb-dl:hover { background: #eee; } .lb-nav { top: 50%; transform: translateY(-50%); width: 48px; height: 48px; border-radius: 50%; font-size: 28px; padding-bottom: 4px; } .lb-prev { left: 14px; } .lb-next { right: 14px; } @@ -1205,54 +1229,217 @@ textarea.input { min-height: 76px; resize: vertical; } .stars-disp { display: inline-flex; gap: 1px; font-size: 17px; line-height: 1; vertical-align: -2px; } .stars-disp .d-on { color: #f5b301; } .stars-disp .d-off { color: #d3d7de; } -/* ---- 同行观影人选择(v10):chips + 弹窗 + 拼音首字母联想(v11 起带头像) ---- */ -.att-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } -.att-chips:empty { display: none; } -.att-chip { +/* ---- 财务 / 账本(v24 第 15 条) ---- */ +/* 金额列右对齐 + 等宽数字(表格里数字不齐会很难扫) */ +.table .num, .table th.num { text-align: right; font-variant-numeric: tabular-nums; } +.table tfoot th { border-top: 2px solid var(--line); border-bottom: 0; color: var(--ink); } +.led-sum-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; } +.led-total { display: flex; flex-wrap: wrap; gap: 18px; margin: 12px 0 4px; } +.led-total-item { display: flex; flex-direction: column; gap: 2px; } +.led-total-lb { font-size: 12px; color: var(--muted); } +.led-total-num { + font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; + color: var(--ink); +} +.led-total-item.led-net .led-total-num { color: var(--brand); } +/* 数字颜色:**收入/资产用红、负债用绿**(与 A 股涨红跌绿同一套约定,不要反) */ +.led-asset { color: #B0342C; } +.led-debt { color: #1F7A4D; } +.led-kinds { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } +.led-kind-chip { + display: inline-flex; align-items: center; gap: 5px; + padding: 3px 9px; border-radius: 999px; font-size: 12px; + background: var(--line-2); color: var(--ink); white-space: nowrap; +} +.led-kind-chip b { font-variant-numeric: tabular-nums; } +.led-kind-chip.is-debt { background: #E7F2EC; } +.led-shares { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 10px; } +.led-share-chip { + display: inline-flex; align-items: center; gap: 6px; + padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px; +} +.led-ops { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px; } +.led-ops .inline-form { flex-wrap: wrap; align-items: center; } +.led-ops .input { min-width: 140px; } +.led-edit summary { cursor: pointer; } +.led-edit .node-form { min-width: min(520px, 86vw); } +/* ---- 纪念日(v24 第 13 条) ---- */ +.ann-up { list-style: none; margin: 0; padding: 0; } +.ann-up-item { + display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; + padding: 8px 2px; border-bottom: 1px solid var(--line-2); font-size: 14px; +} +.ann-up-item:last-child { border-bottom: 0; } +.ann-days { + flex: 0 0 auto; min-width: 62px; font-weight: 600; color: var(--brand); + font-variant-numeric: tabular-nums; +} +.ann-date { flex: 0 0 auto; color: var(--muted); font-variant-numeric: tabular-nums; } +.ann-title { flex: 1 1 auto; color: var(--ink); min-width: 0; } +.chk-inline { display: flex; align-items: center; gap: 6px; font-size: 14px; } +/* ---- 任务设置(v24 第 9、10 条):看板/分类的增删改列表 ---- */ +.kb-boards { margin-bottom: 12px; } +.set-list { display: flex; flex-direction: column; gap: 8px; margin: 4px 0 0; } +/* 一行 = 一个「改名」表单 + 一个「删除」表单,靠 flex 排成一条 */ +.set-row { flex: 1 1 auto; display: flex; align-items: center; gap: 8px; margin: 0; } +.set-row .input { flex: 1 1 auto; min-width: 0; } +.set-tail { flex: 0 0 auto; } +/* 「已完成」归档列表 */ +.done-list { list-style: none; margin: 0; padding: 0; } +.done-item { + display: flex; flex-wrap: wrap; align-items: center; gap: 8px; + padding: 9px 2px; border-bottom: 1px solid var(--line-2); font-size: 14px; +} +.done-item:last-child { border-bottom: 0; } +.done-title { color: var(--ink); text-decoration: none; } +.done-title:hover { color: var(--brand); text-decoration: underline; } +/* ---- 附件缩略图墙 + 全局预览(v24 第 3 条) ---- + 规则:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标; + 可预览的点开在本页这一层里看,不可预览的点了直接下载。 */ +.att-wall { + display: flex; flex-wrap: wrap; gap: 12px; margin: 8px 0 2px; +} +.att-cell { width: 132px; } +/* ⚠️ 缩略图与文档图标**必须是同一个盒子**:用户要的是"图标跟预览图一样大小", + 所以尺寸只在这里写一次,图标靠 flex 撑满,不另给一套尺寸。 */ +.att-thumb { + display: flex; align-items: center; justify-content: center; + width: 132px; height: 96px; border-radius: 8px; overflow: hidden; + border: 1px solid var(--line); background: var(--line-2); position: relative; +} +.att-thumb:hover { border-color: var(--brand); } +.att-thumb img, .att-thumb video { + width: 100%; height: 100%; object-fit: cover; display: block; +} +.att-thumb video { object-fit: cover; pointer-events: none; } +.att-play { + position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + color: #fff; font-size: 22px; text-shadow: 0 2px 8px rgba(0, 0, 0, .6); pointer-events: none; +} +.att-doc { + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 4px; width: 100%; height: 100%; + font-size: 12px; font-weight: 700; letter-spacing: .5px; color: #fff; +} +.att-doc::before { + content: ""; width: 34px; height: 34px; border-radius: 6px; background: rgba(255, 255, 255, .22); +} +.att-doc-pdf { background: #B0342C; } +.att-doc-doc { background: #2C5AA0; } +.att-doc-xls { background: #1F7A4D; } +.att-doc-ppt { background: #B4622A; } +.att-doc-text { background: #5F6672; } +.att-doc-file { background: #5F6672; } +.att-meta { + display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 6px; + margin-top: 4px; font-size: 12px; color: var(--muted); +} +.att-fname { + flex: 1 1 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--ink); +} +.att-fsize { flex: 0 0 auto; } +.att-meta form { display: inline; margin: 0; } +/* ---- 全局预览层 ---- */ +.pv { + position: fixed; inset: 0; z-index: 320; display: flex; flex-direction: column; + background: rgba(0, 0, 0, .9); overscroll-behavior: contain; +} +/* ⚠️ .pv 自己声明了 display:flex,必须补这条,否则 preview.js 里的 + box.hidden = true 藏不住它(与 .pp-item 同一个坑)。 */ +.pv[hidden] { display: none; } +.pv-head { + flex: 0 0 auto; display: flex; align-items: center; gap: 10px; + padding: 12px 16px; color: #fff; +} +.pv-name { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 14px; +} +.pv-meta { flex: 0 0 auto; font-size: 12px; opacity: .7; } +/* 下载按钮恒在右上角(用户第 3 条点名要求) */ +.pv-dl { + margin-left: auto; flex: 0 0 auto; padding: 6px 14px; border-radius: 8px; + background: #fff; color: #1a1a1a; font-size: 13px; font-weight: 600; text-decoration: none; +} +.pv-dl:hover { background: #eee; } +.pv-close { + flex: 0 0 auto; border: 0; background: rgba(255, 255, 255, .16); color: #fff; + width: 34px; height: 34px; border-radius: 50%; font-size: 16px; cursor: pointer; +} +.pv-close:hover { background: rgba(255, 255, 255, .28); } +.pv-stage { + flex: 1 1 auto; min-height: 0; display: flex; align-items: center; justify-content: center; + padding: 0 16px 16px; +} +.pv-img { + max-width: 92vw; max-height: 100%; width: auto; height: auto; + object-fit: contain; border-radius: 8px; +} +.pv-video { max-width: 92vw; max-height: 100%; width: auto; height: auto; background: #000; } +.pv-pdf { + width: 92vw; height: 100%; border: 0; border-radius: 8px; background: #fff; +} +@media (max-width: 560px) { + .att-cell, .att-thumb { width: 108px; } + .att-thumb { height: 80px; } +} +/* ---- 全局人员选择器(v24 第 11 条):chips + 弹窗 + 拼音首字母联想 ---- + ⚠️ .att-* 是这套组件早年在观影页的版本,已由 .pp-* 取代; + 保留只为历史样式,新页面一律用 _person_picker.php。 */ +.pp-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } +.pp-chip { display: inline-flex; align-items: center; gap: 6px; padding: 3px 6px 3px 4px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); font-size: 13px; } -.att-chip img { +.pp-chip img { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; background: #F1E7D6; } -.att-chip .att-x { +.pp-chip .pp-x { border: 0; background: none; color: var(--muted); font-size: 15px; line-height: 1; padding: 0 2px; cursor: pointer; } -.att-chip .att-x:hover { color: #c0392b; } -.att-modal { +.pp-chip .pp-x:hover { color: #c0392b; } +.pp-chip-empty { color: var(--muted); border-style: dashed; padding: 3px 10px; } +.pp-modal { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, .35); padding: 16px; } -.att-modal[hidden] { display: none; } -.att-modal-box { +.pp-modal[hidden] { display: none; } +.pp-modal-box { background: var(--surface); border-radius: 12px; box-shadow: 0 12px 40px rgba(0, 0, 0, .2); width: min(420px, 100%); max-height: 70vh; display: flex; flex-direction: column; padding: 14px 16px; box-sizing: border-box; } -.att-modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } -.att-close { +.pp-modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } +.pp-close { border: 0; background: none; font-size: 20px; line-height: 1; color: var(--muted); cursor: pointer; padding: 2px 6px; } -.att-modal-box .input { margin-bottom: 8px; } -.att-list { overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; } -.att-item { +.pp-modal-box .input { margin-bottom: 8px; } +.pp-list { overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; } +.pp-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--line-2); font-size: 14px; } -.att-item:last-child { border-bottom: 0; } -.att-item:hover { background: var(--line-2); } -.att-item input { accent-color: var(--brand); flex: 0 0 auto; } -.att-item .att-av { +/* ⚠️ 这条不能省:.pp-item 自己写了 display:flex,作者样式压过浏览器 + [hidden] { display:none },缺了它「过滤」就成了摆设 —— 名字和拼音都搜不动。 + 弹窗 .pp-modal 同理(上面已有),凡是**自己声明了 display** 的元素要藏都得补一条。 */ +.pp-item[hidden] { display: none; } +.pp-item:last-child { border-bottom: 0; } +.pp-item:hover { background: var(--line-2); } +.pp-item input { accent-color: var(--brand); flex: 0 0 auto; } +.pp-item .pp-av { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; background: #F1E7D6; } -.att-item .att-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.att-item .att-initials { margin-left: auto; color: var(--muted); font-size: 12px; } -.att-empty { padding: 10px 12px; color: var(--muted); font-size: 13px; } +.pp-item .pp-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pp-item .pp-initials { margin-left: auto; color: var(--muted); font-size: 12px; } +.pp-item-none .pp-name { color: var(--muted); } +.pp-empty { padding: 10px 12px; color: var(--muted); font-size: 13px; } +.pp-empty[hidden] { display: none; } /* ---- 豆瓣自动补充(v10):片名行 + 下拉候选 + 海报预览 ---- */ .db-title-row { display: flex; gap: 8px; align-items: center; } .db-title-row .input { flex: 1 1 auto; } @@ -1764,20 +1951,114 @@ textarea.input { min-height: 76px; resize: vertical; } ⚠️ 断点必须是 **720px**(站点既有的"窄屏留白 16px"断点),不能用 860: 746px 宽时若只改顶栏留白,就会出现「顶栏 x=16、正文 x=24」错位 8px (几何检查会直接报出来)。窄屏规则只在站点真正切到小留白的那一档生效。 */ +/* ==================== 月 / 周 / 天 三选一的分段控件 ==================== */ +/* 三个按钮连成一条(相邻边各留一半边框,拼起来才是一整条外框), + 当前那个用模块主题色填底 —— 一眼看出自己在哪种看法上。 */ +.seg { display: inline-flex; } +.seg-btn { + padding: 6px 14px; font-size: 13px; line-height: 1; + color: var(--muted); background: var(--surface); + border: 1px solid var(--line); text-decoration: none; +} +.seg-btn + .seg-btn { border-left: 0; } +.seg-btn:first-child { border-radius: var(--radius-s) 0 0 var(--radius-s); } +.seg-btn:last-child { border-radius: 0 var(--radius-s) var(--radius-s) 0; } +.seg-btn:hover { background: var(--surface-2, #FDFBF7); text-decoration: none; } +.seg-btn.is-on { + background: var(--brand); border-color: var(--brand); color: #fff; font-weight: 600; +} + +/* ==================== 周 / 天:按小时铺的时间网格 ==================== */ +/* 结构:左边一条"小时刻度"沟槽(tg-gutter),右边 N 个日期列(tg-col)。 + 列高固定 = 24 小时 × 每小时 44px,日程块按 top/height 百分比**绝对定位**, + 所以"09:15 的 20 分钟"就是一小条,不会跟 9 点整的会挤成一样高。 */ +.cal-tg-card { padding: 0; overflow: auto; } +.tg-head { display: flex; position: sticky; top: 0; z-index: 3; + background: var(--surface); border-bottom: 1px solid var(--line); } +.tg-gutter { + flex: 0 0 56px; width: 56px; border-right: 1px solid var(--line); + display: flex; flex-direction: column; align-items: flex-end; +} +.tg-dayname { + flex: 1 1 0; min-width: 0; padding: 8px 6px; text-align: center; + border-left: 1px solid var(--line-2); +} +.tg-dayname:first-of-type { border-left: 0; } +.tg-dayname.is-today { background: #FDF6F1; } +.tg-wd { display: block; font-size: 11px; color: var(--faint); } +.tg-dd { display: block; font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; } +.tg-dayname.is-today .tg-dd { color: var(--brand); } + +.tg-allday { display: flex; border-bottom: 1px solid var(--line); background: #FDFBF7; } +.tg-allday .tg-gutter { padding: 6px 6px; } +.tg-tag { font-size: 11px; color: var(--faint); } +.tg-adcell { + flex: 1 1 0; min-width: 0; padding: 4px 4px 6px; border-left: 1px solid var(--line-2); + display: flex; flex-direction: column; gap: 3px; +} +.tg-adcell:first-of-type { border-left: 0; } + +.tg-body { display: flex; position: relative; } +.tg-hours { padding: 0; } +.tg-hr { + height: 44px; padding-right: 6px; padding-top: 2px; + font-size: 11px; color: var(--faint); font-variant-numeric: tabular-nums; + border-top: 1px solid var(--line-2); box-sizing: border-box; +} +.tg-hr:first-child { border-top: 0; } +.tg-col { + flex: 1 1 0; min-width: 0; position: relative; + height: 1056px; /* 24 × 44px,与 .tg-hr 的高度必须一致 */ + border-left: 1px solid var(--line-2); box-sizing: border-box; +} +.tg-col:first-of-type { border-left: 0; } +.tg-col.is-today { background: #FDF6F1; } +.tg-line { display: block; height: 44px; border-top: 1px solid var(--line-2); box-sizing: border-box; } +.tg-line:first-child { border-top: 0; } +.tg-block { + position: absolute; left: 2px; right: 2px; + display: block; overflow: hidden; padding: 2px 5px; + border-radius: 4px; color: #fff; font-size: 11px; line-height: 1.35; + text-decoration: none; box-sizing: border-box; + border: 1px solid rgba(255,255,255,.35); +} +.tg-block:hover { text-decoration: none; filter: brightness(1.1); z-index: 2; } +.tg-block b { font-weight: 600; margin-right: 3px; } + +/* 天视图只有一列,把列拉宽好读;周视图 7 列时列宽由 flex 平分 */ +@media (max-width: 720px) { + /* 窄屏留不住 56px 的刻度沟槽 + 7 列,改成整体可横向滚动(.cal-tg-card 已 overflow:auto), + 给每列一个最小宽度,滚动着看而不是把字挤成一团。 */ + .tg-col, .tg-dayname, .tg-adcell { min-width: 92px; } + .cal-tg-card { -webkit-overflow-scrolling: touch; } +} + +/* ⚠️ v24:导航从 7 个模块加到 9 个(纪念日 / 财务)之后,**746px 就放不下了**, + 而"放不下"的表现不是换行、是顶栏被撑宽 20.5px: + `.nav` 内部产生横向滚动条(哪怕已用 scrollbar-width:none 隐藏), + `.topbar-inner` 的可用宽度就比 `.container` 少一档, + 几何检查直接报「顶栏右 742.5 / 正文右 722.0」。 + 所以**不能**只在 720px 断点里给 .nav 加快收缩 —— + 那样 721~1080 这一段区间没人管,正是出问题的地方。 + 正确做法:把「导航可收缩 + 横向滚动」提到**无媒体查询的基态**, + 只在真的窄到需要缩留白时才改 padding(下面 720px 那段)。 */ +.nav { + /* flex:1 1 auto + min-width:0 才真的会"被压缩";只写 min-width 时它仍是 auto 宽度, + 内容一多就把顶栏撑出去(这就是 746px 那 20.5px 的来源)。 */ + flex: 1 1 auto; min-width: 0; + overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; +} +.nav::-webkit-scrollbar { display: none; } +.nav a { flex: 0 0 auto; } @media (max-width: 720px) { .topbar-inner { gap: 12px; padding: 0 16px; } /* min-width:0 是关键:flex 子项默认 min-width:auto,会拒绝被压缩, 于是内部再 overflow-x 也撑不出滚动条,而是把父级一起顶宽。 */ .nav { flex: 1 1 auto; - min-width: 0; margin-left: 0; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; /* Firefox */ } - .nav::-webkit-scrollbar { display: none; } /* Chrome/Safari:藏掉滚动条,仍可滑 */ - .nav a { flex: 0 0 auto; padding: 6px 10px; } + .nav a { padding: 6px 10px; } .topbar-right { margin-left: 0; gap: 6px; } /* 窄屏把「家书」两个字收起来,只留图标 —— 名字占的宽度正好够多放两个模块 */ .brand span { display: none; } diff --git a/assets/js/person_picker.js b/assets/js/person_picker.js new file mode 100644 index 0000000..2144d2a --- /dev/null +++ b/assets/js/person_picker.js @@ -0,0 +1,189 @@ +/** + * 全局人员选择器(v24 用户第 11 条) + * + * 一个页面里可能有多个 .pp(观影同行人 / 旅程同行人 / 资源归属 / 共享给谁 …), + * 所以这里按容器逐个初始化,状态全挂在容器自己身上,互不影响。 + * + * 三件事: + * ① 汉字 → 拼音首字母(Intl 拼音排序 + 23 个锚字二分,零依赖) + * ② 搜索:名字 / 显示名 / 首字母 三者任一命中就留下 + * ③ chips 与弹窗里的勾选状态双向同步 + */ +(function () { + 'use strict'; + + /* ------------------------------------------------------------------ + * 汉字 → 拼音首字母 + * + * 做法:a..z 各挑一个「锚字」(没有 i / u / v 开头的常用字,所以是 23 个), + * 用拼音 collation 二分定位某个汉字落在哪一段 —— 落在「垃」之后、「妈」之前 + * 就是 l。整套零依赖,不需要塞一张几万字的拼音表进来。 + * + * ⚠️ 浏览器不支持拼音排序时返回 null:此时只按名字搜,不硬出错误结果。 + * ---------------------------------------------------------------- */ + var initialsOf = (function () { + var anchors = '阿八擦搭娥发嘎哈击喀垃妈拿哦啪期然撒塌挖昔压匝'; + var letters = 'abcdefghjklmnopqrstwxyz'; + var coll; + try { coll = new Intl.Collator('zh-Hans-CN-u-co-pinyin'); } catch (e) { return null; } + // 排序没真正生效时 compare 会退化成按码点/笔画,这里直接认不出来 → 放弃拼音 + if (!coll || coll.compare('阿', '匝') >= 0) { return null; } + return function (s) { + var out = ''; + for (var i = 0; i < s.length; i++) { + var ch = s.charAt(i); + if (/[a-zA-Z]/.test(ch)) { out += ch.toUpperCase(); continue; } + if (ch.charCodeAt(0) < 0x2E80) { continue; } // 非 CJK:不产字母 + var lo = 0, hi = anchors.length - 1, best = -1; + while (lo <= hi) { + var mid = (lo + hi) >> 1; + if (coll.compare(ch, anchors.charAt(mid)) >= 0) { best = mid; lo = mid + 1; } + else { hi = mid - 1; } + } + if (best >= 0) { out += letters.charAt(best); } + } + return out; + }; + })(); + + /** + * q 是否是 s 的**子序列**。 + * 为什么不用 indexOf:「杨永全」的首字母是 yyq,用户很自然地会打 yq —— + * 连续子串匹配不到,子序列能。选人这种小列表宁可放宽一点,也别让人以为坏了。 + */ + function isSubSeq(q, s) { + if (!q) { return true; } + var i = 0; + for (var j = 0; j < s.length && i < q.length; j++) { + if (s.charAt(j) === q.charAt(i)) { i++; } + } + return i === q.length; + } + + function init(root) { + var id = root.getAttribute('data-pp') || ''; + var isMulti = root.getAttribute('data-pp-mode') !== 'single'; + var modal = root.querySelector('.pp-modal'); + var openBtn = root.querySelector('.pp-open'); + var closeBtn = root.querySelector('.pp-close'); + var search = root.querySelector('.pp-search'); + var list = root.querySelector('.pp-list'); + var chips = root.querySelector('.pp-chips'); + var emptyTip = root.querySelector('.pp-empty'); + if (!modal || !list || !chips) { return; } + + // 预计算每个人的首字母串,挂在 data-initials 上(列表再长也只是算一次) + Array.prototype.forEach.call(list.querySelectorAll('.pp-item'), function (el) { + var s = (el.getAttribute('data-name') || '') + (el.getAttribute('data-shown') || ''); + var ini = initialsOf ? initialsOf(s) : ''; + el.setAttribute('data-initials', ini.toLowerCase()); + var badge = el.querySelector('.pp-initials'); + if (badge && ini) { badge.textContent = ini; badge.hidden = false; } + }); + + function syncChips() { + chips.innerHTML = ''; + var any = false; + Array.prototype.forEach.call(list.querySelectorAll('.pp-item input:checked'), function (box) { + if (box.value === '0') { return; } // 「不选」不进 chips + var item = box.closest('.pp-item'); + if (!item) { return; } + any = true; + // ⚠️ 必须点名取 .pp-name:.pp-item 里第一个 span 是隐藏的首字母占位, + // 早年靠「取第一个 span 当名字」碰巧成立,加了首字母徽标后 chip 就没名字了。 + var nameEl = item.querySelector('.pp-name'); + var av = item.querySelector('.pp-av'); + var chip = document.createElement('span'); + chip.className = 'pp-chip'; + chip.setAttribute('data-id', box.value); + if (av) { + var im = document.createElement('img'); + im.src = av.getAttribute('src'); // 服务端已 e() 过,这里原样搬 + im.alt = ''; + chip.appendChild(im); + } + var nm = document.createElement('span'); + nm.className = 'pp-name'; + nm.textContent = nameEl ? nameEl.textContent : box.value; + chip.appendChild(nm); + if (isMulti) { + var x = document.createElement('button'); + x.type = 'button'; x.className = 'pp-x'; x.textContent = '×'; + x.title = '移除'; x.setAttribute('data-id', box.value); + chip.appendChild(x); + } + chips.appendChild(chip); + }); + // ⚠️ 多选且一个都没选时**不要**塞占位 chip:.pp-chip 自己写了 display:inline-flex, + // 再给它 hidden 也藏不掉(作者样式压过 UA 的 [hidden]),会留下一个空胶囊。 + if (!any && !isMulti) { + var ph = document.createElement('span'); + ph.className = 'pp-chip pp-chip-empty'; + ph.textContent = '未选择'; + chips.appendChild(ph); + } + } + + function applyFilter() { + var q = (search.value || '').trim().toLowerCase(); + var shown = 0; + Array.prototype.forEach.call(list.querySelectorAll('.pp-item'), function (el) { + var hit = !q || + (el.getAttribute('data-name') || '').toLowerCase().indexOf(q) >= 0 || + (el.getAttribute('data-shown') || '').toLowerCase().indexOf(q) >= 0 || + isSubSeq(q, el.getAttribute('data-initials') || ''); + el.hidden = !hit; + if (hit) { shown++; } + }); + if (emptyTip) { emptyTip.hidden = shown > 0; } + } + + function openModal() { + modal.hidden = false; + search.value = ''; + applyFilter(); + syncChips(); + search.focus(); + } + function closeModal() { modal.hidden = true; } + + if (openBtn) { openBtn.addEventListener('click', openModal); } + if (closeBtn) { closeBtn.addEventListener('click', closeModal); } + modal.addEventListener('click', function (e) { if (e.target === modal) { closeModal(); } }); + search.addEventListener('input', applyFilter); + + chips.addEventListener('click', function (e) { + var x = e.target.closest ? e.target.closest('.pp-x') : null; + if (!x) { return; } + var gid = x.getAttribute('data-id'); + Array.prototype.forEach.call(list.querySelectorAll('.pp-item input'), function (box) { + if (box.value === gid) { box.checked = false; } + }); + syncChips(); + }); + + /* 多选:勾一个 = 出 chip + 关弹窗(再添加就再点一次「+ 添加人」); + 单选:点一个 = 换掉当前这个人 + 关弹窗 */ + list.addEventListener('change', function (e) { + if (!e.target || e.target.tagName !== 'INPUT') { return; } + syncChips(); + if (e.target.checked) { closeModal(); } + }); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && !modal.hidden) { closeModal(); } + }); + + syncChips(); + } + + function boot() { + Array.prototype.forEach.call(document.querySelectorAll('.pp'), init); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/assets/js/preview.js b/assets/js/preview.js new file mode 100644 index 0000000..7ce1c12 --- /dev/null +++ b/assets/js/preview.js @@ -0,0 +1,101 @@ +/** + * 全局附件预览(v24 用户第 3 条) + * + * 点缩略图 → **在本页**弹一层把内容放出来,不再新开标签页。 + * · 图片 → + * · 视频 →