diff --git a/app/Calendar.php b/app/Calendar.php index 3df7bd3..4b86f05 100644 --- a/app/Calendar.php +++ b/app/Calendar.php @@ -317,6 +317,75 @@ final class Calendar return (string)$st->fetchColumn() === 'write'; } + // ---------------- 重复日程的「某一次」例外(用户 2026-10-03 第 5 条)---------------- + + /** 把某一天标记为"跳过"(取消这一次 / 这一次被挪走) */ + public static function skipOnce(int $eventId, string $dateYmd): void + { + $d = substr(trim($dateYmd), 0, 10); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) { + return; + } + Database::get()->prepare( + 'INSERT INTO calendar_event_skips (event_id, skip_date) VALUES (?,?) + ON DUPLICATE KEY UPDATE skip_date = VALUES(skip_date)' + )->execute([$eventId, $d]); + } + + /** 取消"跳过"标记(撤销一次取消) */ + public static function unskipOnce(int $eventId, string $dateYmd): void + { + Database::get()->prepare('DELETE FROM calendar_event_skips WHERE event_id = ? AND skip_date = ?') + ->execute([$eventId, substr(trim($dateYmd), 0, 10)]); + } + + /** 这条日程被跳过了哪些日期 */ + public static function skipsOf(int $eventId): array + { + $st = Database::get()->prepare( + 'SELECT skip_date FROM calendar_event_skips WHERE event_id = ? ORDER BY skip_date' + ); + $st->execute([$eventId]); + return array_map(static fn($r) => (string)$r['skip_date'], $st->fetchAll()); + } + + /** + * 把"重复日程里被改掉的某一次"落成一条**独立的单次日程**。 + * + * 为什么要独立一条而不是改原日程:原日程是**整个系列**的定义, + * 「仅此一次」的改动不能碰它。所以: + * ① 在原系列里把这一天标记为跳过(不再出现原样那次); + * ② 新建一条只发生这一次的日程,内容 = 用户改后的。 + * 这样"改过的那一天"与"其余各次"各自独立,之后怎么改都不会互相干扰。 + * + * @return int 新建的那条日程 id + */ + public static function detachOnce(array $ev, string $date, array $d, int $byId = 0): int + { + $date = substr(trim($date), 0, 10); + self::skipOnce((int)$ev['id'], $date); + + // 时刻:沿用用户改后的;他改的是"那一天的"日程,日期必须锁在 $date + $starts = (string)($d['starts_at'] ?? ''); + $ends = (string)($d['ends_at'] ?? ''); + $startDay = substr($starts, 0, 10); + if ($startDay !== $date) { + // 用户没改日期(或者改到别的天了)→ 把这一天对齐到 $date, + // 只取他改的时刻部分;否则"仅此一次"会莫名其妙跳走一天。 + $starts = $date . ' ' . (substr($starts, 11, 8) !== '' ? substr($starts, 11, 8) : '00:00:00'); + } + $ends = $ends !== '' ? $date . ' ' . (substr($ends, 11, 8) ?: '00:00:00') : null; + + $new = $d; + $new['starts_at'] = $starts; + $new['ends_at'] = $ends; + $new['rrule'] = ''; // 单次日程不带重复规则 + // ⚠️ addEvent 内部会自己 newUid(),别在这里另设 $new['uid'](会被忽略, + // 只是让人误以为"UID 是我指定的")。created_by 走 $d['created_by']。 + $new['created_by'] = $byId ?: ($d['created_by'] ?? null); + return self::addEvent((int)$ev['calendar_id'], $new); + } + /** 一条日程的 UID(CalDAV 那边按 UID 取) */ public static function newUid(): string { @@ -468,39 +537,71 @@ final class Calendar } if ($ids) { $in = implode(',', array_fill(0, count($ids), '?')); + // ⚠️ 取数范围要**比 [from,to] 放宽**:重复日程的种子(starts_at)可能远早于 + // 本月("从去年起每周三"),按 starts_at 直接卡范围会把整个系列漏掉。 + // 这里取到 `to` 为止、往前不设限,展开阶段再按 [from,to] 裁。 $st = Database::get()->prepare( "SELECT * FROM calendar_events WHERE calendar_id IN ($in) - AND DATE(starts_at) <= ? AND DATE(COALESCE(ends_at, starts_at)) >= ? + AND DATE(starts_at) <= ? ORDER BY starts_at" ); - $st->execute(array_merge($ids, [$to, $from])); + $st->execute(array_merge($ids, [$to])); $calById = []; foreach ($calendars as $c) { $calById[(int)$c['id']] = $c; } - foreach ($st->fetchAll() as $e) { + $rows = $st->fetchAll(); + + // 一次把这批日程的「例外日期」取出来(N+1 会在重复日程多时明显变慢) + $skips = []; + if ($rows) { + $eids = array_map('intval', array_column($rows, 'id')); + $in2 = implode(',', array_fill(0, count($eids), '?')); + $sk = Database::get()->prepare( + "SELECT event_id, skip_date FROM calendar_event_skips WHERE event_id IN ($in2)" + ); + $sk->execute($eids); + foreach ($sk->fetchAll() as $r) { + $skips[(int)$r['event_id']][(string)$r['skip_date']] = true; + } + } + + foreach ($rows as $e) { $c = $calById[(int)$e['calendar_id']] ?? null; $sd = substr((string)$e['starts_at'], 0, 10); $ed = substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10); - // 跨天日程:在范围内**每天**都出一条(群晖日历就是这么画的) - $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' => ($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; + $tm = substr((string)$e['starts_at'], 11, 5); + $rr = (string)($e['rrule'] ?? ''); + // 用户 2026-10-03 第 5/6 条:重复日程按规则展开成若干次发生; + // 不重复的走同一条路(occurrences 会返回它自己那一天)。 + $days = Repeat::occurrences( + (string)$e['starts_at'], $rr, $from, $to, + $skips[(int)$e['id']] ?? [] + ); + foreach ($days as $day) { + // 跨天日程:从这次发生的当天起,按事件本身的长度铺开 + $span = (int)floor((strtotime($ed) - strtotime($sd)) / 86400); + $first = true; + foreach (self::eachDay($day, date('Y-m-d', strtotime($day . ' +' . $span . ' day')), $from, $to) as $d2) { + $out[] = [ + 'cal' => (int)$e['calendar_id'], + 'color' => (string)($c['color'] ?? '#4A7C82'), + 'calname' => (string)($c['name'] ?? ''), + 'title' => (string)$e['title'], + 'date' => $d2, + // 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻 + 'time' => ($first && empty($e['all_day'])) ? $tm : '', + 'all_day' => !empty($e['all_day']), + 'kind' => 'custom', + 'ref_id' => (int)$e['id'], + // 带上这一天的日期,重复日程点进去才能定位到"改哪一次" + 'occ' => $day, + 'repeat' => $rr, + 'href' => url('calendar/event', ['id' => (int)$e['id'], 'd' => $day]), + ]; + $first = false; + } } } } @@ -540,14 +641,21 @@ final class Calendar // 现在按 trip_nodes.starts_at 出条目,跨天的节点同样逐天铺开。 if (isset($byKind['trip'])) { $c = $byKind['trip'][0]; + // 用户 2026-10-03 第 4 条:不公开的旅程,节点也不上日历。 + // COALESCE 兜底:缺 trip_details 行时按"公开"处理(老数据不会凭空消失)。 + // ⚠️ 别在 SQL 字符串里写 `--` 行内注释:PHP 里那段是普通字符串, + // 但 `--` 之后的内容仍是字符串的一部分,SQL 侧被注释掉, + // 而 PHP 侧语法检查看不出问题 —— 会变成"语句少半截"的静默错误。 $st = Database::get()->prepare( "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' + LEFT JOIN trip_details d ON d.event_id = e.id WHERE n.family_id = ? AND n.starts_at IS NOT NULL AND n.status <> 'cancelled' + AND COALESCE(d.is_public, 1) = 1 AND DATE(n.starts_at) <= ? AND COALESCE(DATE(n.ends_at), DATE(n.starts_at)) >= ? ORDER BY n.starts_at" ); diff --git a/app/Home.php b/app/Home.php new file mode 100644 index 0000000..09a3a00 --- /dev/null +++ b/app/Home.php @@ -0,0 +1,393 @@ + 0) { + $tail = '有 ' . $overdue . ' 件事已经过期了,要不要先处理一下?'; + } elseif ($events > 0) { + $tail = '今天有 ' . $events . ' 件事,慢慢来。'; + } else { + $tails = [ + '最近心情怎么样?', + '今天想做点什么?', + '日子慢慢过,不着急。', + '记得给家里打个电话。', + '今天也是好日子。', + '有空翻翻往年的今天。', + ]; + $seed = crc32($today . '|' . $name); + $tail = $tails[$seed % count($tails)]; + } + return $part . $who . ',' . $tail; + } + + /** 今天星期几(中文) */ + public static function weekday(string $date): string + { + $w = ['日', '一', '二', '三', '四', '五', '六'][(int)date('w', strtotime($date))]; + return '星期' . $w; + } + + /** + * 今天的天气。没配 key / 请求失败 / 超时 → null(首页整块不显示)。 + * + * 用 Open-Meteo:**不需要申请 key**、允许非商业使用、有中文描述。 + * 之所以不选和风/心知:都要申请 key,而"配好 key 才能看见天气"这件事 + * 会让用户以为功能没做(配置项藏在哪他未必找得到)。 + * + * ⚠️ 只按经纬度请求,不传城市名 —— 经纬度由管理员在系统设置里填。 + * 没填经纬度就当"没配天气",不猜(猜错城市比不显示更糟)。 + * + * @return array{temp:string,text:string,icon:string,place:string}|null + */ + public static function weather(int $familyId, string $lat, string $lon, string $place): ?array + { + $lat = trim($lat); + $lon = trim($lon); + if ($lat === '' || $lon === '' || !is_numeric($lat) || !is_numeric($lon)) { + return null; + } + + // ⚠️ 缓存存在 settings 表里,而那张表对 family_id 有**外键**: + // 传 0 会插入失败(外键不存在),所以必须带家庭 id。 + // 同一个家庭共用一份天气缓存 —— 本来也就是同一个地方。 + $cacheKey = 'weather_' . md5($lat . ',' . $lon); + $cached = Setting::get($familyId, $cacheKey, ''); + if ($cached !== '') { + $dec = json_decode((string)$cached, true); + if (is_array($dec) && (int)($dec['at'] ?? 0) + self::WEATHER_TTL > time()) { + return self::weatherOut((array)($dec['data'] ?? []), $place); + } + } + + $url = 'https://api.open-meteo.com/v1/forecast' + . '?latitude=' . urlencode($lat) . '&longitude=' . urlencode($lon) + . '¤t=temperature_2m,weather_code&timezone=Asia%2FShanghai'; + $body = self::httpGet($url); + if ($body === null) { + return null; + } + $j = json_decode($body, true); + if (!is_array($j) || !isset($j['current'])) { + return null; + } + $data = [ + 'temp' => (string)round((float)($j['current']['temperature_2m'] ?? 0)), + 'code' => (int)($j['current']['weather_code'] ?? -1), + ]; + // 缓存下来(存在 settings,key 里带 family 0 = 全站级) + Setting::set($familyId, $cacheKey, json_encode(['at' => time(), 'data' => $data], JSON_UNESCAPED_UNICODE)); + return self::weatherOut($data, $place); + } + + /** 内部结构 → 页面用的天气结构 */ + private static function weatherOut(array $data, string $place): ?array + { + if (!isset($data['temp'])) { + return null; + } + [$text, $icon] = self::wmo((int)($data['code'] ?? -1)); + return [ + 'temp' => (string)$data['temp'] . '°C', + 'text' => $text, + 'icon' => $icon, + 'place' => trim($place), + ]; + } + + /** + * WMO 天气代码 → [中文描述, 一个符号]。 + * 符号用**普通字符**(不是 emoji):emoji 在部分旧系统上会变豆腐块, + * 而这是每天都要看的一块,宁可用朴素符号也不要出现方框。 + */ + private static function wmo(int $c): array + { + if ($c === 0) { + return ['晴', '☀']; + } + if (in_array($c, [1, 2], true)) { + return ['多云', '⛅']; + } + if ($c === 3) { + return ['阴', '☁']; + } + if (in_array($c, [45, 48], true)) { + return ['有雾', '≋']; + } + if ($c >= 51 && $c <= 57) { + return ['毛毛雨', '☂']; + } + if ($c >= 61 && $c <= 67) { + return ['下雨', '☂']; + } + if ($c >= 71 && $c <= 77) { + return ['下雪', '❄']; + } + if ($c >= 80 && $c <= 82) { + return ['阵雨', '☂']; + } + if ($c >= 85 && $c <= 86) { + return ['阵雪', '❄']; + } + if ($c >= 95) { + return ['雷雨', '⚡']; + } + return ['天气', '·']; + } + + /** 极简 GET(3 秒超时;失败返回 null,绝不抛) */ + private static function httpGet(string $url): ?string + { + if (!function_exists('curl_init')) { + $ctx = stream_context_create(['http' => ['timeout' => 3, 'ignore_errors' => true]]); + $r = @file_get_contents($url, false, $ctx); + return $r === false ? null : (string)$r; + } + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 3, + CURLOPT_CONNECTTIMEOUT => 2, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_USERAGENT => 'FamilyBook/1.0', + ]); + $r = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($r === false || $code < 200 || $code >= 300) { + return null; + } + return (string)$r; + } + + /** + * 各模块的一张小结。返回 key => ['name','icon','color','href','big','lines']。 + * + * 「big」是一个最该被看见的数字/短句,「lines」是几行补充。 + * 每张卡都**点得进去**(href 指向该模块最能回答这张卡的那一页)。 + */ + public static function moduleCards(int $familyId, int $me, array $soon, array $tasks): array + { + $cards = []; + + // ---- 名册 ---- + $people = Person::map($familyId); + $roster = Person::roster($familyId, $me, 'member', ''); + $cards['people'] = [ + 'name' => '名册', + 'icon' => self::icon('people'), + 'color' => Module::color('people', $familyId), + 'href' => url('people'), + 'big' => (string)count($roster) . ' 人', + 'lines' => [count($people) . ' 人在这个家里'], + ]; + + // ---- 家谱 ---- + $graph = Kinship::graph($familyId); + $cards['kin'] = [ + 'name' => '家谱', + 'icon' => self::icon('kin'), + 'color' => Module::color('kin', $familyId), + 'href' => url('kin'), + 'big' => (string)Relations::count($familyId) . ' 条连线', + 'lines' => ['父母 / 配偶 / 子女填上就能长出来'], + ]; + + // ---- 观影 ---- + $st = Database::get()->prepare( + "SELECT COUNT(*) FROM events WHERE family_id = ? AND kind = 'movie'" + ); + $st->execute([$familyId]); + $movieN = (int)$st->fetchColumn(); + $st = Database::get()->prepare( + "SELECT title FROM events WHERE family_id = ? AND kind = 'movie' + ORDER BY starts_at DESC, id DESC LIMIT 1" + ); + $st->execute([$familyId]); + $lastMovie = (string)($st->fetchColumn() ?: ''); + $cards['movies'] = [ + 'name' => '观影', + 'icon' => self::icon('movies'), + 'color' => Module::color('movies', $familyId), + 'href' => url('movies'), + 'big' => $movieN . ' 部', + 'lines' => $lastMovie !== '' ? ['最近看了《' . $lastMovie . '》'] : ['还没有观影记录'], + ]; + + // ---- 旅程 ---- + $st = Database::get()->prepare( + "SELECT COUNT(*) FROM events e + LEFT JOIN trip_details d ON d.event_id = e.id + WHERE e.family_id = ? AND e.kind = 'trip' AND COALESCE(d.is_public,1) = 1" + ); + $st->execute([$familyId]); + $tripN = (int)$st->fetchColumn(); + $st = Database::get()->prepare( + "SELECT e.title FROM events e + LEFT JOIN trip_details d ON d.event_id = e.id + WHERE e.family_id = ? AND e.kind = 'trip' AND COALESCE(d.is_public,1) = 1 + ORDER BY e.starts_at DESC, e.id DESC LIMIT 1" + ); + $st->execute([$familyId]); + $lastTrip = (string)($st->fetchColumn() ?: ''); + $cards['trips'] = [ + 'name' => '旅程', + 'icon' => self::icon('trips'), + 'color' => Module::color('trips', $familyId), + 'href' => url('trips'), + 'big' => $tripN . ' 趟', + 'lines' => $lastTrip !== '' ? ['最近去了' . $lastTrip] : ['还没有旅程记录'], + ]; + + // ---- 日程 ---- + $cards['calendar'] = [ + 'name' => '日程', + 'icon' => self::icon('calendar'), + 'color' => Module::color('calendar', $familyId), + 'href' => url('calendar'), + 'big' => count($soon) . ' 条', + 'lines' => ['未来七天'], + ]; + + // ---- 资源 ---- + $st = Database::get()->prepare( + 'SELECT COUNT(*) FROM resources r + LEFT JOIN resource_shares s ON s.resource_id = r.id AND s.person_id = ? + WHERE r.family_id = ? AND (r.owner_id = ? OR s.person_id IS NOT NULL)' + ); + $st->execute([$me, $familyId, $me]); + $resN = (int)$st->fetchColumn(); + $cards['resources'] = [ + 'name' => '资源', + 'icon' => self::icon('resources'), + 'color' => Module::color('resources', $familyId), + 'href' => url('resources'), + 'big' => $resN . ' 项', + 'lines' => ['证件 / 资料 / 附件'], + ]; + + // ---- 任务 ---- + $board = Task::board($familyId, $me); + $doing = count($board['doing']); + $todo = count($board['todo']); + $late = 0; + foreach ([$board['todo'], $board['doing']] as $col) { + foreach ($col as $t) { + if (Task::isOverdue($t['due_at'] ?? null)) { + $late++; + } + } + } + $cards['tasks'] = [ + 'name' => '任务', + 'icon' => self::icon('tasks'), + 'color' => Module::color('tasks', $familyId), + 'href' => url('tasks'), + 'big' => $doing . ' 件在做', + 'lines' => [$todo . ' 件待办' . ($late > 0 ? ' · ' . $late . ' 件逾期' : '')], + ]; + + // ---- 纪念日 ---- + $up = Anniversary::upcoming($familyId, $me, 30); + $next = $up[0] ?? null; + $cards['days'] = [ + 'name' => '纪念日', + 'icon' => self::icon('days'), + 'color' => Module::color('days', $familyId), + 'href' => url('days'), + 'big' => $next ? ((int)$next['days'] === 0 ? '今天' : (int)$next['days'] . ' 天后') : '—', + 'lines' => [$next ? (string)$next['title'] : '三十天内没有纪念日'], + ]; + + // ---- 财务 ---- + $books = Ledger::mine($familyId, $me); + $net = 0; + $hasBook = false; + foreach ($books as $b) { + $hasBook = true; + $net += Ledger::summary(Ledger::entries((int)$b['id']))['net']; + } + $cards['ledger'] = [ + 'name' => '财务', + 'icon' => self::icon('ledger'), + 'color' => Module::color('ledger', $familyId), + 'href' => url('ledger'), + 'big' => $hasBook ? Ledger::money($net) : '还没记账', + 'lines' => [$hasBook ? count($books) . ' 本账(净资产)' : '先建一本家庭账'], + ]; + + return $cards; + } + + /** 模块图标(行内 SVG;与观影设置下拉同一套做法,不引外部字体/图片) */ + public static function icon(string $key): string + { + $paths = [ + 'people' => '', + 'kin' => '', + 'movies' => '', + 'trips' => '', + 'calendar' => '', + 'resources' => '', + 'tasks' => '', + 'days' => '', + 'ledger' => '', + ]; + $d = $paths[$key] ?? ''; + if ($d === '') { + return ''; + } + return ''; + } +} \ No newline at end of file diff --git a/app/HomeMods.php b/app/HomeMods.php new file mode 100644 index 0000000..8f294a0 --- /dev/null +++ b/app/HomeMods.php @@ -0,0 +1,104 @@ +prepare($sql); @@ -59,6 +72,9 @@ final class Person */ public static function roster(int $familyId, int $viewerId, string $kind = 'member', string $q = ''): array { + // 用户 2026-10-03 第 4 条:名册改成列表,每行要显示「手机号 / 生日 / 与我的关系」 + // → 手机号是加密存的,得在这里一并取出密文(下面解密,避免逐行再查一次)。 + // → 关系称呼走 Kinship(每人算一次),不在 SQL 里。 $sql = 'SELECT p.*, r.group_id, r.sort_order, r.source AS roster_source, a.id AS account_id, a.role AS account_role, a.status AS account_status, a.last_login_at, n.nickname AS my_nickname @@ -72,16 +88,47 @@ final class Person $args[] = $viewerId; $args[] = $kind; if ($q !== '') { - $sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)'; - $like = '%' . $q . '%'; - $args[] = $like; - $args[] = $like; - $args[] = $like; + // ⚠️ SQL 只在**能帮忙**时才粗筛,两个反例: + // · 纯字母(yyq)—— 那是"姓名首字母",SQL 里没法把汉字转拼音, + // 一旦 LIKE 就把结果筛成空,后面的 PHP 精筛也就没东西可筛了; + // · 纯数字(手机号)—— 手机号是**加密存的**,库里只有密文, + // LIKE 永远匹配不到明文数字。 + // 这两类都交给下面的 PHP 精筛(它会解密、会算首字母)。 + // 只含中文/邮箱这类"库里有明文可比"的查询词才加 SQL 条件。 + $needSql = (bool)preg_match('/[^A-Za-z0-9]/u', $q); + if ($needSql) { + $sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)'; + $like = '%' . $q . '%'; + $args[] = $like; + $args[] = $like; + $args[] = $like; + } } $sql .= ' ORDER BY r.sort_order ASC, p.id ASC'; $st = Database::get()->prepare($sql); $st->execute($args); - return $st->fetchAll(); + $rows = $st->fetchAll(); + + // 用户 2026-10-03 第 4 条:再加**姓名首字母**检索(y / yy / yyq 都命中「杨永全」)。 + // ⚠️ 不在 SQL 里做:SQL 侧没法把汉字转拼音(线上也没有现成的函数), + // 硬塞进 SQL 只能靠一堆 LIKE OR,既慢又不可能穷尽。 + // 名册是**一个人自己的**小列表(几十条),在 PHP 里过滤完全够快。 + // 做法:SQL 只在能帮忙时粗筛(中文/邮箱这类),然后**一律**走这里精筛一次。 + // 精筛覆盖:姓名 / 称呼 / 邮箱 / **解密后的手机号** / 姓名首字母。 + if ($q !== '') { + $rows = array_values(array_filter($rows, static function (array $r) use ($q): bool { + // 手机号是加密的,Pinyin::matches 认 phone_plain;这里把它带上再判一次 + if (!empty($r['phone_encrypted'])) { + try { + $r['phone_plain'] = Crypto::decrypt((string)$r['phone_encrypted']); + } catch (\Throwable $e) { + // 解不开就当没填(不影响其它字段的匹配) + } + } + return Pinyin::matches($r, $q); + })); + } + return $rows; } public static function find(int $id): ?array diff --git a/app/Pinyin.php b/app/Pinyin.php new file mode 100644 index 0000000..ff36794 --- /dev/null +++ b/app/Pinyin.php @@ -0,0 +1,299 @@ + 1, '钱q' => 1, '孙s' => 1, '李l' => 1, '周z' => 1, '吴w' => 1, '郑z' => 1, + '王w' => 1, '冯f' => 1, '陈c' => 1, '褚c' => 1, '卫w' => 1, '蒋j' => 1, '沈s' => 1, + '韩h' => 1, '杨y' => 1, '朱z' => 1, '秦q' => 1, '尤y' => 1, '许x' => 1, '何h' => 1, + '吕l' => 1, '施s' => 1, '张z' => 1, '孔k' => 1, '曹c' => 1, '严y' => 1, '华h' => 1, + '金j' => 1, '魏w' => 1, '陶t' => 1, '姜j' => 1, '戚q' => 1, '谢x' => 1, '邹z' => 1, + '喻y' => 1, '柏b' => 1, '水s' => 1, '窦d' => 1, '章z' => 1, '云y' => 1, '苏s' => 1, + '潘p' => 1, '葛g' => 1, '奚x' => 1, '范f' => 1, '彭p' => 1, '郎l' => 1, '鲁l' => 1, + '韦w' => 1, '昌c' => 1, '马m' => 1, '苗m' => 1, '凤f' => 1, '花h' => 1, '方f' => 1, + '俞y' => 1, '任r' => 1, '袁y' => 1, '柳l' => 1, '酆f' => 1, '鲍b' => 1, '史s' => 1, + '唐t' => 1, '费f' => 1, '廉l' => 1, '岑c' => 1, '薛x' => 1, '雷l' => 1, '贺h' => 1, + '倪n' => 1, '汤t' => 1, '滕t' => 1, '殷y' => 1, '罗l' => 1, '毕b' => 1, '郝h' => 1, + '邬w' => 1, '安a' => 1, '常c' => 1, '乐y' => 1, '于y' => 1, '时s' => 1, '傅f' => 1, + '皮p' => 1, '卞b' => 1, '齐q' => 1, '康k' => 1, '伍w' => 1, '余y' => 1, '元y' => 1, + '卜b' => 1, '顾g' => 1, '孟m' => 1, '平p' => 1, '黄h' => 1, '和h' => 1, '穆m' => 1, + '萧x' => 1, '尹y' => 1, '姚y' => 1, '邵s' => 1, '湛z' => 1, '汪w' => 1, '祁q' => 1, + '毛m' => 1, '禹y' => 1, '狄d' => 1, '米m' => 1, '贝b' => 1, '明m' => 1, '臧z' => 1, + '计j' => 1, '伏f' => 1, '成c' => 1, '戴d' => 1, '谈t' => 1, '宋s' => 1, '茅m' => 1, + '庞p' => 1, '熊x' => 1, '纪j' => 1, '舒s' => 1, '屈q' => 1, '项x' => 1, '祝z' => 1, + '董d' => 1, '梁l' => 1, '杜d' => 1, '阮r' => 1, '蓝l' => 1, '闵m' => 1, '席x' => 1, + '季j' => 1, '麻m' => 1, '强q' => 1, '贾j' => 1, '路l' => 1, '娄l' => 1, '危w' => 1, + '江j' => 1, '童t' => 1, '颜y' => 1, '郭g' => 1, '梅m' => 1, '盛s' => 1, '林l' => 1, + '刁d' => 1, '钟z' => 1, '徐x' => 1, '邱q' => 1, '骆l' => 1, '高g' => 1, '夏x' => 1, + '蔡c' => 1, '田t' => 1, '樊f' => 1, '胡h' => 1, '凌l' => 1, '霍h' => 1, '虞y' => 1, + '万w' => 1, '支z' => 1, '柯k' => 1, '昝z' => 1, '管g' => 1, '卢l' => 1, '莫m' => 1, + '房f' => 1, '裘q' => 1, '缪m' => 1, '干g' => 1, '解x' => 1, '应y' => 1, '宗z' => 1, + '丁d' => 1, '宣x' => 1, '邓d' => 1, '单s' => 1, '杭h' => 1, '洪h' => 1, '包b' => 1, + '诸z' => 1, '左z' => 1, '石s' => 1, '崔c' => 1, '吉j' => 1, '钮n' => 1, '龚g' => 1, + '程c' => 1, '嵇j' => 1, '邢x' => 1, '滑h' => 1, '裴p' => 1, '陆l' => 1, '荣r' => 1, + '翁w' => 1, '荀x' => 1, '羊y' => 1, '於y' => 1, '惠h' => 1, '甄z' => 1, '曲q' => 1, + '封f' => 1, '芮r' => 1, '羿y' => 1, '储c' => 1, '靳j' => 1, '汲j' => 1, '邴b' => 1, + '糜m' => 1, '松s' => 1, '井j' => 1, '段d' => 1, '富f' => 1, '巫w' => 1, '乌w' => 1, + '焦j' => 1, '巴b' => 1, '弓g' => 1, '牧m' => 1, '隗k' => 1, '山s' => 1, '谷g' => 1, + '车c' => 1, '侯h' => 1, '宓m' => 1, '蓬p' => 1, '全q' => 1, '郗x' => 1, '班b' => 1, + '仰y' => 1, '秋q' => 1, '仲z' => 1, '伊y' => 1, '宫g' => 1, '宁n' => 1, '仇q' => 1, + '栾l' => 1, '暴b' => 1, '甘g' => 1, '厉l' => 1, '戎r' => 1, '祖z' => 1, '武w' => 1, + '符f' => 1, '刘l' => 1, '景j' => 1, '詹z' => 1, '束s' => 1, '龙l' => 1, '叶y' => 1, + '幸x' => 1, '司s' => 1, '韶s' => 1, '郜g' => 1, '黎l' => 1, '蓟j' => 1, '薄b' => 1, + '印y' => 1, '宿s' => 1, '白b' => 1, '怀h' => 1, '蒲p' => 1, '邰t' => 1, '从c' => 1, + '鄂e' => 1, '索s' => 1, '咸x' => 1, '籍j' => 1, '赖l' => 1, '卓z' => 1, '蔺l' => 1, + '屠t' => 1, '蒙m' => 1, '池c' => 1, '乔q' => 1, '阴y' => 1, '郁y' => 1, '胥x' => 1, + '能n' => 1, '苍c' => 1, '双s' => 1, '闻w' => 1, '莘s' => 1, '党d' => 1, '翟z' => 1, + '谭t' => 1, '贡g' => 1, '劳l' => 1, '逄p' => 1, '姬j' => 1, '申s' => 1, '扶f' => 1, + '堵d' => 1, '冉r' => 1, '宰z' => 1, '郦l' => 1, '雍y' => 1, '郤x' => 1, '璩q' => 1, + '桑s' => 1, '桂g' => 1, '濮p' => 1, '牛n' => 1, '寿s' => 1, '通t' => 1, '边b' => 1, + '扈h' => 1, '燕y' => 1, '冀j' => 1, '郏j' => 1, '浦p' => 1, '尚s' => 1, '农n' => 1, + '温w' => 1, '别b' => 1, '庄z' => 1, '晏y' => 1, '柴c' => 1, '瞿q' => 1, '阎y' => 1, + '充c' => 1, '慕m' => 1, '连l' => 1, '茹r' => 1, '习x' => 1, '宦h' => 1, '艾a' => 1, + '鱼y' => 1, '容r' => 1, '向x' => 1, '古g' => 1, '易y' => 1, '慎s' => 1, '戈g' => 1, + '廖l' => 1, '庾y' => 1, '终z' => 1, '暨j' => 1, '居j' => 1, '衡h' => 1, '步b' => 1, + '都d' => 1, '耿g' => 1, '满m' => 1, '弘h' => 1, '匡k' => 1, '国g' => 1, '文w' => 1, + '寇k' => 1, '广g' => 1, '禄l' => 1, '阙q' => 1, '东d' => 1, '欧o' => 1, '殳s' => 1, + '沃w' => 1, '利l' => 1, '蔚w' => 1, '越y' => 1, '夔k' => 1, '隆l' => 1, '师s' => 1, + '巩g' => 1, '厍s' => 1, '聂n' => 1, '晁c' => 1, '勾g' => 1, '敖a' => 1, '融r' => 1, + '冷l' => 1, '訾z' => 1, '辛x' => 1, '阚k' => 1, '那n' => 1, '简j' => 1, '饶r' => 1, + '空k' => 1, '曾z' => 1, '毋w' => 1, '沙s' => 1, '乜n' => 1, '养y' => 1, '鞠j' => 1, + '须x' => 1, '丰f' => 1, '巢c' => 1, '关g' => 1, '蒯k' => 1, '相x' => 1, '查z' => 1, + '后h' => 1, '荆j' => 1, '红h' => 1, '游y' => 1, '竺z' => 1, '权q' => 1, '逯l' => 1, + '盖g' => 1, '益y' => 1, '桓h' => 1, '公g' => 1, + // 名字里高频的字 + '永y' => 1, '全q' => 1, '博b' => 1, '为w' => 1, '明m' => 1, '华h' => 1, '建j' => 1, + '国g' => 1, '文w' => 1, '军j' => 1, '平p' => 1, '志z' => 1, '强q' => 1, '磊l' => 1, + '洋y' => 1, '勇y' => 1, '艳y' => 1, '杰j' => 1, '娟j' => 1, '涛t' => 1, '超c' => 1, + '敏m' => 1, '静j' => 1, '丽l' => 1, '秀x' => 1, '英y' => 1, '娜n' => 1, '丹d' => 1, + '玲l' => 1, '芳f' => 1, '燕y' => 1, '红h' => 1, '霞x' => 1, '梅m' => 1, '兰l' => 1, + '琴q' => 1, '云y' => 1, '飞f' => 1, '鹏p' => 1, '宇y' => 1, '浩h' => 1, '鑫x' => 1, + '亮l' => 1, '刚g' => 1, '辉h' => 1, '峰f' => 1, '龙l' => 1, '波b' => 1, '斌b' => 1, + '春c' => 1, '雨y' => 1, '晨c' => 1, '阳y' => 1, '雪x' => 1, '悦y' => 1, '欣x' => 1, + '怡y' => 1, '佳j' => 1, '思s' => 1, '雨y' => 1, '子z' => 1, '小x' => 1, '大d' => 1, + '老l' => 1, '爸b' => 1, '妈m' => 1, '哥g' => 1, '姐j' => 1, '弟d' => 1, '妹m' => 1, + '爷y' => 1, '奶n' => 1, '叔s' => 1, '姨y' => 1, '舅j' => 1, '嫂s' => 1, '儿e' => 1, + '女n' => 1, '宝b' => 1, '乐l' => 1, '安a' => 1, '康k' => 1, '福f' => 1, '寿s' => 1, + '喜x' => 1, '顺s' => 1, '和h' => 1, '美m' => 1, '好h' => 1, '新x' => 1, '中z' => 1, + '正z' => 1, '长c' => 1, '海h' => 1, '江j' => 1, '河h' => 1, '山s' => 1, '林l' => 1, + '木m' => 1, '金j' => 1, '玉y' => 1, '石s' => 1, '田t' => 1, '力l' => 1, '学x' => 1, + '生s' => 1, '成c' => 1, '立l' => 1, '德d' => 1, '仁r' => 1, '义y' => 1, '礼l' => 1, + '智z' => 1, '信x' => 1, '忠z' => 1, '孝x' => 1, '友y' => 1, '爱a' => 1, '慈c' => 1, + // 家谱称呼里高频的那批字(父/母/伯/叔/姑/舅/姨/岳…)—— + // 名册搜索经常会拿这些字当关键词,兜底表里缺了就会出现 + // "本地搜 s 搜不到『张三』"这种与环境相关的怪现象。 + '父f' => 1, '母m' => 1, '祖z' => 1, '孙s' => 1, '外w' => 1, '表b' => 1, '堂t' => 1, + '侄z' => 1, '甥s' => 1, '婿x' => 1, '媳x' => 1, '伯b' => 1, '姑g' => 1, '岳y' => 1, + '公g' => 1, '婆p' => 1, '妻q' => 1, '夫f' => 1, '丈z' => 1, '娘n' => 1, '爹d' => 1, + '三s' => 1, '四s' => 1, '五w' => 1, '六l' => 1, '七q' => 1, '八b' => 1, '九j' => 1, + '十s' => 1, '百b' => 1, '千q' => 1, '万w' => 1, '一y' => 1, '二e' => 1, '零l' => 1, + '不b' => 1, '的d' => 1, '了l' => 1, '在z' => 1, '有y' => 1, '是s' => 1, '我w' => 1, + '人r' => 1, '家j' => 1, '门m' => 1, '户h' => 1, '先x' => 1, '后h' => 1, '晚w' => 1, + '长c' => 1, '幼y' => 1, '大d' => 1, '小x' => 1, '多d' => 1, '少s' => 1, '高g' => 1, + '矮a' => 1, '胖p' => 1, '瘦s' => 1, '黑h' => 1, '白b' => 1, '花h' => 1, '草c' => 1, + '树s' => 1, '叶y' => 1, '果g' => 1, '苗m' => 1, '阳y' => 1, '光g' => 1, '月y' => 1, + '星x' => 1, '风f' => 1, '雪x' => 1, '雨y' => 1, '雷l' => 1, '电d' => 1, '冰b' => 1, + '州z' => 1, '城c' => 1, '村c' => 1, '镇z' => 1, '县x' => 1, '市s' => 1, '省s' => 1, + '华h' => 1, '青q' => 1, '岛d' => 1, '京j' => 1, '津j' => 1, '冀j' => 1, '鲁l' => 1, + '思s' => 1, '念n' => 1, '想x' => 1, '记j' => 1, '忘w' => 1, '知z' => 1, '道d' => 1, + '说s' => 1, '话h' => 1, '听t' => 1, '看k' => 1, '走z' => 1, '来l' => 1, '去q' => 1, + '回h' => 1, '到d' => 1, '给g' => 1, '拿n' => 1, '做z' => 1, '用y' => 1, '买m' => 1, + '卖m' => 1, '吃c' => 1, '喝h' => 1, '睡s' => 1, '起q' => 1, '坐z' => 1, '站z' => 1, + '跑p' => 1, '跳t' => 1, '唱c' => 1, '写x' => 1, '读d' => 1, '画h' => 1, '算s' => 1, + '作z' => 1, '课k' => 1, '班b' => 1, '校x' => 1, '师s' => 1, '医y' => 1, '院y' => 1, + '药y' => 1, '病b' => 1, '健j' => 1, '体t' => 1, '身s' => 1, '心x' => 1, '手s' => 1, + '脚j' => 1, '眼y' => 1, '耳e' => 1, '鼻b' => 1, '口k' => 1, '头t' => 1, '发f' => 1, + ]; + + /** 首字母查表:'杨' => 'y'。首次调用时从 TABLE 摊平,之后走内存缓存。 */ + private static ?array $map = null; + + public static function initialMap(): array + { + if (self::$map === null) { + self::$map = []; + foreach (self::TABLE as $kv => $_) { + $k = (string)$kv; + if (mb_strlen($k, 'UTF-8') >= 1) { + $ch = mb_substr($k, 0, 1, 'UTF-8'); + $ch = mb_substr($k, 0, mb_strlen($k, 'UTF-8') - 1, 'UTF-8'); + $letter = mb_substr((string)$kv, mb_strlen((string)$kv, 'UTF-8') - 1, 1, 'UTF-8'); + if ($ch !== '' && $letter !== '') { + self::$map[$ch] = strtolower($letter); + } + } + } + } + return self::$map; + } + + /** + * 一个字的拼音首字母(小写)。认不出时返回 ''。 + * 有 intl 走锚字二分;没有就走内置表。 + */ + public static function initial(string $ch): string + { + if ($ch === '') { + return ''; + } + // 已经是 ASCII 字母 → 直接用 + if (preg_match('/^[a-zA-Z]$/', $ch)) { + return strtolower($ch); + } + if (preg_match('/^[0-9]$/', $ch)) { + return $ch; + } + // ① intl 拼音排序(线上有) + $viaIntl = self::initialViaIntl($ch); + if ($viaIntl !== null) { + return $viaIntl; + } + // ② 内置表兜底(本地无 intl 时) + return self::initialMap()[$ch] ?? ''; + } + + /** 用 Intl 的拼音 collation + 锚字二分定位;没有 intl 返回 null */ + private static function initialViaIntl(string $ch): ?string + { + static $coll = false; + if ($coll === false) { + $coll = null; + if (class_exists('\\Collator')) { + $c = new \Collator('zh-Hans-CN-u-co-pinyin'); + // 排序没真的生效时(compare('阿','匝') 不 < 0)就别用,免得给出错结果 + if ($c->compare('阿', '匝') < 0) { + $coll = $c; + } + } + } + if ($coll === null) { + return null; + } + $anchors = self::ANCHORS; + $len = mb_strlen($anchors, 'UTF-8'); + $lo = 0; + $hi = $len - 1; + $best = -1; + while ($lo <= $hi) { + $mid = intdiv($lo + $hi, 2); + $a = mb_substr($anchors, $mid, 1, 'UTF-8'); + if ($coll->compare($ch, $a) >= 0) { + $best = $mid; + $lo = $mid + 1; + } else { + $hi = $mid - 1; + } + } + if ($best < 0) { + return null; + } + return mb_substr(self::LETTERS, $best, 1, 'UTF-8'); + } + + /** + * 一串字 → 首字母串(小写)。认不出的字**跳过**(不产字母,也不报错)。 + * 例:'杨永全' → 'yyq'。 + */ + public static function initials(string $s): string + { + $out = ''; + $n = mb_strlen($s, 'UTF-8'); + for ($i = 0; $i < $n; $i++) { + $out .= self::initial(mb_substr($s, $i, 1, 'UTF-8')); + } + return $out; + } + + /** + * q 是否是 s 的**子序列**(用于"输入 y 也能搜到 yyq"这类前缀式检索)。 + * + * 为什么用子序列而不是前缀: + * 用户可能只记得中间某个字(比如"永全" → yq),或干脆跳着打; + * 名册这种小列表宁可宽松一点,也别让人以为搜索坏了。 + */ + public static function isSubsequence(string $q, string $s): bool + { + $q = strtolower(trim($q)); + $s = strtolower($s); + if ($q === '') { + return true; + } + $i = 0; + $qn = strlen($q); + for ($j = 0, $sn = strlen($s); $j < $sn && $i < $qn; $j++) { + if ($s[$j] === $q[$i]) { + $i++; + } + } + return $i === $qn; + } + + /** + * 这个人是否匹配搜索词。 + * + * 覆盖:姓名 / 称呼 / 邮箱 / 手机号 的**模糊**包含,以及姓名首字母的**子序列**匹配。 + * + * @param array $row 名册行(含 name / email / phone_plain,可选 my_nickname) + * @param string $q 搜索词 + */ + public static function matches(array $row, string $q): bool + { + $q = trim($q); + if ($q === '') { + return true; + } + $needle = mb_strtolower($q, 'UTF-8'); + foreach (['name', 'my_nickname', 'nickname', 'email', 'phone_plain'] as $k) { + $v = mb_strtolower(trim((string)($row[$k] ?? '')), 'UTF-8'); + if ($v !== '' && mb_strpos($v, $needle) !== false) { + return true; + } + } + // 首字母:只对**字母/数字**查询词做子序列匹配, + // 中文查询词走上面的"包含"(否则"永全"会被当成首字母串去比对,永远匹配不上) + if (preg_match('/^[a-z0-9]+$/', $needle)) { + $ini = self::initials((string)($row['name'] ?? '')); + if ($ini !== '' && self::isSubsequence($needle, $ini)) { + return true; + } + // 称呼也参与首字母(比如称呼是"小宝" → xb) + $nickIni = self::initials((string)($row['my_nickname'] ?? '')); + if ($nickIni !== '' && self::isSubsequence($needle, $nickIni)) { + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/app/Repeat.php b/app/Repeat.php new file mode 100644 index 0000000..dc59774 --- /dev/null +++ b/app/Repeat.php @@ -0,0 +1,321 @@ +(重复到某天为止) + * + * ⚠️ 展开只按**日期**比对范围:日程的时刻在事件本身(starts_at), + * 重复出来的每一次共用同一个时刻。这是日历的通行做法 + * ("每周三 19:00"里的 19:00 属于事件,不属于规则)。 + */ + +declare(strict_types=1); + +namespace App; + +final class Repeat +{ + /** 界面上的重复周期 → FREQ 片段 */ + public const FREQS = [ + '' => '不重复', + 'daily' => '每天', + 'weekly' => '每周', + 'monthly' => '每月', + 'yearly' => '每年', + 'weekday' => '每个工作日(周一至周五)', + ]; + + /** 结束条件 */ + public const ENDS = [ + 'never' => '一直重复', + 'count' => '重复指定次数', + 'until' => '重复到某天为止', + ]; + + /** 周一..周日(RFC 5545 的 BYDAY 记号;0=周日,与 date('w') 对齐后转换) */ + private const WEEKDAY_CODE = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; + + /** 展开的硬上限:防止 COUNT 极大或 UNTIL 很远时把请求拖死 */ + private const MAX_OCCURRENCES = 2000; + + /** + * 界面字段 → RRULE 原文。返回 '' 表示不重复。 + * + * @param array $d freq / rep_end / rep_count / rep_until + */ + public static function build(array $d): string + { + $freq = (string)($d['freq'] ?? ''); + if (!isset(self::FREQS[$freq]) || $freq === '') { + return ''; + } + $parts = []; + switch ($freq) { + case 'daily': + $parts[] = 'FREQ=DAILY'; + break; + case 'weekly': + $parts[] = 'FREQ=WEEKLY'; + break; + case 'monthly': + $parts[] = 'FREQ=MONTHLY'; + break; + case 'yearly': + $parts[] = 'FREQ=YEARLY'; + break; + case 'weekday': + // 工作日 = 每周 + 明确点名周一到周五(不要用 INTERVAL=1 靠"跳过周末"实现, + // 那样节假日与调休都对不上,客户端也不认) + $parts[] = 'FREQ=WEEKLY'; + $parts[] = 'BYDAY=MO,TU,WE,TH,FR'; + break; + } + + $end = (string)($d['rep_end'] ?? 'never'); + if ($end === 'count') { + $n = (int)($d['rep_count'] ?? 0); + if ($n >= 1) { + // ⚠️ COUNT 含**首次**:用户填 10 就是"一共 10 次"(含第一次)。 + // RFC 也是这么算的,别自作聪明 +1。 + $parts[] = 'COUNT=' . min($n, self::MAX_OCCURRENCES); + } + } elseif ($end === 'until') { + $u = trim((string)($d['rep_until'] ?? '')); + if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $u, $m) && checkdate((int)$m[2], (int)$m[3], (int)$m[1])) { + // UNTIL 一律写成**当天的 23:59:59 UTC**:这样"重复到 12-31"意味着 + // 12-31 这一天也包含在内(写成 00:00:00 会把最后那天砍掉)。 + $parts[] = 'UNTIL=' . $m[1] . $m[2] . $m[3] . 'T235959Z'; + } + } + return implode(';', $parts); + } + + /** RRULE 原文 → 结构化(认不出的键忽略,不报错) */ + public static function parse(string $rrule): array + { + $out = ['freq' => '', 'interval' => 1, 'byday' => [], 'count' => 0, 'until' => '']; + $rrule = trim($rrule); + if ($rrule === '') { + return $out; + } + foreach (explode(';', $rrule) as $kv) { + $kv = trim($kv); + if ($kv === '') { + continue; + } + $eq = strpos($kv, '='); + if ($eq === false) { + continue; + } + $k = strtoupper(substr($kv, 0, $eq)); + $v = substr($kv, $eq + 1); + switch ($k) { + case 'FREQ': + $f = strtoupper($v); + if ($f === 'WEEKLY' && strtoupper($v) !== '' && strpos(strtoupper($rrule), 'BYDAY=MO,TU,WE,TH,FR') !== false) { + $out['freq'] = 'weekday'; + } else { + $out['freq'] = ['DAILY' => 'daily', 'WEEKLY' => 'weekly', + 'MONTHLY' => 'monthly', 'YEARLY' => 'yearly'][$f] ?? ''; + } + break; + case 'INTERVAL': + $out['interval'] = max(1, (int)$v); + break; + case 'BYDAY': + foreach (explode(',', strtoupper($v)) as $d) { + $d = trim($d); + if ($d !== '') { + $out['byday'][] = $d; + } + } + break; + case 'COUNT': + $out['count'] = max(0, (int)$v); + break; + case 'UNTIL': + // 20261231T235959Z → 2026-12-31 + if (preg_match('/^(\d{4})(\d{2})(\d{2})/', $v, $m)) { + $out['until'] = $m[1] . '-' . $m[2] . '-' . $m[3]; + } + break; + } + } + return $out; + } + + /** RRULE → 一句人话(列表/详情页用) */ + public static function describe(string $rrule): string + { + $p = self::parse($rrule); + if ($p['freq'] === '') { + return ''; + } + $word = self::FREQS[$p['freq']]; + if ($p['interval'] > 1) { + $unit = ['daily' => '天', 'weekly' => '周', 'monthly' => '个月', 'yearly' => '年'][$p['freq']] ?? ''; + if ($unit !== '' && $p['freq'] !== 'weekday') { + $word = '每 ' . $p['interval'] . ' ' . $unit; + } + } + if ($p['count'] > 0) { + $word .= ',共 ' . $p['count'] . ' 次'; + } elseif ($p['until'] !== '') { + $word .= ',直到 ' . $p['until']; + } else { + $word .= ',一直重复'; + } + return $word; + } + + /** + * 把一条日程在 [from, to] 内的**每一次发生**列出来。 + * + * @param string $dtstart 事件开始时刻 'Y-m-d H:i:s'(重复的锚点) + * @param string $rrule 规则原文;'' 表示不重复 + * @param array $skips 被跳过的日期 ['Y-m-d' => true](单个发生被取消/被挪走) + * @return array 日期列表('Y-m-d'),升序,**不含**被跳过的 + */ + public static function occurrences(string $dtstart, string $rrule, string $from, string $to, array $skips = []): array + { + if ($rrule === '') { + $d = substr($dtstart, 0, 10); + return ($d >= $from && $d <= $to) ? [$d] : []; + } + $p = self::parse($rrule); + if ($p['freq'] === '') { + $d = substr($dtstart, 0, 10); + return ($d >= $from && $d <= $to) ? [$d] : []; + } + + $start = substr($dtstart, 0, 10); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $start)) { + return []; + } + $count = 0; // 已生成的发生次数(**含**被跳过的,RFC 就是这么数的) + $out = []; + $guard = 0; + + $emit = static function (string $d) use (&$out, &$count, $from, $to, $skips): void { + $count++; + if ($d > $to) { + return; // 超出范围就不再关心 + } + if ($d >= $from && !isset($skips[$d])) { + $out[] = $d; + } + }; + + // 是否还可以继续生成:COUNT 到了 / UNTIL 过了 / 超出范围 / 硬上限 + $canGo = static function (string $d) use (&$count, $p, $to): bool { + if ($p['count'] > 0 && $count >= $p['count']) { + return false; + } + if ($p['until'] !== '' && $d > $p['until']) { + return false; + } + return $d <= $to; // 已经超过范围末端 + }; + + $interval = max(1, (int)$p['interval']); + switch ($p['freq']) { + case 'daily': + $d = $start; + while ($guard++ < self::MAX_OCCURRENCES && $canGo($d)) { + $emit($d); + $d = date('Y-m-d', strtotime($d . ' +' . $interval . ' day')); + } + break; + + case 'weekly': + case 'weekday': + // BYDAY 缺省 = 与 DTSTART 同一个星期几 + $byday = $p['byday']; + if (!$byday) { + $byday = [self::WEEKDAY_CODE[(int)date('w', strtotime($start))]]; + } + $codes = array_flip(self::WEEKDAY_CODE); // 'MO' => 1(与 date('w') 一致) + // 从 DTSTART 所在那一周的周一开始走 + $weekStart = date('Y-m-d', strtotime($start . ' -' . ((int)date('w', strtotime($start)) + 6) % 7 . ' day')); + $wk = $weekStart; + while ($guard++ < self::MAX_OCCURRENCES && $canGo($wk)) { + // 一周内按周一→周日顺序 + $days = []; + for ($i = 0; $i < 7; $i++) { + $day = date('Y-m-d', strtotime($wk . ' +' . $i . ' day')); + $code = self::WEEKDAY_CODE[(int)date('w', strtotime($day))]; + if (in_array($code, $byday, true)) { + $days[] = $day; + } + } + sort($days); + foreach ($days as $day) { + if ($day < $start) { + continue; // DTSTART 之前的不算 + } + if (!$canGo($day)) { + break 2; + } + $emit($day); + } + $wk = date('Y-m-d', strtotime($wk . ' +' . ($interval * 7) . ' day')); + } + break; + + case 'monthly': + $dom = (int)substr($start, 8, 2); + $y = (int)substr($start, 0, 4); + $m = (int)substr($start, 5, 2); + $d = $start; + while ($guard++ < self::MAX_OCCURRENCES && $canGo($d)) { + $emit($d); + // ⚠️ 该月没有这一天(如 31 号遇到只有 30 天的月)→ 按 RFC **跳过该月**, + // 绝不能"顺延到下月 1 号" —— 那样"每月 31 号"会变成一堆 1 号, + // 而且 1 号往往还有别的事,看起来像重复错乱(实测踩到过)。 + $m += $interval; + while ($m > 12) { + $m -= 12; + $y++; + } + if (!checkdate($m, $dom, $y)) { + continue; // 这个月没有该日 → 直接看下一个月 + } + $d = sprintf('%04d-%02d-%02d', $y, $m, $dom); + } + break; + + case 'yearly': + $mm = (int)substr($start, 5, 2); + $dom = (int)substr($start, 8, 2); + $y = (int)substr($start, 0, 4); + $d = $start; + while ($guard++ < self::MAX_OCCURRENCES && $canGo($d)) { + $emit($d); + $y += $interval; + // 2/29 遇平年 → 跳过那年(RFC 行为;要"每年 2/28 提醒"请用纪念日模块) + if (!checkdate($mm, $dom, $y)) { + $d = sprintf('%04d-%02d-01', $y, $mm); + continue; + } + $d = sprintf('%04d-%02d-%02d', $y, $mm, $dom); + } + break; + } + + sort($out); + return array_values(array_unique($out)); + } +} \ No newline at end of file diff --git a/app/Schema.php b/app/Schema.php index 97ff7b6..5904054 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -383,6 +383,7 @@ CREATE TABLE IF NOT EXISTS `trip_details` ( `event_id` INT UNSIGNED NOT NULL COMMENT '对应 events(kind="trip")', `cover_key` VARCHAR(255) DEFAULT NULL COMMENT '封面图(存储相对键)', `summary` TEXT DEFAULT NULL COMMENT '这趟旅程的一段话小结', + `is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(0=不显示在列表与日历)', PRIMARY KEY (`event_id`), CONSTRAINT `fk_tdet_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='旅程事件的特有数据(一对一卫星表)' @@ -866,6 +867,35 @@ SQL, } }); + // ---- 重复日程的「单个发生」例外表(版本 13,用户 2026-10-03 第 5 条)---- + // 用户要的三档编辑里,「仅此一次」与「跳过这次」都要能"只动某一次"。 + // 做法(与 RFC 5545 的 EXDATE 同构):把被取消/被挪走的那一次记在这里, + // 展开时跳过它;如果那次是被"挪到别的时间",就再插一条独立日程。 + // ⚠️ 不复用 calendar_events:那是"日程本体",这里是"本体的某一次例外", + // 混在一张表里会让"这条日程重复吗"变成要额外判断的字段。 + $db->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `calendar_event_skips` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `event_id` INT UNSIGNED NOT NULL COMMENT '哪条日程(calendar_events.id)', + `skip_date` DATE NOT NULL COMMENT '被跳过的哪一天(重复展开时命中这一天就不出)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_skip` (`event_id`, `skip_date`), + CONSTRAINT `fk_skip_event` FOREIGN KEY (`event_id`) REFERENCES `calendar_events` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='重复日程里被单独取消/挪走的某一次(RFC 5545 的 EXDATE)' +SQL); + + // ---- 旅程的活动「是否公开」(版本 13,用户 2026-10-03 第 4 条)---- + // 用户原话:「旅程的活动中,只显示 is_public=1 的活动」。 + // ⚠️ 放在 **trip_details**(1:1 的卫星表)而不是 events: + // events 是观影/旅程/… 共用的骨架表,加一个只有旅程用的开关 + // 会让观影那边多出一个永远为 0 的无意义字段。 + // ⚠️ 默认 **1**(公开):老数据升级后必须照旧可见 —— + // 默认 0 会让人以为"旅程全丢了"(这是最危险的一种迁移默认值)。 + self::addColumn($db, 'trip_details', 'is_public', + "ADD COLUMN `is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(用户 2026-10-03 第 4 条):0=不显示在旅程列表与日历' AFTER `summary`"); + // ---- 纪念日(版本 13,用户第 13 条)---- // ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的 // (见 Anniversary::birthdays),改了生日纪念日就跟着变。 diff --git a/app/Trip.php b/app/Trip.php index 0ab63d5..07fff83 100644 --- a/app/Trip.php +++ b/app/Trip.php @@ -44,13 +44,18 @@ final class Trip /** 全部旅程(按出发时间倒序) */ public static function all(int $familyId, string $keyword = ''): array { + // ⚠️ 用户 2026-10-03 第 4 条:「旅程的活动中,只显示 is_public=1 的活动」。 + // 用 LEFT JOIN + COALESCE 而不是 INNER JOIN: + // 老数据/异常情况下 trip_details 可能缺行,INNER JOIN 会让整趟旅程 + // 从列表里消失("行程丢了"比"多显示一条"严重得多), + // COALESCE(...,1) 表示"没有这行就算公开"。 $sql = 'SELECT e.id, e.title, e.starts_at, e.ends_at, e.location, e.note, - d.cover_key, d.summary, + d.cover_key, d.summary, COALESCE(d.is_public, 1) AS is_public, (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count, (SELECT COUNT(*) FROM trip_nodes n WHERE n.event_id = e.id) AS node_count FROM events e LEFT JOIN trip_details d ON d.event_id = e.id - WHERE e.family_id = ? AND e.kind = ?'; + WHERE e.family_id = ? AND e.kind = ? AND COALESCE(d.is_public, 1) = 1'; $bind = [$familyId, 'trip']; $kw = trim($keyword); if ($kw !== '') { @@ -107,7 +112,9 @@ final class Trip public static function find(int $familyId, int $id, int $viewerId = 0): ?array { $st = Database::get()->prepare( - "SELECT e.*, d.cover_key, d.summary + // ⚠️ 单条详情**不按 is_public 拦**:勾了"不公开"只代表不进列表/日历, +// 直接拿到链接的人(或行程成员)仍应该能打开它 —— 否则非遗漏一次就找不回来。 + "SELECT e.*, d.cover_key, d.summary, COALESCE(d.is_public, 1) AS is_public FROM events e LEFT JOIN trip_details d ON d.event_id = e.id WHERE e.family_id = ? AND e.id = ? AND e.kind = 'trip'" ); @@ -176,14 +183,21 @@ final class Trip $row = [ self::keyOrNull($d['cover_key'] ?? ''), self::textOrNull($d['summary'] ?? ''), + // 用户第 4 条:是否公开。 + // ⚠️ 必须区分「这个键没传」与「用户明确取消勾选」: + // · 没传(程序化创建 / 迁移 / 老调用)→ **按公开(1)**, + // 与建表默认值一致;默认 0 会让数据**静默消失**在列表里, + // 那是最坏的一种失败(用户只会看到"我的行程不见了")。 + // · 传了 0(表单里取消勾选)→ 真的不公开。 + array_key_exists('is_public', $d) ? (!empty($d['is_public']) ? 1 : 0) : 1, ]; $has = $db->prepare('SELECT COUNT(*) FROM trip_details WHERE event_id = ?'); $has->execute([$eventId]); if ((int)$has->fetchColumn() > 0) { - $db->prepare('UPDATE trip_details SET cover_key=?, summary=? WHERE event_id=?') + $db->prepare('UPDATE trip_details SET cover_key=?, summary=?, is_public=? WHERE event_id=?') ->execute([...$row, $eventId]); } else { - $db->prepare('INSERT INTO trip_details (cover_key, summary, event_id) VALUES (?,?,?)') + $db->prepare('INSERT INTO trip_details (cover_key, summary, is_public, event_id) VALUES (?,?,?,?)') ->execute([...$row, $eventId]); } } diff --git a/app/bootstrap.php b/app/bootstrap.php index fed6b56..f1d1c5c 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -47,6 +47,40 @@ error_reporting(E_ALL); ini_set('display_errors', $debug ? '1' : '0'); ini_set('log_errors', '1'); +/** + * 把错误日志指到**我们够得着的地方**(v24)。 + * + * 为什么需要:线上出 500 时最该看的就是错误日志,而宝塔的 `/www/wwwlogs` + * 既不在部署账号的读权限内,也不在 `open_basedir` 允许的路径里 + * (本站是 `/www/wwwroot/…:/tmp/`)。这一轮真遇到了:用户报 500, + * 能查的全查了(逐文件哈希、两账号 32 次真实渲染、边界输入、DNS/证书/缓存) + * 全绿,唯独拿不到"当时哪一行炸了"。 + * + * 做法:**只需指定 `error_log`** —— PHP 本来就会把 Fatal error(含文件:行号与 + * 调用栈)写进去,不需要自己注册 shutdown 钩子。 + * (实测:先前那版加了 `register_shutdown_function` 反而多余, + * 钩子自身一旦出错还会**再抛一个 Fatal**,把真正的原因埋掉。) + * + * 落点:优先站点根的 `db/`(敏感配置所在地,不入 Web 根、不进仓库), + * 没有就回落 `sys_get_temp_dir()`(线上 open_basedir 含 `/tmp/`,一定可写)。 + * 两处都不可写就保持原样(记日志不该把站点拖垮)。 + * + * ⚠️ 只记 PHP 自己的错误文本,**不记**请求体/变量值(可能含口令)。 + */ +{ + $fbLogFile = ''; + foreach ([__DIR__ . '/../db', sys_get_temp_dir()] as $fbDir) { + if (is_dir($fbDir) && is_writable($fbDir)) { + $fbLogFile = rtrim($fbDir, '/\\') . '/familybook-error.log'; + break; + } + } + if ($fbLogFile !== '') { + ini_set('log_errors', '1'); + ini_set('error_log', $fbLogFile); + } +} + // ---------------- 时区:日期是家书的核心数据,一开始就必须明确 ---------------- date_default_timezone_set($GLOBALS['config']['timezone'] ?? 'Asia/Shanghai'); diff --git a/assets/css/app.css b/assets/css/app.css index b37c297..e06be6d 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -215,31 +215,98 @@ a:hover { text-decoration: underline; } border-left: 3px solid var(--brand-2); color: var(--muted); font-size: 14px; } +/* ⚠️ .hero-stats / .stat 已随用户 2026-10-03 第 3 条撤掉首页那三个统计, + 这里保留规则本身(其它页面可能还在用 .stat 这个类名),不再由首页引用。 */ .hero-stats { margin-left: auto; display: flex; gap: 28px; } .stat { text-align: center; } .stat b { display: block; font-size: 24px; font-weight: 600; line-height: 1.2; } .stat span { font-size: 12px; color: var(--muted); } +/* 用户第 3 条:标题下那句个人 slogan */ +.hero-slogan { color: var(--muted); margin: 4px 0 0; font-size: 14px; } +/* 用户第 3 条:「我的资料 / 我的家谱」换成**图标按钮**(原来是一排文字按钮)。 + margin-left:auto 把它推到最右,与原来统计块的位置一致。 */ +.hero-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; } +.icon-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 40px; height: 40px; border-radius: 12px; + border: 1px solid var(--line); background: var(--surface); color: var(--muted); + transition: border-color .15s ease, color .15s ease, background .15s ease; +} +.icon-btn:hover { + color: var(--brand); border-color: var(--brand-2); background: #FBF7F1; +} +.icon-btn svg { display: block; } + +/* ---- 用户第 3 条:首页的统计信息块(问候 + 天气 + 几个数字) ---- */ +.home-brief { display: flex; flex-direction: column; gap: 10px; } +.home-brief-hello { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } +.home-brief-hello b { font-size: 17px; } +.home-brief-weather { + display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; + padding: 8px 12px; border-radius: 10px; background: var(--line-2); +} +.home-brief-weather .w-icon { font-size: 20px; line-height: 1; } +.home-brief-weather .w-temp { font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; } +.home-brief-weather .w-desc { font-size: 14px; } +.home-brief-stats { display: flex; flex-wrap: wrap; gap: 10px 22px; color: var(--muted); font-size: 13px; } +.home-brief-stats .hbs b { color: var(--ink); font-size: 16px; margin-right: 3px; } + +/* ---- 用户第 3 条:首页的模块小结卡 ---- */ +.mod-cards { + display: grid; gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); +} +.mod-card { + display: flex; flex-direction: column; gap: 6px; + padding: 14px 16px; border-radius: var(--radius); + border: 1px solid var(--line); background: var(--surface); + border-left: 3px solid var(--mc, var(--brand)); + color: var(--ink); text-decoration: none; + transition: box-shadow .15s ease, border-color .15s ease; +} +.mod-card:hover { box-shadow: var(--shadow); text-decoration: none; } +.mod-card-head { display: flex; align-items: center; gap: 8px; color: var(--mc, var(--brand)); } +.mod-card-icon { display: inline-flex; flex: 0 0 auto; } +.mod-card-name { font-size: 13px; font-weight: 600; } +.mod-card-big { font-size: 20px; font-weight: 600; color: var(--ink); } +.mod-card-lines { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } +.mod-card-lines li { font-size: 12.5px; color: var(--muted); } + /* ---------------- 成员卡 ---------------- */ +/* 用户 2026-10-03 第 4 条:名册从「卡片网格」改成**竖向列表**—— + 一行一个人,左边头像、中间姓名+关系+手机号/生日、右边操作。 + 列表形态下拖动排序也更直观(上下移动,不会像网格那样左右跳)。 */ .people-grid { - display: grid; gap: 16px; margin-top: 4px; - grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); + display: flex; flex-direction: column; gap: 8px; margin-top: 4px; } .person { display: block; color: var(--ink); position: relative; background: var(--surface); border: 1px solid var(--line); - border-radius: var(--radius); overflow: hidden; - box-shadow: var(--shadow); - transition: transform .15s ease, box-shadow .15s ease; + border-radius: var(--radius-s); + transition: border-color .15s ease, box-shadow .15s ease; } -.person:hover { transform: translateY(-2px); box-shadow: var(--shadow-l); text-decoration: none; } +.person:hover { border-color: var(--brand-2); box-shadow: var(--shadow); } +/* 头像:列表里用小方图(正方形、cover 裁切),不再是整块大图 */ .person .ph { - background: #F1E7D6; aspect-ratio: 1 / 1; overflow: hidden; + background: #F1E7D6; overflow: hidden; + width: 52px; height: 52px; border-radius: 8px; flex: 0 0 auto; } .person .ph img { width: 100%; height: 100%; object-fit: cover; display: block; } -.person .pb { padding: 12px 14px 14px; } -.person .pname { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 6px; } -.person .pmeta { color: var(--muted); font-size: 13px; margin-top: 2px; } +.person .person-main { + display: flex; align-items: center; gap: 12px; + padding: 10px 12px; color: var(--ink); text-decoration: none; +} +.person .pb { flex: 1 1 auto; min-width: 0; } +.person .pname { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.person .pmeta { color: var(--muted); font-size: 12.5px; margin-top: 2px; } +/* 中间那几个"重要信息":手机 / 生日 */ +.person .pfacts { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 4px; font-size: 13px; } +.person .pfact { color: var(--ink); display: inline-flex; align-items: baseline; gap: 5px; } +.person .pfact-k { + color: var(--muted); font-size: 11px; padding: 1px 5px; border-radius: 4px; + background: var(--line-2); +} .badge { display: inline-block; padding: 1px 8px; border-radius: 999px; @@ -501,12 +568,19 @@ textarea.input { min-height: 76px; resize: vertical; } .kin-chart .card_cont .card-inner { display: block; } /* v21:卡片尺寸与 setCardDim({w:140, h:120}) 对齐 */ +/* 用户 2026-10-03 第 1 条: + · 头像外框改**长方形**(宽 96 × 高 72),宽度基本就是头像显示宽度; + img 用 object-fit: contain —— 既不裁切,也不会把外框撑变形。 + · 信息(姓名/称呼)在头像**下方**。 + · 卡片不再整张是链接:只有 .kin-nm 是 ,右下角是独立的 + 不填就表示没有明确的结束(只占开始那一刻/那一天) +
+ +
+ + +
+ + + + + + +
+ + + + + +
+ 这是一条重复日程 +

