query('SELECT MIN(id) FROM families')->fetchColumn() ?: 0); } catch (\Throwable $ex) { $familyIdForSchema = 0; } } if ($familyIdForSchema > 0) { // ⚠️ 判据是 **Schema 源码指纹**,不是版本号 —— 见 Schema::fingerprint() 的说明。 // 版本号靠人记得改,本项目已因此翻车一次(2026-10-03:两批结构变更都没改 // VERSION → 线上判定"已是最新" → 整段升级被跳过 → Calendar::month() 500)。 // 指纹改成"改了 Schema 就跑",不再依赖任何人的记性。 if (Schema::needsUpgrade($familyIdForSchema)) { try { Schema::ensure(); Schema::markUpgraded($familyIdForSchema); } catch (\Throwable $ex) { // 升级失败不能把整站挡住;下次请求会再试。 // ⚠️ 但**必须留下痕迹**:这里曾经是空的 catch,结果线上结构升级 // 从 2026-09-30 起一次都没跑完(报错被吞、版本号永远停在 2), // 而首页/名册都 200,看着完全正常 —— 直到「个人设置 500」才暴露。 // 一行 error_log 就能省掉几小时排查,别再删掉它。 error_log('[familybook] Schema::ensure() 失败(已跳过,下次请求重试):' . $ex->getMessage() . ' @ ' . $ex->getFile() . ':' . $ex->getLine()); if (!empty($GLOBALS['config']['debug'])) { throw $ex; } } } } } $r = isset($_GET['r']) ? trim((string)$_GET['r'], '/') : 'home'; $r = $r === '' ? 'home' : $r; $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; $publicRoutes = ['auth/login']; if (!Auth::check() && !in_array($r, $publicRoutes, true)) { redirect('auth/login'); } /** * 从表单里读「三个生日」。 * * 为什么要有 `birthday_source` 这个显式字段: * 阳历生日与农历生日说的是**同一个人的同一天**,不能各填各的(会自相矛盾)。 * 用户手填时必然是在其中一边操作,所以让他明确"我这次填的是哪一个", * 服务端据此**反推另一个** —— 不依赖 JS,也就不会有"JS 没跑到就静默存错"。 * 兼容:source=solar 但阳历留空、农历有值 → 按农历算(用户只填了一边)。 */ function read_birthday_input(array $existing = []): array { $source = (($_POST['birthday_source'] ?? 'solar') === 'lunar') ? 'lunar' : 'solar'; $solar = (string)(read_dt_input('birthday_solar') ?? ''); $lunar = (string)(read_dt_input('birthday_lunar') ?? ''); if ($source === 'solar' && $solar === '' && $lunar !== '') { $source = 'lunar'; } return [ 'birthday_source' => $source, 'birthday_solar' => $solar, 'birthday_lunar' => $lunar, 'birthday_lunar_leap' => !empty($_POST['birthday_lunar_leap']) ? 1 : 0, // 库里已有的身份证生日要**带回来**:它是"只进不出"的,不能被一次提交清掉 'birthday_id_card' => (string)($existing['birthday_id_card'] ?? ''), ]; } /** * 从表单解析「补充联系方式」(vCard 对齐,v6 起)。 * * 每类两个平行数组:ct__label[] / ct__value[] —— * 平行数组而不是二维键名(ct[phone][0][value]),是因为 label 与 value * 分列在两个 input 里,PHP 自动按数字补齐下标,zip 起来最直观。 * * @return string[][][] kind => 行列表(label/value,value 保留原样,空行不丢 —— * 交给 Person::replaceContacts 丢弃;校验失败重画表单时还要用它回填) */ function read_contacts_input(): array { $out = []; foreach (['phone', 'email', 'address', 'url'] as $kind) { $labels = $_POST['ct_' . $kind . '_label'] ?? []; $values = $_POST['ct_' . $kind . '_value'] ?? []; if (!is_array($labels) || !is_array($values)) { $out[$kind] = []; continue; } $rows = []; $n = max(count($labels), count($values)); for ($i = 0; $i < $n; $i++) { $rows[] = ['label' => (string)($labels[$i] ?? ''), 'value' => trim((string)($values[$i] ?? ''))]; } $out[$kind] = $rows; } return $out; } /** 补充联系方式的宽松校验(主手机号/主邮箱的校验在 Person::validate 里) */ function validate_contacts(array $contacts): string { foreach ($contacts as $kind => $rows) { foreach ($rows as $i => $row) { $v = trim((string)($row['value'] ?? '')); if ($v === '') { continue; } $no = $i + 1; if ($kind === 'email' && !filter_var($v, FILTER_VALIDATE_EMAIL)) { return '第 ' . $no . ' 个补充邮箱的格式看起来不对:' . $v; } if ($kind === 'phone' && !preg_match('/^\+?[0-9\- ]{6,25}$/', $v)) { return '第 ' . $no . ' 个补充电话的格式看起来不对:' . $v; } } } return ''; } /** * 渲染「个人设置」页(settings/me)。 * * 从 v5 起这一页只剩两件事:**我怎么称呼家人**、**我的分组**。 * 「我的资料」表单不在这里 —— 它和「成员资料」是同一张表单 * (`templates/people/form.php`,两条入口都指过去),页面顶部给一个按钮过去。 * 这是用户明确要求的:「编辑成员」和「修改自己的资料」本来就是一件事, * 两套表单必然填出两种口径。 * * @param array $person 当前登录者自己的名册记录 * @param array $errors 字段名 => 错误文案(保留给"校验失败就地重画"用) */ function render_me_settings(array $person, array $errors = []): void { $familyId = (int)Auth::familyId(); $me = (int)$person['id']; View::render('settings/me', [ 'title' => '个人设置', 'person' => $person, 'errors' => $errors, // 我怎么称呼别人(视角属性,只有我自己能定) 'nicknames' => Nickname::mineWithNames($familyId, $me), 'options' => Person::options($familyId, $me, $me), 'birthdays' => Birthday::describe($person), // 我的分组 = 公共的 + 我自己加的,顺序是我的(user_settings.group_order) 'groups' => PersonGroup::orderFor($familyId, $me), 'groupCounts' => PersonGroup::counts($familyId, $me), 'rosterCount' => PersonRoster::countFor($familyId, $me), // 用户 2026-10-03 第 3 条:首页上显示哪些模块、什么顺序(每人一份) 'homeOrder' => HomeMods::orderedForPicker($me), 'homeOn' => HomeMods::forPerson($me), ], 'settings'); } /** * 上传并挂一组附件到某个宿主(旅程照片 / 资源附件 / 任务附件…全走这一处)。 * * 同时吃「单文件」与「多文件」两种表单字段:`` * 与 ``(后者 PHP 会把每个子项变成数组)。 * 逐个交给 App\Attachment::save —— 一张失败不该连累其它几张(照片多选尤其明显)。 * * @return string[] 失败原因(空数组 = 全部成功) */ function attach_save_files(int $familyId, string $type, int $targetId, string $field, int $uploaderId): array { $files = []; if (!empty($_FILES[$field]) && is_array($_FILES[$field])) { $f = $_FILES[$field]; if (isset($f['name']) && is_array($f['name'])) { foreach ($f['name'] as $i => $_n) { $files[] = [ 'name' => $f['name'][$i], 'type' => $f['type'][$i] ?? '', 'tmp_name' => $f['tmp_name'][$i] ?? '', 'error' => $f['error'][$i] ?? UPLOAD_ERR_NO_FILE, 'size' => $f['size'][$i] ?? 0, ]; } } elseif (isset($f['name'])) { $files[] = $f; } } $warns = []; foreach ($files as $f) { $res = \App\Attachment::save($familyId, $type, $targetId, $f, $uploaderId); if (empty($res['ok']) && (string)($res['error'] ?? '') !== '') { $warns[] = (string)($f['name'] ?: '附件') . ':' . $res['error']; } } return $warns; } /** * 月视图网格的起止日期(**含前后补白的整周**)。 * * ⚠️ 网格必须按整周补白,否则"这个月 1 号是周三"时第一行会空出 3 格而没法画 —— * 补白的日子要一起取日程(跨周/跨月的日程也落在网格里)。 * @return array{0:string,1:string} ['Y-m-d', 'Y-m-d'] */ function month_grid_range(string $ym): array { $firstTs = strtotime($ym . '-01'); if ($firstTs === false) { $firstTs = strtotime(date('Y-m') . '-01'); } $w = (int)date('w', $firstTs); // 0=周日 … 6=周六 $start = strtotime('-' . $w . ' days', $firstTs); $cells = $w + (int)date('t', $firstTs); $rows = (int)ceil($cells / 7); $endTs = strtotime('+' . ($rows * 7 - 1) . ' days', $start); return [date('Y-m-d', $start), date('Y-m-d', $endTs ?: $start)]; } /** * 把一段日期里的日程摆成 6×7 的月网格。 * * @return array 周 => [7 格],每格 ['date','day','in_month','is_today','events'] */ function build_month_grid(string $ym, array $events): array { [$from, $to] = month_grid_range($ym); $byDay = []; foreach ($events as $e) { $byDay[(string)$e['date']][] = $e; } $weeks = []; $cur = $from; for ($r = 0; $r < 6; $r++) { $row = []; for ($d = 0; $d < 7; $d++) { $row[] = [ 'date' => $cur, 'day' => (int)date('j', strtotime($cur)), 'in_month' => substr($cur, 0, 7) === $ym, 'is_today' => $cur === date('Y-m-d'), 'events' => $byDay[$cur] ?? [], ]; $cur = date('Y-m-d', strtotime($cur . ' +1 day')); } $weeks[] = $row; if (strcmp($cur, $to) > 0) { break; } } 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]; } /** * 读任务的「截止日期(+ 可选时刻)」。 * * ⚠️ 只填日期、不填时刻时存 00:00:00,而 00:00 在展示层与日历里都当作**全天** * (与日程同一个口径):"某天之前做完"本来就没有具体几点。 */ 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; } $t = trim((string)($_POST['due_time'] ?? '')); return $d . ' ' . (preg_match('/^\d{2}:\d{2}$/', $t) ? $t . ':00' : '00:00:00'); } /** * 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。 * * ⚠️ v24(用户 2026-10-02 第 19 条):原来同一天没有命中时会**退回同一个月**, * 标题随之变成"往年这个月" —— 但用户要的就是**往年的今天**: * "10 月 2 日"给我看"去年 10 月 8 日去看了场电影",这根本不是同一天的事, * 凑出来的条目看着像回忆,其实只是同月的随机噪声,反而把"往年今日"的意味毁了。 * 现在只认同一天;没命中就明说"往年的这一天还没有记录"。 * * @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容) */ function home_memories(int $familyId, int $limit = 6): array { $today = date('Y-m-d'); $year = (int)date('Y'); $sql = "SELECT e.id, e.title, e.starts_at, e.kind, (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS n 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, '%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(); 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' => true]; } /** 只认本家庭、确实存在的 person_id(表单可以改,不能凭它塞进别人家的人) */ function filter_family_person_ids(array $ids, int $familyId): array { $map = Person::map($familyId); $out = []; foreach ($ids as $v) { $v = (int)$v; if ($v > 0 && isset($map[$v]) && !in_array($v, $out, true)) { $out[] = $v; } } return $out; } switch ($r) { // ==================== 登录 / 退出 ==================== case 'auth/login': if (Auth::check()) { redirect('home'); } if ($method === 'POST') { csrf_check(); $phone = trim((string)($_POST['phone'] ?? '')); $pass = (string)($_POST['password'] ?? ''); if (Auth::login($phone, $pass)) { redirect('home'); } View::renderBare('login', [ 'title' => '登录', 'familyName' => fb_config('site_name', '家书'), 'error' => '手机号或密码不对,或者账号还没开通/已被停用。', 'phone' => $phone, ]); exit; } View::renderBare('login', [ 'title' => '登录', 'familyName' => fb_config('site_name', '家书'), ]); break; case 'auth/logout': if (isset($_GET['_csrf'], $_SESSION['_csrf']) && hash_equals($_SESSION['_csrf'], (string)$_GET['_csrf'])) { Auth::logout(); } redirect('auth/login'); break; case 'auth/password': Auth::requireLogin(); if ($method === 'POST') { csrf_check(); $old = (string)($_POST['old_password'] ?? ''); $new = (string)($_POST['password'] ?? ''); $new2 = (string)($_POST['password2'] ?? ''); $st = Database::get()->prepare('SELECT password_hash FROM accounts WHERE id = ?'); $st->execute([Auth::id()]); $hash = (string)$st->fetchColumn(); $err = null; if (!password_verify($old, $hash)) { $err = '当前密码不对'; } elseif ($new !== $new2) { $err = '两次输入的新密码不一致'; } elseif (Account::validatePassword($new)) { $err = implode(';', Account::validatePassword($new)); } if ($err) { View::render('auth/password', ['title' => '修改密码', 'error' => $err], 'home'); exit; } Account::setPassword((int)Auth::id(), $new); flash('ok', '密码已更新,下次登录用新密码。'); redirect('home'); } View::render('auth/password', ['title' => '修改密码'], 'home'); break; // ==================== 首页(以我为中心) ==================== case 'home': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $people = Person::map($familyId); $graph = Kinship::graph($familyId); // 「我的家人」= 以我为中心上下各三代那张家谱里的人。 // ⚠️ 早先只取「直接关系」(父母/配偶/子女/兄弟姐妹),于是**姻亲与祖孙都被漏掉**: // 爱人登进来看不到「公公婆婆」,我看不到「岳父岳母」,爷爷奶奶也不在首页上。 // 这恰恰是「以个人为中心」最该体现的地方,所以改用家谱的节点集合。 $tree = Kinship::tree($graph, $me, $people, 3, 3); $kinIds = array_diff(array_map('intval', array_keys($tree['nodes'])), [$me]); $kin = []; // 用 Person::all()(带账号信息)而不是 Person::map(),首页才能显示管理员/访客徽标 foreach (Person::all($familyId) as $p) { $pid = (int)$p['id']; if (in_array($pid, $kinIds, true)) { $kin[$pid] = $p + ['label' => Kinship::label($graph, $me, $pid, $people)]; } } $birthdays = Person::upcomingBirthdays($familyId); // 生日条上也标出称谓 foreach ($birthdays as &$b) { $b['label'] = isset($people[(int)$b['id']]) ? Kinship::label($graph, $me, (int)$b['id'], $people) : ''; // 名字优先用"我叫他什么" —— 同一个孩子,爸爸看到"儿子"、奶奶看到"小宝" $b['shown'] = Nickname::display($me, $people[(int)$b['id']] ?? null) ?: $b['name']; } unset($b); // ---- dashboard 的另外三块(用户第 13 条)---- // 未来 7 天的日程:自定义日程 + 观影 + 旅行 + 有截止时间的任务,一次汇总 $cals = Calendar::mine($familyId, $me); $today = date('Y-m-d'); $soon = Calendar::month($familyId, $cals, $me, $today, date('Y-m-d', strtotime('+7 days'))); // 进行中的任务 + 已经过期的待办(**只有这些才值得占首页**; // 没截止日期的待办放任务看板里看,堆到首页会变成一张长长的清单) $board = Task::board($familyId, $me); $tasks = $board['doing']; foreach ($board['todo'] as $t) { if (Task::isOverdue($t['due_at'] ?? null)) { $tasks[] = $t; } } $memories = home_memories($familyId); // ---- 用户 2026-10-03 第 3 条:首页要显示哪些模块、什么顺序(每人一份)---- $homeMods = HomeMods::forPerson($me); $moduleCards = Home::moduleCards($familyId, $me, $soon, $tasks); // 统计信息块:随机问候 + 今日天气 + 近七天的数字 $weekEnd = date('Y-m-d', strtotime('+7 days')); $st7 = Database::get()->prepare( "SELECT COUNT(*) FROM calendar_events e JOIN calendars c ON c.id = e.calendar_id WHERE c.family_id = ? AND DATE(e.starts_at) BETWEEN ? AND ? AND (c.owner_id = ? OR EXISTS ( SELECT 1 FROM calendar_shares sh WHERE sh.calendar_id = c.id AND sh.person_id = ?))" ); $st7->execute([$familyId, $today, $weekEnd, $me, $me]); $ev7 = (int)$st7->fetchColumn(); $todoN = count($board['todo']); $lateN = 0; foreach ([$board['todo'], $board['doing']] as $col) { foreach ($col as $t) { if (Task::isOverdue($t['due_at'] ?? null)) { $lateN++; } } } $meName = Nickname::display($me, $people[$me] ?? null) ?: (string)($people[$me]['name'] ?? ''); $greeting = Home::greeting($meName, $today, count($soon), $lateN); // 天气:经纬度在系统设置里配;没配就整块不显示(不猜城市) $weather = Home::weather( $familyId, (string)Setting::get($familyId, 'weather_lat', ''), (string)Setting::get($familyId, 'weather_lon', ''), (string)Setting::get($familyId, 'weather_place', '') ); View::render('home', [ 'title' => '首页', 'family' => Family::find($familyId), 'me' => $people[$me] ?? null, 'meId' => $me, 'kin' => $kin, 'allCount' => count($people), 'relCount' => Relations::count($familyId), 'birthdays' => $birthdays, 'soon' => $soon, 'tasks' => $tasks, 'memories' => $memories, // 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页) 'mySlogan' => trim((string)($people[$me]['slogan'] ?? '')), 'myNicknames'=> Nickname::mapFor($me), // 用户第 3 条新增 'greeting' => $greeting, 'weekday' => Home::weekday($today), 'weather' => $weather, 'stat7' => ['events' => $ev7, 'todos' => $todoN, 'overdue' => $lateN], 'homeModules'=> $homeMods, 'moduleCards'=> $moduleCards, ], 'home'); break; // ==================== 家谱(以我为中心) ==================== case 'kin': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $people = Person::map($familyId); $graph = Kinship::graph($familyId); // ⚠️ 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' => $focusId === $me ? '我的家谱' : ((string)($people[$focusId]['name'] ?? '') . ' 的家谱'), 'meId' => $me, 'focusId' => $focusId, 'people' => $people, 'graph' => $graph, ], 'kin'); break; // ==================== 名册 ==================== case 'people': $q = trim((string)($_GET['q'] ?? '')); $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); // ⚠️ 用 roster() 而不是 all():名册是「每人一份」—— // 只列我加的人 + 别人分享给我的(v5 起,见 App\PersonRoster)。 $rows = Person::roster($familyId, $me, 'member', $q); $people = Person::map($familyId); $graph = Kinship::graph($familyId); // 名册里也标出「我该怎么称呼他」 foreach ($rows as &$row) { $row['label'] = Kinship::label($graph, $me, (int)$row['id'], $people); $row['shown'] = Nickname::display($me, $row) ?: (string)$row['name']; // 用户 2026-10-03 第 4 条:列表要显示手机号(密文解密)与生日。 // ⚠️ 解密只对**能看名册的人**做(这页本来就要登录 + 在名册里), // 但页面上仍然只显示"前 3 后 4"以外的形态由模板决定。 $row['phone_plain'] = ''; if (!empty($row['phone_encrypted'])) { try { $row['phone_plain'] = Crypto::decrypt((string)$row['phone_encrypted']); } catch (\Throwable $e) { $row['phone_plain'] = ''; } } // 生日优先显示阳历(真实生日),没有就显示农历 $row['birthday_show'] = Birthday::pretty(Birthday::solarOf($row)); $row['birthday_kind'] = '阳历'; if ($row['birthday_show'] === '') { $lun = Birthday::dateOnly((string)($row['birthday_lunar'] ?? '')); if ($lun !== '') { $row['birthday_show'] = $lun; $row['birthday_kind'] = '农历'; } } } unset($row); // 按关系分组分栏。顺序用**当前用户自己的**(没自定义过就是默认顺序); // 归到键 0 的那一堆是还没分类的人,模板单独放在最后一栏。 $grouped = []; foreach ($rows as $row) { $grouped[(int)($row['group_id'] ?? 0)][] = $row; } View::render('people/index', [ 'title' => '家庭名册', 'people' => $rows, 'grouped'=> $grouped, 'groups' => PersonGroup::orderFor($familyId, $me), 'q' => $q, 'meId' => $me, // 家里已经有人、但还不在我名册里 → 给一个「直接挑」的入口, // 免得为了同一个人又录一遍(录两遍会变成两条记录,账号与亲属关系各存一份) 'pickable' => $q === '' ? count(PersonRoster::pickable($familyId, $me)) : 0, ], 'people'); break; /** * 名册卡片上的「调整分组」。 * * 从 v5 起改的是**我自己那份名册**里的归类(person_roster.group_id), * 不再动 persons —— 所以「我把张伟归到同事」不会让妈妈那边也变成同事。 * 单开一个路由而不是让人去「编辑成员」里改,是因为那张表很长 * (三个生日、身份证号、住址…),为了挪一个分组去翻半页是折磨。 * 而且分组**本来就只能在名册里改**(它是"我和他的关系")。 */ case 'people/group': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能改名册里的分组。'); redirect('people'); } csrf_check(); $pid = (int)($_POST['person_id'] ?? 0); $gid = (int)($_POST['group_id'] ?? 0); $p = Person::find($pid); if (!$p || (int)$p['family_id'] !== $familyId) { flash('err', '找不到这个人。'); redirect('people'); } if (!PersonRoster::assignGroup($familyId, $me, $pid, $gid > 0 ? $gid : null)) { flash('err', '这个人不在你的名册里,或者那个分组你用不了。'); redirect('people'); } flash('ok', $gid > 0 ? '已把「' . $p['name'] . '」归到「' . PersonGroup::name($familyId, $gid) . '」。' : '已把「' . $p['name'] . '」改成未分类。'); redirect('people'); break; /** * 名册拖动排序 / 拖动换栏 —— 整版提交。 * * 前端把每一栏(含「未分类」)的有序 id 列表发过来,服务端一次写完。 * 为什么不"移动一条算一次":拖拽的结果是一组**相对顺序**, * 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。 * 没有 JS 也能用:卡片上的「调整分组」表单还在(只是没有拖拽那么顺手)。 */ case 'people/reorder': Auth::requireLogin(); // 拖动是 AJAX 发的(见 app.js)→ 出错也回 JSON 而不是 302: // 不然那句 flash 会攒到"下一次刷新"才冒出来,看着像迟到的鬼影。 $isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest'); if (!Auth::canWrite()) { if ($isAjax) { header('Content-Type: application/json; charset=utf-8', true, 403); echo json_encode(['ok' => false, 'error' => 'readonly']); exit; } flash('err', '你的身份是访客,不能调整名册顺序。'); redirect('people'); } csrf_check(); $raw = (string)($_POST['layout'] ?? ''); $layout = json_decode($raw, true); if (!is_array($layout)) { if ($isAjax) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => false, 'error' => 'invalid layout']); exit; } flash('err', '没收到有效的顺序,请刷新页面再试一次。'); redirect('people'); } $n = PersonRoster::applyLayout((int)Auth::familyId(), (int)Auth::personId(), $layout); if ($isAjax) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => true, 'n' => $n], JSON_UNESCAPED_UNICODE); exit; } flash('ok', $n > 0 ? '名册顺序已更新。' : '没有需要改动的。'); redirect('people'); break; /** * 「从家里已有的人里挑」—— 添加成员的第二个入口。 * * 存在的理由:同一个人不该被录两遍。录两遍会变成两条 persons, * 于是账号、亲属关系、生日各存一份,后面永远对不上。 * 所以只要人已经在这个家庭里(别人先录过了),就走这里把他**复用**进我的名册。 */ case 'people/pick': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能往名册里加人。'); redirect('people'); } $familyPick = PersonRoster::pickable($familyId, $me); if ($method === 'POST') { csrf_check(); $ids = array_map('intval', (array)($_POST['ids'] ?? [])); $gid = (int)($_POST['group_id'] ?? 0); // 只认「确实可挑」的那些 id —— 表单可以改,不能凭它把别人家的拉进来 $ok = array_column($familyPick, 'id'); $ok = array_map('intval', $ok); $n = 0; foreach ($ids as $pid) { if (!in_array($pid, $ok, true)) { continue; } if (PersonRoster::ensure($familyId, $me, $pid, $gid > 0 ? $gid : null, 'self')) { $n++; } } flash($n > 0 ? 'ok' : 'err', $n > 0 ? '已把 ' . $n . ' 个人加进你的名册。' : '一个人都没加上 —— 先勾选要加的人。'); redirect('people'); } View::render('people/pick', [ 'title' => '从家里已有的人里挑', 'people' => $familyPick, 'groups' => PersonGroup::orderFor($familyId, $me), 'meId' => $me, ], 'people'); break; /** * 把一张名片分享到别人的名册里(需求原话:「我可以把一个人的名片,分享给一个用户, * 则这个名片,自动出现在这个用户的名册中」)。 * 这只是"让他看得见",不改变任何资料本身;对方可以自己决定归到哪一组。 */ case 'people/share': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能分享名片。'); redirect('people'); } csrf_check(); $pid = (int)($_POST['person_id'] ?? 0); $to = (int)($_POST['to_id'] ?? 0); $back = (int)($_POST['back'] ?? 0); $p = Person::find($pid); $t = Person::find($to); if (!$p || !$t || (int)$p['family_id'] !== $familyId || (int)$t['family_id'] !== $familyId) { flash('err', '找不到这个人。'); redirect('people'); } if ($pid === $to) { flash('err', '不用把名片分享给他自己。'); redirect('people/show', ['id' => $back > 0 ? $back : $pid]); } PersonRoster::share($familyId, $to, [$pid], 'shared'); flash('ok', '已把「' . $p['name'] . '」的名片放进「' . $t['name'] . '」的名册里。'); redirect('people/show', ['id' => $back > 0 ? $back : $pid]); break; /** 把一个人从**我的**名册里移掉(人还在名册与家谱里,只是我不再看到他) */ case 'people/forget': Auth::requireLogin(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能改名册。'); redirect('people'); } csrf_check(); $pid = (int)($_POST['person_id'] ?? 0); $p = Person::find($pid); if ($p && (int)$p['family_id'] === (int)Auth::familyId()) { // v7:家谱关系带来的人不许「移走」——他在首页/家谱上照样看得见, // 单独从名册藏掉只会把三处口径又搅回打架的状态。 // 想不再看到他,解除那条关系(名册条目会跟着自动收回)。 $src = (string)(PersonRoster::mine((int)Auth::familyId(), (int)Auth::personId())[$pid]['source'] ?? ''); if ($src === 'kin') { flash('err', '「' . $p['name'] . '」是因为家谱关系自动进你名册的,不能单独移走;解除关系后他会自动移出。'); } else { PersonRoster::remove((int)Auth::familyId(), (int)Auth::personId(), $pid); flash('ok', '已把「' . $p['name'] . '」从你的名册里移走(他还在家里)。'); } } redirect('people'); break; // ==================== 我自己的资料 / 个人设置 ==================== // 顶栏头像点开 →「个人设置」。访客也能改自己的资料 —— // 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。 case 'people/me': // 「我的资料」已并进「个人设置」(设置分两个维度:系统设置 / 个人设置)。 // 旧链接、老书签、以及别处写死的 people/me 一律跳过去,不再留第二个入口。 redirect('settings/me'); break; case 'settings/me': Auth::requireLogin(); $p = Person::find((int)Auth::personId()); if (!$p) { flash('err', '找不到你自己的名册记录。'); redirect('home'); } clear_old(); render_me_settings($p); break; /** * 旧的「保存我的资料」入口。 * * v5 起资料表单只剩一张(`templates/people/form.php`,提交到 people/update), * 所以这两个地址不再处理表单 —— 只把还开着旧标签页的人送回个人设置, * 免得一次提交撞上 404 让人以为"保存坏了"。 */ case 'settings/me_save': case 'people/me_save': redirect('settings/me'); break; /** * 保存「导航条上显示哪些模块、什么顺序」(用户第 10 条,每人一份)。 * * 表单同时可能带 `move=up:` / `move=down:`(▲▼ 按钮)—— 那是 * **不用 JS 也能排序**的兜底:先在当前顺序里跟邻居换一位,再一起存。 * 拖动则由前端直接搬 DOM(每个
  • 自带 order[],搬 li 就等于搬顺序)。 */ case 'settings/nav_save': Auth::requireLogin(); csrf_check(); $me = (int)Auth::personId(); $order = array_values(array_map('strval', (array)($_POST['order'] ?? []))); $on = array_map('strval', (array)($_POST['on'] ?? [])); // ⚠️ 只收认识的 key,且**按 order 的顺序**取交集(顺序就是用户拖出来的顺序) $keys = array_values(array_filter( $order, static fn($k) => Module::exists($k) && in_array($k, $on, true) )); $move = (string)($_POST['move'] ?? ''); if (strpos($move, ':') !== false) { [$dir, $key] = explode(':', $move, 2); $dir = $dir === 'up' ? 'up' : 'down'; // ▲▼ 作用于**全部模块**的排列(含未勾选的),否则关掉的那些会被挤走 $all = array_values(array_filter($order, static fn($k) => Module::exists($k))); foreach (Module::keys() as $k) { if (!in_array($k, $all, true)) { $all[] = $k; } } $i = array_search($key, $all, true); if ($i !== false) { $j = $dir === 'up' ? (int)$i - 1 : (int)$i + 1; if ($j >= 0 && $j < count($all)) { [$all[$i], $all[$j]] = [$all[$j], $all[$i]]; } } $keys = array_values(array_filter($all, static fn($k) => in_array($k, $on, true))); } if (!$keys) { $keys = Module::DEFAULT_NAV; // 一个都不勾 = 没配过,退回默认(不然导航条会空掉) } Module::saveNav($me, $keys); flash('ok', '导航条已更新。'); redirect('settings/me', [], 'modules'); break; /** * 保存「首页上显示哪些模块、什么顺序」(用户 2026-10-03 第 3 条,每人一份)。 * * 与导航条那套(settings/nav_save)**同一套交互**:勾选 + 拖动/▲▼ 排序。 * 用同一个表单字段名(order[] / on[] / move),前端那段拖拽脚本也就能共用。 */ case 'settings/home_save': Auth::requireLogin(); csrf_check(); $me = (int)Auth::personId(); $order = array_values(array_map('strval', (array)($_POST['order'] ?? []))); $on = array_map('strval', (array)($_POST['on'] ?? [])); // 「▲▼」按钮(没有 JS 也能排序):在当前顺序里跟邻居换一位 $move = (string)($_POST['move'] ?? ''); if ($move !== '' && strpos($move, ':') !== false) { [$dir, $key] = explode(':', $move, 2); $all = HomeMods::orderedForPicker($me); $pos = array_search($key, $all, true); if ($pos !== false) { $swap = $dir === 'up' ? $pos - 1 : $pos + 1; if ($swap >= 0 && $swap < count($all)) { [$all[$pos], $all[$swap]] = [$all[$swap], $all[$pos]]; } // 换完之后 order 用新顺序、on 保持原勾选状态 $order = $all; } } $keys = array_values(array_filter( $order, static fn($k) => Module::exists($k) && in_array($k, $on, true) )); HomeMods::save($me, $keys); flash('ok', '首页显示已保存。'); redirect('settings/me', [], 'home-mods'); break; /** * 保存模块主题色(用户第 11 条,**家庭一份**,所以只有管理员能改)。 */ case 'settings/module_colors': Auth::requireLogin(); csrf_check(); $familyId = (int)Auth::familyId(); if (!Auth::isAdmin()) { flash('err', '模块主题色是全家共用的一套,只有管理员能改。'); redirect('settings/me'); } if (!empty($_POST['reset'])) { Module::saveColors($familyId, []); flash('ok', '已恢复默认配色。'); redirect('settings/me', [], 'modules'); } Module::saveColors($familyId, (array)($_POST['c'] ?? [])); flash('ok', '模块主题色已保存。刷新页面就能看到。'); redirect('settings/me', [], 'modules'); break; // ==================== 分组的顺序 ==================== // 两套:「我自己的顺序」(只影响我看到的)与「公共分组的默认顺序」(全家兜底)。 // 靠表单里的 scope 字段区分 —— 同一个动作,落到两个地方。 case 'settings/groups/move': Auth::requireLogin(); csrf_check(); $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $id = (int)($_POST['id'] ?? 0); $dir = ($_POST['dir'] ?? '') === 'up' ? 'up' : 'down'; $scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me'; if ($scope === 'public') { Auth::requireAdmin(); } $list = $scope === 'public' ? PersonGroup::forOwner($familyId, PersonGroup::PUBLIC_OWNER) : PersonGroup::orderFor($familyId, $me); $ids = array_map(fn($g) => (int)$g['id'], $list); $at = array_search($id, $ids, true); if ($at === false) { flash('err', '找不到这个分组。'); redirect($scope === 'public' ? 'settings' : 'settings/me'); } // 挪到边界就什么也不做(按钮在两端本来就是 disabled 的, // 但地址栏里手敲一次也能到这儿,别让它把顺序搞乱) $to = $dir === 'up' ? $at - 1 : $at + 1; if ($to >= 0 && $to < count($ids)) { [$ids[$at], $ids[$to]] = [$ids[$to], $ids[$at]]; if ($scope === 'public') { PersonGroup::savePositions($familyId, PersonGroup::PUBLIC_OWNER, $ids); } else { PersonGroup::saveOrder($familyId, $me, $ids); } } redirect($scope === 'public' ? 'settings' : 'settings/me'); break; // ==================== 分组的增删改名 ==================== // 两套:公共的(管理员在系统设置里维护,全家共用)与我自己的(个人设置里, // 只有自己看得见)。访客只读,给个明确的说法再回去, // 而不是甩一张 403 页面(他明明是点了自己设置里的按钮)。 case 'settings/groups/add': Auth::requireLogin(); csrf_check(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能加分组。'); redirect('settings/me'); } $scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me'; $owner = PersonGroup::PUBLIC_OWNER; if ($scope === 'public') { Auth::requireAdmin(); } else { $owner = (int)Auth::personId(); } $res = PersonGroup::add((int)Auth::familyId(), (string)($_POST['name'] ?? ''), $owner); flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '已加上分组「' . trim((string)($_POST['name'] ?? '')) . '」。' : (string)($res['error'] ?? '加不上。')); redirect($scope === 'public' ? 'settings' : 'settings/me'); break; case 'settings/groups/rename': Auth::requireLogin(); csrf_check(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能改分组。'); redirect('settings/me'); } $scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me'; $owner = PersonGroup::PUBLIC_OWNER; if ($scope === 'public') { Auth::requireAdmin(); } else { $owner = (int)Auth::personId(); } $res = PersonGroup::rename((int)Auth::familyId(), (int)($_POST['id'] ?? 0), (string)($_POST['name'] ?? ''), $owner); flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '分组名已改。' : (string)($res['error'] ?? '改不了。')); redirect($scope === 'public' ? 'settings' : 'settings/me'); break; case 'settings/groups/delete': Auth::requireLogin(); csrf_check(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能删分组。'); redirect('settings/me'); } $scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me'; $owner = PersonGroup::PUBLIC_OWNER; if ($scope === 'public') { Auth::requireAdmin(); } else { $owner = (int)Auth::personId(); } $res = PersonGroup::delete((int)Auth::familyId(), (int)($_POST['id'] ?? 0), $owner); if ($res['ok']) { $n = (int)($res['moved'] ?? 0); flash('ok', $n > 0 ? '分组已删掉,那 ' . $n . ' 个人现在显示在「未分类」里,去名册重新归类即可。' : '分组已删掉(那一组本来也没人)。'); } else { flash('err', (string)($res['error'] ?? '删不了。')); } redirect($scope === 'public' ? 'settings' : 'settings/me'); break; // ==================== 别人怎么称呼(只有我自己能定) ==================== case 'nickname/save': Auth::requireLogin(); csrf_check(); $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $target = (int)($_POST['target_id'] ?? 0); $nick = (string)($_POST['nickname'] ?? ''); $t = Person::find($target); $back = (string)($_POST['back'] ?? 'people/show'); if (!$t || (int)$t['family_id'] !== $familyId) { flash('err', '找不到这个人。'); redirect('people'); } Nickname::set($familyId, $me, $target, $nick); flash('ok', trim($nick) === '' ? '已恢复成用姓名显示。' : '记下了:你叫他「' . trim($nick) . '」。这只影响你自己看到的称呼。'); if ($back === 'settings/me' || $back === 'people/me') { // 两个写法都认:老表单发的是 people/me(那一页现在是跳转) redirect('settings/me'); } redirect('people/show', ['id' => $target]); break; case 'people/new': Auth::requireWrite(); clear_old(); View::render('people/form', [ 'title' => '添加成员', 'person' => null, 'errors' => [], 'canEditAll' => true, 'isSelf' => false, ], 'people'); break; case 'people/create': Auth::requireWrite(); csrf_check(); $familyId = (int)Auth::familyId(); $d = [ 'name' => trim((string)($_POST['name'] ?? '')), 'gender' => (string)($_POST['gender'] ?? ''), 'phone' => trim((string)($_POST['phone'] ?? '')), 'id_card' => trim((string)($_POST['id_card'] ?? '')), 'email' => trim((string)($_POST['email'] ?? '')), 'address' => trim((string)($_POST['address'] ?? '')), 'org' => trim((string)($_POST['org'] ?? '')), 'title' => trim((string)($_POST['title'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')), 'slogan' => trim((string)($_POST['slogan'] ?? '')), 'avatar' => '', 'kind' => 'member', // ⚠️ 这两项必须显式从表单取回:表单里有,但漏了这一步就会被静默丢掉, // 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。 'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')), 'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0, ] + read_birthday_input(); $errors = Person::validate($d); $contacts = read_contacts_input(); $ctErr = validate_contacts($contacts); if ($ctErr !== '') { $errors['contacts'] = $ctErr; } // ⚠️ 头像上传失败**不再挡住整条新增**(用户原话:「添加人员功能不能用了」—— // 早先一张 5MB 的手机原图就把整个人挡在名册外面,看起来像功能坏了)。 // 现在是:人先存进去,头像的事单独提示。 $up = Upload::avatar($familyId, $_FILES['avatar'] ?? [], (string)($_POST['avatar_crop'] ?? '')); $avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : ''; if ($errors) { set_old($_POST); View::render('people/form', [ 'title' => '添加成员', 'person' => null, 'errors' => $errors, 'phoneValue' => $d['phone'], 'idCardValue'=> $d['id_card'], 'canEditAll' => true, 'isSelf' => false, 'contacts' => $contacts, ], 'people'); exit; } if ($up['ok']) { $d['avatar'] = $up['file']; } try { $id = Person::create($familyId, $d); // 头像主色:上传时已算好,这里顺手存下(算不出就不写,走姓名兜底) if ($up['ok']) { Person::setAvatarColor($id, (string)($up['color'] ?? '')); } } catch (\PDOException $ex) { set_old($_POST); View::render('people/form', [ 'title' => '添加成员', 'person' => null, 'errors' => ['phone' => '这个手机号已经在名册里了'], 'phoneValue' => $d['phone'], 'idCardValue'=> $d['id_card'], 'canEditAll' => true, 'isSelf' => false, 'contacts' => $contacts, ], 'people'); exit; } // 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。 // 分组:表单里选了就用它(用户第 12 条「增加名册时可选择所属分组」); // ⚠️ 只认**我自己**的分组列表里的 id —— 表单可以改,不能凭它把人塞进 // 别人私有的分组、或根本不存在的分组里。 $gid = (int)($_POST['group_id'] ?? 0); $okGids = array_map('intval', array_column( PersonGroup::orderFor($familyId, (int)Auth::personId()), 'id')); if ($gid > 0 && !in_array($gid, $okGids, true)) { $gid = 0; } PersonRoster::ensure($familyId, (int)Auth::personId(), $id, $gid > 0 ? $gid : null, 'self'); Person::replaceContacts($id, $contacts); clear_old(); if ($avatarWarn !== '') { flash('ok', '已把「' . $d['name'] . '」加进名册。头像没有保存:' . $avatarWarn); } else { flash('ok', '已把「' . $d['name'] . '」加进名册。'); } redirect('people/show', ['id' => $id]); break; /** * 成员资料表单 —— **只有这一张**(需求:「编辑成员」与「改自己的资料」本来就是一件事)。 * * 两个入口都指到这里:名册卡片/详情页的「编辑资料」、以及个人设置顶部的 * 「编辑我的资料」。区别只有一个:编辑**自己**时不要求能写(访客也能改自己的名字), * 而编辑别人必须是能写的人。 */ case 'people/edit': $id = (int)($_GET['id'] ?? 0); $isSelf = $id > 0 && $id === (int)Auth::personId(); if (!$isSelf) { Auth::requireWrite(); } $p = Person::find($id); if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) { flash('err', '找不到这个人。'); redirect('people'); } clear_old(); // 补充联系方式:kind=phone 存的是密文,编辑表单里要给本人(能写的人)看明文 $editContacts = Person::contactsOf($id); foreach ($editContacts['phone'] as &$r) { $r['value'] = Crypto::decrypt($r['value']); } unset($r); View::render('people/form', [ 'title' => $isSelf ? '我的资料' : '编辑成员', 'person' => $p, 'errors' => [], 'canEditAll' => Auth::canWrite(), 'isSelf' => $isSelf, 'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']), 'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']), 'contacts' => $editContacts, ], 'people'); break; case 'people/update': csrf_check(); $familyId = (int)Auth::familyId(); $id = (int)($_POST['id'] ?? 0); $p = Person::find($id); if (!$p || (int)$p['family_id'] !== $familyId) { flash('err', '找不到这个人。'); redirect('people'); } $isSelf = $id === (int)Auth::personId(); if (!$isSelf) { Auth::requireWrite(); } // 改别人(= 能写的人编名册):字段是完整的一套。 // 改自己(可能是访客):只允许动属于自己的那些 —— 与 Person::updateSelf 一一对应。 $canEditAll = Auth::canWrite(); $d = [ 'name' => trim((string)($_POST['name'] ?? '')), 'gender' => (string)($_POST['gender'] ?? ''), 'phone' => $canEditAll ? trim((string)($_POST['phone'] ?? '')) : Crypto::decrypt((string)$p['phone_encrypted']), 'id_card' => $canEditAll ? trim((string)($_POST['id_card'] ?? '')) : Crypto::decrypt((string)$p['id_card_encrypted']), 'email' => trim((string)($_POST['email'] ?? '')), 'address' => trim((string)($_POST['address'] ?? '')), 'org' => trim((string)($_POST['org'] ?? '')), 'title' => trim((string)($_POST['title'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')), 'slogan' => trim((string)($_POST['slogan'] ?? '')), 'avatar' => (string)$p['avatar'], 'birth_rank' => $canEditAll ? trim((string)($_POST['birth_rank'] ?? '')) : ($p['birth_rank'] ?? ''), 'is_placeholder' => $canEditAll ? (!empty($_POST['is_placeholder']) ? 1 : 0) : (int)($p['is_placeholder'] ?? 0), ] + read_birthday_input($p); $errors = Person::validate($d); $contacts = read_contacts_input(); $ctErr = validate_contacts($contacts); if ($ctErr !== '') { $errors['contacts'] = $ctErr; } // 同 people/create:头像失败不挡保存(见 create 分支里的说明) $up = Upload::avatar($familyId, $_FILES['avatar'] ?? [], (string)($_POST['avatar_crop'] ?? '')); $avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : ''; if ($errors) { set_old($_POST); View::render('people/form', [ 'title' => $isSelf ? '我的资料' : '编辑成员', 'person' => $p, 'errors' => $errors, 'canEditAll' => $canEditAll, 'isSelf' => $isSelf, 'phoneValue' => $d['phone'], 'idCardValue' => $d['id_card'], 'contacts' => $contacts, ], 'people'); exit; } if ($up['ok']) { Upload::remove($familyId, (string)$p['avatar']); $d['avatar'] = $up['file']; // 换图就换色:新头像的主色马上覆盖旧的(算不出时保留旧值,不清空) Person::setAvatarColor($id, (string)($up['color'] ?? '')); } try { // 改自己且没有编名册的权限 → 只能动属于自己的字段(Person::updateSelf)。 // 这一条不是"防贼",是为了让访客也能改自己的名字,同时不会顺手把 // 手机号(= 登录凭据)或身份证号改掉。 if ($isSelf && !$canEditAll) { Person::updateSelf($id, $d); } else { Person::update($id, $d); } } catch (\PDOException $ex) { set_old($_POST); View::render('people/form', [ 'title' => $isSelf ? '我的资料' : '编辑成员', 'person' => $p, 'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'], 'canEditAll' => $canEditAll, 'isSelf' => $isSelf, 'phoneValue' => $d['phone'], 'idCardValue' => $d['id_card'], 'contacts' => $contacts, ], 'people'); exit; } Person::replaceContacts($id, $contacts); if ((int)$p['id'] === (int)Auth::personId()) { Auth::refreshAvatar((string)$d['avatar']); // 姓名也可能改了 —— 不同步会话的话顶栏还是旧名字 $_SESSION['name'] = (string)$d['name']; } clear_old(); if ($avatarWarn !== '') { flash('ok', '已保存。头像没有更换:' . $avatarWarn); } else { flash('ok', '已保存。'); } redirect('people/show', ['id' => $id]); break; case 'people/show': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $id = (int)($_GET['id'] ?? 0); $p = Person::findWithAccount($id, $me); if (!$p || (int)$p['family_id'] !== $familyId) { flash('err', '找不到这个人。'); redirect('people'); } $people = Person::map($familyId); $graph = Kinship::graph($familyId); // 这个人在「我」眼里是谁,以及我与他之间的直接关系(可编辑的那些) $tree = Kinship::tree($graph, $id, $people, 2, 2); View::render('people/show', [ 'title' => $p['name'], 'person' => $p, 'meId' => $me, 'people' => $people, 'graph' => $graph, 'labelToMe' => Kinship::label($graph, $me, $id, $people), 'isMe' => $id === $me, // 以这个人为中心的两代亲属,供详情页展示 'theirTree' => $tree, 'theirRels' => Relations::forPerson($familyId, $id), 'options' => Person::options($familyId, $id, $me), 'kinReady' => Kinship::graph($familyId) !== [], // 「我叫他什么」—— 只有我自己能改,改了也只影响我看到的 'myNickname' => $id === $me ? '' : Nickname::get($me, $id), 'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']), 'birthdays' => Birthday::describe($p), 'birthdayNext'=> Birthday::nextOccurrences($p, date('Y-m-d'), 400), // 补充联系方式(vCard 对齐):kind=phone 的 value 是密文,模板里用 maskPhone 展示 'contacts' => Person::contactsOf($id), // 名册(每人一份):他是不是在我的名册里、可以把他的名片分享给谁 'inMyRoster' => in_array($id, PersonRoster::visibleIds($familyId, $me), true), ], 'people'); break; case 'people/destroy': Auth::requireAdmin(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $p = Person::find($id); if ($p && (int)$p['family_id'] === (int)Auth::familyId()) { if ((int)$p['id'] === (int)Auth::personId()) { flash('err', '不能把自己从名册里移除。'); redirect('people/show', ['id' => $id]); } // 不让最后一个管理员消失:系统里没人能管理家庭就锁死了 $acc = Account::findByPerson($id); if ($acc && $acc['role'] === 'admin' && Account::adminCount() <= 1) { flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。'); redirect('people/show', ['id' => $id]); } Upload::remove((int)Auth::familyId(), (string)$p['avatar']); Person::delete($id); flash('ok', '已从名册移除「' . $p['name'] . '」。'); } redirect('people'); break; // ==================== 账号 ==================== case 'accounts/create': Auth::requireAdmin(); csrf_check(); $personId = (int)($_POST['person_id'] ?? 0); $p = Person::find($personId); if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) { flash('err', '找不到这个人。'); redirect('people'); } if (Account::findByPerson($personId)) { flash('err', '这个人的账号已经开通过了。'); redirect('people/show', ['id' => $personId]); } if (Crypto::decrypt((string)$p['phone_encrypted']) === '') { flash('err', '要先填手机号,才能开通登录。'); redirect('people/show', ['id' => $personId]); } $pass = (string)($_POST['password'] ?? ''); $errs = Account::validatePassword($pass); if ($errs) { flash('err', implode(';', $errs)); redirect('people/show', ['id' => $personId]); } $r0 = (string)($_POST['role'] ?? ''); Account::create($personId, $pass, in_array($r0, Account::ROLES, true) ? $r0 : 'member'); // v7:多了一个「看家谱的人」——他的名册也要按树播种,一登录三处口径就是对的 PersonRoster::syncKinForFamily((int)Auth::familyId()); flash('ok', '账号已开通,用手机号就能登录。'); redirect('people/show', ['id' => $personId]); break; case 'accounts/role': Auth::requireAdmin(); csrf_check(); $personId = (int)($_POST['person_id'] ?? 0); $role = in_array(($r1 = (string)($_POST['role'] ?? '')), Account::ROLES, true) ? $r1 : 'member'; $p = Person::find($personId); $acc = Account::findByPerson($personId); if ($p && $acc) { // ⚠️ 判的是 `$role !== 'admin'` 而不是 `$role === 'member'`: // 早先只拦"降成家庭成员",于是把**最后一位管理员**降成访客能一路通过 —— // 降完家里一个管理员都不剩,谁也进不了系统设置了。 // 允许改自己的权限(包括给自己降级),只在"会一个管理员都不剩"时拦。 if ($role !== 'admin' && $acc['role'] === 'admin' && Account::adminCount() <= 1) { flash('err', '至少要留一位管理员 —— 把最后一位管理员降级,全家就没人能管理系统了。'); redirect('people/show', ['id' => $personId]); } Account::setRole((int)$acc['id'], $role); flash('ok', '权限已更新。'); } redirect('people/show', ['id' => $personId]); break; case 'accounts/status': Auth::requireAdmin(); csrf_check(); $personId = (int)($_POST['person_id'] ?? 0); $status = ($_POST['status'] ?? '') === 'active' ? 'active' : 'disabled'; $acc = Account::findByPerson($personId); if ($acc) { if ((int)$acc['person_id'] === (int)Auth::personId()) { flash('err', '不能停用自己。'); redirect('people/show', ['id' => $personId]); } if ($status === 'disabled' && $acc['role'] === 'admin' && Account::adminCount() <= 1) { flash('err', '至少要留一位管理员。'); redirect('people/show', ['id' => $personId]); } Account::setStatus((int)$acc['id'], $status); flash('ok', $status === 'active' ? '账号已启用。' : '账号已停用。'); } redirect('people/show', ['id' => $personId]); break; // ==================== 亲属关系 ==================== case 'kin/relate': Auth::requireWrite(); csrf_check(); $familyId = (int)Auth::familyId(); $from = (int)($_POST['from_id'] ?? 0); // 我(或当前正在编辑的人) $to = (int)($_POST['to_id'] ?? 0); // 对方 $kind = (string)($_POST['kind'] ?? ''); $role = (string)($_POST['role'] ?? ''); $ok = Person::find($from) && Person::find($to) && (int)Person::find($from)['family_id'] === $familyId && (int)Person::find($to)['family_id'] === $familyId; if (!$ok) { flash('err', '选的人不对。'); redirect('people'); } // ⚠️⚠️ 方向极易写反,而且写反了**不会报任何错**:页面照常显示「关系已记录」, // 只是把人记成了对方的父母 —— 家谱整个倒过来。 // 约定:表单里 from_id 是「当前打开的人」,也就是表单上写的那个「我」; // kind=parent 表示「对方是我的父母」→ 所以我是 child、对方是 parent。 // Relations::addParent($familyId, $childId, $parentId, $role) 的第二个参数是**孩子**。 // // ⚠️⚠️ 这里是**白名单**,不是 else 兜底。原先写成 `else { addSpouse(...) }`, // 于是任何没被识别的 kind(大小写错、拼错、以后新增的种类)都会被**静默记成配偶** // 且照样提示「关系已记录」——关系是错的,用户却以为成功了。 // 实测踩到:夹具传 kind=sibling(兄弟姐妹),库里落的全是 spouse, // 于是「夫妻并排」「父母括号」这些断言看着在测,其实测的是一堆错关系。 // 兄弟姐妹本就不需要单独记 —— 有同一对父母就会自动认出来(见 Kinship::siblingIds)。 $kindMap = ['parent', 'child', 'spouse']; if (!in_array($kind, $kindMap, true)) { flash('err', '不认识这种关系,没有记录。兄弟姐妹不用单独填 —— 有同一对父母就会自动认出来。'); redirect('people/show', ['id' => $from]); } if ($kind === 'parent') { // 对方是我的父母:我是孩子,对方是父母 $r = Relations::addParent($familyId, $from, $to, $role); } elseif ($kind === 'child') { // 对方是我的子女:对方是孩子,我是父母 $r = Relations::addParent($familyId, $to, $from, $role); } else { $r = Relations::addSpouse($familyId, $from, $to); } flash($r['ok'] ? 'ok' : 'err', $r['ok'] ? '关系已记录。' : $r['error']); redirect('people/show', ['id' => $from]); break; case 'kin/unrelate': Auth::requireWrite(); csrf_check(); $familyId = (int)Auth::familyId(); $id = (int)($_POST['id'] ?? 0); $who = (int)($_POST['person_id'] ?? 0); $kinRemoved = Relations::remove($familyId, $id); // v7:关系没了,因这条关系自动进两端名册的条目也收回 —— 把这件事说破, // 不然用户会以为名册里的人凭空消失了 flash('ok', $kinRemoved ? '这条关系已解除,两人名册里因此自动加入的对方也一并移出了。' : '这条关系已解除。'); redirect('people/show', ['id' => $who]); break; // ==================== 访客邀请 ==================== case 'accounts/invite': Auth::requireAdmin(); clear_old(); // ⚠️ 访客建出来是 kind='contact'(不进家谱),而 Person::all() 默认只取 'member' —— // 早先这里没传 kind,于是"已有访客"永远列不出人来(页面看着像是从没开通过)。 View::render('people/invite', [ 'title' => '邀请访客', 'people' => Person::all((int)Auth::familyId(), '', 'contact', (int)Auth::personId()), 'options' => Person::options((int)Auth::familyId()), ], 'people'); break; case 'accounts/invite_create': Auth::requireAdmin(); csrf_check(); $familyId = (int)Auth::familyId(); $name = trim((string)($_POST['name'] ?? '')); $phone = trim((string)($_POST['phone'] ?? '')); $pass = (string)($_POST['password'] ?? ''); $note = trim((string)($_POST['note'] ?? '')); $errors = []; if ($name === '') { $errors['name'] = '请填写对方的名字'; } if (!preg_match('/^\+?[0-9\-]{6,20}$/', $phone)) { $errors['phone'] = '手机号格式看起来不对'; } $pw = Account::validatePassword($pass); if ($pw) { $errors['password'] = implode(';', $pw); } if (!$errors) { try { // 访客也进名册(kind=contact,与家庭成员区分开),但不参与家谱 $pid = Person::create($familyId, [ 'name' => $name, 'gender' => '', 'avatar' => '', 'phone' => $phone, 'id_card' => '', 'email' => '', 'address' => '', 'note' => $note, 'slogan' => '', 'kind' => 'contact', ]); Account::create($pid, $pass, 'guest'); // 我邀的访客也进**我的**名册(名册是每人一份的;访客自己那份由 // Account::create 给他补上他自己) PersonRoster::ensure($familyId, (int)Auth::personId(), $pid, null, 'self'); clear_old(); flash('ok', '访客「' . $name . '」已开通,把手机号和密码告诉他就能登录。'); redirect('accounts/invite'); } catch (\PDOException $ex) { $errors['phone'] = '这个手机号已经有人用了'; } } set_old($_POST); View::render('people/invite', [ 'title' => '邀请访客', 'people' => Person::all($familyId, '', 'contact', (int)Auth::personId()), 'options' => Person::options($familyId), 'errors' => $errors, ], 'people'); break; // ==================== 设置 ==================== case 'settings': Auth::requireAdmin(); $dbVersion = '未知'; try { $dbVersion = Database::get()->query('SELECT VERSION()')->fetchColumn(); } catch (\Throwable $ex) { // 拿不到版本不影响页面 } $rawKey = (string)fb_config('app_key', ''); $acc = Account::roleCounts(); // 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET) $diag = $_SESSION['_oss_diag'] ?? null; unset($_SESSION['_oss_diag']); // 公共分组(owner_id = 0)—— 管理员在这里维护全家共用的那一套。 // 人数按"全家名册"统计(这里要看的是"这一组里全家有多少人",不是我个人那份)。 View::render('settings', [ 'title' => '设置', 'family' => Family::find((int)Auth::familyId()), 'dbVersion' => $dbVersion, // 用户第 3 条:首页天气的坐标(三个都空 = 不显示天气) 'weatherPlace' => (string)Setting::get((int)Auth::familyId(), 'weather_place', ''), 'weatherLat' => (string)Setting::get((int)Auth::familyId(), 'weather_lat', ''), 'weatherLon' => (string)Setting::get((int)Auth::familyId(), 'weather_lon', ''), 'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0, 'roleCounts' => $acc, 'relCount' => Relations::count((int)Auth::familyId()), 'pubGroups' => PersonGroup::forOwner((int)Auth::familyId(), PersonGroup::PUBLIC_OWNER), 'pubCounts' => PersonGroup::counts((int)Auth::familyId(), PersonGroup::PUBLIC_OWNER), 'upgradeWarn'=> (string)Setting::get((int)Auth::familyId(), 'schema_upgrade_warning', ''), 'storageDriver' => \App\Storage\Storage::driver((int)Auth::familyId()), 'storageOss' => \App\Storage\Storage::config((int)Auth::familyId()), 'storageOk' => \App\Storage\Storage::configComplete((int)Auth::familyId()), 'storageEffective' => \App\Storage\Storage::effectiveDriver((int)Auth::familyId()), 'ossDiag' => $diag, ], 'settings'); break; /** * 阿里云 OSS 连通性测试。 * * 与 settings/test_storage 的分工: * · test_storage 测的是**当前生效的驱动**(可能是本地),判"能不能存东西"; * · 这一个测的是**已填的 OSS 配置本身**,即使当前跑在本地也能测 —— * 这样就能"先把 OSS 测通、再切过去",而不是"先切、发现不行、再切回来"。 * 它会把每一步的结论摊开(配置/凭据/地域/公网/内网/读写), * 让"连不上"这件事有具体原因,而不是一句"写入失败"。 */ case 'settings/test_oss': Auth::requireAdmin(); csrf_check(); $familyId = (int)Auth::familyId(); $disk = \App\Storage\Storage::ossDiskForDiagnosis($familyId); if ($disk === null) { $_SESSION['_oss_diag'] = [ 'ok' => false, 'steps' => [[ 'name' => '配置完整性', 'ok' => false, 'ms' => 0, 'detail' => 'OSS 参数还没填全(至少要 Bucket、Endpoint 和一种凭据)。先在下面填好并保存,再测。', ]], 'at' => time(), ]; redirect('settings'); } $steps = $disk->diagnose(); $ok = true; foreach ($steps as $s) { if (empty($s['ok'])) { $ok = false; break; } } $_SESSION['_oss_diag'] = ['ok' => $ok, 'steps' => $steps, 'at' => time()]; redirect('settings'); break; case 'settings/storage': Auth::requireAdmin(); csrf_check(); $familyId = (int)Auth::familyId(); $old = \App\Storage\Storage::config($familyId); $in = (array)($_POST['oss'] ?? []); // 密钥留空 = 不改(避免「保存时没重填就把已存的密钥清掉」) foreach (['access_key_secret', 'access_key_id'] as $k) { if (trim((string)($in[$k] ?? '')) === '') { $in[$k] = (string)$old[$k]; } } $oss = [ 'auth_mode' => ($in['auth_mode'] ?? 'ak') === 'ecs_role' ? 'ecs_role' : 'ak', 'access_key_id' => trim((string)($in['access_key_id'] ?? '')), 'access_key_secret' => trim((string)($in['access_key_secret'] ?? '')), 'bucket' => trim((string)($in['bucket'] ?? '')), 'endpoint' => trim((string)($in['endpoint'] ?? '')), 'prefix' => trim((string)($in['prefix'] ?? '')), 'ram_role_name' => trim((string)($in['ram_role_name'] ?? '')), 'custom_domain' => trim((string)($in['custom_domain'] ?? '')), 'internal_endpoint' => !empty($in['internal_endpoint']) ? '1' : '0', 'https' => !empty($in['https']) ? '1' : '0', ]; \App\Storage\Storage::saveConfig($familyId, (string)($_POST['driver'] ?? 'local'), $oss); flash('ok', '附件存储设置已保存。'); redirect('settings'); break; case 'settings/test_storage': Auth::requireAdmin(); csrf_check(); $familyId = (int)Auth::familyId(); $disk = \App\Storage\Storage::disk($familyId); $err = $disk->validateConfig(); if ($err !== '') { flash('err', '配置不完整:' . $err); redirect('settings'); } // 真写一个探针对象再读回来,最后删掉 —— 比只 ping 一下可靠得多 $probeKey = 'avatars/.probe-' . bin2hex(random_bytes(6)) . '.txt'; $payload = 'familybook-probe-' . time(); $put = \App\Storage\Storage::putContents($familyId, $payload, $probeKey, 'text/plain'); if (empty($put['ok'])) { flash('err', '写入失败:' . (string)($put['error'] ?? '')); redirect('settings'); } $get = \App\Storage\Storage::read($familyId, $probeKey); $ok = !empty($get['ok']) && trim((string)$get['body']) === $payload; \App\Storage\Storage::delete($familyId, $probeKey); flash($ok ? 'ok' : 'err', $ok ? '存储可用:写入 → 读回 → 删除 三步都通过了(驱动:' . $disk->id() . ')' : '写入成功但读回来对不上,请检查桶权限或内网/外网 endpoint。'); redirect('settings'); break; case 'settings/update': Auth::requireAdmin(); csrf_check(); $fid = (int)Auth::familyId(); // ⚠️ 「家庭名称」已从设置里撤掉(见 settings 模板的说明): // 首页标题现在是「某某某的家书」,一个家庭级名字既没人看、也容易和它打架。 // 这里沿用已有值,不再接受表单输入 —— 免得"某次提交把名字清空"。 $cur = Family::find($fid); Family::update( $fid, (string)($cur['name'] ?? '家书'), trim((string)($_POST['motto'] ?? '')) ); // 用户第 3 条:天气坐标(家庭级)。只收合法数字,非法值一律当"没填", // 否则一个手滑的字母会让首页天气接口每次都白请求一次。 foreach ([ 'weather_place' => 'weather_place', 'weather_lat' => 'weather_lat', 'weather_lon' => 'weather_lon', ] as $formKey => $settingKey) { $v = trim((string)($_POST[$formKey] ?? '')); if ($settingKey !== 'weather_place') { $v = ($v !== '' && is_numeric($v)) ? $v : ''; } Setting::set($fid, $settingKey, mb_substr($v, 0, 60, 'UTF-8')); } flash('ok', '已保存。'); redirect('settings'); break; // ==================== 观影(v8) ==================== // // MovieLog 整体迁入后的新家。数据落在统一事件原语上: // events(骨架)+ movie_details(卫星)+ event_attendees + event_photos, // 见 App\Movie。可见性与名册同一口径:能登录就能看,能写才能改; // 迁进来的历史记录 source_ref = 'movielog:movie:'(重跑迁移的幂等锚点)。 /** 观影瀑布流(v9,MovieLog 同款海报墙;带关键词搜索)。 */ case 'movies': $familyId = (int)Auth::familyId(); $keyword = trim((string)($_GET['keyword'] ?? '')); $rows = Movie::all($familyId, $keyword, (int)Auth::personId()); View::render('movies/index', [ 'title' => '观影记录', 'rows' => $rows, 'total' => count($rows), 'keyword' => $keyword, ], 'movies'); break; case 'movies/show': $familyId = (int)Auth::familyId(); $meId = (int)Auth::personId(); $m = Movie::find($familyId, (int)($_GET['id'] ?? 0), $meId); if (!$m) { flash('err', '找不到这条观影记录。'); redirect('movies'); } $rvError = ''; View::render('movies/show', [ 'title' => (string)$m['title'], 'm' => $m, 'reviews' => MovieReview::forEvent((int)$m['id']), 'rvStats' => MovieReview::stats((int)$m['id']), 'myReview' => MovieReview::mine((int)$m['id'], $meId), 'meId' => $meId, 'rvError' => $rvError, 'feelCat' => MovieReview::CATALOG, ], 'movies'); break; case 'movies/new': Auth::requireWrite(); View::render('movies/form', [ 'title' => '记一场电影', 'm' => null, 'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''), 'cinemas' => Cinema::all((int)Auth::familyId()), 'errors' => [], ], 'movies'); break; case 'movies/edit': Auth::requireWrite(); $familyId = (int)Auth::familyId(); $m = Movie::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId()); if (!$m) { flash('err', '找不到这条观影记录。'); redirect('movies'); } View::render('movies/form', [ 'title' => '编辑观影记录', 'm' => $m, 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), 'cinemas' => Cinema::all($familyId), 'errors' => [], ], 'movies'); break; /** 收集表单里和观影有关的字段(create / update 共用一份)。 */ case 'movies/create': case 'movies/update': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $isEdit = $r === 'movies/update'; $id = $isEdit ? (int)($_POST['id'] ?? 0) : 0; $m = $isEdit ? Movie::find($familyId, $id) : null; if ($isEdit && !$m) { flash('err', '找不到这条观影记录。'); redirect('movies'); } // 起止时刻精确到秒(_datetime.php 控件 → read_dt_input) $startsAt = read_dt_input('starts_at'); $endsAt = read_dt_input('ends_at'); $cinemaId = (int)($_POST['cinema_id'] ?? 0); if ($cinemaId > 0 && !Cinema::find($familyId, $cinemaId)) { $cinemaId = 0; // 表单可改:只认本家的影院 } $d = [ 'title' => trim((string)($_POST['title'] ?? '')), 'starts_at' => (string)($startsAt ?? ''), 'ends_at' => (string)($endsAt ?? ''), 'location' => trim((string)($_POST['location'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')), 'info' => trim((string)($_POST['info'] ?? '')), 'cinema_id' => $cinemaId, // 同行的人:只认「确实存在且属于这个家」的 id —— 表单可以改, // 不能凭它把别人家的成员挂进自己家的观影记录里(会顺着详情页漏人名)。 'attendees' => array_map('intval', (array)($_POST['attendees'] ?? [])), ]; // 豆瓣元数据(v10):表单隐藏字段带 JSON,解码后只认白名单键、逐字段限长, // 防手搓请求往库里塞垃圾。整体一个数组交给模型存 douban_meta。 $doubanMeta = []; $metaRaw = trim((string)($_POST['douban_meta'] ?? '')); if ($metaRaw !== '') { $metaDecoded = json_decode($metaRaw, true); if (is_array($metaDecoded)) { foreach (['douban_id', 'douban_url', 'year', 'genre', 'runtime', 'director', 'actors', 'plot', 'rating', 'country', 'alias'] as $mk) { if (isset($metaDecoded[$mk]) && is_scalar($metaDecoded[$mk])) { $mv = trim((string)$metaDecoded[$mk]); if ($mv !== '') { $doubanMeta[$mk] = mb_substr($mv, 0, 2000, 'UTF-8'); } } } } } $d['douban_meta'] = $doubanMeta; $_familyPeople = Person::map($familyId); $d['attendees'] = array_values(array_filter($d['attendees'], static fn($pid) => isset($_familyPeople[$pid]))); $errors = []; if ($d['title'] === '') { $errors['title'] = '片名不能为空'; } elseif ($d['starts_at'] !== '' && $d['ends_at'] !== '' && strcmp($d['ends_at'], $d['starts_at']) < 0) { $errors['ends_at'] = '散场时间不能早于开场时间'; } // ⚠️ 图片上传失败**不挡保存**(与头像同一思路:一条记录因为一张 5MB 原图 // 存不进去,看起来像功能坏了)。现在是先存记录,图片的事单独提示。 // 海报 / 票根:free 槽位,不需要裁剪框;编辑时换新图就顺带删掉旧对象。 $warns = []; foreach (['poster', 'ticket'] as $slot) { $up = Upload::image($familyId, $_FILES[$slot] ?? [], $slot); if ($up['ok']) { $oldKey = $isEdit ? (string)($m[$slot === 'poster' ? 'poster_key' : 'ticket_key'] ?? '') : ''; $d[$slot === 'poster' ? 'poster_key' : 'ticket_key'] = (string)$up['file']; if ($oldKey !== '' && $oldKey !== (string)$up['file']) { \App\Storage\Storage::delete($familyId, $oldKey); } } elseif ((string)($up['error'] ?? '') !== '') { $warns[] = ($slot === 'poster' ? '海报' : '票根') . '没有保存:' . $up['error']; } elseif ($isEdit) { // 没传新图 → 保留库里的旧值 $d[$slot === 'poster' ? 'poster_key' : 'ticket_key'] = (string)($m[$slot === 'poster' ? 'poster_key' : 'ticket_key'] ?? ''); } } // 豆瓣抓取的海报(v10):前端抓取成功时把存储键放进了隐藏 poster_key。 // 用户**自己选的文件优先**($_FILES['poster'] 真有文件就不覆盖); // 键必须是 posters/ 下的本站存储键,编辑时换用新键就顺带删掉旧对象。 $dbPosterKey = trim((string)($_POST['poster_key'] ?? '')); $posterFileGiven = isset($_FILES['poster']['error']) && !is_array($_FILES['poster']['error']) && (int)$_FILES['poster']['error'] === UPLOAD_ERR_OK; if (!$posterFileGiven && $dbPosterKey !== '' && preg_match('#^posters/[A-Za-z0-9._-]+$#', $dbPosterKey)) { if (($d['poster_key'] ?? '') !== $dbPosterKey) { $oldPosterKey = $isEdit ? (string)($m['poster_key'] ?? '') : ''; $d['poster_key'] = $dbPosterKey; if ($oldPosterKey !== '' && $oldPosterKey !== $dbPosterKey) { \App\Storage\Storage::delete($familyId, $oldPosterKey); } } } // 现场照片(多选,仅新增表单提供;编辑页在详情页里逐张补/删) if (!$isEdit) { $photoFiles = []; if (!empty($_FILES['photo']) && is_array($_FILES['photo']['name'])) { foreach ($_FILES['photo']['name'] as $i => $_n) { $photoFiles[] = [ 'name' => $_FILES['photo']['name'][$i], 'type' => $_FILES['photo']['type'][$i], 'tmp_name' => $_FILES['photo']['tmp_name'][$i], 'error' => $_FILES['photo']['error'][$i], 'size' => $_FILES['photo']['size'][$i], ]; } } $photoKeys = []; foreach ($photoFiles as $f) { $up = Upload::image($familyId, $f, 'photo'); if ($up['ok']) { $photoKeys[] = (string)$up['file']; } elseif ((string)($up['error'] ?? '') !== '') { $warns[] = '有一张照片没有保存:' . $up['error']; } } $d['photos'] = $photoKeys; } if ($errors) { set_old($_POST); View::render('movies/form', [ 'title' => $isEdit ? '编辑观影记录' : '记一场电影', 'm' => $m, 'people' => Person::roster($familyId, $me, 'member', ''), 'cinemas' => Cinema::all($familyId), 'errors' => $errors, ], 'movies'); exit; } if ($isEdit) { Movie::update($familyId, $id, $d); clear_old(); flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('movies/show', ['id' => $id]); } else { $newId = Movie::create($familyId, $me, $d); clear_old(); flash('ok', '已记下这场电影。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('movies/show', ['id' => $newId]); } break; /** 编辑页补传一张现场照片(详情页里的小表单,一次一张)。 */ case 'movies/photo_add': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); if (!Movie::find($familyId, $id)) { flash('err', '找不到这条观影记录。'); redirect('movies'); } $up = Upload::image($familyId, $_FILES['photo'] ?? [], 'photo'); if ($up['ok']) { // savePhotos 是整组替换 —— 现有键取出来再追加一张,位置接着排 $keys = array_map('strval', array_column(Movie::photos($id), 'key')); $keys[] = (string)$up['file']; Movie::savePhotos($id, $keys); flash('ok', '照片已加上。'); } else { flash('err', '照片没有保存:' . (string)($up['error'] ?: '没选文件')); } redirect('movies/show', ['id' => $id]); break; case 'movies/photo_del': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $pid = (int)($_POST['photo_id'] ?? 0); if (!Movie::find($familyId, $id)) { flash('err', '找不到这条观影记录。'); redirect('movies'); } Movie::deletePhoto($familyId, $id, $pid); flash('ok', '照片已删除。'); redirect('movies/show', ['id' => $id]); break; case 'movies/destroy': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); if (!Movie::find($familyId, $id)) { flash('err', '找不到这条观影记录。'); redirect('movies'); } Movie::destroy($familyId, $id); flash('ok', '这条观影记录已删除。'); redirect('movies'); break; // ---------------- 豆瓣自动补充(v10,MovieLog fetch_movie 同源迁移) ---------------- /** 片名联想 / 缩略图代理 / 抓取详情+海报入库。全部回 JSON(thumb 回图片字节)。 */ case 'movies/douban': $familyId = (int)Auth::familyId(); $act = (string)($_GET['action'] ?? ''); if (!Auth::check()) { http_response_code(401); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['error' => '未登录或登录已过期,请重新登录后再试'], JSON_UNESCAPED_UNICODE); break; } if ($act === 'suggest') { header('Content-Type: application/json; charset=utf-8'); $res = Douban::suggest((string)($_GET['q'] ?? '')); echo json_encode($res, JSON_UNESCAPED_UNICODE); break; } if ($act === 'thumb') { // 浏览器不能直连豆瓣图片(防盗链),由服务端代取;只读、不落盘 $img = Douban::thumb((string)($_GET['u'] ?? '')); if ($img === null) { http_response_code(404); break; } header('Content-Type: ' . $img['mime']); header('Content-Length: ' . strlen($img['body'])); header('Cache-Control: public, max-age=86400'); echo $img['body']; break; } if ($act !== '') { http_response_code(400); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['error' => '不支持的 action'], JSON_UNESCAPED_UNICODE); break; } // 默认:抓取详情 + 海报入库(写存储,访客不行)。JSON 错误而非 302, // 前端才能在会话过期/无权限时给出明确提示。 header('Content-Type: application/json; charset=utf-8'); if (!Auth::canWrite()) { http_response_code(403); echo json_encode(['error' => '访客账号没有写权限'], JSON_UNESCAPED_UNICODE); break; } $idDb = trim((string)($_GET['id'] ?? '')); $qDb = trim((string)($_GET['q'] ?? '')); if ($idDb !== '') { $resDb = Douban::fetchById($idDb); } elseif ($qDb !== '') { $resDb = Douban::fetchByTitle($qDb); } else { $resDb = ['ok' => false, 'error' => '请输入电影名称']; } if (!$resDb['ok']) { echo json_encode(['error' => $resDb['error']], JSON_UNESCAPED_UNICODE); break; } // 海报:下载字节 → poster 槽位管线(裁 2:3、缩放、转 JPEG、Storage 统一出口) $posterKey = ''; $posterShow = ''; if (($resDb['poster_url'] ?? '') !== '') { $bytes = Douban::downloadPoster((string)$resDb['poster_url']); if ($bytes !== '') { $tmp = tempnam(sys_get_temp_dir(), 'fbdb'); if ($tmp !== false) { file_put_contents($tmp, $bytes); $up = Upload::image($familyId, ['error' => UPLOAD_ERR_OK, 'tmp_name' => $tmp], 'poster'); @unlink($tmp); if ($up['ok']) { $posterKey = (string)$up['file']; $posterShow = \App\Storage\Storage::url($familyId, $posterKey); } } } } // 清掉上一次抓取但没被采用的海报(每次「自动获取」都下一张,不清就堆孤儿) $oldPoster = trim((string)($_GET['old_poster'] ?? '')); if ($oldPoster !== '' && $oldPoster !== $posterKey && preg_match('#^posters/[A-Za-z0-9._-]+$#', $oldPoster)) { $stRef = Database::get()->prepare('SELECT COUNT(*) FROM movie_details WHERE poster_key = ?'); $stRef->execute([$oldPoster]); if ((int)$stRef->fetchColumn() === 0) { \App\Storage\Storage::delete($familyId, $oldPoster); } } echo json_encode([ 'ok' => true, 'source' => 'douban', 'title' => (string)($resDb['title'] ?? ''), 'meta' => $resDb['meta'], 'poster_key' => $posterKey, 'poster' => $posterShow, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); break; // ---------------- 影院管理(v9,MovieLog 同款) ---------------- /** 影院管理单页:上面加/编表单,下面列表。GET 带参数时进入编辑态。 */ case 'movies/cinemas': $familyId = (int)Auth::familyId(); $editId = (int)($_GET['edit'] ?? 0); $editing = $editId > 0 ? Cinema::find($familyId, $editId) : null; View::render('movies/cinemas', [ 'title' => '影院管理', 'cinemas' => Cinema::all($familyId), 'editing' => $editing, ], 'movies'); break; case 'movies/cinemas_save': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $editing = $id > 0 ? Cinema::find($familyId, $id) : null; if ($id > 0 && !$editing) { flash('err', '找不到这家影院。'); redirect('movies/cinemas'); } $err = Cinema::save( $familyId, $id, trim((string)($_POST['place'] ?? '')), trim((string)($_POST['name'] ?? '')), trim((string)($_POST['address'] ?? '')) !== '' ? trim((string)($_POST['address'] ?? '')) : null, Cinema::normalizeColor((string)($_POST['color'] ?? '#888888')) ); if ($err !== '') { flash('err', $err); redirect('movies/cinemas', $id > 0 ? ['edit' => $id] : []); } flash('ok', $id > 0 ? '影院信息已更新。' : '影院已添加。'); redirect('movies/cinemas'); break; case 'movies/cinemas_del': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); if (Cinema::find($familyId, $id)) { Cinema::destroy($familyId, $id); flash('ok', '影院已删除,关联观影记录的影院信息已置空。'); } else { flash('err', '找不到这家影院。'); } redirect('movies/cinemas'); break; // ---------------- 统计 / 高级搜索 / 年度报告(v11,从 MovieLog 迁入) ---------------- // 三个页面都挂在观影模块「设置」按钮下(顶栏不再单独放影院/统计入口)。 // 只读,不需要 requireWrite —— 访客也能看统计,与观影列表同一口径。 /** 统计仪表板 */ case 'movies/stats': $familyId = (int)Auth::familyId(); View::render('movies/stats', [ 'title' => '观影统计', 'ov' => MovieStats::overview($familyId), 'byCinema' => MovieStats::byCinema($familyId), 'byPerson' => MovieStats::byPerson($familyId, (int)Auth::personId()), 'byMonth' => MovieStats::byMonth($familyId, 12), 'byYear' => MovieStats::byYear($familyId), ], 'movies'); break; /** 高级搜索(组合条件;条件全空时不查,页面提示先设置条件) */ case 'movies/search': $familyId = (int)Auth::familyId(); $p = [ 'title' => trim((string)($_GET['title'] ?? '')), 'cinema_id' => trim((string)($_GET['cinema_id'] ?? '')), 'person_id' => trim((string)($_GET['person_id'] ?? '')), 'date_from' => trim((string)($_GET['date_from'] ?? '')), 'date_to' => trim((string)($_GET['date_to'] ?? '')), ]; $hasCond = false; foreach ($p as $v) { if ($v !== '' && $v !== '0') { $hasCond = true; break; } } View::render('movies/search', [ 'title' => '高级搜索', 'cinemas' => Cinema::all($familyId), 'persons' => Person::all($familyId, '', 'member', (int)Auth::personId()), 'p' => $p, 'hasCond' => $hasCond, 'rows' => $hasCond ? MovieStats::search($familyId, $p, (int)Auth::personId()) : [], ], 'movies'); break; /** 年度报告(以登录者为中心) */ case 'movies/report': $familyId = (int)Auth::familyId(); $meId = (int)Auth::personId(); $years = MovieStats::years($familyId); $year = (int)($_GET['year'] ?? 0); if ($year <= 0 || !in_array($year, $years, true)) { $year = $years ? (int)$years[0] : (int)date('Y'); } $people = Person::map($familyId); View::render('movies/report', [ 'title' => $year . ' 年度观影报告', 'year' => $year, 'years' => $years ?: [$year], 'stats' => MovieStats::annual($familyId, $meId, $year), 'meName' => (string)($people[$meId]['name'] ?? '我'), ], 'movies'); break; // ---------------- 评价与讨论(v9,MovieLog 同款) ---------------- // 评价人是「登录账号背后的人」,与同行观影人无关;访客只读(requireWrite)。 case 'movies/review_save': $familyId = (int)Auth::familyId(); $meId = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $eventId = (int)($_POST['id'] ?? 0); $m = Movie::find($familyId, $eventId); if (!$m) { flash('err', '找不到这条观影记录。'); redirect('movies'); } $my = MovieReview::mine($eventId, $meId); $err = MovieReview::save( $eventId, $familyId, $meId, (int)($_POST['rating'] ?? 0), trim((string)($_POST['content'] ?? '')), array_values((array)($_POST['feelings'] ?? [])), $my !== null ); if ($err !== '') { flash('err', $err); } else { flash('ok', $my !== null ? '评价已更新。' : '评价已发布。'); } redirect('movies/show', ['id' => $eventId], 'reviews'); break; case 'movies/review_del': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $eventId = (int)($_POST['id'] ?? 0); if (!Movie::find($familyId, $eventId)) { flash('err', '找不到这条观影记录。'); redirect('movies'); } if (!MovieReview::deleteMine((int)($_POST['review_id'] ?? 0), (int)Auth::personId())) { flash('err', '只能删除自己的评价。'); } else { flash('ok', '评价已删除。'); } redirect('movies/show', ['id' => $eventId], 'reviews'); break; case 'movies/comment_add': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $eventId = (int)($_POST['id'] ?? 0); $reviewId = (int)($_POST['review_id'] ?? 0); $parentId = (int)($_POST['parent_id'] ?? 0); if (!Movie::find($familyId, $eventId)) { flash('err', '找不到这条观影记录。'); redirect('movies'); } $res = MovieReview::addComment( $reviewId, (int)Auth::personId(), trim((string)($_POST['content'] ?? '')), array_values((array)($_POST['feelings'] ?? [])), $parentId ); if (is_string($res)) { flash('err', $res); } else { flash('ok', $parentId > 0 ? '回复已发表。' : '评论已发表。'); } redirect('movies/show', ['id' => $eventId], 'reviews'); break; case 'movies/comment_del': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $eventId = (int)($_POST['id'] ?? 0); if (!Movie::find($familyId, $eventId)) { flash('err', '找不到这条观影记录。'); redirect('movies'); } if (!MovieReview::deleteComment((int)($_POST['comment_id'] ?? 0), (int)Auth::personId())) { flash('err', '只能删除自己的评论。'); } else { flash('ok', '评论已删除。'); } redirect('movies/show', ['id' => $eventId], 'reviews'); break; // ==================== 旅程(v12,TripLog 迁入) ==================== // 与观影共用 events 骨架(kind='trip'):一次出行 = 一个有起止、有人、会上日历的事件。 // 旅程特有的(封面 / 小结)放 trip_details,行程段落放 trip_nodes(计划 / 执行双轨)。 case 'trips': $familyId = (int)Auth::familyId(); $keyword = trim((string)($_GET['keyword'] ?? '')); $rows = Trip::all($familyId, $keyword); Trip::attachAttendees($rows, (int)Auth::personId()); View::render('trips/index', [ 'title' => '旅程', 'rows' => $rows, 'total' => count($rows), 'keyword' => $keyword, ], 'trips'); break; case 'trips/new': Auth::requireWrite(); View::render('trips/form', [ 'title' => '记一趟旅程', 't' => null, // 新建时默认勾上「我自己」—— 一趟旅程里几乎总有我,少点一下 'presetAttendees' => [(int)Auth::personId()], 'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''), 'errors' => [], ], 'trips'); break; case 'trips/edit': Auth::requireWrite(); $familyId = (int)Auth::familyId(); $t = Trip::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId()); if (!$t) { flash('err', '找不到这趟旅程。'); redirect('trips'); } View::render('trips/form', [ 'title' => '编辑旅程', 't' => $t, 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), 'errors' => [], ], 'trips'); break; case 'trips/create': case 'trips/update': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $isEdit = $r === 'trips/update'; $id = $isEdit ? (int)($_POST['id'] ?? 0) : 0; $t = $isEdit ? Trip::find($familyId, $id) : null; if ($isEdit && !$t) { flash('err', '找不到这趟旅程。'); redirect('trips'); } $startsAt = read_dt_input('starts_at'); $endsAt = read_dt_input('ends_at'); $d = [ 'title' => trim((string)($_POST['title'] ?? '')), 'starts_at' => (string)($startsAt ?? ''), 'ends_at' => (string)($endsAt ?? ''), 'location' => trim((string)($_POST['location'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')), 'summary' => trim((string)($_POST['summary'] ?? '')), // 用户 2026-10-03 第 4 条:旅程是否公开(只影响列表与日历,不影响能否直接打开) // 用户第 4 条:是否公开。 // ⚠️ 三种情况必须分清(少了任何一种都会错): // ① 表单**没这个字段**(程序化调用 / 老表单 / 迁移)→ 连键都不放, // 让 Trip::saveDetails 按"默认公开"处理; // ② 表单提交了 hidden=0(用户取消勾选)→ 明确的 0; // ③ 勾选 → '1'。 // 之前写成 `?? ''` 会把 ① 也变成"键存在且为空" → 被当成不公开, // 于是数据在列表里**静默消失**(实测:新建的旅程从列表与日历上都没了)。 'attendees' => filter_family_person_ids((array)($_POST['attendees'] ?? []), $familyId), ]; if (array_key_exists('is_public', $_POST)) { $d['is_public'] = (string)$_POST['is_public']; } $errors = []; if ($d['title'] === '') { $errors['title'] = '旅程名字不能为空'; } elseif ($d['starts_at'] !== '' && $d['ends_at'] !== '' && strcmp($d['ends_at'], $d['starts_at']) < 0) { $errors['ends_at'] = '回来的时间不能早于出发'; } // 封面图(编辑时换新图才动它;失败不挡保存,与观影海报同一个口径) $oldCover = $isEdit ? (string)($t['cover_key'] ?? '') : ''; $d['cover_key'] = $oldCover; $warns = []; $up = Upload::image($familyId, $_FILES['cover'] ?? [], 'photo'); if ($up['ok']) { $d['cover_key'] = (string)$up['file']; if ($oldCover !== '' && $oldCover !== $d['cover_key']) { \App\Storage\Storage::delete($familyId, $oldCover); } } elseif ((string)($up['error'] ?? '') !== '') { $warns[] = '封面图没有保存:' . $up['error']; } if ($errors) { set_old($_POST); View::render('trips/form', [ 'title' => $isEdit ? '编辑旅程' : '记一趟旅程', 't' => $isEdit ? array_merge($t, $d) : null, 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), 'errors' => $errors, ], 'trips'); exit; } if ($isEdit) { Trip::update($familyId, $id, $d); clear_old(); flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('trips/show', ['id' => $id]); } $newId = Trip::create($familyId, (int)Auth::personId(), $d); clear_old(); flash('ok', '这趟旅程记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('trips/show', ['id' => $newId]); break; case 'trips/show': $familyId = (int)Auth::familyId(); $meId = (int)Auth::personId(); $t = Trip::find($familyId, (int)($_GET['id'] ?? 0), $meId); if (!$t) { flash('err', '找不到这趟旅程。'); redirect('trips'); } View::render('trips/show', [ 'title' => (string)$t['title'], 't' => $t, 'photos' => $t['photos'], 'nodes' => Trip::nodes((int)$t['id'], $meId), 'people' => Person::roster($familyId, $meId, 'member', ''), ], 'trips'); break; case 'trips/destroy': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); if (!Trip::find($familyId, $id)) { flash('err', '找不到这趟旅程。'); redirect('trips'); } Trip::destroy($familyId, $id); flash('ok', '这趟旅程已删除。'); redirect('trips'); break; case 'trips/photo_add': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); if (!Trip::find($familyId, $id)) { flash('err', '找不到这趟旅程。'); redirect('trips'); } $warns = attach_save_files($familyId, 'trip', $id, 'photo', (int)Auth::personId()); if ($warns) { flash('err', '有照片没有保存:' . implode(';', $warns)); } else { flash('ok', '照片已加上。'); } redirect('trips/show', ['id' => $id]); break; case 'trips/photo_del': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); if (!Trip::find($familyId, $id)) { flash('err', '找不到这趟旅程。'); redirect('trips'); } \App\Attachment::remove((int)($_POST['photo_id'] ?? 0)); flash('ok', '照片已删除。'); redirect('trips/show', ['id' => $id]); break; // ---- 行程节点(计划 / 执行双轨)---- case 'trips/node_add': case 'trips/node_update': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $tripId = (int)($_POST['trip_id'] ?? 0); $t = Trip::find($familyId, $tripId); if (!$t) { flash('err', '找不到这趟旅程。'); redirect('trips'); } $isEdit = $r === 'trips/node_update'; $nid = $isEdit ? (int)($_POST['id'] ?? 0) : 0; $d = [ 'title' => trim((string)($_POST['title'] ?? '')), 'detail' => trim((string)($_POST['detail'] ?? '')), 'place' => trim((string)($_POST['place'] ?? '')), 'starts_at' => (string)(read_dt_input('starts_at') ?? ''), 'ends_at' => (string)(read_dt_input('ends_at') ?? ''), 'node_type' => (string)($_POST['node_type'] ?? 'other'), 'status' => (string)($_POST['status'] ?? 'planned'), 'category' => (string)($_POST['category'] ?? 'plan'), 'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId), ]; if ($d['title'] === '') { flash('err', '这一段得有个名字。'); redirect('trips/show', ['id' => $tripId]); } $newId = Trip::saveNode($familyId, $tripId, $d, $isEdit ? $nid : null); $warns = attach_save_files($familyId, 'trip_node', $newId, 'attach', (int)Auth::personId()); flash('ok', $isEdit ? '这一段已保存。' : '已加一段。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('trips/show', ['id' => $tripId]); break; case 'trips/node_del': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $tripId = (int)($_POST['trip_id'] ?? 0); if (!Trip::find($familyId, $tripId)) { flash('err', '找不到这趟旅程。'); redirect('trips'); } Trip::deleteNode((int)($_POST['id'] ?? 0)); flash('ok', '这一段已删除。'); redirect('trips/show', ['id' => $tripId]); break; // ==================== 日程 / 日历(v12) ==================== // 自定义日程落在 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(); // 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); 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, '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(); $all = Calendar::mine($familyId, $me); $mine = []; $shared = []; foreach ($all as $c) { if ((int)$c['shared'] === 1) { $shared[] = $c; } else { $mine[] = $c; } } $shares = []; 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) { foreach ($mine as $c) { if ((int)$c['id'] === $editId && (string)$c['kind'] === 'custom') { $editing = $c; } } } View::render('calendar/calendars', [ 'title' => '日历本', 'cals' => $mine, 'shared' => $shared, 'shares' => $shares, 'editing' => $editing, 'people' => Person::roster($familyId, $me, 'member', ''), 'cdHas' => $cdHas, 'cdPlain' => $cdPlain, ], 'calendar'); break; case 'calendar/cal_add': case 'calendar/cal_update': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $name = trim((string)($_POST['name'] ?? '')); $color = trim((string)($_POST['color'] ?? '')); if ($name === '') { flash('err', '日历本得有个名字。'); redirect('calendar/calendars'); } if ($r === 'calendar/cal_update') { $id = (int)($_POST['id'] ?? 0); // 只改自己的自定义本:预置的观影/旅行本不能改名换色(内容是从模块派生的) $cal = Calendar::find($id); if (!$cal || (int)$cal['owner_id'] !== $me || (string)$cal['kind'] !== 'custom') { flash('err', '这本日历不能改。'); redirect('calendar/calendars'); } Calendar::update($id, $name, $color); flash('ok', '日历本已更新。'); } else { Calendar::create($familyId, $me, $name, $color); flash('ok', '日历本已新建。'); } redirect('calendar/calendars'); break; case 'calendar/cal_del': $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $cal = Calendar::find($id); if (!$cal || (int)$cal['owner_id'] !== $me) { flash('err', '这本日历不能删。'); redirect('calendar/calendars'); } Calendar::delete($id); // 内部只删 kind='custom' flash('ok', '日历本已删除。'); redirect('calendar/calendars'); break; case 'calendar/cal_share': $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $cal = Calendar::find($id); $target = (int)($_POST['person_id'] ?? 0); if (!$cal || (int)$cal['owner_id'] !== $me || $target <= 0 || $target === $me) { flash('err', '不能这样共享。'); redirect('calendar/calendars'); } Calendar::setShare($id, $target, (string)($_POST['perm'] ?? 'read')); flash('ok', '已共享。'); redirect('calendar/calendars'); break; case 'calendar/cal_unshare': $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $cal = Calendar::find($id); if (!$cal || (int)$cal['owner_id'] !== $me) { flash('err', '不能这样取消共享。'); redirect('calendar/calendars'); } Calendar::dropShare($id, (int)($_POST['person_id'] ?? 0)); flash('ok', '已取消共享。'); redirect('calendar/calendars'); break; case 'calendar/event_add': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $ym = trim((string)($_POST['ym'] ?? '')); $ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m'); $calId = (int)($_POST['calendar_id'] ?? 0); // ⚠️ 走 mineOne 而不是 Calendar::find():要带 perm,共享本的可读写才判得对 $cal = Calendar::mineOne($familyId, $me, $calId); if (!$cal || !Calendar::canWrite($cal, $me)) { flash('err', '这本日历你没有写权限。'); redirect('calendar', ['ym' => $ym]); } // 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]); } $endsAt = read_dt_input('ends_at'); $title = trim((string)($_POST['title'] ?? '')); if ($title === '') { flash('err', '日程得有个标题。'); redirect('calendar', ['ym' => $ym]); } $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; } $note = trim((string)($_POST['note'] ?? '')); // ⚠️ 观影 / 旅行两本是派生的:往这两本里加日程 = 建一条真正的观影 / 旅程记录, // 而不是往 calendar_events 里塞一行(那样日历里有、观影列表里没有)。 $kind = (string)$cal['kind']; if ($kind === 'movie') { $newId = Movie::create($familyId, $me, [ 'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note, 'attendees' => [$me], ]); flash('ok', '已加进观影,日历里也能看到了。'); redirect('movies/show', ['id' => $newId]); } if ($kind === 'trip') { $newId = Trip::create($familyId, $me, [ 'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note, 'attendees' => [$me], ]); flash('ok', '已加进旅程,日历里也能看到了。'); redirect('trips/show', ['id' => $newId]); } $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']), // 用户 2026-10-03 第 5、6 条:重复规则(RFC 5545 RRULE 原文) 'rrule' => Repeat::build($_POST), '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($startsAt, 0, 7)]); break; case 'calendar/event': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $id = (int)($_GET['id'] ?? 0); $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 = ? AND c.family_id = ?' ); $st->execute([$id, $familyId]); $ev = $st->fetch(); if (!$ev) { flash('err', '找不到这条日程。'); redirect('calendar'); } $ev['_can_write'] = Calendar::canWriteEvent($ev, $me); // 用户第 5 条:从日历点进来时会带 ?d=(那一次是哪天), // 编辑重复日程时用它定位"仅此一次 / 这一次及以后"。 $occ = trim((string)($_GET['d'] ?? '')); $occ = preg_match('/^\d{4}-\d{2}-\d{2}$/', $occ) ? $occ : ''; // 编辑页要回填"哪些天被跳过了"(跳过的那次不再出现在日历里) $ev['skips'] = Calendar::skipsOf((int)$ev['id']); View::render('calendar/event', [ 'title' => (string)$ev['title'], 'ev' => $ev, 'cal' => $cal, 'occ' => $occ, // ⚠️ 不要再传 'ym':模板自己从 $ev['starts_at'] 算(模板里那行更权威, // 传进来反而会在"改过日期后"与模板算出的值不一致)。 ], 'calendar'); break; case 'calendar/event_update': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $ym = trim((string)($_POST['ym'] ?? '')); $ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m'); // ⚠️ 这里要把**整行**取回来(含 rrule / starts_at / 各字段): // 改重复日程时,"仅此一次 / 这一次及以后"都要拿旧内容来裁剪或复制, // 只取 id 的话还得再查一遍(而且容易漏字段)。 $st = Database::get()->prepare( 'SELECT e.*, c.owner_id FROM calendar_events e JOIN calendars c ON c.id = e.calendar_id WHERE e.id = ? AND c.family_id = ?' ); $st->execute([$id, $familyId]); $ev = $st->fetch(); $ev2 = $ev; // 旧内容(scope 分支要用,别被后面的 $d 覆盖) if (!$ev) { flash('err', '找不到这条日程。'); redirect('calendar', ['ym' => $ym]); } $cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: []; if (!Calendar::canWrite($cal, $me)) { flash('err', '这本日历你没有写权限。'); redirect('calendar', ['ym' => $ym]); } // v24 第 5、6 条:起止各是一个完整时刻(精确到秒),见上面 event_add 的说明。 $startsAt = read_dt_input('starts_at'); if ($startsAt === null) { flash('err', '开始时间不对。'); redirect('calendar', ['ym' => $ym]); } $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; } $d = [ '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']), 'rrule' => Repeat::build($_POST), 'note' => trim((string)($_POST['note'] ?? '')), ]; /* ---- 用户 2026-10-03 第 5 条:改一条**重复**日程时,先问"改哪些" ---- scope 三档: all = 整条系列都按新内容改(直接 update 本行,rrule 也跟着改) future = 这一次**以及以后**的:把原系列截止到"这一次"的前一天, 再新建一条从这一天开始的系列 —— 这样过去的历史原样保留, 以后的全部采用新内容(与 iOS/群晖日历的"此事件及后续"一致) once = 仅这一次:原系列里跳掉这一天,另建一条只发生一次(detachOnce) ⚠️ 不重复的日程直接按 all 处理(没有"系列"可言)。 */ $scope = (string)($_POST['scope'] ?? 'all'); $occDay = substr(trim((string)($_POST['occ'] ?? '')), 0, 10); $rruleOld = (string)($ev2['rrule'] ?? ''); if (in_array($scope, ['once', 'future'], true) && $rruleOld !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $occDay)) { if ($scope === 'once') { // 仅这一次:原系列跳掉那天 + 落一条独立单次 Calendar::detachOnce( ['id' => $id, 'calendar_id' => (int)$ev['calendar_id']], $occDay, $d, $me ); flash('ok', '只改了这一次。'); } else { // 这一次及以后:老系列截到前一天,新系列从这天起 $rp = Repeat::parse($rruleOld); $cut = date('Y-m-d', strtotime($occDay . ' -1 day')); $rp['until'] = $cut; $rp['count'] = 0; // 老系列:只保留到 $cut,并**移除**原来的 days 定义之外的东西 Calendar::updateEvent($id, [ 'title' => (string)($ev2['title'] ?? ''), 'location' => (string)($ev2['location'] ?? ''), 'starts_at' => (string)$ev2['starts_at'], 'ends_at' => $ev2['ends_at'] ?: null, 'all_day' => (int)($ev2['all_day'] ?? 0) === 1, 'reminder_minutes' => $ev2['reminder_minutes'] ?? '', 'status' => (string)($ev2['status'] ?? 'confirmed'), 'transp' => (int)($ev2['transp'] ?? 0) === 1, 'rrule' => 'FREQ=' . strtoupper((string)$rp['freq']) . ($rp['freq'] === 'weekday' ? ';BYDAY=MO,TU,WE,TH,FR' : '') . ';UNTIL=' . str_replace('-', '', $cut) . 'T235959Z', 'note' => (string)($ev2['note'] ?? ''), ]); // 新系列:从 $occDay 起,用用户改后的内容 $d2 = $d; $offsetDays = (int)floor((strtotime(substr((string)$d['starts_at'], 0, 10)) - strtotime(substr((string)$ev2['starts_at'], 0, 10))) / 86400); $d2['starts_at'] = $occDay . ' ' . substr((string)$d['starts_at'], 11, 8); $d2['ends_at'] = $d['ends_at'] !== null ? $occDay . ' ' . substr((string)$d['ends_at'], 11, 8) : null; unset($offsetDays); $d2['created_by'] = $me; Calendar::addEvent((int)$ev['calendar_id'], $d2); flash('ok', '这一次及以后的都改好了(过去的保持不变)。'); } redirect('calendar', ['ym' => substr($startsAt, 0, 7)]); } // all(或本来就不重复):整条按新内容改 Calendar::updateEvent($id, $d); $warns = attach_save_files($familyId, Calendar::ATTACH, $id, 'attach', $me); if ($warns) { flash('err', '有附件没有保存:' . implode(';', $warns)); } flash('ok', '日程已保存。'); redirect('calendar', ['ym' => substr($startsAt, 0, 7)]); break; case 'calendar/event_del': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $ym = trim((string)($_POST['ym'] ?? '')); $ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m'); $st = Database::get()->prepare( 'SELECT e.id, e.calendar_id FROM calendar_events e JOIN calendars c ON c.id = e.calendar_id WHERE e.id = ? AND c.family_id = ?' ); $st->execute([$id, $familyId]); $ev = $st->fetch(); if ($ev) { $cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: []; if (Calendar::canWrite($cal, $me)) { Calendar::deleteEvent($id); flash('ok', '日程已删除。'); } else { flash('err', '这本日历你没有写权限。'); } } 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) ==================== // 挂在**某个人名下**的东西(证件 / 照片 / 账号),可共享给别人(只读或读写)。 case 'resources': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $keyword = trim((string)($_GET['keyword'] ?? '')); $ownerId = (int)($_GET['owner'] ?? 0); $rows = Resource::all($familyId, $me, $ownerId, $keyword); // 附件与共享一次取齐(列表里每条都要显示"几个附件""共享给谁") $attachs = []; $shares = []; foreach ($rows as $r) { $attachs[(int)$r['id']] = \App\Attachment::list(Resource::ATTACH, (int)$r['id']); $shares[(int)$r['id']] = Resource::shares((int)$r['id']); } $editing = null; $editId = (int)($_GET['edit'] ?? 0); if ($editId > 0) { $editing = Resource::find($familyId, $editId, $me); if ($editing !== null && !Resource::canWrite($editing, $me)) { $editing = null; // 只读的不给编辑(表单照旧收起) } } View::render('resources/index', [ 'title' => '资源', 'rows' => $rows, 'attachs' => $attachs, 'shares' => $shares, 'editing' => $editing, 'keyword' => $keyword, 'ownerId' => $ownerId, 'people' => Person::roster($familyId, $me, 'member', ''), ], 'resources'); break; case 'resources/create': case 'resources/update': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $isEdit = $r === 'resources/update'; $id = $isEdit ? (int)($_POST['id'] ?? 0) : 0; $d = [ 'title' => trim((string)($_POST['title'] ?? '')), 'content' => trim((string)($_POST['content'] ?? '')), ]; if ($d['title'] === '') { flash('err', '资源得有个标题。'); redirect('resources'); } $ownerId = (int)($_POST['owner_id'] ?? 0); $map = Person::map($familyId); if (!isset($map[$ownerId])) { $ownerId = $me; // 表单可以改,但不能指到不存在的人身上 } if ($isEdit) { $old = Resource::find($familyId, $id, $me); if (!$old || !Resource::canWrite($old, $me)) { flash('err', '这条资源不能改。'); redirect('resources'); } Resource::update($familyId, $id, $d); // 允许改归属人(记错了挂在谁名下) if ((int)$old['owner_id'] !== $ownerId) { Database::get()->prepare('UPDATE resources SET owner_id = ? WHERE id = ? AND family_id = ?') ->execute([$ownerId, $id, $familyId]); } $rid = $id; flash('ok', '资源已保存。'); } else { $rid = Resource::create($familyId, $ownerId, $me, $d); flash('ok', '资源已新建。'); } $warns = attach_save_files($familyId, Resource::ATTACH, $rid, 'attach', $me); if ($warns) { flash('err', '有附件没有保存:' . implode(';', $warns)); } redirect('resources'); break; case 'resources/attach_add': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $r0 = Resource::find($familyId, $id, $me); if (!$r0 || !Resource::canWrite($r0, $me)) { flash('err', '这条资源不能改。'); redirect('resources'); } $warns = attach_save_files($familyId, Resource::ATTACH, $id, 'attach', $me); flash($warns ? 'err' : 'ok', $warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。'); redirect('resources'); break; case 'resources/attach_del': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $aid = (int)($_POST['id'] ?? 0); // ⚠️ 要按"这条附件属于哪条资源"找到宿主再判权限:只传附件 id 的话, // 谁都能构造一个请求删掉别人家的附件。 $st = Database::get()->prepare('SELECT target_id FROM attachments WHERE id = ? AND target_type = ?'); $st->execute([$aid, Resource::ATTACH]); $tid = (int)$st->fetchColumn(); $r0 = $tid > 0 ? Resource::find($familyId, $tid, $me) : null; if (!$r0 || !Resource::canWrite($r0, $me)) { flash('err', '这个附件不能删。'); redirect('resources'); } \App\Attachment::remove($aid); flash('ok', '附件已删除。'); redirect('resources'); break; case 'resources/share': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $r0 = Resource::find($familyId, $id, $me); if (!$r0 || !Resource::canWrite($r0, $me)) { flash('err', '这条资源不能共享。'); redirect('resources'); } $target = (int)($_POST['person_id'] ?? 0); if ($target > 0 && isset(Person::map($familyId)[$target])) { Resource::setShare($id, (int)$r0['owner_id'], $target, (string)($_POST['perm'] ?? 'read')); flash('ok', '已共享。'); } redirect('resources'); break; case 'resources/unshare': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $r0 = Resource::find($familyId, $id, $me); if ($r0 && Resource::canWrite($r0, $me)) { Resource::dropShare($id, (int)($_POST['person_id'] ?? 0)); flash('ok', '已取消共享。'); } redirect('resources'); break; case 'resources/destroy': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $r0 = Resource::find($familyId, $id, $me); if (!$r0 || !Resource::canWrite($r0, $me)) { flash('err', '这条资源不能删。'); redirect('resources'); } Resource::destroy($familyId, $id); flash('ok', '资源已删除。'); redirect('resources'); break; // ==================== 任务(v12) ==================== case 'tasks': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); // v24 第 10 条:看板由用户自己建(家务 / 装修 / …),顶上有个切换条 $boards = Task::boards($familyId); $bid = (int)($_GET['board'] ?? 0); $board = Task::board($familyId, $me, $bid); View::render('tasks/index', [ '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(); $t = Task::find($familyId, (int)($_GET['id'] ?? 0), $me); if (!$t) { flash('err', '找不到这项任务。'); redirect('tasks'); } // 可选的父任务 = 我能看到的**顶层**任务(不含自己) $parents = []; foreach (['todo', 'doing', 'done'] as $sk) { foreach (Task::board($familyId, $me)[$sk] as $p) { if ((int)$p['id'] !== (int)$t['id']) { $parents[] = $p; } } } View::render('tasks/show', [ '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; case 'tasks/create': case 'tasks/update': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $isEdit = $r === 'tasks/update'; $id = $isEdit ? (int)($_POST['id'] ?? 0) : 0; $old = $isEdit ? Task::find($familyId, $id, $me) : null; if ($isEdit && (!$old || !Task::canWrite($old, $me))) { flash('err', '这项任务不能改。'); redirect('tasks'); } $title = trim((string)($_POST['title'] ?? '')); if ($title === '') { flash('err', '任务得写清楚要做什么。'); redirect('tasks'); } $d = [ 'title' => $title, 'note' => trim((string)($_POST['note'] ?? '')), 'due_at' => (string)(read_due_input() ?? ''), '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']])) { $d['assignee_id'] = 0; } if ($isEdit) { Task::update($familyId, $id, $d); $tid = $id; flash('ok', '任务已保存。'); } else { $tid = Task::create($familyId, $me, $d); foreach (filter_family_person_ids((array)($_POST['shares'] ?? []), $familyId) as $pid) { Task::setShare($tid, $pid, 'write'); } $warns = attach_save_files($familyId, Task::ATTACH, $tid, 'attach', $me); if ($warns) { flash('err', '有附件没有保存:' . implode(';', $warns)); } flash('ok', '任务已新建。'); } redirect('tasks/show', ['id' => $tid]); break; case 'tasks/move': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $status = (string)($_POST['status'] ?? ''); $t = Task::find($familyId, $id, $me); if (!$t || !Task::canWrite($t, $me)) { flash('err', '这项任务不能改。'); redirect('tasks'); } $before = (int)($_POST['before'] ?? 0); // 子任务只在 todo / done 之间切(看板里没有子任务的列,给它做定位没意义) if (!empty($t['parent_id'])) { $status = $status === 'done' ? 'done' : 'todo'; Task::setStatus($familyId, $id, $status); } else { Task::moveTo($familyId, $id, $status, $before); } if ($status === 'done' && !empty($t['repeat_rule']) && ($t['due_at'] ?? '') !== '') { flash('ok', '已完成,下一次的也排上了。'); } elseif ($status === 'done') { flash('ok', '已完成。'); } redirect('tasks/show', ['id' => $id]); break; case 'tasks/comment_add': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $t = Task::find($familyId, $id, $me); if (!$t) { flash('err', '找不到这项任务。'); redirect('tasks'); } $cid = Task::addComment($id, $me, (string)($_POST['content'] ?? '')); if ($cid > 0) { attach_save_files($familyId, 'task_comment', $cid, 'attach', $me); flash('ok', '留言已发表。'); } else { flash('err', '留言是空的。'); } redirect('tasks/show', ['id' => $id]); break; case 'tasks/comment_del': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $taskId = (int)($_POST['task_id'] ?? 0); Task::deleteComment((int)($_POST['id'] ?? 0), $me); // 内部只删自己的 flash('ok', '留言已删除。'); redirect('tasks/show', ['id' => $taskId]); break; case 'tasks/attach_add': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $t = Task::find($familyId, $id, $me); if (!$t || !Task::canWrite($t, $me)) { flash('err', '这项任务不能改。'); redirect('tasks'); } $warns = attach_save_files($familyId, Task::ATTACH, $id, 'attach', $me); flash($warns ? 'err' : 'ok', $warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。'); redirect('tasks/show', ['id' => $id]); break; case 'tasks/attach_del': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $aid = (int)($_POST['id'] ?? 0); $st = Database::get()->prepare('SELECT target_id FROM attachments WHERE id = ? AND target_type = ?'); $st->execute([$aid, Task::ATTACH]); $tid = (int)$st->fetchColumn(); $t = $tid > 0 ? Task::find($familyId, $tid, $me) : null; if (!$t || !Task::canWrite($t, $me)) { flash('err', '这个附件不能删。'); redirect('tasks'); } \App\Attachment::remove($aid); flash('ok', '附件已删除。'); redirect('tasks/show', ['id' => $tid]); break; case 'tasks/share': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $t = Task::find($familyId, $id, $me); if (!$t || !Task::canWrite($t, $me)) { flash('err', '这项任务不能共享。'); redirect('tasks'); } $target = (int)($_POST['person_id'] ?? 0); if ($target > 0 && isset(Person::map($familyId)[$target])) { Task::setShare($id, $target, (string)($_POST['perm'] ?? 'write')); flash('ok', '已共享。'); } redirect('tasks/show', ['id' => $id]); break; case 'tasks/unshare': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $t = Task::find($familyId, $id, $me); if ($t && Task::canWrite($t, $me)) { Task::dropShare($id, (int)($_POST['person_id'] ?? 0)); flash('ok', '已取消共享。'); } redirect('tasks/show', ['id' => $id]); break; case 'tasks/destroy': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $t = Task::find($familyId, $id, $me); if (!$t || !Task::canWrite($t, $me)) { flash('err', '这项任务不能删。'); redirect('tasks'); } Task::destroy($familyId, $id); flash('ok', '任务已删除。'); redirect('tasks'); break; default: http_response_code(404); View::render('error', [ 'title' => '页面不存在', 'message' => '找不到这一页。', ], 'home'); break; }