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 条) + * + * 点缩略图 → **在本页**弹一层把内容放出来,不再新开标签页。 + * · 图片 → + * · 视频 → (media.php 支持 Range,进度条能拖) + * · PDF → (用浏览器自带的阅读器) + * · 其它(Word / Excel / …)→ 根本不进这里:那段链接指向的是下载地址,点了就下。 + * + * 右上角恒有一个「下载」按钮(用户原话:所有的预览页面,右上角都有一个下载按钮)。 + * + * 触发方式是数据属性,不用给每个地方写一遍初始化: + * + */ +(function () { + 'use strict'; + + var box = document.getElementById('pv'); + if (!box) { return; } + var stage = box.querySelector('.pv-stage'); + var titleEl = box.querySelector('.pv-name'); + var metaEl = box.querySelector('.pv-meta'); + var dlEl = box.querySelector('.pv-dl'); + var closeEl = box.querySelector('.pv-close'); + var opener = null; + + function clear() { + // ⚠️ 换内容要**整个重建**节点,不能只改 src: + // 改 src 不会自动卸载上一支(声音会叠着放), 同理。 + stage.innerHTML = ''; + } + + function open(trigger) { + var kind = trigger.getAttribute('data-prev') || 'image'; + var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || ''; + var dl = trigger.getAttribute('data-dl') || src; + if (!src) { return; } + opener = trigger; + clear(); + + var el; + if (kind === 'video') { + el = document.createElement('video'); + el.className = 'pv-video'; + el.src = src; + el.controls = true; + el.autoplay = true; + el.playsInline = true; + } else if (kind === 'pdf') { + el = document.createElement('iframe'); + el.className = 'pv-pdf'; + el.src = src; + // PDF 是"另一份文档",别让它拿到本页的 window + el.setAttribute('sandbox', 'allow-same-origin allow-scripts'); + } else { + el = document.createElement('img'); + el.className = 'pv-img'; + el.src = src; + el.alt = ''; + } + stage.appendChild(el); + + titleEl.textContent = trigger.getAttribute('data-name') || ''; + var sz = trigger.getAttribute('data-size') || ''; + metaEl.textContent = sz; + dlEl.setAttribute('href', dl); + // 下载要带上真实文件名:download 属性 + 服务端 Content-Disposition 双保险 + var nm = trigger.getAttribute('data-name') || ''; + if (nm) { dlEl.setAttribute('download', nm); } else { dlEl.removeAttribute('download'); } + + box.hidden = false; + box.setAttribute('aria-hidden', 'false'); + closeEl.focus(); + } + + function close() { + box.hidden = true; + box.setAttribute('aria-hidden', 'true'); + clear(); // 停掉视频 / 卸掉 PDF + if (opener && opener.focus) { opener.focus(); } + opener = null; + } + + document.addEventListener('click', function (e) { + var t = e.target && e.target.closest ? e.target.closest('[data-prev]') : null; + if (t) { + // ⚠️ 只拦"可预览"的:Word/Excel 那类链接没有 data-prev, + // 浏览器照常走 href(就是下载地址),不会被这里挡住。 + e.preventDefault(); + open(t); + return; + } + // 点遮罩(stage 本身或弹层空白处)关闭;点内容不关 + if (!box.hidden && (e.target === box || e.target === stage)) { close(); } + }); + + closeEl.addEventListener('click', close); + document.addEventListener('keydown', function (e) { + if (!box.hidden && e.key === 'Escape') { close(); } + }); +})(); diff --git a/dav.php b/dav.php new file mode 100644 index 0000000..e4ea97d --- /dev/null +++ b/dav.php @@ -0,0 +1,260 @@ +/dav/ + * 账号:手机号或姓名(同登录) + * 口令:**CalDAV 专用口令**(在「日程 → 日历本」页生成,不是登录口令) + * + * ⚠️ 为什么单独一个文件而不是塞进 index.php: + * CalDAV 用的是 HTTP 方法名(PROPFIND / REPORT / OPTIONS / PUT), + * index.php 那套 `?r=xxx` 路由是 GET/POST 世界里的东西, + * 混在一起会让两边都变难懂。这里只做"按方法分派",业务在 App\CalDav 里。 + * + * 本阶段是**只读**实现(RFC 4791 的子集):写方法一律 403。 + * 与其"半支持"(客户端以为写成功了、服务端却没落库/没合冲突),不如明确拒绝。 + */ + +declare(strict_types=1); + +require __DIR__ . '/app/bootstrap.php'; + +use App\Auth; +use App\CalDav; +use App\Calendar; + +/** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */ +function dav_fail(int $code, string $msg): void +{ + http_response_code($code); + header('Content-Type: text/plain; charset=utf-8'); + echo $msg; + exit; +} + +// ---- 1. 鉴权:HTTP Basic(DAVx⁵ / iOS 都原生支持)---- +$user = $_SERVER['PHP_AUTH_USER'] ?? null; +$pass = $_SERVER['PHP_AUTH_PW'] ?? null; +// 有些 SAPI 不填 PHP_AUTH_USER,得自己从 Authorization 头里拆 +if ($user === null && !empty($_SERVER['HTTP_AUTHORIZATION'])) { + if (preg_match('/^Basic\s+(.+)$/i', (string)$_SERVER['HTTP_AUTHORIZATION'], $m)) { + $decoded = base64_decode($m[1], true); + if ($decoded !== false && strpos($decoded, ':') !== false) { + [$user, $pass] = explode(':', $decoded, 2); + } + } +} +$who = Auth::caldavAuth($user, $pass); +if (!$who) { + header('WWW-Authenticate: Basic realm="FamilyBook CalDAV"'); + dav_fail(401, '需要 CalDAV 口令(在「日程 → 日历本」页生成,不是登录密码)。'); +} +$familyId = (int)$who['family_id']; +$me = (int)$who['person_id']; + +$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); +$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/dav/'), PHP_URL_PATH); +$hit = CalDav::parsePath($path); +$bookId = (int)$hit['book']; +$evId = (int)$hit['event']; + +// ---- 2. 只读:写方法直接拒(明确拒比"写一半"好)---- +if (in_array($method, ['PUT', 'DELETE', 'PROPPATCH', 'MKCALENDAR', 'POST', 'PATCH'], true)) { + dav_fail(403, '家书的 CalDAV 目前是只读订阅:请在本站里修改日程。'); +} + +// ---- 3. OPTIONS:客户端第一句就问"你支持什么" ---- +if ($method === 'OPTIONS') { + http_response_code(200); + header('DAV: 1, 2, calendar-access'); + header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT'); + header('MS-Author-Via: DAV'); + exit; +} + +// ---- 4. 找不到那本 → 404(不要回 200 空列表:客户端会以为"这本是空的")---- +$cal = null; +if ($bookId > 0) { + $cal = Calendar::mineOne($familyId, $me, $bookId); + if (!$cal) { + dav_fail(404, '没有这本日历。'); + } +} + +/** 日历本的显示名:带上是"谁的本"(共享来的本子同名时能分清) */ +$dispName = static function (array $c) use ($me): string { + $nm = (string)$c['name']; + if ((int)($c['owner_id'] ?? 0) !== $me && !empty($c['owner_name'])) { + $nm .= '(' . (string)$c['owner_name'] . ')'; + } + return $nm; +}; + +// ---- 5. PROPFIND:清单 / 属性 / 资源列表 ---- +if ($method === 'PROPFIND') { + $depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0'); + + if ($bookId === 0) { + // 根:列出这个人可见的全部日历本 + $parts = []; + foreach (CalDav::books($familyId, $me) as $c) { + $href = CalDav::hrefOf($c); + $parts[] = '' . CalDav::x($href) . '' + . '' + . '' + . '' . CalDav::x($dispName($c)) . '' + . '' . CalDav::x('家书 · ' . (string)$c['name']) . '' + . '' . CalDav::x((string)$c['color']) . '' + . '' + . '' + . ''; + } + CalDav::multis(); + echo '' . "\n" + . '' + . implode('', $parts) . ''; + exit; + } + + if ($depth === '1') { + // 某一本里的资源清单:客户端拿这个 ETag 判断"要不要重新拉" + $rows = CalDav::rows($cal, $familyId, $me); + $parts = []; + foreach ($rows as $r) { + $rid = (int)($r['id'] ?? 0); + $uid = (string)($r['uid'] ?? ''); + // 派生本(观影 / 旅程)没有 calendar_events.id,用 uid 段做资源名 + $seg = $rid > 0 ? (string)$rid : preg_replace('/[^A-Za-z0-9._-]/', '_', $uid); + if ($seg === '') { + continue; + } + $etag = '"' . md5($uid . '|' . (string)($r['starts_at'] ?? '') . '|' . (string)($r['updated_at'] ?? '')) . '"'; + $parts[] = '' . CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '' + . '' + . '' . CalDav::x($etag) . '' + . '' + . 'text/calendar; charset=utf-8' + . ''; + } + CalDav::multis(); + echo '' . "\n" + . '' + . implode('', $parts) . ''; + exit; + } + + // Depth 0:这一天历本自身的属性 + CalDav::multis(); + echo '' . "\n" + . '' + . '' . CalDav::x(CalDav::bookHref($bookId)) . '' + . '' + . '' + . '' . CalDav::x($dispName($cal)) . '' + . '' + . '' + . ''; + exit; +} + +// ---- 6. GET / HEAD:整本导出成 ics(不少客户端就走这一条)---- +if ($method === 'GET' || $method === 'HEAD') { + if (!$cal) { + dav_fail(404, '没有这本日历。'); + } + $ics = CalDav::ics($cal, $familyId, $me); + $etag = '"' . md5($ics) . '"'; + if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) { + http_response_code(304); + header('ETag: ' . $etag); + exit; + } + http_response_code(200); + header('Content-Type: text/calendar; charset=utf-8'); + header('ETag: ' . $etag); + header('Cache-Control: private, max-age=0, must-revalidate'); + $fn = preg_replace('/[^A-Za-z0-9._-]/', '_', (string)$cal['name']); + header('Content-Disposition: inline; filename="' . $fn . '.ics"'); + if ($method === 'GET') { + echo $ics; + } + exit; +} + +// ---- 7. REPORT:calendar-query(按时间范围) / calendar-multiget(按 href 取)---- +if ($method === 'REPORT') { + if (!$cal) { + dav_fail(404, '没有这本日历。'); + } + $bodyRaw = (string)file_get_contents('php://input'); + + // calendar-multiget:客户端点明要哪几条(我们按整本回,客户端自己挑) + $wantsMulti = stripos($bodyRaw, 'calendar-multiget') !== false; + + // calendar-query 的时间范围(有就按范围裁;没有就整本) + $from = ''; + $to = ''; + if (preg_match('#]*start="([^"]+)"#i', $bodyRaw, $m)) { + $from = substr($m[1], 0, 8); // 20260101T000000Z → 20260101 + $from = substr($m[1], 0, 4) . '-' . substr($m[1], 4, 2) . '-' . substr($m[1], 6, 2); + } + if (preg_match('#]*end="([^"]+)"#i', $bodyRaw, $m)) { + $to = substr($m[1], 0, 4) . '-' . substr($m[1], 4, 2) . '-' . substr($m[1], 6, 2); + } + + // 单条资源请求(/dav/3.ics/12.ics) + if ($evId > 0) { + $one = CalDav::oneEvent($bookId, $evId, [$familyId, $me]); + if (!$one) { + dav_fail(404, '没有这条日程。'); + } + $evIcs = \App\Ics::export([$one], (string)$cal['name']); + CalDav::multis(); + echo '' . "\n" + . '' + . '' . CalDav::x(CalDav::bookHref($bookId) . $evId . '.ics') . '' + . '' + . '' . CalDav::x('"' . md5($evIcs) . '"') . '' + . '' . CalDav::x($evIcs) . '' + . ''; + exit; + } + + $rows = CalDav::rows($cal, $familyId, $me); + $parts = []; + foreach ($rows as $r) { + $day = substr((string)($r['starts_at'] ?? ''), 0, 10); + if ($from !== '' && $day !== '' && $day < $from) { + continue; + } + if ($to !== '' && $day !== '' && $day > $to) { + continue; + } + $rid = (int)($r['id'] ?? 0); + $uid = (string)($r['uid'] ?? ''); + $seg = $rid > 0 ? (string)$rid : preg_replace('/[^A-Za-z0-9._-]/', '_', $uid); + if ($seg === '') { + continue; + } + $evIcs = \App\Ics::export([$r], (string)$cal['name']); + $parts[] = '' . CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '' + . '' + . '' . CalDav::x('"' . md5($evIcs) . '"') . '' + . '' . CalDav::x($evIcs) . '' + . ''; + // calendar-multiget 时客户端自己按 href 挑;我们整本给,列表不长(家庭场景) + unset($wantsMulti); + } + CalDav::multis(); + echo '' . "\n" + . '' + . implode('', $parts) . ''; + exit; +} + +// ---- 8. 其它方法一律 405(并告知支持哪些)---- +header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT'); +dav_fail(405, '不支持这个请求方法。'); \ No newline at end of file diff --git a/index.php b/index.php index 26f5317..ac3e353 100644 --- a/index.php +++ b/index.php @@ -19,7 +19,10 @@ use App\Crypto; use App\Database; use App\Douban; use App\Family; +use App\Anniversary; +use App\Ics; use App\Kinship; +use App\Ledger; use App\Movie; use App\MovieReview; use App\Module; @@ -293,6 +296,81 @@ function build_month_grid(string $ym, array $events): array return $weeks; } +/** + * 周视图 / 天视图的日期列(用户 2026-10-02 第 4 条)。 + * + * 周视图固定 7 天;天视图就 1 天 —— 两者共用同一个"按小时铺的时间网格", + * 只是列数不同,所以放在一个函数里。 + * + * @return array + */ +function day_columns(string $anchor, int $span): array +{ + $ts = strtotime($anchor); + if ($ts === false) { + $ts = time(); + } + $w = (int)date('w', $ts); // 0=周日 … 6=周六 + $mon = strtotime('-' . (($w + 6) % 7) . ' days', $ts); // 周一开头 + $out = []; + for ($i = 0; $i < max(1, $span); $i++) { + $d = date('Y-m-d', strtotime('+' . $i . ' days', $mon)); + $out[] = [ + 'date' => $d, + 'w' => (int)date('w', strtotime($d)), + 'day' => (int)date('j', strtotime($d)), + 'is_today' => $d === date('Y-m-d'), + ]; + } + return $out; +} + +/** + * 把日程摆进「小时 × 日期列」的时间网格(周视图 / 天视图共用)。 + * + * 与月视图的不同:这里**按时段定位**(top/height 用百分比), + * 所以"09:15 的 20 分钟会"在格子里就是一小条,而不是跟"9 点的会"挤成一样高。 + * + * ⚠️ 全天日程不进时间格 —— 它们没有时段,硬塞进 0 点会变成一条压在最上面的怪东西。 + * 单独挂在顶上的一行(跟群晖日历一样)。 + * + * @return array{all_day:array,timed:array} all_day 按列分组;timed 按列分组,含 top/height + */ +function build_time_grid(array $events, array $cols): array +{ + $dayIndex = []; + foreach ($cols as $i => $c) { + $dayIndex[(string)$c['date']] = $i; + } + $allDay = array_fill(0, count($cols), []); + $timed = array_fill(0, count($cols), []); + + foreach ($events as $e) { + $ci = $dayIndex[(string)$e['date']] ?? null; + if ($ci === null) { + continue; + } + if (!empty($e['all_day'])) { + $allDay[$ci][] = $e; + continue; + } + $t = (string)($e['time'] ?? ''); + // 有具体时刻才进时间格;没有时刻又不是全天的(如任务全天),按全天处理 + if ($t === '' || !preg_match('/^(\d{2}):(\d{2})/', $t, $m)) { + $allDay[$ci][] = $e; + continue; + } + $startMin = ((int)$m[1]) * 60 + (int)$m[2]; + // 没有结束时刻的按 30 分钟画一条最短的可见块(不然高度是 0,等于看不见) + $endMin = $startMin + 30; + $timed[$ci][] = $e + [ + 'top' => round($startMin / 1440 * 100, 4), + 'height' => round(max(30, $endMin - $startMin) / 1440 * 100, 4), + ]; + } + return ['all_day' => $allDay, 'timed' => $timed]; +} + /** * 读任务的「截止日期(+ 可选时刻)」。 * @@ -301,6 +379,13 @@ function build_month_grid(string $ym, array $events): array */ function read_due_input(): ?string { + // v24 第 5 条:截止时刻与全局其它地方一样走 `_datetime.php`(日期 + 时/分/秒一起选, + // 精确到秒)。下面那段 due_date / due_time 只是老表单的兜底,别删 —— + // 旧的链接、书签、脚本还在按老名字提交,删了就静默变成"没有截止日期"。 + $full = read_dt_input('due_at'); + if ($full !== null) { + return $full; + } $d = trim((string)($_POST['due_date'] ?? '')); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) { return null; @@ -312,10 +397,13 @@ function read_due_input(): ?string /** * 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。 * - * ⚠️ 同一天太窄时会一条都没有(尤其刚用不久),所以**退回同月**: - * 先给"就是这天",不够再补"这个月"的往年记录。标题由调用方按实际命中改写。 + * ⚠️ v24(用户 2026-10-02 第 19 条):原来同一天没有命中时会**退回同一个月**, + * 标题随之变成"往年这个月" —— 但用户要的就是**往年的今天**: + * "10 月 2 日"给我看"去年 10 月 8 日去看了场电影",这根本不是同一天的事, + * 凑出来的条目看着像回忆,其实只是同月的随机噪声,反而把"往年今日"的意味毁了。 + * 现在只认同一天;没命中就明说"往年的这一天还没有记录"。 * - * @return array{rows:array,exact:bool} exact=true 表示命中的真是"同一天" + * @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容) */ function home_memories(int $familyId, int $limit = 6): array { @@ -326,24 +414,17 @@ function home_memories(int $familyId, int $limit = 6): array FROM events e WHERE e.family_id = ? AND e.kind IN ('movie','trip') AND e.starts_at IS NOT NULL AND YEAR(e.starts_at) < ? - AND DATE_FORMAT(e.starts_at, '%s') = ? - ORDER BY e.starts_at DESC LIMIT %d"; - $st = Database::get()->prepare(sprintf($sql, '%m-%d', $limit)); + AND DATE_FORMAT(e.starts_at, '%m-%d') = ? + ORDER BY e.starts_at DESC LIMIT " . (int)$limit; + $st = Database::get()->prepare($sql); $st->execute([$familyId, $year, substr($today, 5, 5)]); $rows = $st->fetchAll(); - $exact = true; - if (!$rows) { - $st = Database::get()->prepare(sprintf($sql, '%m', $limit)); - $st->execute([$familyId, $year, substr($today, 5, 2)]); - $rows = $st->fetchAll(); - $exact = false; - } foreach ($rows as &$r) { $r['years_ago'] = $year - (int)substr((string)$r['starts_at'], 0, 4); $r['href'] = url($r['kind'] === 'trip' ? 'trips/show' : 'movies/show', ['id' => (int)$r['id']]); } unset($r); - return ['rows' => $rows, 'exact' => $exact]; + return ['rows' => $rows, 'exact' => true]; } /** 只认本家庭、确实存在的 person_id(表单可以改,不能凭它塞进别人家的人) */ @@ -502,11 +583,18 @@ switch ($r) { // ⚠️ v15:家谱模板不再吃 Kinship::tree() 的产物(那套 units/generations 是给 // 旧画法用的),改由模板里调 Kinship::familyMap() 现算。这里就不再白算一遍。 + // v24 第 12 条:可以按某个人为中心重画(姻亲不在主图里时点卡片上的入口进来)。 + // ⚠️ 「看的是谁的家谱」与「我登录着」是两件事:$meId 决定称呼视角,$focusId 决定画谁的树。 + $focusId = (int)($_GET['focus'] ?? 0); + if ($focusId <= 0 || !isset($people[$focusId])) { + $focusId = $me; + } View::render('kin/tree', [ - 'title' => '我的家谱', - 'meId' => $me, - 'people' => $people, - 'graph' => $graph, + 'title' => $focusId === $me ? '我的家谱' : ((string)($people[$focusId]['name'] ?? '') . ' 的家谱'), + 'meId' => $me, + 'focusId' => $focusId, + 'people' => $people, + 'graph' => $graph, ], 'kin'); break; @@ -2415,25 +2503,245 @@ switch ($r) { // 自定义日程落在 calendar_events;**观影与旅行两本是派生的**(从 events 实时汇总, // 不复制),给这两本"加日程"= 真的建一条观影 / 旅程记录。 + /** + * 财务(v24 第 15 条):账本 + 账目 + 共享。 + * + * 用户原话要的是「一个可以共享的家庭总账」:先有账本,账本共享给某人, + * 双方都能往里记账、都能看到全部账目,最后算出总数。 + * ⚠️ 金额一律以**分**存取(见 Ledger::toCents 的注释),显示时再转元。 + */ + case 'ledger': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + $books = Ledger::mine($familyId, $me); + // 一本都没有 → 自动建一本(不然新用户进来一片空白,也不知道要先"创建家庭") + if (!$books) { + Ledger::ensureDefault($familyId, $me); + $books = Ledger::mine($familyId, $me); + } + $lid = (int)($_GET['book'] ?? 0); + $cur = null; + foreach ($books as $b) { + if ($lid > 0 && (int)$b['id'] === $lid) { + $cur = $b; + break; + } + } + if (!$cur) { + $cur = $books[0]; + } + $entries = Ledger::entries((int)$cur['id']); + View::render('ledger/index', [ + 'title' => '财务', + 'books' => $books, + 'cur' => $cur, + 'entries' => $entries, + 'summary' => Ledger::summary($entries), + 'shares' => Ledger::shares((int)$cur['id']), + 'people' => Person::roster($familyId, $me, 'member', ''), + ], 'ledger'); + break; + + case 'ledger/book_add': + case 'ledger/book_rename': + case 'ledger/book_del': + case 'ledger/entry_add': + case 'ledger/entry_update': + case 'ledger/entry_del': + case 'ledger/share': + case 'ledger/unshare': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $lid = (int)($_POST['ledger_id'] ?? 0); + $act = (string)($_GET['r'] ?? ''); + $book = $lid > 0 ? Ledger::find($familyId, $lid, $me) : null; + + if ($act === 'ledger/book_add') { + $id = Ledger::create($familyId, $me, (string)($_POST['name'] ?? '')); + flash('ok', '账本已创建。'); + redirect('ledger', ['book' => $id]); + } + if (!$book) { + flash('err', '找不到这本账。'); + redirect('ledger'); + } + if (!Ledger::canWrite($book)) { + flash('err', '这本账你只有只读权限。'); + redirect('ledger', ['book' => $lid]); + } + if ($act === 'ledger/book_rename') { + Ledger::rename($familyId, $lid, (string)($_POST['name'] ?? '')); + flash('ok', '账本已改名。'); + } elseif ($act === 'ledger/book_del') { + Ledger::remove($familyId, $lid); + flash('ok', '账本已删除。'); + redirect('ledger'); + } elseif ($act === 'ledger/entry_add') { + Ledger::addEntry($lid, [ + 'kind' => (string)($_POST['kind'] ?? 'other'), + 'title' => (string)($_POST['title'] ?? ''), + 'amount' => (string)($_POST['amount'] ?? ''), + 'note' => (string)($_POST['note'] ?? ''), + 'person_id' => (int)($_POST['person_id'] ?? 0), + ], $me); + flash('ok', '账目已添加。'); + } elseif ($act === 'ledger/entry_update') { + Ledger::updateEntry($lid, (int)($_POST['id'] ?? 0), [ + 'kind' => (string)($_POST['kind'] ?? 'other'), + 'title' => (string)($_POST['title'] ?? ''), + 'amount' => (string)($_POST['amount'] ?? ''), + 'note' => (string)($_POST['note'] ?? ''), + 'person_id' => (int)($_POST['person_id'] ?? 0), + ]); + flash('ok', '账目已保存。'); + } elseif ($act === 'ledger/entry_del') { + Ledger::removeEntry($lid, (int)($_POST['id'] ?? 0)); + flash('ok', '账目已删除。'); + } elseif ($act === 'ledger/share') { + $pid = (int)($_POST['person_id'] ?? 0); + if ($pid > 0 && isset(Person::map($familyId)[$pid])) { + Ledger::setShare($lid, $pid, (string)($_POST['perm'] ?? 'read')); + flash('ok', '已共享。'); + } else { + flash('err', '请选择要共享给的人。'); + } + } else { + Ledger::unshare($lid, (int)($_POST['person_id'] ?? 0)); + flash('ok', '已取消共享。'); + } + redirect('ledger', ['book' => $lid]); + break; + + /** + * 纪念日(v24 第 13 条):阴历/阳历都行;名册里每个人的生日默认可见 + * (生日本身就是纪念日的一种,且**实时派生**、不复制一份)。 + * 所有纪念日都会作为**全天日程**出现在日历里(见 Calendar::month 那段)。 + */ + case 'days': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + View::render('days/index', [ + 'title' => '纪念日', + 'rows' => Anniversary::all($familyId, $me), + 'upcoming' => Anniversary::upcoming($familyId, $me, 60), + 'people' => Person::roster($familyId, $me, 'member', ''), + ], 'days'); + break; + + case 'days/new': + case 'days/edit': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + $id = (int)($_GET['id'] ?? 0); + $editing = $id > 0 ? Anniversary::find($familyId, $id) : null; + if ($id > 0 && !$editing) { + flash('err', '找不到这条纪念日。'); + redirect('days'); + } + View::render('days/form', [ + 'title' => $id > 0 ? '编辑纪念日' : '新建纪念日', + 'editing' => $editing, + 'people' => Person::roster($familyId, $me, 'member', ''), + ], 'days'); + break; + + case 'days/save': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + Anniversary::save($familyId, [ + 'title' => (string)($_POST['title'] ?? ''), + 'repeat_type' => (string)($_POST['repeat_type'] ?? 'solar'), + 'month' => (int)($_POST['month'] ?? 1), + 'day' => (int)($_POST['day'] ?? 1), + 'is_leap' => !empty($_POST['is_leap']), + 'kind' => (string)($_POST['kind'] ?? ''), + 'note' => (string)($_POST['note'] ?? ''), + 'person_id' => (int)($_POST['person_id'] ?? 0), + ], $id); + flash('ok', $id > 0 ? '纪念日已保存。' : '纪念日已添加。'); + redirect('days'); + break; + + case 'days/destroy': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + Anniversary::remove($familyId, (int)($_POST['id'] ?? 0)); + flash('ok', '纪念日已删除。'); + redirect('days'); + break; + case 'calendar': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); - $ym = trim((string)($_GET['ym'] ?? '')); - if (!preg_match('/^\d{4}-\d{2}$/', $ym)) { - $ym = date('Y-m'); + // v24 第 4 条:月 / 周 / 天三种看法。view 只认这三个,认不出就回落月视图。 + $view = (string)($_GET['view'] ?? 'month'); + if (!in_array($view, ['month', 'week', 'day'], true)) { + $view = 'month'; } + $anchor = trim((string)($_GET['d'] ?? '')); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $anchor)) { + $anchor = date('Y-m-d'); + } + $ym = substr($anchor, 0, 7); $cals = Calendar::mine($familyId, $me); - [$from, $to] = month_grid_range($ym); - $events = Calendar::month($familyId, $cals, $me, $from, $to); + + if ($view === 'month') { + $gym = trim((string)($_GET['ym'] ?? '')); + if (preg_match('/^\d{4}-\d{2}$/', $gym)) { + $ym = $gym; + } + [$from, $to] = month_grid_range($ym); + $events = Calendar::month($familyId, $cals, $me, $from, $to); + $grid = build_month_grid($ym, $events); + } else { + // 周视图 7 列、天视图 1 列,共用同一个时间网格 + $cols = day_columns($anchor, $view === 'week' ? 7 : 1); + $from = $cols[0]['date']; + $to = $cols[count($cols) - 1]['date']; + $events = Calendar::month($familyId, $cals, $me, $from, $to); + $grid = build_time_grid($events, $cols); + } + View::render('calendar/index', [ 'title' => '日程', 'ym' => $ym, - 'weeks' => build_month_grid($ym, $events), + 'view' => $view, + 'anchor' => $anchor, + 'cols' => $view === 'month' ? [] : ($cols ?? []), + 'weeks' => $view === 'month' ? $grid : [], + 'tgrid' => $view === 'month' ? null : $grid, 'cals' => $cals, 'people' => Person::roster($familyId, $me, 'member', ''), ], 'calendar'); break; + /** + * CalDAV 专用口令(v24 第 7 条后半):生成 / 重置 / 撤销。 + * + * ⚠️ 明文口令只在这**一次**跳转里带回页面显示(存 $_SESSION 一次性取用), + * 库里只有 sha256。不这么做的话,要么明文落库(一旦泄露全暴露), + * 要么用户永远看不到自己刚设的花口令。 + */ + case 'calendar/caldav_token': + case 'calendar/caldav_revoke': + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + if ($r === 'calendar/caldav_token') { + $_SESSION['caldav_plain'] = Auth::caldavIssue($me); + flash('ok', 'CalDAV 口令已生成(旧口令立即失效,请在客户端里更新)。'); + } else { + Auth::caldavRevoke($me); + flash('ok', 'CalDAV 口令已撤销,所有已订阅的客户端会失去访问。'); + } + redirect('calendar/calendars'); + break; + case 'calendar/calendars': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); @@ -2451,6 +2759,10 @@ switch ($r) { foreach ($mine as $c) { $shares[(int)$c['id']] = Calendar::shares((int)$c['id']); } + // v24 第 7 条后半:CalDAV 专用口令的状态(明文只在刚生成那一次给用户看) + $cdHas = Auth::hasCalDav($me); + $cdPlain = (string)($_SESSION['caldav_plain'] ?? ''); + unset($_SESSION['caldav_plain']); $editing = null; $editId = (int)($_GET['edit'] ?? 0); if ($editId > 0) { @@ -2467,6 +2779,8 @@ switch ($r) { 'shares' => $shares, 'editing' => $editing, 'people' => Person::roster($familyId, $me, 'member', ''), + 'cdHas' => $cdHas, + 'cdPlain' => $cdPlain, ], 'calendar'); break; @@ -2553,43 +2867,39 @@ switch ($r) { $ym = trim((string)($_POST['ym'] ?? '')); $ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m'); $calId = (int)($_POST['calendar_id'] ?? 0); - $cal = null; - foreach (Calendar::mine($familyId, $me) as $c) { - if ((int)$c['id'] === $calId) { - $cal = $c; - } - } + // ⚠️ 走 mineOne 而不是 Calendar::find():要带 perm,共享本的可读写才判得对 + $cal = Calendar::mineOne($familyId, $me, $calId); if (!$cal || !Calendar::canWrite($cal, $me)) { flash('err', '这本日历你没有写权限。'); redirect('calendar', ['ym' => $ym]); } - $date = trim((string)($_POST['date'] ?? '')); - if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { - flash('err', '日期不对。'); + // v24 第 5、6 条:起止各是一个「日期+时分秒」的完整时刻(_datetime.php 控件), + // 不再拆成 date / end_date / start_time / end_time 四格。 + $startsAt = read_dt_input('starts_at'); + if ($startsAt === null) { + flash('err', '开始时间不对。'); redirect('calendar', ['ym' => $ym]); } - $endDate = trim((string)($_POST['end_date'] ?? '')); - if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) { - $endDate = null; - } + $endsAt = read_dt_input('ends_at'); $title = trim((string)($_POST['title'] ?? '')); if ($title === '') { flash('err', '日程得有个标题。'); redirect('calendar', ['ym' => $ym]); } $allDay = !empty($_POST['all_day']); - $st = $allDay ? '' : (string)($_POST['start_time'] ?? ''); - $et = $allDay ? '' : (string)($_POST['end_time'] ?? ''); + if ($allDay) { + // 全天日程:时刻部分一律归零(跨天的全天日程截止时刻含到当天末尾) + $startsAt = substr($startsAt, 0, 10) . ' 00:00:00'; + $endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null; + } $note = trim((string)($_POST['note'] ?? '')); // ⚠️ 观影 / 旅行两本是派生的:往这两本里加日程 = 建一条真正的观影 / 旅程记录, // 而不是往 calendar_events 里塞一行(那样日历里有、观影列表里没有)。 - $starts = $date . ' ' . ($st !== '' && preg_match('/^\d{2}:\d{2}$/', $st) ? $st . ':00' : '00:00:00'); - $ends = ($endDate ?: $date) . ' ' . ($et !== '' && preg_match('/^\d{2}:\d{2}$/', $et) ? $et . ':00' : '00:00:00'); - $kind = (string)$cal['kind']; + $kind = (string)$cal['kind']; if ($kind === 'movie') { $newId = Movie::create($familyId, $me, [ - 'title' => $title, 'starts_at' => $starts, 'ends_at' => $ends, 'note' => $note, + 'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note, 'attendees' => [$me], ]); flash('ok', '已加进观影,日历里也能看到了。'); @@ -2597,24 +2907,30 @@ switch ($r) { } if ($kind === 'trip') { $newId = Trip::create($familyId, $me, [ - 'title' => $title, 'starts_at' => $starts, 'ends_at' => $ends, 'note' => $note, + 'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note, 'attendees' => [$me], ]); flash('ok', '已加进旅程,日历里也能看到了。'); redirect('trips/show', ['id' => $newId]); } - Calendar::addEvent((int)$cal['id'], [ - 'title' => $title, - 'starts_at' => $date, - 'ends_at' => $endDate, - 'start_time' => $st !== '' ? $st . ':00' : null, - 'end_time' => $et !== '' ? $et . ':00' : null, - 'all_day' => $allDay, - 'note' => $note, - 'created_by' => $me, + $evId = Calendar::addEvent((int)$cal['id'], [ + 'title' => $title, + 'location' => trim((string)($_POST['location'] ?? '')), + 'starts_at' => $startsAt, + 'ends_at' => $endsAt, + 'all_day' => $allDay, + 'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''), + 'status' => (string)($_POST['status'] ?? 'confirmed'), + 'transp' => !empty($_POST['transp']), + 'note' => $note, + 'created_by' => $me, ]); + $warns = attach_save_files($familyId, Calendar::ATTACH, $evId, 'attach', $me); + if ($warns) { + flash('err', '有附件没有保存:' . implode(';', $warns)); + } flash('ok', '日程已加上。'); - redirect('calendar', ['ym' => substr($date, 0, 7)]); + redirect('calendar', ['ym' => substr($startsAt, 0, 7)]); break; case 'calendar/event': @@ -2632,8 +2948,7 @@ switch ($r) { flash('err', '找不到这条日程。'); redirect('calendar'); } - $cal = Calendar::find((int)$ev['calendar_id']) ?: []; - $ev['_can_write'] = Calendar::canWrite($cal, $me); + $ev['_can_write'] = Calendar::canWriteEvent($ev, $me); View::render('calendar/event', [ 'title' => (string)$ev['title'], 'ev' => $ev, @@ -2660,31 +2975,40 @@ switch ($r) { flash('err', '找不到这条日程。'); redirect('calendar', ['ym' => $ym]); } - $cal = Calendar::find((int)$ev['calendar_id']) ?: []; + $cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: []; if (!Calendar::canWrite($cal, $me)) { flash('err', '这本日历你没有写权限。'); redirect('calendar', ['ym' => $ym]); } - $date = trim((string)($_POST['date'] ?? '')); - if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { - flash('err', '日期不对。'); + // v24 第 5、6 条:起止各是一个完整时刻(精确到秒),见上面 event_add 的说明。 + $startsAt = read_dt_input('starts_at'); + if ($startsAt === null) { + flash('err', '开始时间不对。'); redirect('calendar', ['ym' => $ym]); } - $allDay = !empty($_POST['all_day']); - $endDate = trim((string)($_POST['end_date'] ?? '')); + $endsAt = read_dt_input('ends_at'); + $allDay = !empty($_POST['all_day']); + if ($allDay) { + $startsAt = substr($startsAt, 0, 10) . ' 00:00:00'; + $endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null; + } Calendar::updateEvent($id, [ - 'title' => trim((string)($_POST['title'] ?? '')), - 'starts_at' => $date, - 'ends_at' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) ? $endDate : null, - 'start_time' => !$allDay && preg_match('/^\d{2}:\d{2}$/', (string)($_POST['start_time'] ?? '')) - ? (string)$_POST['start_time'] . ':00' : null, - 'end_time' => !$allDay && preg_match('/^\d{2}:\d{2}$/', (string)($_POST['end_time'] ?? '')) - ? (string)$_POST['end_time'] . ':00' : null, - 'all_day' => $allDay, - 'note' => trim((string)($_POST['note'] ?? '')), + 'title' => trim((string)($_POST['title'] ?? '')), + 'location' => trim((string)($_POST['location'] ?? '')), + 'starts_at' => $startsAt, + 'ends_at' => $endsAt, + 'all_day' => $allDay, + 'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''), + 'status' => (string)($_POST['status'] ?? 'confirmed'), + 'transp' => !empty($_POST['transp']), + 'note' => trim((string)($_POST['note'] ?? '')), ]); + $warns = attach_save_files($familyId, Calendar::ATTACH, $id, 'attach', $me); + if ($warns) { + flash('err', '有附件没有保存:' . implode(';', $warns)); + } flash('ok', '日程已保存。'); - redirect('calendar', ['ym' => substr($date, 0, 7)]); + redirect('calendar', ['ym' => substr($startsAt, 0, 7)]); break; case 'calendar/event_del': @@ -2703,7 +3027,7 @@ switch ($r) { $st->execute([$id, $familyId]); $ev = $st->fetch(); if ($ev) { - $cal = Calendar::find((int)$ev['calendar_id']) ?: []; + $cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: []; if (Calendar::canWrite($cal, $me)) { Calendar::deleteEvent($id); flash('ok', '日程已删除。'); @@ -2714,6 +3038,98 @@ switch ($r) { redirect('calendar', ['ym' => $ym]); break; + // ==================== ics 导出 / 导入(v24 第 7 条)==================== + case 'calendar/export': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + $calId = (int)($_GET['id'] ?? 0); + $cal = Calendar::mineOne($familyId, $me, $calId); + if (!$cal) { + flash('err', '找不到这本日历。'); + redirect('calendar'); + } + $rows = Calendar::exportRows($cal, $familyId, $me); + $ics = Ics::export($rows, (string)$cal['name']); + // ⚠️ 文件名要用**本子名 + 日期**,别用中文之外的怪字符: + // Content-Disposition 的非 ASCII 文件名各家浏览器编码方式不一样, + // 最稳的做法是给一个 ASCII 兜底名(filename=)再给一个 UTF-8 的(filename*=)。 + $ascii = 'calendar-' . (int)$cal['id'] . '-' . date('Ymd') . '.ics'; + $utf8 = rawurlencode((string)$cal['name'] . '-' . date('Y-m-d') . '.ics'); + header('Content-Type: text/calendar; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $ascii . '"; filename*=UTF-8\'\'' . $utf8); + header('Content-Length: ' . strlen($ics)); + echo $ics; + exit; + + case 'calendar/import': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $calId = (int)($_POST['calendar_id'] ?? 0); + $cal = Calendar::mineOne($familyId, $me, $calId); + if (!$cal || !Calendar::canWrite($cal, $me)) { + flash('err', '这本日历你没有写权限。'); + redirect('calendar/calendars'); + } + // ⚠️ 只能导进**自定义本**:观影 / 旅行两本是派生的(内容是 events / trip_nodes), + // 往里塞 calendar_events 会出现"日历里有、观影列表里没有"的孤儿。 + if ((string)$cal['kind'] !== 'custom') { + flash('err', '只能往自定义日历本里导入。观影、旅行两本是从记录里自动汇总的。'); + redirect('calendar/calendars'); + } + if (empty($_FILES['ics']) || (int)($_FILES['ics']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + flash('err', '没收到 .ics 文件。'); + redirect('calendar/calendars'); + } + $text = (string)file_get_contents((string)$_FILES['ics']['tmp_name']); + $list = Ics::parse($text); + if (!$list) { + flash('err', '这个文件里没有找到任何日程(VEVENT)。'); + redirect('calendar/calendars'); + } + $added = 0; + $updated = 0; + foreach ($list as $v) { + if (($v['starts_at'] ?? null) === null) { + continue; // 没有 DTSTART 的 VEVENT 是坏数据,跳过而不是写成 1970 年 + } + if (trim((string)$v['title']) === '') { + $v['title'] = '(无标题日程)'; + } + if (Calendar::importEvent($calId, $v, $me) === 'added') { + $added++; + } else { + $updated++; + } + } + flash('ok', '导入完成:新增 ' . $added . ' 条,更新 ' . $updated . ' 条。'); + redirect('calendar/calendars'); + break; + + case 'calendar/attach_del': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $aid = (int)($_POST['attach_id'] ?? 0); + $ev = Calendar::event($id); + if ($ev && Calendar::canWriteEvent($ev, $me)) { + // ⚠️ 附件 id 与宿主 id 一起判:只按 attach_id 删,就能用别的日程的 + // 附件 id 把人家文件删掉(宿主对不上就静默放行是很典型的越权)。 + $st = Database::get()->prepare( + 'SELECT id FROM attachments WHERE id = ? AND target_type = ? AND target_id = ?' + ); + $st->execute([$aid, Calendar::ATTACH, $id]); + if ($st->fetchColumn() !== false) { + Attachment::remove($aid); + flash('ok', '附件已删除。'); + } + } + redirect('calendar/event', ['id' => $id]); + break; + // ==================== 资源(v12) ==================== // 挂在**某个人名下**的东西(证件 / 照片 / 账号),可共享给别人(只读或读写)。 @@ -2887,15 +3303,99 @@ switch ($r) { case 'tasks': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); - $board = Task::board($familyId, $me); + // v24 第 10 条:看板由用户自己建(家务 / 装修 / …),顶上有个切换条 + $boards = Task::boards($familyId); + $bid = (int)($_GET['board'] ?? 0); + $board = Task::board($familyId, $me, $bid); View::render('tasks/index', [ - 'title' => '任务', - 'board' => $board, - 'total' => count($board['todo']) + count($board['doing']) + count($board['done']), - 'people' => Person::roster($familyId, $me, 'member', ''), + 'title' => '任务', + 'board' => $board, + 'boards' => $boards, + 'curBoard' => $bid > 0 ? $bid : Task::ensureDefaultBoard($familyId), + 'categories' => Task::categories($familyId), + 'total' => count($board['todo']) + count($board['doing']), + 'people' => Person::roster($familyId, $me, 'member', ''), ], 'tasks'); break; + /** + * 任务设置(v24 第 9、10 条):分类 / 看板 / 已完成的归档。 + * 入口在「新建任务」旁边那个 ☰ 下拉里,与观影的 ☰ 是同一套做法。 + */ + case 'tasks/settings': + $familyId = (int)Auth::familyId(); + View::render('tasks/settings', [ + 'title' => '任务设置', + 'boards' => Task::boards($familyId), + 'categories' => Task::categories($familyId), + ], 'tasks'); + break; + + /** 已完成的任务(按分类查看 / 按关键词搜) */ + case 'tasks/done': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + $cid = (int)($_GET['cat'] ?? 0); + $kw = trim((string)($_GET['kw'] ?? '')); + View::render('tasks/done', [ + 'title' => '已完成', + 'rows' => Task::doneList($familyId, $me, $cid, $kw), + 'categories' => Task::categories($familyId), + 'cat' => $cid, + 'kw' => $kw, + ], 'tasks'); + break; + + case 'tasks/board_add': + case 'tasks/board_update': + case 'tasks/board_del': + case 'tasks/cat_add': + case 'tasks/cat_update': + case 'tasks/cat_del': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $nm = trim((string)($_POST['name'] ?? '')); + $act = (string)($_GET['r'] ?? ''); + if ($act === 'tasks/board_add') { + if ($nm === '') { + flash('err', '看板名不能为空。'); + } else { + Task::createBoard($familyId, $nm); + flash('ok', '看板已添加。'); + } + } elseif ($act === 'tasks/board_update') { + if ($nm === '') { + flash('err', '看板名不能为空。'); + } else { + Task::renameBoard($familyId, $id, $nm); + flash('ok', '看板已改名。'); + } + } elseif ($act === 'tasks/board_del') { + Task::deleteBoard($familyId, $id); + flash('ok', '看板已删除,里头的任务挪到了默认看板。'); + } elseif ($act === 'tasks/cat_add') { + if ($nm === '') { + flash('err', '分类名不能为空。'); + } else { + Task::createCategory($familyId, $nm); + flash('ok', '分类已添加。'); + } + } elseif ($act === 'tasks/cat_update') { + if ($nm === '') { + flash('err', '分类名不能为空。'); + } else { + Task::renameCategory($familyId, $id, $nm); + flash('ok', '分类已改名。'); + } + } else { + Task::deleteCategory($familyId, $id); + flash('ok', '分类已删除,用它标过的任务变成「未分类」。'); + } + redirect('tasks/settings'); + break; + case 'tasks/show': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); @@ -2914,10 +3414,13 @@ switch ($r) { } } View::render('tasks/show', [ - 'title' => (string)$t['title'], - 't' => $t, - 'people' => Person::roster($familyId, $me, 'member', ''), - 'parents' => $parents, + 'title' => (string)$t['title'], + 't' => $t, + 'people' => Person::roster($familyId, $me, 'member', ''), + 'parents' => $parents, + // v24 第 9、10 条:详情页的编辑表单里也能改分类与看板 + 'boards' => Task::boards($familyId), + 'categories' => Task::categories($familyId), ], 'tasks'); break; @@ -2946,6 +3449,9 @@ switch ($r) { 'repeat_rule' => trim((string)($_POST['repeat_rule'] ?? '')), 'parent_id' => (int)($_POST['parent_id'] ?? 0), 'assignee_id' => (int)($_POST['assignee_id'] ?? 0), + // v24 第 9、10 条:分类与看板。Task 侧会校验归属(不认别的家庭的 id)。 + 'category_id' => (int)($_POST['category_id'] ?? 0), + 'board_id' => (int)($_POST['board_id'] ?? 0), 'status' => $isEdit ? (string)$old['status'] : 'todo', ]; if ($d['assignee_id'] > 0 && !isset(Person::map($familyId)[$d['assignee_id']])) { diff --git a/router.php b/router.php new file mode 100644 index 0000000..129d6a0 --- /dev/null +++ b/router.php @@ -0,0 +1,31 @@ + 值](如日程要连带传宿主 id 才能判权限) + * + * ⚠️ 无 JS 也能用:可预览的是 ,不可预览的是 , + * 脚本只是把"可预览"的那部分拦下来改成弹层。 + */ + +$attCanEdit = !empty($attCanEdit); +$attRows = $attRows ?? []; +$attDelField = $attDelField ?? 'id'; +$attExtra = $attExtra ?? []; +$attDelUrl = $attDelUrl ?? ''; +?> + + + + 1]); + $nm = (string)($a['name'] ?? ''); + $sz = \App\Attachment::sizeWord($a['size'] ?? 0); + ?> + + + + + + + + + ▶ + + PDF + + + + + = e(\App\Attachment::kindWord($kind)) ?> + + + + = e($nm !== '' ? $nm : \App\Attachment::kindWord($kind)) ?> + = e($sz) ?> + 下载 + + + = csrf_field() ?> + + $ev): ?> + + + 删除 + + + + + + + + + diff --git a/templates/_datetime.php b/templates/_datetime.php index 573e625..db2a904 100644 --- a/templates/_datetime.php +++ b/templates/_datetime.php @@ -24,7 +24,13 @@ $dtRequired = (bool)($dtRequired ?? false); $dtId = (string)($dtId ?? ''); $dtSeconds = (bool)($dtSeconds ?? true); -$dtTs = (!empty($dtValue) && ($t = strtotime(trim((string)$dtValue)))) ? $t : false; +// ⚠️⚠️ 临时变量**一律带 dt 前缀**,一个都不能漏:这里原来写成 +// `$t = strtotime(...)`,把宿主模板里表示「这条任务 / 这条记录」的 `$t` +// 覆盖成了 int —— 于是 include 之后凡是读 `$t['id']`、`$t['status']` 的地方 +// 全部拿到 null,子任务列表空、评论不渲染、@高亮消失,而且**不报任何错** +// (PHP 8 对 "Trying to access array offset on value of type int" 只 Warning)。 +// tasks/show.php 上就是这么被 e2e 抓到的(2026-10-02)。 +$dtTs = (!empty($dtValue) && ($dtTsTmp = strtotime(trim((string)$dtValue)))) ? $dtTsTmp : false; $dtDate = $dtTs ? date('Y-m-d', $dtTs) : ''; $dtHour = $dtTs ? (int)date('G', $dtTs) : 0; // G = 0..23,天然 24 小时制 $dtMin = $dtTs ? (int)date('i', $dtTs) : 0; @@ -62,6 +68,6 @@ $dtSec = $dtTs ? (int)date('s', $dtTs) : 0; diff --git a/templates/_person_picker.php b/templates/_person_picker.php new file mode 100644 index 0000000..d4435a8 --- /dev/null +++ b/templates/_person_picker.php @@ -0,0 +1,93 @@ + 下拉 —— 名册一多就变成"在一长串里瞪眼找",而且三种地方三种手感。 + * + * 用法(在模板里先准备好变量再 include): + * $ppName 表单字段名。多选传 'attendees[]';单选传 'owner_id' 等 + * $ppPeople 候选的人(Person::roster() 的行,至少要有 id / name) + * $ppSelected 已选的 id 数组(单选传 0 或 1 个) + * $ppMode 'multi'(默认)| 'single' + * $ppOpenBtn 打开按钮文案(默认 multi=「+ 添加人」/ single=「选择」) + * $ppTitle 弹窗标题(默认「选择人」) + * $ppAllowEmpty 单选模式下是否给一个「不选」选项(筛选表单用;值为 0) + * $ppEmptyText 单选且未选时的占位(默认「未选择」) + * $ppTip 可选,渲染在按钮下面的一句话 + * + * ⚠️ 无 JS 时也能用:选项就是弹窗列表里的原生 checkbox / radio, + * noscript 样式把弹窗摊平成一个普通列表,提交语义与有 JS 时完全一致。 + * + * ⚠️ 同一页出现多个选择器时 $ppId 必须不同 —— 片段自己按出现顺序编号, + * 调用方不用管(但同一页重复 include 同一份变量也不会撞)。 + */ + +$ppMode = $ppMode ?? 'multi'; +$ppPeople = $ppPeople ?? []; +$ppSelected = array_map('intval', (array)($ppSelected ?? [])); +$ppOpenBtn = $ppOpenBtn ?? ($ppMode === 'single' ? '选择' : '+ 添加人'); +$ppTitle = $ppTitle ?? '选择'; +$ppEmptyText = $ppEmptyText ?? '未选择'; +$ppAllowEmpty = !empty($ppAllowEmpty); + +// 同一页多个实例要各有一套 id,否则 document.getElementById 全抓到第一个 +static $ppSeq = 0; +$ppSeq++; +$ppId = 'pp' . $ppSeq; +$ppIsMulti = $ppMode === 'single' ? false : true; +$ppInputType = $ppIsMulti ? 'checkbox' : 'radio'; +// ⚠️ 「已选」要按**非零**判断:筛选表单常用 0 表示「全部」,[0] 是个非空数组, +// 直接 !$ppSelected 会误判成"选了人",于是「不选」那一项反而不勾。 +$ppHasSel = count(array_filter($ppSelected)) > 0; +?> + + + + + + = e(person_name($ppRow)) ?>× + + + + = e($ppEmptyText) ?> + + + = e($ppOpenBtn) ?> + = e($ppTip) ?> + + + + = e($ppTitle) ?> + × + + + + + + > + 不选 + + + + + > + + = e(person_name($ppRow)) ?> + + + + + 没有匹配的人。 + + + + + diff --git a/templates/calendar/_event_fields.php b/templates/calendar/_event_fields.php new file mode 100644 index 0000000..54b7cd5 --- /dev/null +++ b/templates/calendar/_event_fields.php @@ -0,0 +1,120 @@ + + + 标题 * + + + + + 地点 + + + + + 提醒 + + + + >不提醒 + $word): ?> + > + = e($word) ?> + + + + + + + + 开始 * + + + + 结束 + + + + + + > + 全天 + + 勾上就不管具体几点,只占这一天 + + + + 状态 + + + >确定 + >待定 + >取消 + + + + + 这段时间算 + + + >忙碌 + >空闲(别人还能约) + + + + + 备注 + = e((string)($evF['note'] ?? '')) ?> + + + + 附件(可多选) + + + + + diff --git a/templates/calendar/calendars.php b/templates/calendar/calendars.php index e754c1c..c49ee28 100644 --- a/templates/calendar/calendars.php +++ b/templates/calendar/calendars.php @@ -102,12 +102,17 @@ $canWrite = \App\Auth::canWrite(); = csrf_field() ?> - - - - = e(person_name($p)) ?> - - + (int)$p['id'] !== (int)\App\Auth::personId())); + $ppSelected = []; + $ppMode = 'single'; + $ppOpenBtn = '选择要共享给谁'; + $ppTitle = '共享给谁'; + $ppEmptyText = '未选择'; + require __DIR__ . '/../_person_picker.php'; + ?> 只读 可写 @@ -129,6 +134,9 @@ $canWrite = \App\Auth::canWrite(); + + 导出 ics 编辑 @@ -152,6 +160,89 @@ $canWrite = \App\Auth::canWrite(); + + + + + 导入 ics + 把别的日历导出的 .ics 文件整批装进来 + + + 同一个文件导两次也不会重复:每条日程有个全球唯一的 UID, + 已经导过的会被更新,而不是再插一条。 + + + = csrf_field() ?> + + + 导进哪一本 * + + + + = e((string)$c['name']) ?> + + + + + .ics 文件 * + + + + + 导入 + + + + + + + + + CalDAV 订阅 + 在手机日历 / DAVx⁵ / Thunderbird 里订阅你的日历本 + + + 填下面这个地址 + 你的姓名或手机号 + CalDAV 口令,就能把「我的日历本」 + 同步到别的日历程序里。这是只读订阅的 —— 在手机上改日程请回到本站来改。 + 口令与登录密码是分开的:订阅凭据会被客户端长期存着,所以另发一个, + 随时可换、不影响登录。 + + + 服务器地址 + + 在日历客户端里填这一条即可,会列出你所有的日历本。 + + + + 这是你的新口令,只显示这一次,请立刻复制走: + = e($cdPlain) ?> + + + + + + = csrf_field() ?> + + = !empty($cdHas) ? '重置 CalDAV 口令' : '生成 CalDAV 口令' ?> + + + + + = csrf_field() ?> + 撤销口令 + + 口令已配置 + + 还没配置口令 + + + + + 别人共享给我的 diff --git a/templates/calendar/event.php b/templates/calendar/event.php index bef8c6b..88e399a 100644 --- a/templates/calendar/event.php +++ b/templates/calendar/event.php @@ -16,61 +16,75 @@ $canWrite = \App\Auth::canWrite() && !empty($ev['_can_write']); + - 日期= e((string)$ev['starts_at']) ?> - - 到= e((string)$ev['ends_at']) ?> - - 时间 - = empty($ev['all_day']) ? e(substr((string)$ev['start_time'], 0, 5) . ' – ' . substr((string)$ev['end_time'], 0, 5)) : '全天' ?> + 开始 + = e(empty($ev['all_day']) ? (string)$ev['starts_at'] : substr((string)$ev['starts_at'], 0, 10)) ?> + + 结束 + = e(empty($ev['all_day']) ? (string)$ev['ends_at'] : substr((string)$ev['ends_at'], 0, 10)) ?> + + + + 全天 + + + 地点= e((string)$ev['location']) ?> + + + 提醒 + = e(\App\Calendar::REMINDERS[(int)$ev['reminder_minutes']] ?? '') ?> + + 备注= nl2br(e((string)$ev['note'])) ?> + + + 附件 + + + + + = (int)$a['is_image'] === 1 ? '🖼' : '📄' ?> = e((string)($a['name'] ?: '附件')) ?> + + 下载 + + + + + 这本日历你只有只读权限,看得到但不能改。 - + = csrf_field() ?> - - - 标题 * - - - - 开始日期 * - - - - 结束日期(跨天时填) - - - - > 全天(不填具体时分) - - - 开始时刻 - - - - 结束时刻 - - - - 备注 - = e((string)($ev['note'] ?? '')) ?> - + 保存 取消 + + + + 已有附件 + (int)$ev['id']]; + require __DIR__ . '/../_attachments.php'; + ?> + + 套 是非法 HTML, 浏览器会把外层表单在嵌套处切开,"保存"按钮就失效了。 */ ?> \App\Calendar::canWrite($c, (int)\App\Auth::personId()))); + +// 三种看法各自的「上一段 / 下一段」:月按整月走,周按 7 天走,天按 1 天走 +$step = $view === 'week' ? 7 : ($view === 'day' ? 1 : 0); +$prevD = date('Y-m-d', strtotime('-' . $step . ' days', strtotime($anchor))); +$nextD = date('Y-m-d', strtotime('+' . $step . ' days', strtotime($anchor))); +$todayD = date('Y-m-d'); +$headText = $view === 'month' ? date('Y 年 n 月', $ts) + : ($view === 'week' ? date('Y 年 n 月 j 日', strtotime($anchor)) . ' 起这一周' + : date('Y 年 n 月 j 日', strtotime($anchor))); +$mkUrl = static function (string $v) use ($ym, $prevD, $nextD, $todayD, $prevYm, $nextYm, $thisYm) { + if ($v === 'month') { + return url('calendar', ['view' => 'month', 'ym' => $ym]); + } + return null; // 周/天要把具体哪天传进去,由调用方拼 +}; ?> 日程 - = e(date('Y 年 n 月', $ts)) ?> + = e($headText) ?> - 上一月 - 今天 - 下一月 + + + 月 + 周 + 天 + + + 上一月 + 今天 + 下一月 + + + = $view === 'week' ? '上一周' : '前一天' ?> + + 今天 + + = $view === 'week' ? '下一周' : '后一天' ?> + + 日历本 新建日程 @@ -68,7 +104,7 @@ $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::can 新建日程 - + = csrf_field() ?> @@ -80,34 +116,14 @@ $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::can - - 标题 * - - - - 日期 * - - - - 结束日期(跨天时填) - - - - 全天(不填具体时分) - - - 备注 - - - - 开始时刻 - - - - 结束时刻 - - + + ($ym === $thisYm ? date('Y-m-d') : $ym . '-01') . ' 09:00:00', + 'ends_at' => ($ym === $thisYm ? date('Y-m-d') : $ym . '-01') . ' 10:00:00']; + $evAllDay = false; + require __DIR__ . '/_event_fields.php'; + ?> 加上 @@ -117,6 +133,60 @@ $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::can + + + + + + + + = e(['日','一','二','三','四','五','六'][$c['w']]) ?> + = (int)$c['day'] ?> + + + + + + + + 全天 + $c): ?> + + + = e((string)$ev['title']) ?> + + + + + + + + + + = sprintf('%02d', $h) ?> + + + $c): ?> + + + + + + + = e((string)$ev['time']) ?> = e((string)$ev['title']) ?> + + + + + + + @@ -150,6 +220,7 @@ $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::can + @@ -180,18 +251,6 @@ $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::can }); } if (cancel && card) { cancel.addEventListener('click', function () { card.hidden = true; }); } - - /* 「全天」勾上就把两个时刻输入框藏掉 —— 没有具体时分就是全天(用户第 7 条) */ - var allday = document.getElementById('ce-allday'); - var ts = document.getElementById('ce-time-s'); - var te = document.getElementById('ce-time-e'); - if (allday && ts && te) { - function syncTime() { - var hide = allday.checked; - ts.hidden = hide; te.hidden = hide; - } - allday.addEventListener('change', syncTime); - syncTime(); - } + /* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */ })(); diff --git a/templates/days/form.php b/templates/days/form.php new file mode 100644 index 0000000..649b886 --- /dev/null +++ b/templates/days/form.php @@ -0,0 +1,96 @@ + $e0[$k] ?? $def; +$repeat = (string)($e0['repeat_type'] ?? 'solar'); +?> + + = $editing ? '编辑纪念日' : '新建纪念日' ?> + + 返回 + + + + + = csrf_field() ?> + + + + + 叫什么 * + + + + + 按什么重复 * + + $rv): ?> + >= e($rv) ?> + + + 选「农历」时,下面填的是**农历**的月日(不是公历)。 + + + + 分类 + + + + + + + + + + 月 * + + + + 日 * + + + + + 与谁相关 + + 不指定 + + > + = e(person_name($p)) ?> + + + + + + + 农历闰月 + + > + 这个农历月是闰月 + + + + + 备注 + = e((string)$v('note')) ?> + + + + + = $editing ? '保存' : '添加' ?> + 取消 + + \ No newline at end of file diff --git a/templates/days/index.php b/templates/days/index.php new file mode 100644 index 0000000..53b009f --- /dev/null +++ b/templates/days/index.php @@ -0,0 +1,105 @@ +' . e($k) . ''; +}; +$srcWord = static function (array $r): string { + $src = (string)($r['source'] ?? 'custom'); + if ($src === 'birthday_solar') { + return '名册生日(阳历)'; + } + if ($src === 'birthday_lunar') { + return '名册生日(农历)'; + } + return \App\Anniversary::repeatWord((string)($r['repeat_type'] ?? 'solar')); +}; +?> + + 纪念日 + 生日本身就是一种纪念日 · 阳历与农历都能记 + + + 新建纪念日 + + 看日历 + + + + + 接下来的日子 + + 未来 60 天里没有纪念日。 + + + + + = (int)$u['days'] === 0 ? '今天' : ((int)$u['days'] . ' 天后') ?> + = e(substr((string)$u['date'], 5)) ?> + = e((string)$u['title']) ?> + = (int)$u['age'] ?> 岁 + = $kindBadge($u) ?> + + + + + + + + 全部纪念日(= count($rows) ?>) + + 还没有纪念日。先到名册里把家人的生日填上,这里就会自动出现。 + + + + + 日期纪念日分类重复 + + + + + = (int)$r['month'] ?> 月 = (int)$r['day'] ?> 日 + + = e((string)$r['title']) ?> + (闰月) + + = $kindBadge($r) ?> + = e($srcWord($r)) ?> + + + 改 + + = csrf_field() ?> + + 删 + + + 来自名册 + + + + + + + + + + 名册里那部分(生日)是**实时读出来的** —— 改生日,这里跟着变,不用重复维护。 + 所有纪念日都会作为**全天日程**出现在日历里。 + + \ No newline at end of file diff --git a/templates/home.php b/templates/home.php index be5a0b5..34ee701 100644 --- a/templates/home.php +++ b/templates/home.php @@ -15,7 +15,7 @@ */ $kinCount = count($kin); // 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出 -$shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '农历生日']; +$shortKind = \App\Birthday::KINDS; $today = date('Y-m-d'); ?> @@ -159,14 +159,15 @@ $today = date('Y-m-d'); + - = !empty($memories['exact']) ? '往年今日' : '往年这个月' ?> + 往年的今天 = e(date('n 月 j 日')) ?> - = !empty($memories['exact']) ? '往年的这一天还没有记录' : '往年的这个月还没有记录' ?> + 往年的这一天还没有记录 日子还长,等有了第一次,明年这时候就会自己冒出来。 diff --git a/templates/kin/tree.php b/templates/kin/tree.php index 668e1c1..24c317f 100644 --- a/templates/kin/tree.php +++ b/templates/kin/tree.php @@ -20,12 +20,18 @@ $people = $people ?? []; // familyMap 的 $up/$down 只影响**代数标号**(超出就压到边界那一档), // 遍历本身是走遍整个连通图的 —— 所以这两个值不放宽的话, // 曾祖辈与祖辈会被压成同一代,排序就乱了。给足余量。 -$map = \App\Kinship::familyMap((int)\App\Auth::familyId(), (int)$meId, $people, 9, 9); +// ⚠️ v24 第 12 条:中心是 $focusId(默认=我)。点姻亲入口进来时,中心换成那个人, +// 这样 TA 那一侧的姻亲(岳父母等)才画得出来。 +$focusId = (int)($focusId ?? $meId); +if (!isset($people[$focusId]) || !is_array($people[$focusId])) { + $focusId = (int)$meId; +} +$map = \App\Kinship::familyMap((int)\App\Auth::familyId(), $focusId, $people, 9, 9); $gen = $map['gen']; $units = $map['units']; $unitOf = $map['unit_of']; -if (!isset($people[$meId]) || !is_array($people[$meId])) { +if (!isset($people[$focusId]) || !is_array($people[$focusId])) { $canChart = false; } else { $onTree = array_fill_keys(array_map('intval', array_keys($gen)), true); @@ -37,7 +43,7 @@ if (!isset($people[$meId]) || !is_array($people[$meId])) { // 把树根换成我的最高祖先,整棵树就变成「这位祖先的全部后代」, // 上一代、这一代、下一代的血亲与旁支才能全部落进来。 // 代价是「我」不再是几何中心 —— 用「回到我」按钮 + 加载后自动居中补回来。 - $rootId = (int)$meId; + $rootId = (int)$focusId; for ($guard = 0; $guard < 8; $guard++) { $up = \App\Kinship::parentIds($graph, $rootId); $next = 0; @@ -108,7 +114,9 @@ if (!isset($people[$meId]) || !is_array($people[$meId])) { 'name' => \App\Nickname::display((int)$meId, $p) ?: (string)($p['name'] ?? ''), 'label' => \App\Kinship::label($graph, (int)$meId, $pid, $people), 'avatar' => avatar_url($p['avatar'] ?? null), - 'me' => $pid === (int)$meId, + // 「我」在图上高亮;中心那个人另有标记($focusId) + 'me' => $pid === (int)$focusId, + 'is_me_viewer' => $pid === (int)$meId, 'ph' => (int)($p['is_placeholder'] ?? 0) === 1, ], 'rels' => [ @@ -119,6 +127,16 @@ if (!isset($people[$meId]) || !is_array($people[$meId])) { ]; } + // v24 第 12 条:本图上「有姻亲但没画出来」的人 —— 卡片上给个入口 + $branchOuts = \App\Kinship::branchOuts($graph, $people, $onTree); + foreach ($datum as &$dn) { + $did = (int)$dn['id']; + if (isset($branchOuts[$did])) { + $dn['data']['branch'] = $branchOuts[$did]['name']; + } + } + unset($dn); + $canChart = count($datum) > 1; } @@ -130,8 +148,18 @@ $datumJson = json_encode( ?> - 我的家谱 - 以我为中心 · 上下一共五代 · 夫妻并排,子女从两人之间的连线连出 + = $focusId === (int)$meId ? '我的家谱' : e((string)($people[$focusId]['name'] ?? '') . ' 的家谱') ?> + + = $focusId === (int)$meId + ? '以我为中心 · 夫妻并排,子女从两人之间的连线连出' + : '以 TA 为中心(这样 TA 那一侧的姻亲才露得出来)' ?> + + + + + ← 返回我的家谱 + + @@ -144,7 +172,8 @@ $datumJson = json_encode( 看全部 - 回到我 + + = $focusId === (int)$meId ? '回到我' : '回到我的家谱' ?> - + 每代一行;一对夫妻之间有一条短横线, @@ -197,22 +227,29 @@ $datumJson = json_encode( // 会跳过深度截断,等于**不限代数**,有多少画多少。 // 之前写死了具体代数,真实家庭一超过就被砍掉。 // 用户要求「全部取出来」,所以这里也不设。 - .setCardXSpacing(180) + .setCardXSpacing(190) .setCardYSpacing(180); chart.setCardHtml() .setStyle('rect') - .setCardDim({ w: 140, h: 120, mini_tree: false }) + .setCardDim({ w: 148, h: 138, mini_tree: false }) .setCardInnerHtmlCreator(function (d) { var p = (d.data && d.data.data) || {}; var id = (d.data && d.data.id) || ''; var cls = 'kin-card' + (p.me ? ' is-me' : '') + (p.ph ? ' is-ph' : ''); + // v24 第 12 条:头像改**方形**(原来是圆角矩形 + cover,把脸裁掉了)。 + // 卡片本身是 进成员页;有姻亲没画出来的,再叠一个独立的小按钮 + // (用 而不是 : 不能套 )。 return '' + '' + '' + esc(p.name) + '' + '' + esc(p.label) + '' + (p.ph ? '待确认' : '') + + (p.branch + ? '⇄ 姻亲' + : '') + ''; }); @@ -256,12 +293,15 @@ $datumJson = json_encode( } window.kinCenterOn = centerOn; - var meId = root.getAttribute('data-me') || ''; + var focusId = root.getAttribute('data-focus') || ''; + var meId = root.getAttribute('data-me-viewer') || focusId; + // ⚠️ 中心是别人时,「回到我」要跳到 focus=我 的图,而不是在图里找我的坐标 + var centerTarget = root.getAttribute('data-me-viewer') || focusId; // fit 动画完成后把镜头挪到「我」(树根是最高祖先,我不再是几何中心) if (meId) { - setTimeout(function () { centerOn(meId); }, 900); + setTimeout(function () { centerOn(focusId); }, 900); // 万一 zoom 对象还没初始化好,再补一次 - setTimeout(function () { centerOn(meId); }, 1400); + setTimeout(function () { centerOn(focusId); }, 1400); } var btnFit = document.getElementById('kinFit'); @@ -271,8 +311,33 @@ $datumJson = json_encode( chart.updateTree({ tree_position: 'fit' }); }); } + /* v24 第 12 条:卡片右下角的「⇄ 姻亲」按钮 —— 以那个人为中心重画一张家谱。 + ⚠️ 这个 span 在卡片的 里,必须 preventDefault + stopPropagation, + 否则会先触发外面那个链接跳去成员页。 */ + Array.prototype.forEach.call(root.querySelectorAll('.kin-branch'), function (btn) { + function go(e) { + e.preventDefault(); + e.stopPropagation(); + var bid = btn.getAttribute('data-branch-id') || ''; + if (bid) { window.location.href = '/index.php?r=kin&focus=' + encodeURIComponent(bid); } + } + btn.addEventListener('click', go); + btn.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { go(e); } + }); + // 键盘/触摸都别让它冒泡到外层链接 + btn.addEventListener('mousedown', function (e) { e.stopPropagation(); }); + }); + if (btnMe && meId) { - btnMe.addEventListener('click', function () { centerOn(meId); }); + btnMe.addEventListener('click', function () { + // 图是别人的家谱时,先跳回「我的家谱」再居中 + if (focusId !== meId) { + window.location.href = '/index.php?r=kin'; + return; + } + centerOn(centerTarget); + }); } })(); diff --git a/templates/layout.php b/templates/layout.php index 2e09573..7ca3032 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -99,6 +99,20 @@ $isLoggedIn = \App\Auth::check(); + + + + + + 下载 + ✕ + + + + + +
+ 同一个文件导两次也不会重复:每条日程有个全球唯一的 UID, + 已经导过的会被更新,而不是再插一条。 +
+ 填下面这个地址 + 你的姓名或手机号 + CalDAV 口令,就能把「我的日历本」 + 同步到别的日历程序里。这是只读订阅的 —— 在手机上改日程请回到本站来改。 + 口令与登录密码是分开的:订阅凭据会被客户端长期存着,所以另发一个, + 随时可换、不影响登录。 +
+ 这是你的新口令,只显示这一次,请立刻复制走: + = e($cdPlain) ?> +
= e($cdPlain) ?>
这本日历你只有只读权限,看得到但不能改。
未来 60 天里没有纪念日。
还没有纪念日。先到名册里把家人的生日填上,这里就会自动出现。
+ 名册里那部分(生日)是**实时读出来的** —— 改生日,这里跟着变,不用重复维护。 + 所有纪念日都会作为**全天日程**出现在日历里。 +
每代一行;一对夫妻之间有一条短横线, @@ -197,22 +227,29 @@ $datumJson = json_encode( // 会跳过深度截断,等于**不限代数**,有多少画多少。 // 之前写死了具体代数,真实家庭一超过就被砍掉。 // 用户要求「全部取出来」,所以这里也不设。 - .setCardXSpacing(180) + .setCardXSpacing(190) .setCardYSpacing(180); chart.setCardHtml() .setStyle('rect') - .setCardDim({ w: 140, h: 120, mini_tree: false }) + .setCardDim({ w: 148, h: 138, mini_tree: false }) .setCardInnerHtmlCreator(function (d) { var p = (d.data && d.data.data) || {}; var id = (d.data && d.data.id) || ''; var cls = 'kin-card' + (p.me ? ' is-me' : '') + (p.ph ? ' is-ph' : ''); + // v24 第 12 条:头像改**方形**(原来是圆角矩形 + cover,把脸裁掉了)。 + // 卡片本身是 进成员页;有姻亲没画出来的,再叠一个独立的小按钮 + // (用 而不是 : 不能套 )。 return '' + '' + '' + esc(p.name) + '' + '' + esc(p.label) + '' + (p.ph ? '待确认' : '') + + (p.branch + ? '⇄ 姻亲' + : '') + ''; }); @@ -256,12 +293,15 @@ $datumJson = json_encode( } window.kinCenterOn = centerOn; - var meId = root.getAttribute('data-me') || ''; + var focusId = root.getAttribute('data-focus') || ''; + var meId = root.getAttribute('data-me-viewer') || focusId; + // ⚠️ 中心是别人时,「回到我」要跳到 focus=我 的图,而不是在图里找我的坐标 + var centerTarget = root.getAttribute('data-me-viewer') || focusId; // fit 动画完成后把镜头挪到「我」(树根是最高祖先,我不再是几何中心) if (meId) { - setTimeout(function () { centerOn(meId); }, 900); + setTimeout(function () { centerOn(focusId); }, 900); // 万一 zoom 对象还没初始化好,再补一次 - setTimeout(function () { centerOn(meId); }, 1400); + setTimeout(function () { centerOn(focusId); }, 1400); } var btnFit = document.getElementById('kinFit'); @@ -271,8 +311,33 @@ $datumJson = json_encode( chart.updateTree({ tree_position: 'fit' }); }); } + /* v24 第 12 条:卡片右下角的「⇄ 姻亲」按钮 —— 以那个人为中心重画一张家谱。 + ⚠️ 这个 span 在卡片的 里,必须 preventDefault + stopPropagation, + 否则会先触发外面那个链接跳去成员页。 */ + Array.prototype.forEach.call(root.querySelectorAll('.kin-branch'), function (btn) { + function go(e) { + e.preventDefault(); + e.stopPropagation(); + var bid = btn.getAttribute('data-branch-id') || ''; + if (bid) { window.location.href = '/index.php?r=kin&focus=' + encodeURIComponent(bid); } + } + btn.addEventListener('click', go); + btn.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { go(e); } + }); + // 键盘/触摸都别让它冒泡到外层链接 + btn.addEventListener('mousedown', function (e) { e.stopPropagation(); }); + }); + if (btnMe && meId) { - btnMe.addEventListener('click', function () { centerOn(meId); }); + btnMe.addEventListener('click', function () { + // 图是别人的家谱时,先跳回「我的家谱」再居中 + if (focusId !== meId) { + window.location.href = '/index.php?r=kin'; + return; + } + centerOn(centerTarget); + }); } })(); diff --git a/templates/layout.php b/templates/layout.php index 2e09573..7ca3032 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -99,6 +99,20 @@ $isLoggedIn = \App\Auth::check(); + + + + + + 下载 + ✕ + + + + + +