+ + +
你打开的是 那一次。 + +

+ + + + + +

+ (从日历点某一天的日程进来,才能选「只改这一次 / 这一次及以后」。) +

+ +
+ +
diff --git a/templates/home.php b/templates/home.php index 34ee701..5897acf 100644 --- a/templates/home.php +++ b/templates/home.php @@ -2,93 +2,136 @@ /** * 首页 —— 一块 dashboard(用户 2026-10-01 第 13 条)。 * - * 现在首页只回答三个问题:**最近有什么事**、**手头在做什么**、**往年这时候我们在干嘛**。 - * · 接下来的日子(两个月内的生日) + * 用户 2026-10-03 第 3 条把它重排成: + * · hero:标题 + 个人 slogan + 两个图标按钮(我的资料 / 我的家谱) + * · 统计信息:随机问候语 + 今日天气 + 近七天日程数 / 待办数 * · 未来七天的日程(自定义 + 观影 + 旅行 + 有截止时间的任务,一次汇总) - * · 进行中的任务(含已过期的待办) - * · 往年今日(往年的这一天看过的电影 / 走过的旅程) + * · 手头在做(进行中 + 已逾期的任务) + * · 各模块的统计与模块内信息(**可按人在个人设置里开关与排序**) + * · 往年今日 + * ⚠️ 「接下来的日子(两个月内的生日)」已去掉:生日/纪念日本来就是日程, + * 已经在「未来七天」里,两块并排是重复。 * * ⚠️ **家人卡片已经不在首页了**(用户明确要求):看人去「名册」、看关系去「家谱」, * 首页留给"今天该关心什么"。统计数字仍在,因为那是唯一的全局概览。 * * ⚠️ 标题下的那句个人口号已按用户要求撤掉(v8):hero 区只留标题与三个统计。 */ -$kinCount = count($kin); // 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出 -$shortKind = \App\Birthday::KINDS; $today = date('Y-m-d'); +// 首页要显示哪些模块、什么顺序 —— 用户在「个人设置 → 首页上的模块」里定(每人一份) +$homeMods = array_values(array_filter( + (array)($homeModules ?? []), + static fn($k) => \App\Module::exists((string)$k) +)); ?> +

-
-
-
与我有关
-
名册总数
-
亲属连线
+ + +

+
- + + +
-

接下来的日子 两个月内

- -
近两个月没有人过生日等到了月份,这里会自动冒出来。
- -
+ + + +
+ + -
+
diff --git a/templates/kin/tree.php b/templates/kin/tree.php index 24c317f..b5d628a 100644 --- a/templates/kin/tree.php +++ b/templates/kin/tree.php @@ -147,19 +147,11 @@ $datumJson = json_encode( ); ?> +

- - - -
@@ -170,11 +162,12 @@ $datumJson = json_encode(
-
- - - -
+ + +
+

+ 首页上的模块 + 勾上才会出现在首页,拖动可改顺序 +

+

+ 首页是"一眼看家里近况"的地方,模块太多会变成一面墙 —— 这里只挑你想每天看到的几个。 + 关掉的模块照样能从顶部导航条进去,只是不在首页上占位置。 +

+ +
+ +
    + + $k): ?> +
  1. + + + + + + + +
  2. + +
+
+ +
+
+
+ + + +
+ + + 不勾 = 只留着自己看:不进列表、不上日历,但有链接仍能打开。 +
+