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'; /* 🔴 v44(用户 2026-10-12 第 6 条):移动端 API 也会走到这里 —— `?r=api/v1/tasks` 这种形式(个别网络库 / 代理会把 PATH_INFO 吃掉, 只留 query)。⇒ 必须在**登录门槛之前**交给 API 内核,否则未带 session 的 App 请求会被 302 到登录页(拿到一坨 HTML,JSON 解析失败)。 ⚠️ 这里**不 require api.php**:那个文件会再 require 一次 bootstrap.php, 导致 `Constant FB_ROOT already defined` 警告(实测过)。 bootstrap 在 index.php 开头已经加载,直接调内核即可。 ⚠️ 只认 `api/` 前缀,别的一律照旧走网页路由。 */ if (strpos($r, 'api/') === 0) { // `$r` 形如 `api/v1/tasks` ⇒ 去掉 `api/` 四字、补回 `/api/` 前缀, // 得到内核认识的标准路径 `/api/v1/tasks`(与 .htaccess/router.php 同一口径)。 $_SERVER['REQUEST_URI'] = '/api/' . substr($r, 4); Api::dispatch(); exit; } $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 丢弃;校验失败重画表单时还要用它回填) */ /** * 从表单读「工作经历」那组行(用户 2026-10-03 第 4 条)。 * * 表单长这样(与「补充联系方式」同一套平行数组做法): * job_org[] / job_title[] / job_rank[] / job_start[] / job_end[] / job_note[] * 浏览器会按同一下标自动补齐,zip 起来最直观;空行由 PersonJob::replace 丢掉。 * * start/end 用 ``,值就是 'YYYY-MM' —— 正好是库里存的格式, * 不需要再做一次"日期 → 年月"的换算(少一处能写错的地方)。 * * @return array */ function read_jobs_input(): array { $org = (array)($_POST['job_org'] ?? []); $title = (array)($_POST['job_title'] ?? []); $rank = (array)($_POST['job_rank'] ?? []); $start = (array)($_POST['job_start'] ?? []); $end = (array)($_POST['job_end'] ?? []); $note = (array)($_POST['job_note'] ?? []); $n = max(count($org), count($title), count($rank), count($start), count($end), count($note)); $out = []; for ($i = 0; $i < $n; $i++) { $out[] = [ 'org' => (string)($org[$i] ?? ''), 'title' => (string)($title[$i] ?? ''), 'rank_name' => (string)($rank[$i] ?? ''), 'start_ym' => (string)($start[$i] ?? ''), 'end_ym' => (string)($end[$i] ?? ''), 'note' => (string)($note[$i] ?? ''), ]; } return $out; } 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') { /* 🔴 v51(用户第 5 条):与主手机号**同一套口径** —— 先去分隔符再校验。 原来这里收 `[0-9\- ]`(含半角空格), 但**全角空格 U+3000** 同样会被判错(PCRE 的 `\s` 不加 /u 不认它)。 两处口径不同 = 同一个号码填主字段报错、填补充字段却通过 ⇒ 用户懵。 ⇒ 统一成"先归一(抹掉空格/短横/括号/全角空格)、再判纯数字"。 */ $n = (string)preg_replace('/[\s\-()]+/u', '', str_replace( ["\xE3\x80\x80", "\xC2\xA0"], ' ', $v)); if (!preg_match('/^\+?[0-9]{5,25}$/', $n)) { 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), // 用户 2026-10-03 第 1 条:我所在的城市(决定首页那格天气)。 // 同时把"查到的是哪儿"带过去,好让用户当场确认没填错(而不是回首页才发现)。 'weatherCity' => (string)UserSetting::get($me, 'weather_city', ''), 'weatherLat' => (string)UserSetting::get($me, 'weather_lat', ''), 'weatherLon' => (string)UserSetting::get($me, 'weather_lon', ''), 'weatherAt' => Home::weatherLocation($me, $familyId), // v24 第 22 批:DAV 通用口令(日历 CalDAV + 通讯录 CardDAV 共用一把)。 // ⚠️ 明文只从**一次性会话槽位**取、取完立刻 unset —— 用户刷新页面就看不到了, // 这是刻意的:口令只该在那一次被复制走(页面上也这么写)。 'davHas' => Auth::hasDav($me), 'davPlain' => (string)($_SESSION['dav_plain'] ?? ''), 'davLastUsed' => Auth::davLastUsed($me), 'davBase' => rtrim((string)fb_config('base_url', ''), '/'), // 通讯录同步范围要和名册页**完全一致**(用户 2026-10-06 第 22 批确认): // 名册页看到谁就同步谁。这里把人数带过去,用户当场就能对上。 'davRosterN' => PersonRoster::countFor($familyId, $me), /* 🔴 v63(用户 2026-10-19 第 7 条):`apiTokens` / `apiPlain` / `apiLabel` / `apiBase` 四个变量**随"手动签发令牌"那块一起撤掉** —— 界面已经不渲染它们, 还每次进设置页查一遍令牌表只是白花钱(`listFor` 是一次真实查询)。 ⚠️ 但 `api_plain` 那两个会话槽位**仍然要 unset**(见本函数尾部): 用户可能在升级前那一刻生成过令牌、明文还挂在会话里, 不清掉它就等于这页永远留着一份能看的明文。 */ /* 🔴 v57(用户 2026-10-17):「在个人设置里面有这个设备管理」。 ⚠️ 这里传的是**按设备聚合**的列表(`listDevices`),不是令牌清单 —— 同一台手机重登过 5 次,网页上只该出现 1 行。 */ 'devices' => \App\ApiToken::listDevices($me), 'deviceMax' => \App\ApiToken::MAX_DEVICES, 'deviceTtl' => \App\ApiToken::TTL_HOURS, // 网页这边不知道"哪台是当前设备"(发请求的是浏览器,不是 App) // ⇒ 页面不标"当前设备",只标"App 设备"。 ], 'settings'); // ⚠️ 取完即清:明文令牌只该被看到一次(刷新页面就没了)。 // 放在 View::render **之后** —— 早了就拿不到值了。 unset($_SESSION['dav_plain'], $_SESSION['api_plain'], $_SESSION['api_plain_label']); } /** * 上传并挂一组附件到某个宿主(旅程照片 / 资源附件 / 任务附件…全走这一处)。 * * 同时吃「单文件」与「多文件」两种表单字段:`` * 与 ``(后者 PHP 会把每个子项变成数组)。 * 逐个交给 App\Attachment::save —— 一张失败不该连累其它几张(照片多选尤其明显)。 * * @return string[] 失败原因(空数组 = 全部成功) */ function attach_save_files(int $familyId, string $type, int $targetId, string $field, int $uploaderId): array { // v24 第 20 批 第 4 条:先把**拖动即时上传**的暂存文件认领到这个条目上。 // 用户原话:「拖动一个进去,就开始上传…最后统一保存即可。」 // 那些文件在拖动时就已经传到服务器了(upload_stage.php),但**没有挂到任何条目**; // 保存时(这里)才真正认领 —— 于是"立刻上传"与"最后统一保存"两件事都成立。 // ⚠️ 认领放在临时文件之前:两者最终都进同一张附件表、按 position 顺序排, // 先认领的在前(也就是"拖动加进来的先出现"),符合操作顺序。 // // 🔴 v24 第 21 批 第 4 条:字段名从 `stage` 改成 **`stage_token`**。 // 原来叫 `stage` 时,它和行程表单里那个隐藏的 `stage`(plan/actual) // **同名** —— PHP 解析表单会把两者合并进同一个 `$_POST['stage']`, // 而且因为上传那侧带 `[]`(追加记号),合并结果是**数组**。 // 于是 `trips/node_update` 里 `(string)($_POST['stage'] ?? 'plan')` // 拿到 "Array" → 不在 ['plan','actual'] 里 → 静默退回 'plan'。 // 症状:拖过图片的"实际行程"一保存就变回计划行程(不拖图时正常 → 时好时坏)。 // ⚠️ 改字段名是唯一正确的修法:同名合并是 PHP 的表单语义, // 在路由里加"是数组就取第一个"只会把这个坑留给下一次加字段。 // 详细来龙去脉见 templates/_stage_drop.php 头部。 $staged = (array)($_POST['stage_token'] ?? []); $stageWarns = []; if ($staged) { $res = \App\StagedUpload::claim($familyId, $uploaderId, $staged, $type, $targetId); $stageWarns = (array)($res['warns'] ?? []); } // 顺手清掉过期没认领的暂存(不必挂定时任务) \App\StagedUpload::sweep(); $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 = $stageWarns ?? []; 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'); } // ⚠️ v24 第 11 批 #6(用户原话「月视图,从周一开始。而不是周日。」): // 这里原来是"周日开头"——$w 直接当回退天数(周日=0 → 不回退)。 // 现在改成**周一开头**:把周日(0)折算成 6、其余减 1,回退到本周一。 // ⚠️ 月网格与周视图必须用**同一套**起算规则,否则同一天在两张视图里 // 落在不同的列上,用户会以为日程"跳"了。 $wSun = (int)date('w', $firstTs); // 0=周日 … 6=周六 $w = ($wSun + 6) % 7; // 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(); } // ⚠️ v24 第 10 批 #5(用户报「日视图点后一天/前一天没反应」): // 这里原来**无条件**把日期吸到"这一周的周一"—— 周视图要这样(7 列从周一排), // 但天视图只有 1 列,吸过去就等于**永远显示那一周的周一**: // d=2026-10-04(周日)→ 列 09-28;d=2026-09-30(周三)→ 列 09-28; // 只有 d 本来就是周一时才"看着正常"。而页头标题用的是 $anchor(正确), // 于是症状正是"标题变了、日程区域纹丝不动"。 $start = $ts; if ($span > 1) { $w = (int)date('w', $ts); // 0=周日 … 6=周六 $start = strtotime('-' . (($w + 6) % 7) . ' days', $ts); // 周一开头(仅周视图) } $out = []; for ($i = 0; $i < max(1, $span); $i++) { $d = date('Y-m-d', strtotime('+' . $i . ' days', $start)); $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 点会变成一条压在最上面的怪东西。 * 单独挂在顶上的一行(跟群晖日历一样)。 * * 用户 2026-10-03 第 5 条:「日程色块的高度不对,应该是从开始时间到结束时间, * 高度随着日程的时间而不同才对。」 * 旧实现把**所有**日程都写成「30 分钟高」(`$endMin = $startMin + 30`), * 于是三小时的会议和十五分钟的站立会同高 —— 一眼看不出"哪段时间被占住了", * 而"时间网格"的全部意义就在于把时长画出来。 * 现在:有 ends_at 就按真实时长;没填结束才算"最短可见块"(30 分钟)。 * * @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]; // 结束时刻:有就按真实时长;没有就先空着(下面还要看**下面那一条的起点**)。 $et = (string)($e['end_time'] ?? ''); $endMin = null; if ($et !== '' && preg_match('/^(\d{2}):(\d{2})/', $et, $m2)) { $endMin = ((int)$m2[1]) * 60 + (int)$m2[2]; if ($endMin <= $startMin) { // 结束时刻不在开始之后(跨天日程的第二天、或用户填反了)→ 当没有结束 $endMin = null; } } $timed[$ci][] = $e + ['_start' => $startMin, '_end' => $endMin]; } /* ==================== 冲突错开(v30 第 4 条)==================== 用户原话:「如果两个事件时间上有冲突了,现在是堆叠在一起,应该错开,显示两个色块, 如果三个冲突了,就是三个色块,错开显示。」 算法借鉴 SyncCalendar 的 TimelineUtil(见 App\Calendar::conflictLanes)。 ⚠️ 高度要在**分列之后**才算: ① "没填结束时间"的日程不能一律画 30 分钟 —— 那会让它和后面所有日程都"冲突" (一个 09:00 没结束的日程配一个 09:30 的日程,就假冲突成两列)。 ⇒ 优先拿**同一列里下一条的起点**当它的结束(那正是"视觉上到哪儿为止"); 没有下一条时才退到 30 分钟的最短可见块。 ② 分列用的"结束分钟"必须**按列内顺序**算,所以必须先分列、再算高度。 这里用一个中间量 `_effEnd` 把"实际显示到几点"带回给模板。 */ foreach ($timed as $ci => $list) { if (!$list) { continue; } // ① 先按"声明的结束"排一次序,好让同列的下一条能被看到(同一列内互不重叠) $sorted = $list; usort($sorted, static function (array $a, array $b): int { return $a['_start'] <=> $b['_start']; }); // ② 分列:暂时用"声明结束;没有则 +30'",只为拿到 lane(宽度/左偏移) $probe = array_map(static function (array $e): array { return [ 'time' => sprintf('%02d:%02d', intdiv($e['_start'], 60), $e['_start'] % 60), 'end_time' => $e['_end'] === null ? '' : sprintf('%02d:%02d', intdiv($e['_end'], 60), $e['_end'] % 60), ]; }, $sorted); $lanes = \App\Calendar::conflictLanes($probe); // ③ 逐条算"实际显示结束":优先同列下一条的起点 $out = []; $laneLastEnd = []; // 每列当前已排到哪儿(分钟) foreach ($sorted as $k => $e) { [$left, $width] = $lanes[$k] ?? [0.0, 1.0]; $lane = $width > 0 ? (int)round($left / $width) : 0; $s = $e['_start']; $en = $e['_end']; if ($en === null) { // 同列下一条(时间上紧接着的那条)的起点,就是它"视觉上"的结束 $next = null; foreach ($sorted as $k2 => $e2) { if ($k2 <= $k) { continue; } $l2 = $lanes[$k2] ?? [0.0, 1.0]; $lane2 = $l2[1] > 0 ? (int)round($l2[0] / $l2[1]) : 0; if ($lane2 === $lane && $e2['_start'] > $s) { $next = $e2['_start']; break; } } $en = $next !== null ? $next : $s + 30; } $spanMin = max(15, $en - $s); // ⚠️ 上限到 24:00:超出的部分已经在网格外面,硬画出去会把整列撑长 $spanMin = min($spanMin, 1440 - $s); $out[$k] = [ 'top' => round($s / 1440 * 100, 4), 'height' => round($spanMin / 1440 * 100, 4), 'left' => round($left * 100, 4), 'width' => round($width * 100, 4), ]; } // ⚠️ `$out` 的键必须与 `$sorted` 对齐(上面按 $k 写入),**不能**在这里 // usort 它 —— usort 会重排下标,随后 `$out[$k]` 就取到别人的分列结果了。 // 要排序请在拼好 `$clean` 之后排。 $clean = []; foreach ($sorted as $k => $e) { unset($e['_start'], $e['_end']); $clean[] = $e + ($out[$k] ?? ['top' => 0, 'height' => 0, 'left' => 0, 'width' => 100]); } usort($clean, static function (array $a, array $b): int { return $a['top'] <=> $b['top']; }); $timed[$ci] = $clean; } 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'); } /** * 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。 * * 🔴 v54(用户 2026-10-16):**实现已搬到 `App\Home::memories()`**, * 这里只做一次转发。 * 为什么搬:App 端首页也要显示「往年的今天」(用户原话 * 「近期任务的下面,应该加上**往年的今天**,跟电脑端一样」), * 而 App 走的是 `/api/v1/home`。**两端必须同一份实现** —— * 否则会出现"网页说 2 年前、手机说 3 年前"这种对不上的事 * (本项目在农历/节气上已经吃过一次这个亏)。 * * ⚠️ 保留这个函数名而不是把调用点全改掉:模板里 `$memories` 的取用位置、 * 以及可能的旧书签/脚本都还在按老路径走,删了就静默少一块。 * 顺便这个包装也让"模板注释里提到的 home_memories()"仍然指得到东西。 * * @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容) */ function home_memories(int $familyId, int $limit = 6): array { return \App\Home::memories($familyId, $limit); } /** 只认本家庭、确实存在的 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) { /** * 暂存上传(v24 第 20 批 用户第 4 条)。 * * 用户原话:「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传, * 拖动一个进去,就开始上传,还可以继续拖动,继续上传,最后统一保存即可。」 * * ⚠️ 真正的实现放在**独立文件** `upload_stage.php` 里,这里只做一次转发: * · 那个端点要自己控制响应头(纯 JSON)、自己的错误处理 * (任何 PHP 警告被 display_errors 打成 HTML,前端 JSON.parse 就炸); * · 放在独立文件里也和 media.php 同一个思路:**自成一条出入口,便于审阅**。 * ⚠️ 转发必须把这次请求的**全部**上下文原样交给它($_FILES/$_POST 是超全局, * 自动带过去;但 __DIR__ 与相对 require 的基准会变,所以先 chdir 到站点根)。 * ⚠️ `require` 之后脚本**不会回来**(那个文件自己 exit),所以这里不必写 break; * 但语法上留着更清楚"这里就是终点"。 */ case 'upload/stage': chdir(__DIR__); require __DIR__ . '/upload_stage.php'; break; // ==================== 登录 / 退出 ==================== 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'))); // 用户 2026-10-04 第 11 批 #1:「首页,未来七天,不显示农历」。 // ⚠️ 农历是**每天一条**的派生条目(节假日也一样),七天就是七八行"廿一 / 廿二…", // 把真正要看的日程全挤下去了 —— 它在月视图里当"日历刻度"很有用, // 在首页这种"接下来要做什么"的清单里纯属噪音。 // 节假日保留("这周六放假"是有信息量的),农历整类去掉。 $soon = array_values(array_filter($soon, static function (array $e): bool { return (string)($e['kind'] ?? '') !== 'lunar'; })); // 进行中的任务 + 待办(用户 2026-10-16 v53 第 2 条) // // ⚠️ v24 第 14 批 用户第 2 条:**按状态取,不看板取**。 // 原话:「首页上显示的任务,是和任务的状态有关,而不是和它在那个看板有关」。 // 原来调的是 Task::board() —— 它只取**默认看板**,于是建在别的看板上的 // 「进行中」永远上不了首页(用户看到的就是"我明明在做,首页却没有")。 // // 🔴🔴 v53(用户 2026-10-16 第 2 条):「首页的近期任务,**把待办也放进来吧, // 放在进行中的下面**。」 // ⇒ 原来只收「进行中 + **已逾期**的待办」(当年怕"待办一多首页变成一张长清单")。 // 现在用户明确要待办也进来,那就**全部**收进来,并按**状态分组**: // 进行中在前、待办在后 —— "放在进行中的下面"就是这个意思, // 而不是"混在一起按截止时间排"(那样进行中的会被一堆待办淹掉)。 // ⚠️ 两组**各自内部**仍按截止时间升序(byStatus 的 ORDER BY 已经排好), // 所以"马上要做的"在各自那一段里仍然靠前。 // ⚠️ 逾期仍是**红色整行**(状态语义,见模板),它落在"待办"那一段里 —— // 与原来"逾期待办会进清单"的行为一致,只是现在**没逾期的待办也在**。 $tasks = Task::byStatus($familyId, $me, 'doing'); foreach (Task::byStatus($familyId, $me, 'todo') as $t) { $tasks[] = $t; } $memories = home_memories($familyId); // ---- 用户 2026-10-03 第 3 条:首页要显示哪些模块、什么顺序(每人一份)---- $homeMods = HomeMods::forPerson($me); $moduleCards = Home::moduleCards($familyId, $me, $soon, $tasks); /* 🔴 v62(用户 2026-10-18 第 3 条):「今天,我们正式引入卡片的概念。」 ⇒ 首页从"三块写死的区块"改成**卡片网格**: · `$homeCards` 是这个人要摆哪几张、各自多宽多高(user_settings.home_cards); · `$cardData` 只给那几张需要额外查询的卡片取数(纪念日/观影/读书/联系人)。 ⚠️ 缺的卡片不查库 —— 关掉的卡片不该白跑一次 SQL。 ⚠️ 日程/任务/往年的今天/概述这四张用的还是下面那几个变量(同一份数据), 不要在模板里再查第二遍。 */ $homeCards = HomeCards::layout($me); $cardData = HomeCards::data($homeCards, $familyId, $me); // 统计信息块:随机问候 + 今日天气 + 近七天的数字 $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(); // ⚠️ 同上(用户第 2 条):这两个数字也是**按状态、跨看板**算的。 // $board 在首页已经不再使用(它只取默认看板)—— 留着它只会让统计与 // 上面的清单对不上("清单上有 3 件,卡片却说 1 件在做")。 $todoN = count(Task::byStatus($familyId, $me, 'todo')); $lateN = 0; foreach ($tasks 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); // ---- v24 第 24 批 用户第 2 条:信息面板要的那几样 ---- // 「今天是几月几日?星期几?农历几月几日?如果有节气/重要节日/纪念日/生日, // 在这里显示;紧接着下面显示今天一共有多少条日程、几件待办正在进行。」 $lunarToday = Lunar::solarToLunar((int)date('Y'), (int)date('n'), (int)date('j')); $lunarLine = $lunarToday === null ? '' : Lunar::label( (int)$lunarToday['year'], (int)$lunarToday['month'], (int)$lunarToday['day'], !empty($lunarToday['is_leap'])); // 抬头要的是「农历几月几日」,不要"农历二〇二六年"那一段(面板上塞不下) $lunarShort = $lunarToday === null ? '' : ( Lunar::monthName((int)$lunarToday['month'], !empty($lunarToday['is_leap'])) . Lunar::dayName((int)$lunarToday['day'])); $todayMarks = Home::todayMarks($familyId, $me, $today); // 今天的日程(v24 第 24 批 用户第 3 条:左侧那块要"今天的日程,日视图那样") // ⚠️ 与「未来七天」不同:这里**只要今天**,而且要按时间排好(日视图从上往下就是时间序)。 $todayEvents = array_values(array_filter($soon, static function (array $e) use ($today): bool { return (string)($e['date'] ?? '') === $today; })); usort($todayEvents, static function (array $a, array $b): int { // 全天事件(无时间)排最前 —— 日视图的习惯就是这样 $ta = (string)($a['time'] ?? ''); $tb = (string)($b['time'] ?? ''); if ($ta === '' && $tb !== '') { return -1; } if ($tb === '' && $ta !== '') { return 1; } return strcmp($ta, $tb); }); // 天气:地点在**个人设置**里(v24 用户 2026-10-03 第 1 条),每人一份。 // 三级回落都在这一个调用里:自定义经纬度 → 内置城市表 → 不显示。 $weatherLoc = Home::weatherLocation($me, $familyId); $weather = $weatherLoc === null ? null : Home::weather($familyId, $weatherLoc['lat'], $weatherLoc['lon'], $weatherLoc['place']); /* 🔴 v78(用户 2026-10-22):「在全部任务旁边,放一个新建任务的功能, 其实就是把任务中的新建和编辑功能,搬过来,用弹出框来处理。」 ⇒ 那张弹框(`templates/tasks/_add_modal.php`)需要看板 / 分类 / 名册三样。 ⚠️ 只有「近期任务」卡真的摆在首页上、且当前账号可写时才准备: 卡片关掉就没有入口,不该让每个首页请求白跑三条查询。 ⚠️ 弹框本身也在模板里按 `$tkAdd` 是否为空决定渲不渲染 —— 两处一个判据。 */ $tkAdd = null; if (Auth::canWrite()) { foreach ($homeCards as $hcRow) { if ((string)($hcRow['k'] ?? '') === 'tasks') { $tkAdd = [ 'boards' => Task::boards($familyId), 'categories' => Task::categories($familyId), 'people' => Person::roster($familyId, $me, 'member', ''), ]; break; } } } /* 🔴 v81(用户 2026-10-22):「首页上的其他卡片也应该有这些基本功能。 比如电影观影,它有一个"新增电影"的功能;如果是纪念日,就有一个"新增纪念日"。 实际上,就是把原来模块里弹的那个框,现在拿到这边来。」 ⇒ 首页要能内嵌 `movies/form.php` 与 `days/form.php`,就要给它们准备好数据。 **判据与 `$tkAdd` 完全一致**:卡片真摆在首页 + 账号可写,才准备; 否则关掉卡片之后还白白跑 `Person::roster()` / `Cinema::all()`。 ⚠️ 这里的 key 必须与 `HomeCards::TYPES` 里的 `add_modal` **一一对应** (`movies` ⇄ `mvAddCard`、`days` ⇄ `anAddCard`): `home.php` 按 key 走不同分支 require 不同模板,写错一个就是 "按钮点开了一张不存在的弹框"(点了没反应,最难查的那种)。 ⚠️ 别把这里扩成"遍历所有卡片的 add"就完事:每张卡要的数据不同, 新增一张要新建弹框的卡片时必须回来加一个分支(宁可多写三行, 也不要写一个"猜字段名"的通用装配器)。 */ $homeAddModals = []; if (Auth::canWrite()) { foreach ($homeCards as $hcRow) { $hcKey = (string)($hcRow['k'] ?? ''); if ($hcKey === 'movies' && !isset($homeAddModals['movies'])) { // 与 movies/index.php 内嵌时**同一组**数据(同一个表单要同一份料) $homeAddModals['movies'] = [ 'people' => Person::roster($familyId, $me, 'member', ''), 'cinemas' => Cinema::all($familyId), ]; } elseif ($hcKey === 'days' && !isset($homeAddModals['days'])) { // 与 days/index.php 内嵌时同一组:分类清单(名称 + 图标)+ 名册 $homeAddModals['days'] = [ 'kindsFull' => Anniversary::kindsFull($familyId), 'people' => Person::roster($familyId, $me, 'member', ''), ]; } elseif ($hcKey === 'agenda' && !isset($homeAddModals['agenda'])) { /* 🔴 v82(用户 2026-10-23):「日程,没有新建日程按钮」—— 首页「今天的日程」卡片要能就地新建日程。 ⚠️ 这里给的 `writable` 必须与 `calendar/index.php` 算的**同一口径**: `Calendar::mine()` 取我可见的本 → 再按 `Calendar::canWrite()` 过一道。 少走 canWrite 的话,只读共享本会出现在下拉里,选了就被服务端拒。 ⚠️ 顺手 `ensurePersonalBook()`:与日程页同一条幂等补本 —— 老用户库里的"我自己的日程本"是 `seedDefaults()` 之后才有的, 不补的话首页这张弹框的默认本可能落到**观影本**上, 用户直接点"加上"就建出一条观影记录(v24 第 21 批真事故)。 */ Calendar::ensurePersonalBook($familyId, $me); /* 🔴 v95(用户 2026-10-26):口径统一走 `writableForEvent()`。 ⚠️ 原来这里是首页自己写的 `array_filter(canWrite)`,与日程页 "看起来一样、实则不是同一份代码" —— 改一处忘另一处就漂移。 canWrite 拦不住观影/旅行派生本,也拦不住系统只读本 (它们的 perm 是 owner),详见 `Calendar::writableForEvent()`。 */ $homeAddModals['agenda'] = [ 'writable' => Calendar::writableForEvent($familyId, $me), ]; } elseif ($hcKey === 'contacts' && !isset($homeAddModals['contacts'])) { /* 🔴 v82(用户 2026-10-23):「名册,也没有新增成员这个按钮」—— 首页「联系人」卡片要能就地新增成员。 ⚠️ 只给标签候选(表单要用),**不给名册**:新增态不需要选人, 白跑一次 roster 查询(1000+ 人)没有意义。 */ $homeAddModals['contacts'] = [ 'tagChoices' => PersonTag::choices($familyId, $me), ]; } } } View::render('home', [ 'title' => '首页', 'family' => Family::find($familyId), 'me' => $people[$me] ?? null, 'meId' => $me, 'meName' => $meName, 'kin' => $kin, 'allCount' => count($people), 'relCount' => Relations::count($familyId), 'birthdays' => $birthdays, 'soon' => $soon, 'todayEvents'=> $todayEvents, '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], // v24 第 24 批:信息面板 'todayLabel' => date('n 月 j 日'), 'lunarShort' => $lunarShort, 'lunarLine' => $lunarLine, 'todayMarks' => $todayMarks, 'today' => $today, 'homeModules'=> $homeMods, 'moduleCards'=> $moduleCards, // v62 卡片体系 'homeCards' => $homeCards, 'cardData' => $cardData, // v78:首页「新建任务」弹框的数据(卡片没摆/只读账号时为 null) 'tkAdd' => $tkAdd, // v81:首页「新增电影」「新建纪念日」弹框的数据(同上,卡片没摆就没有) 'homeAddModals' => $homeAddModals, /* 🔴 v71c(用户 2026-10-21):「通知消息这个,也是首页的卡片,可以调整 顺序和大小。」 ⇒ 通知**不再是**这里单独传的两个变量(`notifs` / `notifUnread`), 而是卡片自己的取数(`HomeCards::notifData()`,见 `_card_notif.php`)。 ⚠️ 这样就**不可能**再出现"卡片被关掉了、SQL 还在跑"这种白查 —— `data()` 只对用到的 key 取数。 ⚠️ 头像下拉的未读角标**不在这里**:`layout.php` 自己调 unreadCount, 与首页摆不摆这张卡片无关(关掉卡片不该让角标也消失)。 */ ], 'home'); break; /** * 首页卡片布局设置(v62 用户 2026-10-18 第 3 条)。 * * 用户原话:「卡片的规格,我们可以定义一下,例如,把宽度定义为 8,高度可以是 * 2、4、6、8……你可以先规划一些,实现出来,我们再讨论。」 * * ⇒ 这是**每人一份**的设置(首页怎么摆是很个人的事),存 user_settings。 * ⚠️ 与「模块设置 → 导航」那套完全分开:导航管顶栏,这里只管首页的卡片。 */ case 'home/cards': Auth::requireLogin(); $me = (int)Auth::personId(); View::render('home_cards', [ 'title' => '首页卡片', 'rows' => HomeCards::orderedForPicker($me), ], 'home'); break; /** * 保存首页卡片布局。 * * 表单形状:每行一个 `order[]`(屏幕顺序)+ `on[]` 勾选 + `w[]` / `h[]` * + `n[]`(只有"联系人"用得上)。 * ⚠️ 顺序**按 order[] 取**,不按 POST 里 on 的顺序 —— 浏览器提交复选框的顺序 * 并不保证与 DOM 顺序一致(有些浏览器按 name 分组提交)。 */ case 'home/cards_save': Auth::requireLogin(); csrf_check(); $me = (int)Auth::personId(); $order = array_map('strval', (array)($_POST['order'] ?? [])); /* ⚠️ `on[]` 是**按卡片 key 作数组下标**提交的(`on[brief]=1`), 所以判"勾没勾"必须 `isset($on[$k])` —— 写成 `in_array($k, $on)` 是错的:那比的是**值**(全是 "1"), 结果一个都匹配不上,保存出来是**空布局**(首页变成"卡片都关掉了")。 */ $on = (array)($_POST['on'] ?? []); $ws = (array)($_POST['w'] ?? []); $hs = (array)($_POST['h'] ?? []); $ns = (array)($_POST['n'] ?? []); /* ▲▼ 是**没有 JS 时的兜底**(页面里还有拖动排序,两条路都要能走): 先把屏幕顺序里这两个换位,再照新顺序建卡片列表 —— 于是"点了上移" 与"改完再点保存"得到的是同一个结果。 */ $move = (string)($_POST['move'] ?? ''); if ($move !== '' && strpos($move, ':') !== false) { [$dir, $mkey] = explode(':', $move, 2); $mi = array_search($mkey, $order, true); if ($mi !== false) { $mj = $dir === 'up' ? $mi - 1 : $mi + 1; if ($mj >= 0 && $mj < count($order)) { [$order[$mi], $order[$mj]] = [$order[$mj], $order[$mi]]; } } } $items = []; foreach ($order as $k) { if (!isset($on[$k])) { continue; } $items[] = [ 'k' => $k, 'w' => (int)($ws[$k] ?? 0), 'h' => (int)($hs[$k] ?? 0), 'n' => (int)($ns[$k] ?? 0), ]; } // 一个都没勾 = 空首页。这是用户的选择(不是错误),照存。 HomeCards::save($me, $items); /* ⚠️ ▲▼ 只是"编辑中的一个动作",**不能跳去首页**: 跳走等于把编辑器关掉,用户想再挪一格就得重新进来(实测就是这样, 连点两下只会生效一次)。只有真正"保存"才回首页。 */ if ($move !== '') { flash('ok', '顺序已调整。'); redirect('home/cards'); } flash('ok', '首页卡片已保存。'); redirect('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 第 3 条:列表要显示手机号(密文解密)与生日。 // 解密与生日格式化统一走 PersonQuery(见该类的类注释: // 名册 / 挑人 / 高级搜索必须**同一套判据**,否则同一句搜索在不同页面结果不同)。 $row['phone_plain'] = PersonQuery::phonePlain($row); // 用户 2026-10-04 第 3 条:阴历和阳历**都**列出来,且只显示月和日 $row['birthdays'] = PersonQuery::birthdaysOf($row); // 首字母供前端"输入即筛选"用(filter.js 对 data-initials 做子序列匹配) $row['initials'] = Pinyin::initials((string)$row['name'] . ' ' . (string)($row['my_nickname'] ?? '')); } unset($row); // 🔴🔴 v28 用户第 5 条:「名册的分组,用下来不太好,我感觉应该改成打标签, // 就是说,一个人,他可能并不是只在一个分组,例如,他既是我的小学同学, // 又是我的初中同学,就没法分组,改成标签,似乎更合理。」 // ⇒ 不再**按分组分栏**(`$grouped`),改成: // · 名册**平铺**成一张列表(带拖动排序,用户明确要求保留); // · 顶部一条**标签筛选**(`?tag=xxx`),点哪个看哪个; // · 每张卡片上挂着他自己的那几个标签(一个人可以有好几个)。 // ⚠️ 仍然传 `groups`:这就是"标签的候选来源"(用户可以在卡片上选已有的标签), // 它本身没废 —— 只是从"分栏依据"降级成"可选的标签词表"。 $tagFilter = trim((string)($_GET['tag'] ?? '')); $tagsOf = PersonTag::mapFor($familyId, $me); // 按标签筛选:留在这个标签里的人(`?tag=` 为空 = 全部) if ($tagFilter !== '') { $rows = array_values(array_filter($rows, static fn(array $r): bool => in_array($tagFilter, $tagsOf[(int)$r['id']] ?? [], true))); } foreach ($rows as &$row) { $row['tags'] = $tagsOf[(int)$row['id']] ?? []; } unset($row); // v24 第 9 批 #5:千人名册的 HTML 有 3~4 MB。本机 php -S 与部分虚拟机 // 都不会替 PHP 输出开 gzip,原样吐 3.8 MB 又慢又费流量; // 这里在渲染前挂 ob_gzhandler(自动按 Accept-Encoding 协商, // 不支持 gzip 的客户端拿到的还是原文)。压 3.8 MB 约 60~90ms, // 换来 10 倍体积差;media.php / dav 的二进制输出不走这条路。 if (($_SERVER['HTTP_ACCEPT_ENCODING'] ?? '') !== '' && stripos((string)$_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== false) { ob_start('ob_gzhandler'); } View::render('people/index', [ 'title' => '家庭名册', 'people' => $rows, // v28:`$grouped`(按分栏分堆)已不再需要 —— 名册平铺(见上面注释)。 // 不再传它,模板也不再读(多传一个变量迟早有人用它,把分栏又带回来)。 'groups' => PersonGroup::orderFor($familyId, $me), // v28 用户第 5 条:标签(筛选条 + 卡片上的标签 + 编辑标签时的候选) // 🔴 v54(第 2 条):筛选条与"打标签候选"是**两件事**,别混: // · 筛选条只该列**真有人的**标签(cloud:JOIN 了名册,点进去不会空)—— // 把没人的词也列上去,会出现"写着 5 个、点进去 0 个"; // · 打标签的候选要**词表 ∪ 用过的词**(模板里的弹层用 tagChoices)。 'tagCloud' => PersonTag::cloud($familyId, $me), 'tagChoices' => PersonTag::choices($familyId, $me), 'tagFilter' => $tagFilter, 'q' => $q, 'meId' => $me, // 家里已经有人、但还不在我名册里 → 给一个「直接挑」的入口, // 免得为了同一个人又录一遍(录两遍会变成两条记录,账号与亲属关系各存一份) 'pickable' => $q === '' ? count(PersonRoster::pickable($familyId, $me)) : 0, // v24 第 22 批:CardDAV 那张卡要知道"口令配了没有"(决定显示"去生成"还是"去管理")。 // ⚠️ 这里**不传明文** —— 明文只走一次性会话槽位、只在「个人设置」那页显示一次; // 名册页能反复看到明文就等于口令多了一个泄露面。 'davHas' => \App\Auth::hasDav($me), ], 'people'); break; /** * 名册卡片上的「调整分组」。 * * 从 v5 起改的是**我自己那份名册**里的归类(person_roster.group_id), * 不再动 persons —— 所以「我把张伟归到同事」不会让妈妈那边也变成同事。 * 单开一个路由而不是让人去「编辑成员」里改,是因为那张表很长 * (三个生日、身份证号、住址…),为了挪一个分组去翻半页是折磨。 * 而且分组**本来就只能在名册里改**(它是"我和他的关系")。 */ case 'people/group': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); // v24 第 25 批 用户第 1 条:「名册模块在列表中,对列表中的某一个人进行分组, // 也不应该刷新界面,而是应该动态调整页面。」 // → 与 people/reorder 同一套做法:**AJAX 请求回 JSON**(前端就地挪卡片), // 普通表单提交仍走 302 兜底(没有 JS 时照样能用)。 $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', 'msg' => '你的身份是访客,不能改名册里的分组。'], JSON_UNESCAPED_UNICODE); exit; } 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) { if ($isAjax) { header('Content-Type: application/json; charset=utf-8', true, 404); echo json_encode(['ok' => false, 'msg' => '找不到这个人。'], JSON_UNESCAPED_UNICODE); exit; } flash('err', '找不到这个人。'); redirect('people'); } if (!PersonRoster::assignGroup($familyId, $me, $pid, $gid > 0 ? $gid : null)) { if ($isAjax) { header('Content-Type: application/json; charset=utf-8', true, 200); echo json_encode(['ok' => false, 'msg' => '这个人不在你的名册里,或者那个分组你用不了。'], JSON_UNESCAPED_UNICODE); exit; } flash('err', '这个人不在你的名册里,或者那个分组你用不了。'); redirect('people'); } $gname = $gid > 0 ? PersonGroup::name($familyId, $gid) : ''; $msg = $gid > 0 ? '已把「' . $p['name'] . '」归到「' . $gname . '」。' : '已把「' . $p['name'] . '」改成未分类。'; if ($isAjax) { // ⚠️ 前端需要**分组名**去改那颗按钮上的文字("改成未分类"时是空串) header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => true, 'pid' => $pid, 'gid' => $gid, 'gname' => $gname, 'msg' => $msg, ], JSON_UNESCAPED_UNICODE); exit; } flash('ok', $msg); redirect('people'); break; /** * 名册卡片上的「标签」(v28 用户第 5 条:分组 → 标签)。 * * 与 people/group 同一套做法(AJAX 回 JSON 就地更新 / 普通提交 302 兜底), * 只是把"选一个分组"换成"整组替换他的标签"。 * * ⚠️ 标签是**每人一份**的(owner_id = 我),改它只影响我自己的名册 —— * 我给他打「球友」不该让全家的名册都多出这个词。 */ case 'people/tags': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $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, 'msg' => '你的身份是访客,不能改名册标签。'], JSON_UNESCAPED_UNICODE); exit; } flash('err', '你的身份是访客,不能改名册标签。'); redirect('people'); } csrf_check(); $pid = (int)($_POST['person_id'] ?? 0); $p = Person::find($pid); if (!$p || (int)$p['family_id'] !== $familyId) { if ($isAjax) { header('Content-Type: application/json; charset=utf-8', true, 404); echo json_encode(['ok' => false, 'msg' => '找不到这个人。'], JSON_UNESCAPED_UNICODE); exit; } flash('err', '找不到这个人。'); redirect('people'); } // ⚠️ 只允许给**在我名册里的人**打标签 —— // 否则会给一份"看不见的名册条目"写上标签,用户永远看不到、也删不掉。 // // 🔴🔴 v50(用户 2026-10-14)——**这里原来是个真 bug**,用户原话: // 「如果我把人给搜索出来的话,然后选择给他打标签,上面它会显示一个说 // 这个人不在我的名册里,其实他是在我的名册里的。」 // 原写法是 `isset(PersonRoster::visibleIds(...)[$pid])`,而 // `visibleIds()` 返回的是 **id 值列表**(`[2,3,4,5]`,键是 0,1,2,3)—— // 拿它当"id → 值"的映射去 isset,只有当 `$pid` 恰好等于数组下标时才是真: // · 名册里人少 ⇒ 下标都小于 pid ⇒ **谁都判"不在名册"**(用户遇到的就是这个); // · 一般情况 ⇒ 只有"第 pid+1 个人"碰巧对得上(时好时坏,最难查)。 // ⇒ 改用 `in_array`(与 views/show 那处 `inMyRoster` 的写法**一致**)。 // ⚠️ 别改成"给 visibleIds 换形状":另一个调用点已经是对的 in_array, // 改形状会把那处静默弄坏(同类陷阱:PersonGroup::visibleIds 是**集合**, // 同名不同形状,见 PersonRoster::visibleIds 的注释)。 if (!in_array($pid, PersonRoster::visibleIds($familyId, $me), true)) { $msg = '这个人不在你的名册里。'; if ($isAjax) { header('Content-Type: application/json; charset=utf-8', true, 200); echo json_encode(['ok' => false, 'msg' => $msg], JSON_UNESCAPED_UNICODE); exit; } flash('err', $msg); redirect('people'); } $saved = PersonTag::setFor($familyId, $me, $pid, (string)($_POST['tags'] ?? '')); $msg = $saved ? '标签已改成:' . implode('、', array_map(static fn($t): string => '#' . $t, $saved)) . '。' : '标签已清空。'; if ($isAjax) { // ⚠️ 前端要用这个数组去改卡片上那颗按钮的文字(空数组 = 显示「标签」) header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => true, 'pid' => $pid, 'tags' => $saved, 'msg' => $msg, ], JSON_UNESCAPED_UNICODE); exit; } flash('ok', $msg); redirect('people'); break; /** * 名册拖动排序 —— 整版提交。 * * 🔴 v28:名册已**平铺**(用户第 5 条:分组→标签),所以这里只剩"排序"这一件事, * 不再有"换栏"。前端发上来的是一个不含 `g` 的栏(见 app.js 的 save), * 服务端 `applyLayout` 据此只更新 sort_order、不碰 group_id。 * * 前端把有序 id 列表发过来,服务端一次写完。 * 为什么不"移动一条算一次":拖拽的结果是一组**相对顺序**, * 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。 */ 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; /** * 名册导入(v24 用户 2026-10-03 第 1 条)。 * * 用户原话:「需要增加一个导入功能,可以导入常见的联系人格式,例如 vcf 等。 * 导入时,如果名字或手机号在已有的名册中存在,则导入其他信息, * 如果这些信息在系统中已经存在,则只导入其他信息。」 * * 流程:上传文件 → ContactImport::parse()(纯解析)→ ContactImporter::run()(判重+落库)。 * 判重与"只补空不覆盖"的规则都在 ContactImporter 里,这里只负责收文件、报告结果。 */ case 'people/import': Auth::requireWrite(); $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); if ($method !== 'POST') { View::render('people/import', [ 'title' => '导入联系人', // ⚠️ v54:`groups` 仍传(兼容),新界面用 `tagChoices`(打标签候选) 'groups' => PersonGroup::orderFor($familyId, $me), 'tagChoices' => PersonTag::choices($familyId, $me), 'report' => null, ], 'people'); break; } csrf_check(); $f = $_FILES['contacts'] ?? null; if (!$f || (int)($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { flash('err', '没收到文件(或上传失败)。'); redirect('people/import'); } // ⚠️ 不去校验扩展名/类型:手机导出的 vcf 常被改名成 .txt 或 .csv, // 按扩展名拦会把正常文件挡在外面。判据用**内容**(见 ContactImport::parse)。 $raw = (string)file_get_contents((string)$f['tmp_name']); if (trim($raw) === '') { flash('err', '文件是空的。'); redirect('people/import'); } $records = ContactImport::parse($raw, (string)($f['name'] ?? '')); if (!$records) { flash('err', '没从这份文件里认出任何联系人。支持 .vcf(vCard)与 .csv/.txt' . '(表头含 姓名/手机/邮箱 即可)。'); redirect('people/import'); } $groupId = (int)($_POST['group_id'] ?? 0); // 🔴 v54(第 2 条):导入时可以顺手打一串标签(原来只给一个分组) $impTags = PersonTag::clean((string)($_POST['tags'] ?? '')); $res = ContactImporter::run($familyId, $me, $records, $groupId, $impTags); $msg = '导入完成:新增 ' . $res['added'] . ' 人,补充信息 ' . $res['updated'] . ' 人'; if ($res['skipped'] > 0) { $msg .= ',' . $res['skipped'] . ' 条无需改动'; } flash('ok', $msg . '。'); View::render('people/import', [ 'title' => '导入联系人', 'groups' => PersonGroup::orderFor($familyId, $me), 'tagChoices' => PersonTag::choices($familyId, $me), 'report' => $res, ], '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); /* 🔴 v54(用户 2026-10-16 第 2 条):挑人时改成**打标签**。 ⚠️ `group_id` 仍然收着(老书签/脚本、以及别的调用方可能还在传), 只是新界面**不再展示**下拉 —— 有值就照旧写进 roster.group_id, 没值(新界面的常态)就 NULL,与"未分类"同义。 */ $pickTags = PersonTag::clean((string)($_POST['tags'] ?? '')); // 只认「确实可挑」的那些 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++; // 这串标签给**每个**新进名册的人各打一遍(见模板里的说明) foreach ($pickTags as $tg) { PersonTag::addOne($familyId, $me, $pid, $tg); } } } flash($n > 0 ? 'ok' : 'err', $n > 0 ? '已把 ' . $n . ' 个人加进你的名册。' : '一个人都没加上 —— 先勾选要加的人。'); redirect('people'); } View::render('people/pick', [ 'title' => '从家里已有的人里挑', 'people' => $familyPick, // ⚠️ v54:`groups` 仍传(老模板/兼容),但新界面用 `tagChoices` 打标签 'groups' => PersonGroup::orderFor($familyId, $me), 'tagChoices' => PersonTag::choices($familyId, $me), 'meId' => $me, ], 'people'); break; /** * 名册设置(v52 用户 2026-10-15 第 6 条):设置下拉里「名册分组」的目标页。 * * 内容 = 从个人设置**整块搬**过来的「我的分组」(见 templates/_person_groups.php)。 * ⚠️ 保存仍走既有的 `settings/groups/*` 路由(一个字段没改)—— * 分组是"我的分组",与它挂在哪一页无关;换路由只会多一处要维护的东西。 */ case 'people/settings': Auth::requireLogin(); $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能改分组。'); redirect('people'); } $gs = PersonGroup::orderFor($familyId, $me); View::render('people/settings', [ 'title' => '名册设置', 'groups' => $gs, 'groupCounts' => PersonGroup::counts($familyId, $me), 'rosterCount' => count(Person::roster($familyId, $me, 'member', '')), ], '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; // ==================== 通知消息(v30 第 5 条)==================== // 用户原话:「引入通知模块,这个模块,不在导航显示,在个人头像点击那里显示, // 放在个人设置前面,名字:通知消息。」 // ⚠️ 它**不在 Module::LIST** 里(见 app/Notification.php 头部的第 ①条)—— // 所以顶栏不渲染它、个人设置的"模块显示与排序"里也没有它。 case 'notifications': Auth::requireLogin(); $me = (int)Auth::personId(); // 打开这一页 = 都看过了。放在**取列表之前**会把刚进来的那批也标成已读, // 但列表照旧照常显示(只是左侧那个"未读"蓝点没了)—— 与消息中心的惯例一致。 Notification::markAllRead($me); View::render('notifications/index', [ 'title' => '通知消息', 'items' => Notification::mine($me), ], 'notifications'); break; case 'notifications/del': Auth::requireLogin(); csrf_check(); Notification::delete((int)($_POST['id'] ?? 0), (int)Auth::personId()); flash('ok', '这条通知已删除。'); redirect('notifications'); break; case 'notifications/clear': Auth::requireLogin(); csrf_check(); $n = Notification::clearMine((int)Auth::personId()); flash('ok', $n > 0 ? ('已清空 ' . $n . ' 条通知。') : '本来就没有通知。'); redirect('notifications'); break; /** * v32 第 3 条:**浏览器通知**的轮询口(JSON)。 * * 用户原话:「当有消息通知时,还有日历中日程到期提醒时…则需要在 9:50 时 * 浏览器中发提醒通知。」 * * 前端每 60 秒拉一次,本口回两样东西: * · `unread` —— 未读站内消息(前端弹"你有 N 条新消息",**不进通知页就不标已读**) * · `reminders` —— 落在 [now-2min, now+30s] 窗口内的日程提醒 * * ⚠️ 为什么"到点"要在**服务端**算,而不是前端拿 starts_at 自己减: * 时区、`reminder_minutes` 是逗号串(多档)、重复日程…这些判断都在 * PHP 侧已经有一套(reminderList / 可见性),前端再写一遍必然漂移。 * 前端只负责"把服务端说该响的响出来"。 * * ⚠️ 窗口要**留过去的一点**(-2 分钟):浏览器标签页休眠/被节流时轮询会迟到, * 只取"未来 30 秒"会整整错过一条(用户会看到"明明设了提醒却没弹")。 * ⚠️ 但不能留太长:会把"我刚打开页面"时早就过去的提醒一次性全弹出来。 * * ⚠️ **不标已读**:浏览器的提示条不是"打开消息页",不该把消息标成已读。 * 标已读只在用户真的进 `?r=notifications` 时发生。 */ case 'notifications/poll': Auth::requireLogin(); $me = (int)Auth::personId(); $familyId = (int)Auth::familyId(); header('Content-Type: application/json; charset=utf-8'); $now = time(); echo json_encode([ 'unread' => Notification::unreadCount($me), 'items' => array_map(static function (array $n): array { return [ 'id' => (int)$n['id'], 'actor' => (string)($n['actor_name'] ?? ''), 'body' => Notification::bodyWithoutActor((string)$n['title']), 'icon' => Notification::icon((string)($n['kind'] ?? '')), 'href' => Notification::href($n), ]; }, Notification::mine($me, 5, true)), 'reminders' => Calendar::remindersBetween( $familyId, $me, date('Y-m-d H:i:s', $now - 120), date('Y-m-d H:i:s', $now + 30) ), ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; // ==================== 我自己的资料 / 个人设置 ==================== // 顶栏头像点开 →「个人设置」。访客也能改自己的资料 —— // 「个人信息」是每个人自己的事,不该因为角色是 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; /** * 保存「我所在的城市」(v24 用户 2026-10-03 第 1 条)—— **每人一份**。 * * 用户原话:「天气这个这样吧,让用户在个人设置中填写自己的城市。」 * * 三段式: * ① 填了城市名 → 用内置表 app/Geo 查坐标(不联网,见 Geo 的类注释); * 查不到就**明说**查不到,不猜(猜错城市比不显示天气更糟)。 * ② 内置表没有的地方 → 允许直接填经纬度(镇 / 村 / 海外)。 * ③ 都不填 → 首页不显示天气那一块(不是错误,只是没配)。 * * ⚠️ 存的键挂在 **user_settings**(每人一份)而不是家庭 settings 表: * 一家人可能分处两个城市(孩子在外地),天气是"我看到的那一份天空", * 家庭级的设置一改全家都变,正好是这里要避免的。 */ case 'settings/weather': Auth::requireLogin(); csrf_check(); $me = (int)Auth::personId(); $city = trim((string)($_POST['city'] ?? '')); $latIn = trim((string)($_POST['lat'] ?? '')); $lonIn = trim((string)($_POST['lon'] ?? '')); UserSetting::set($me, 'weather_city', mb_substr($city, 0, 30, 'UTF-8')); // 坐标只收合法数字,非法值一律当"没填" —— // 一个手滑的字母会让首页每次请求都白白打一次外部接口。 $okNum = static fn(string $v): string => ($v !== '' && is_numeric($v) && (float)$v >= -180 && (float)$v <= 180) ? $v : ''; UserSetting::set($me, 'weather_lat', $okNum($latIn)); UserSetting::set($me, 'weather_lon', $okNum($lonIn)); if ($city !== '' && Geo::lookup($city) === null && ($latIn === '' || $lonIn === '')) { flash('err', '没有「' . $city . '」这座城市的坐标。换个写法(如「山东青岛」),' . '或者直接在下面填经纬度。'); } else { flash('ok', '天气地点已保存。'); } redirect('settings/me', [], 'weather'); 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; /** * 🔴 v52(用户 2026-10-15 第 7 条):**一站式保存模块设置**。 * * 用户原话:「个人设置里面,模块相关的设置太多了,应该**整合**,先列出来所有模块, * 中间有一列,让他勾选是否在导航显示,再给他一列,让他选择是否在首页显示, * 再给他一列,让他选择颜色,最后给他一列,让他**调整顺序**, * 这样就把几个设置,整合到一起了,更加简洁。」 * * ⇒ 原来那是**三张卡三个表单**(导航条勾选+排序 / 首页勾选+排序 / 主题色), * 内容高度重叠(同一串模块名出现两遍,颜色又另起一张),用户看得累、改起来要点三次保存。 * 现在合成**一张表**:一行一个模块,四列(导航 / 首页 / 颜色 / 顺序),一次保存。 * * ⚠️ 底层存储**一个都没改**(仍是 nav_modules / home_modules / module_colors 三个键): * 这条路由只是把三个 save 按顺序调一遍 —— 老数据天然兼容, * 别的入口(老表单、脚本、移动端 API)照旧能写。 * ⚠️ 权限仍是分开的:导航/首页是**每人一份**,颜色是**家庭一份**(非管理员只能看)。 * 非管理员提交时**忽略颜色**那一列(不能因为"合并成一张表"就顺手给了他改配色的权力)。 */ case 'settings/modules_save': Auth::requireLogin(); csrf_check(); $me = (int)Auth::personId(); $familyId = (int)Auth::familyId(); // 顺序:表里每行自带 `order[]`(拖动搬的是整行,提交上来就是屏幕顺序) $order = array_values(array_filter( array_map('strval', (array)($_POST['order'] ?? [])), static fn($k) => Module::exists($k) )); // 补上没出现在表单里的模块(理论上不会,但别让它们"消失") foreach (Module::keys() as $k) { if (!in_array($k, $order, true)) { $order[] = $k; } } $navOn = array_map('strval', (array)($_POST['nav'] ?? [])); $homeOn = array_map('strval', (array)($_POST['home'] ?? [])); $move = (string)($_POST['move'] ?? ''); // ▲▼ 兜底(没有 JS 时也能排序):在当前顺序里跟邻居换一位 if ($move !== '' && strpos($move, ':') !== false) { [$dir, $key] = explode(':', $move, 2); $i = array_search($key, $order, true); if ($i !== false) { $j = $dir === 'up' ? (int)$i - 1 : (int)$i + 1; if ($j >= 0 && $j < count($order)) { [$order[$i], $order[$j]] = [$order[$j], $order[$i]]; } } } // 按 order 的顺序取交集 —— 顺序就是用户拖出来的顺序 $navKeys = array_values(array_filter($order, static fn($k) => in_array($k, $navOn, true))); $homeKeys = array_values(array_filter($order, static fn($k) => in_array($k, $homeOn, true))); if (!$navKeys) { $navKeys = Module::DEFAULT_NAV; // 一个都不勾 = 导航条会空掉,退回默认 } Module::saveNav($me, $navKeys); /* ⚠️ v62:模块设置里**已经没有「首页」那一列**(首页改由「首页卡片」摆, 见 HomeCards)。所以只有表单真的带了 `home[]` 才写 —— 否则每次保存导航 都会把 HomeMods 清成空数组("关了还在"的反面:"没动也丢")。 */ if (array_key_exists('home', $_POST)) { HomeMods::save($me, $homeKeys); } // 主题色:家庭一份,只有管理员能改(非管理员提交时**静默忽略**,不报错) if (Auth::isAdmin() && isset($_POST['c']) && is_array($_POST['c'])) { 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(); } // 🔴 v63:颜色可随新建一起给(`settings/groups/add` 的公共标签加色块选择)。 // ⚠️ 没传 `color` 这个键 = 不设颜色(存 NULL,界面按 id 取默认色); // 传了空值 = 用户主动选了"不要颜色"⇒ 同样存 NULL。 // 两种都落到同一个值上,所以这里不用像 rename 那样区分 null / ''。 $res = PersonGroup::add((int)Auth::familyId(), (string)($_POST['name'] ?? ''), $owner, (string)($_POST['color'] ?? '')); 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(); } /* 🔴 v63(用户 2026-10-19 第 5 条):改名与改颜色走**同一个动作** (「每个标签增加一个颜色。如果要编辑,就点击这个标签后面的编辑按钮, 然后弹出一个框,编辑后点击完成就行了」)。 ⚠️ 用 `array_key_exists` 判断"表单里到底有没有这个字段", 不能用 `!== ''` —— 空值是一个**合法输入**(= 清掉颜色), 用 `!== ''` 就再也清不掉了。 */ $hasColor = array_key_exists('color', $_POST); $newColor = $hasColor ? (string)$_POST['color'] : null; $res = PersonGroup::rename((int)Auth::familyId(), (int)($_POST['id'] ?? 0), (string)($_POST['name'] ?? ''), $owner, $newColor); 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, // 工作经历先给一行空白(第 4 条),省得还要点「再加一段」 'jobRows' => [[]], 'myNote' => '', // 🔴 v53(第 4/5 条):新增页也要能打标签 + 填「我对他的称呼 / 备注」。 // 标签候选与编辑页同一个来源(我名下用过的全部标签)。 'myTags' => [], 'tagChoices' => PersonTag::choices((int)Auth::familyId(), (int)Auth::personId()), 'myNick' => '', ], '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'] ?? '')), // 用户 2026-10-04 第 4 条:地址是**结构化五级**(国家/省/市/区/详细), // 不再是一整串。四个下拉提交的是标准名而不是代码(见 Person::addrOrNull)。 'addr_country' => trim((string)($_POST['addr_country'] ?? '')), 'addr_province' => trim((string)($_POST['addr_province'] ?? '')), 'addr_city' => trim((string)($_POST['addr_city'] ?? '')), 'addr_district' => trim((string)($_POST['addr_district'] ?? '')), 'org' => trim((string)($_POST['org'] ?? '')), 'title' => trim((string)($_POST['title'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')), // 新增的人还没有"我的这一句话"(那是他自己的东西,由他之后自己填) 'slogan' => '', // 工作经历(第 4 条):一组行,整组替换;见 PersonJob::replace 'jobs' => read_jobs_input(), '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; } /* 🔴🔴 v52(用户 2026-10-15 第 5 条):**先问一句"是不是这个人"**。 用户原话:「当其他的用户新增名册中的人员时,当输入的信息,例如姓名、手机号、身份证号, 和名册中已有人员匹配时,提示他**是不是这个人**,如果是, **直接让他添加到自己的名册中即可**。」 ⇒ 校验通过后、**真的建人之前**按 姓名/手机号/身份证号 找一找家里人; 找到了就让用户确认(`confirm_existing` 带上来匹配到的 id 才真加)。 ⚠️ 只在校验通过时问 —— 表还没填对就先问"是不是这个人"是本末倒置。 ⚠️ 判"是不是第一次提交"用 `array_key_exists('confirm_existing')`: 没这个字段 = 第一次提交 → 出确认卡; ='1' → 用户已确认"就是这个人" ⇒ 不新建,直接加进我的名册; ='0' → 用户点了"不是,另建一个" ⇒ 照常往下新建。 ⚠️ 不能光看"匹不匹配"就替用户决定 —— 同名的人真的存在("张伟"), 系统只能**问一句**,决定权在用户。 */ if (!$errors && !array_key_exists('confirm_existing', $_POST)) { $hit = Person::matchExisting($familyId, $d); if ($hit) { set_old($_POST); /* 把「是不是这个人」做成**表单页里的一个确认条**,而不是单独一页 —— ⚠️ 关键理由:单独一页就得把用户刚填的一整张表(含工作经历/补充联系方式 这些**数组字段**)原样回传,任何一类字段漏传都会静默丢数据。 在**同一张表**里问,字段天然全在,一个都不会丢。 */ View::render('people/form', [ 'title' => '添加成员', 'person' => null, 'errors' => [], 'phoneValue' => $d['phone'], 'idCardValue'=> $d['id_card'], 'canEditAll' => true, 'isSelf' => false, 'contacts' => $contacts, 'jobRows' => (array)($d['jobs'] ?? []), 'myNote' => trim((string)($_POST['my_note'] ?? '')), // 🔴 v53:新增页新加的这三样也要**原样回显**,否则一撞名/一报错 // 用户刚打的标签、刚写的称呼与备注就全没了(重填一遍)。 'myTags' => PersonTag::clean((string)($_POST['tags'] ?? '')), 'tagChoices' => PersonTag::choices((int)Auth::familyId(), (int)Auth::personId()), 'myNick' => trim((string)($_POST['my_nick'] ?? '')), 'confirmHit' => $hit, 'confirmWhy' => (string)($hit['_match'] ?? 'name'), ], 'people'); exit; } } // 用户确认"就是这个人" ⇒ 不新建,直接加进我的名册(正是用户要的"直接添加") if (array_key_exists('confirm_existing', $_POST) && (string)$_POST['confirm_existing'] === '1') { $pid = (int)($_POST['existing_id'] ?? 0); $ex = $pid > 0 ? Person::find($pid) : null; if ($ex && (int)$ex['family_id'] === $familyId) { $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; } $newly = PersonRoster::ensure($familyId, (int)Auth::personId(), $pid, $gid > 0 ? $gid : null, 'self'); clear_old(); flash('ok', $newly ? '已把「' . (string)$ex['name'] . '」加进你的名册 —— 资料共用同一份,不用重录。' : '「' . (string)$ex['name'] . '」本来就在你的名册里。'); redirect('people/show', ['id' => $pid]); } flash('err', '那个人不在了,重新填一次吧。'); redirect('people/new'); } // ⚠️ 头像上传失败**不再挡住整条新增**(用户原话:「添加人员功能不能用了」—— // 早先一张 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, // 校验失败要把用户刚填的工作经历**原样回显**(第 4 条), // 私备注同理(第 5 条)—— 别让他重填一遍。 'jobRows' => (array)($d['jobs'] ?? []), 'myNote' => trim((string)($_POST['my_note'] ?? '')), 'myTags' => PersonTag::clean((string)($_POST['tags'] ?? '')), 'tagChoices' => PersonTag::choices($familyId, (int)Auth::personId()), 'myNick' => trim((string)($_POST['my_nick'] ?? '')), ], '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, // 校验失败要把用户刚填的工作经历**原样回显**(第 4 条), // 私备注同理(第 5 条)—— 别让他重填一遍。 'jobRows' => (array)($d['jobs'] ?? []), 'myNote' => trim((string)($_POST['my_note'] ?? '')), 'myTags' => PersonTag::clean((string)($_POST['tags'] ?? '')), 'tagChoices' => PersonTag::choices($familyId, (int)Auth::personId()), 'myNick' => trim((string)($_POST['my_nick'] ?? '')), ], 'people'); exit; } // 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。 // 🔴 v53(用户 2026-10-16 第 4 条):「添加成员时**不是选择分组,而是打标签**, // 现在已经没有分组的概念了,只有标签的概念。」 // ⇒ 新增表单不再给 group_id 下拉(改成标签输入)。这里硬写 null: // · 名册里"未分组"本来就合法(拖到哪栏都能再归); // · 列表页还有「调整分组」那套(拖动归档),需要时再用。 // ⚠️ 表结构 `person_roster.group_id` **保留不动**(拖动归档还在用它, // 历史数据也在)—— 只是**新增入口不再要求人选**。 PersonRoster::ensure($familyId, (int)Auth::personId(), $id, null, 'self'); Person::replaceContacts($id, $contacts); // 工作经历:整组替换(第 4 条)。空的那组会把这段经历清空 —— 与联系人同一口径。 PersonJob::replace($id, (array)($d['jobs'] ?? [])); // 🔴 v53(用户 2026-10-16 第 5 条):新增页也要能一次填完「我对他的称呼」与 // 「我对他的备注」,否则用户还得"先添加 → 再回他的名册 → 再填这两样"。 // ⚠️ 两者都挂在**我**名下(别人写的互不影响): // · 称呼 → nicknames(viewer=我, target=他); // · 备注 → person_notes(owner=我, person=他)。 // ⚠️ 判"表单里有没有这一栏"用 `array_key_exists`(而不是判值是否为空)—— // 与编辑路径同一个口径:空串是明确的"清空"语义(`Nickname::set('')` 会删行)。 if (array_key_exists('my_nick', $_POST)) { Nickname::set($familyId, (int)Auth::personId(), $id, (string)$_POST['my_nick']); } if (array_key_exists('my_note', $_POST)) { PersonNote::set($id, (int)Auth::personId(), (string)$_POST['my_note']); } // 标签(v53 第 4 条):与"编辑成员"页同一个入口、同一把尺(owner 永远是"我")。 if (array_key_exists('tags', $_POST)) { PersonTag::setFor($familyId, (int)Auth::personId(), $id, (string)$_POST['tags']); } 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, // 工作经历(第 4 条):库里已有的多段 'jobRows' => PersonJob::list($id), // 我对他的私备注(第 5 条):**只有我自己**那一份 'myNote' => $isSelf ? '' : PersonNote::get($id, (int)Auth::personId()), /* 🔴 v51(用户 2026-10-15 第 4 条):「名册这个功能里面,当我编辑资料的时候, 在这个页面竟然**没有办法选择它的标签**。按道理讲,名册编辑成员这个地方 应该能编辑这一个人的**所有信息**才对。」 ⇒ 这一页补上「标签」编辑(与名册列表上那颗标签按钮同一份数据源)。 ⚠️ 标签是**每人一份**的(owner = 我),所以只有"我"名册里的标签有意义: · `myTags` 这个人当前在我名下的标签 → 回填输入框; 🔴 v54(用户 2026-10-16 第 2 条):候选改成 `PersonTag::choices()` —— **设置页配的词表 ∪ 我用过的词**(原来只读 `cloud()` = 用过的词, 于是"设置里列着一堆、打标签时却只有几个")。 列表页、新增页、编辑页**三处同一个函数**,口径必须一致。 ⚠️ 改自己($isSelf)不给标签:标签是"我给别人打的",给自己打没有意义 (名册页那颗标签按钮也只在 `$canMove` 时出现)。 */ 'myTags' => $isSelf ? [] : PersonTag::of((int)Auth::personId(), $id), 'tagChoices' => $isSelf ? [] : PersonTag::choices((int)Auth::familyId(), (int)Auth::personId()), // 🔴 v53(第 5 条):编辑页也要回显「我对他的称呼」(与新增页同一个字段名), // 否则在新增页填了称呼、到编辑页却看不见(会以为没存上)。 'myNick' => $isSelf ? '' : Nickname::get((int)Auth::personId(), $id), ], '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(); // 「我的这一句话」只能本人改(用户第 3 条)→ 非本人提交时沿用库里现有值。 $keepSlogan = (string)($p['slogan'] ?? ''); $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'] ?? '')), // 用户 2026-10-04 第 4 条:地址是**结构化五级**(国家/省/市/区/详细), // 不再是一整串。四个下拉提交的是标准名而不是代码(见 Person::addrOrNull)。 'addr_country' => trim((string)($_POST['addr_country'] ?? '')), 'addr_province' => trim((string)($_POST['addr_province'] ?? '')), 'addr_city' => trim((string)($_POST['addr_city'] ?? '')), 'addr_district' => trim((string)($_POST['addr_district'] ?? '')), 'org' => trim((string)($_POST['org'] ?? '')), 'title' => trim((string)($_POST['title'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')), // ⚠️ 用户 2026-10-03 第 3 条:这一句话**只能本人改** —— // 表单里这一栏也只在 $isSelf 时出现。别人提交时这里读不到字段, // 若直接 $_POST 取值会拿到空串 → **把本人的那句话清空**。 // 所以非本人一律沿用库里已有的值($keepSlogan)。 'slogan' => $isSelf ? trim((string)($_POST['slogan'] ?? '')) : $keepSlogan, // 工作经历(第 4 条):一组行,整组替换;见 PersonJob::replace 'jobs' => read_jobs_input(), '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, /* 🔴 v51:校验失败**必须把标签也带回去**,否则用户刚打的一串标签 随着这一屏重画就没了("改了半天的东西突然消失")。 ⚠️ 取自 `$_POST['tags']`(用户刚提交的那份),**不是**库里的旧值 —— 这才叫"保留用户填的东西",`clear_old`/`old()` 那一套同理。 */ 'myTags' => $isSelf ? [] : PersonTag::clean((string)($_POST['tags'] ?? '')), 'tagChoices' => $isSelf ? [] : PersonTag::choices($familyId, (int)Auth::personId()), ], '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, // v51:同上面那条 —— 重画时保留用户刚提交的标签 'myTags' => $isSelf ? [] : PersonTag::clean((string)($_POST['tags'] ?? '')), 'tagChoices' => $isSelf ? [] : PersonTag::choices($familyId, (int)Auth::personId()), ], 'people'); exit; } Person::replaceContacts($id, $contacts); // 工作经历:整组替换(第 4 条)。空的那组会把这段经历清空 —— 与联系人同一口径。 PersonJob::replace($id, (array)($d['jobs'] ?? [])); /* 🔴 v51(用户 2026-10-15 第 4 条):编辑页也能改「标签」了。 ⚠️ **owner 永远是"我"**(`PersonTag::setFor(家庭, 我, 他, ...)`)—— 标签是"我给别人打的",改它不能影响全家的标签(与列表页那颗标签按钮同一口径)。 ⚠️ 守卫与"私备注"同一条思路,但不同: · 改自己时表单里**没有** tags 字段 → 不能读(读到空串会把我的标签清空); · 所以先用 `array_key_exists` 判"这次提交里有没有这个字段", 没有就**不碰**(而不是"当成清空")。 ⚠️ 这正是 v49 那次踩过的形状("字段不在就当成空"会把数据弄丢)—— 凡"条件出现的字段"都必须按"存在与否"分流,不能按"值是否为空"。 */ if ((int)$id !== (int)Auth::personId() && array_key_exists('tags', $_POST)) { PersonTag::setFor($familyId, (int)Auth::personId(), $id, (string)$_POST['tags']); } // 「我对他的私人备注」(第 5 条):落在**写的人自己**名下(person_notes)。 // ⚠️ 两条守卫: // ① 编辑自己时表单里没有这个字段(不需要"我对我的备注")→ 别把已有的清掉; // ② 即使有,也不该对自己写(`$id !== 我`)。 if ((int)$id !== (int)Auth::personId() && array_key_exists('my_note', $_POST)) { PersonNote::set($id, (int)Auth::personId(), (string)$_POST['my_note']); } // 🔴 v53(用户 2026-10-16 第 5 条):**我对他的称呼** —— 与上面那条同一把尺, // 新增页与编辑页都会提交 `my_nick`(同一个字段名,一处处理)。 // ⚠️ 空串 = **清空**("留空就用他自己的名字"是明确语义,与"没这个字段"不同)—— // 所以这里用 `array_key_exists` 判"表单里有没有这一栏",而不是判"值是不是空"。 if ((int)$id !== (int)Auth::personId() && array_key_exists('my_nick', $_POST)) { Nickname::set($familyId, (int)Auth::personId(), $id, (string)$_POST['my_nick']); } 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; /** * 保存「我对他的私人备注」(用户 2026-10-03 第 5 条)。 * * 独立成一个路由而不是塞进 people/update:这两件事的**可见性**不同 —— * people/update 改的是"这个人的资料"(有写权限的人都能改), * 而这条备注是"**我**和他之间的事",只有写的人自己看得到。 * 混在一起的话,"有写权限"就等于"能改别人对某人的私评",那是另一回事。 */ case 'people/note': Auth::requireLogin(); csrf_check(); $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $id = (int)($_POST['id'] ?? 0); if ($id <= 0 || $id === $me || !isset(Person::map($familyId)[$id])) { flash('err', '找不到这个人。'); redirect('people'); } PersonNote::set($id, $me, (string)($_POST['note'] ?? '')); flash('ok', '备注已保存(只有你自己看得到)。'); redirect('people/show', ['id' => $id]); 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']), // 工作经历(第 4 条):可以有多段,详情页整列出来 'jobs' => PersonJob::list($id), // 我对他的私人备注(第 5 条):**只有我自己**那一份;看自己时为空 'myNote' => $id === $me ? '' : PersonNote::get($id, $me), '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); // ---- v24 第 21 批 第 1 条:名册页的删除改成**无刷新**(用户原话: // 「删除一个联系人后,能否不刷新页面,删除成功后,直接把这个人, // 从当前页面的 dom 中删除,页面保持不动。」) // // ⚠️ 同一个路由要同时服务两种调用者,所以**必须认 X-Requested-With**: // · 名册页的 JS 带它 → 回 JSON,前端自己摘卡片; // · 详情页底部那个删除**不带**(原生表单)→ 照旧 302 回名册。 // 判据不能用"有没有 POST 参数"之类的猜测 —— 那两处的参数是一样的。 // // ⚠️ 每一条**失败**也要回 JSON(而且 msg 要说清为什么): // 前端唯一的退路是"退回原生提交",而退回意味着刷页 —— // "不能删自己"这种可预期的拒绝根本不该刷页。 $ajax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') || (strpos((string)($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json') !== false); $jsonOut = static function (bool $ok, string $msg, array $extra = []) use ($ajax) { if (!$ajax) { flash($ok ? 'ok' : 'err', $msg); return false; } header('Content-Type: application/json; charset=utf-8'); echo json_encode(array_merge(['ok' => $ok, 'msg' => $msg], $extra), JSON_UNESCAPED_UNICODE); exit; }; if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) { // 原本这里是静默 redirect("点了没反应");现在明确回一句 if ($jsonOut(false, '找不到这个人,可能已经被删掉了。')) { } redirect('people'); } if ((int)$p['id'] === (int)Auth::personId()) { // 这条原本是 redirect 到**详情页**(用的人自己);AJAX 时不该跳页 if ($jsonOut(false, '不能把自己从名册里移除。')) { } flash('err', '不能把自己从名册里移除。'); redirect('people/show', ['id' => $id]); } // 不让最后一个管理员消失:系统里没人能管理家庭就锁死了 $acc = Account::findByPerson($id); if ($acc && $acc['role'] === 'admin' && Account::adminCount() <= 1) { if ($jsonOut(false, '这是唯一的管理员,先把另一个人设为管理员再移除。')) { } flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。'); redirect('people/show', ['id' => $id]); } Upload::remove((int)Auth::familyId(), (string)$p['avatar']); $gone = (string)$p['name']; Person::delete($id); if ($jsonOut(true, '已删除「' . $gone . '」。', ['id' => $id])) { } flash('ok', '已从名册移除「' . $gone . '」。'); redirect('people'); break; /** * 行政区划级联数据(用户 2026-10-04 第 4 条:地址要「省 / 市 / 区」三级联动)。 * * 只吐一个 JSON 列表给前端填下拉 —— 3429 条一次性塞进页面会让表单页重一百多 KB, * 而用户一次只会用到其中两个下拉,按级取就够了。 * ⚠️ 必须登录才能取(虽然不是什么秘密,但没必要给匿名访客开一个能枚举全国的接口)。 */ case 'regions': Auth::requireLogin(); $parent = (string)($_GET['parent'] ?? ''); header('Content-Type: application/json; charset=utf-8'); echo json_encode( ['ok' => true, 'items' => \App\Region::childrenOf($parent)], JSON_UNESCAPED_UNICODE ); exit; // ==================== 账号 ==================== 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, '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'] ?? '')) ); // ⚠️ 天气坐标**不再从系统设置收**(用户 2026-10-03 第 1 条:改到个人设置填城市)。 // 这里也不再 Set 那三个键 —— 但**不删**库里已有的旧值: // 万一有人还没去个人设置填城市,旧的家庭级坐标留着还能兜底(见下)。 // (Home::weatherLocation 只读 user_settings,旧的家庭值已经不参与; // 留着只是为了"哪天想回滚"时有据可查,代价是三个字符串。) 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(); $me = (int)Auth::personId(); $keyword = trim((string)($_GET['keyword'] ?? '')); $rows = Movie::all($familyId, $keyword, $me); /* 🔴 v37 用户第 6 条第 4 点:「观影模块也是一样,新增一部电影时, 也弹出一个框来输入所有信息。」⇒ 列表页内嵌"记一场电影"弹框, 所以这里要把表单需要的两组数据一并给它(原来只有 movies/new 才传)。 */ View::render('movies/index', [ 'title' => '观影记录', 'rows' => $rows, 'total' => count($rows), 'keyword' => $keyword, 'peopleForModal' => Person::roster($familyId, $me, 'member', ''), 'cinemasForModal' => Cinema::all($familyId), /* 从"新建影院"弹框存完回来的(?cinema=)→ 顺手把外层弹框打开 */ 'openModal' => (int)($_GET['cinema'] ?? 0) > 0, ], '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'); } /* 🔴 v43(用户 2026-10-11 第 6 条):条目详情浮层里点「编辑」时, 要把它**就地**换成这张编辑表单 —— `?embed=1` 只吐弹框那一块 (不套 layout),由浮层塞进正文。与 `trips/edit` 同一套约定。 ⚠️ 详情页那一路(`movies/show` 内嵌 `$mvModalOnly+$mvModalForce`) 继续走原样,不受影响。 */ $mvArgs = [ 'm' => $m, 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), 'cinemas' => Cinema::all($familyId), 'errors' => [], ]; if ((string)($_GET['embed'] ?? '') === '1') { header('Content-Type: text/html; charset=utf-8'); echo View::renderPartial('movies/form', $mvArgs + ['mvModalOnly' => true, 'mvModalForce' => true, 'mvOpen' => true]); exit; } View::render('movies/form', ['title' => '编辑观影记录'] + $mvArgs, '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); } } } // 现场照片(多选)。 // ⚠️ v24 第 20 批 第 1 条:**编辑时也收**这里传上来的照片了。 // 以前只有新增表单给这个字段(编辑页只在详情页里逐张补),所以这里写着 // `if (!$isEdit)`。现在编辑页也能一次多选加几张,所以要处理"合并"而不是替换: // 编辑 = 库里已有的(在编辑页逐张删过的会被 photo_del 立刻删掉,库里就是最新的) // + 这次新选上来的。 // ⚠️ 不能沿用"整组替换":那样一编辑就会把已有的照片全冲掉(用户报的 // "合影没带进来"如果按替换实现,会变成更难发现的"合影丢了")。 $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], ]; } } /* 🔴🔴 v35:**拖动/点击上传的照片走的是暂存区**(`stage_token[]`), 而这里原来**只读 `$_FILES['photo']`** —— 暂存令牌**从来没人认领**, 于是"上传的照片全没了"。用户报的「上传的附件都没有保存」的真根因。 ⚠️ 为什么照片与海报/票根不一样:海报/票根各有独立字段(`stage_poster` / `stage_ticket`,见下面那段),照片是**多个**,所以走通用那组 `stage_token[]`(`_stage_drop.php` 里 `$sdField` 的规则)。 海报/票根那两段只认自己的字段,通用那组**没有落点**。 ⇒ 这里把 `stage_token[]` 逐个读出来、按 'photo' 槽位重跑 Upload::image。 */ $stagedPhotoTokens = (array)($_POST['stage_token'] ?? []); $photoKeys = $isEdit ? array_map('strval', array_column(Movie::photos($id), 'key')) // 保住已有的 : []; $photoTouched = false; foreach ($stagedPhotoTokens as $stoken) { $stoken = trim((string)$stoken); if ($stoken === '') { continue; } $sf = \App\StagedUpload::read($familyId, (int)Auth::personId(), $stoken); if ($sf === null) { $warns[] = '有一张照片:暂存文件已失效,请重传一次。'; continue; } $fake = [ 'name' => (string)$sf['name'], 'type' => (string)$sf['mime'], 'tmp_name' => (string)$sf['path'], 'error' => UPLOAD_ERR_OK, 'size' => (int)@filesize((string)$sf['path']), ]; $up = Upload::image($familyId, $fake, 'photo'); if ($up['ok']) { $photoKeys[] = (string)$up['file']; $photoTouched = true; \App\StagedUpload::forget($stoken); } elseif ((string)($up['error'] ?? '') !== '') { $warns[] = '有一张照片没有保存:' . $up['error']; } } if ($photoFiles) { foreach ($photoFiles as $f) { $up = Upload::image($familyId, $f, 'photo'); if ($up['ok']) { $photoKeys[] = (string)$up['file']; $photoTouched = true; } elseif ((string)($up['error'] ?? '') !== '') { $warns[] = '有一张照片没有保存:' . $up['error']; } } } if ($photoTouched || !$isEdit) { $d['photos'] = $photoKeys; } /* ---- v24 第 20 批 第 4 条:拖动**即时上传**的海报 / 票根 ---- 用户原话:「拖动一个进去,就开始上传…最后统一保存即可。」 拖动会上传到暂存区并回一个 stoken,前端放进隐藏字段 `stage_poster` / `stage_ticket`(**海报/票根各一个字段名**,因为它们要区分槽位)。 ⚠️ 这里直接取 stoken 对应的暂存文件来重跑 Upload::image(按该槽位的比例), 而不是把暂存文件原样搬过去 —— 海报是 2:3、票根是 free, 不按槽位处理就会存成比例不对的图(页面上会变形)。 ⚠️ 临时文件上传(`$_FILES[$slot]`)优先:用户**又选又拖**时以"选"的为准 (那是他更明确的动作),免得两个来源打架。 ⚠️ 认领失败要记进 $warns(不静默丢),且**不影响保存**(与既有口径一致)。 */ foreach (['poster' => 'stage_poster', 'ticket' => 'stage_ticket'] as $slotKey => $fieldName) { if (!empty($_FILES[$slotKey]) && (int)($_FILES[$slotKey]['error'] ?? 4) === UPLOAD_ERR_OK) { continue; // 选了新文件 → 上面的常规路径已经处理过了 } $stoken = trim((string)($_POST[$fieldName] ?? '')); if ($stoken === '') { continue; } $sf = \App\StagedUpload::read($familyId, (int)Auth::personId(), $stoken); if ($sf === null) { $warns[] = ($slotKey === 'poster' ? '海报' : '票根') . ':暂存文件已失效,请重拖一次。'; continue; } $fake = [ 'name' => (string)$sf['name'], 'type' => (string)$sf['mime'], 'tmp_name' => (string)$sf['path'], 'error' => UPLOAD_ERR_OK, 'size' => (int)@filesize((string)$sf['path']), ]; $up = Upload::image($familyId, $fake, $slotKey); if (!empty($up['ok'])) { $newKey = (string)$up['file']; $oldKey = $isEdit ? (string)($m[$slotKey . '_key'] ?? '') : ''; $d[$slotKey . '_key'] = $newKey; if ($oldKey !== '' && $oldKey !== $newKey) { \App\Storage\Storage::delete($familyId, $oldKey); } \App\StagedUpload::forget($stoken); // 暂存那份不再需要 } elseif ((string)($up['error'] ?? '') !== '') { $warns[] = ($slotKey === 'poster' ? '海报' : '票根') . '没有保存:' . $up['error']; } } if ($errors) { set_old($_POST); /* 🔴🔴 v40(用户 2026-10-10):「观影详情里面的编辑按钮应该也是弹出的, 和新增是一样的,编辑完以后,页面动态更新。」 ⇒ 详情页的编辑现在走**弹框 + AJAX**。校验失败时**两条路必须给同一件事**: · AJAX(弹框里提交)→ 回 JSON,带上**重画后的整个弹窗体**, 前端直接塞回弹框容器 ⇒ 用户看到红字与填过的值(与整页路径一模一样); · 普通 POST(无 JS 兜底)→ 照旧整页重画。 ⚠️ 判断放在**这里**而不是函数入口:只有"真的校验失败"才需要重画, 成功路径不管谁来的都是 302(AJAX 那边靠 `fetch` 跟到最终页再替换)。 ⚠️ 弹窗体用 `$modalOnly = false` 渲染(要那个 cal-modal **壳**), 因为详情页就是靠这一整块壳来弹的(列表页内嵌走的是另一条 `$mvModalOnly = true`,别混)。 */ $isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest'); if ($isAjax) { header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => false, 'errors' => $errors, // ⚠️ 关键:用 `$mvModalForce = true` 让模板**一定**输出弹框壳 // 并处于打开态(编辑态的 `$mvOpen` 本来是 false,见 form.php)。 'modal' => View::renderPartial('movies/form', [ 'm' => $m, 'people' => Person::roster($familyId, $me, 'member', ''), 'cinemas' => Cinema::all($familyId), 'errors' => $errors, 'mvModalOnly' => true, // 不输出 page-head(塞回弹框里) 'mvModalForce' => true, // 强制成"弹框壳" 'mvOpen' => true, // 且保持打开(用户正在看它) ]), ], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); exit; } 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); // v30 第 5 条:把"这次把谁加进来了"变成通知(撤销逻辑见 Notification::syncForTarget) Notification::syncForTarget( $familyId, Notification::MOVIE, $id, (string)$d['title'], $me, (array)($d['attendees'] ?? []), 'movies/show', ['id' => $id] ); clear_old(); flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('movies/show', ['id' => $id]); } else { $newId = Movie::create($familyId, $me, $d); Notification::syncForTarget( $familyId, Notification::MOVIE, $newId, (string)$d['title'], $me, (array)($d['attendees'] ?? []), 'movies/show', ['id' => $newId] ); clear_old(); flash('ok', '已记下这场电影。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); /* 🔴 v81(用户 2026-10-22):「把原来模块里弹的那个框,现在拿到这边来。 目的就是为了在首页上就能快速完成一定工作,而不用进到模块里面。」 ⇒ 从**首页**那张弹框提交的,回首页(卡片上立刻能翻到刚记的这部)。 ⚠️ 默认仍是「跳详情页」—— 从观影模块新建时,用户接着就要补 评价、加照片、传票根,跳详情页才对;**只有首页来的才改道**。 ⚠️ 白名单取值,不把 POST 字符串直接当跳转地址(开放重定向)。 */ if ((string)($_POST['back'] ?? '') === 'home') { redirect('home'); } 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'); } $staged = (array)($_POST['stage_token'] ?? []); $up = Upload::image($familyId, $_FILES['photo'] ?? [], 'photo'); if (!$up['ok'] && $staged) { // 🔴 v35:详情页"补一张照片"的框也是**暂存式**(_stage_drop 默认走暂存), // 所以这里原来只读 $_FILES['photo'] 时**永远拿不到文件**(用户报的 // "上传的附件都没有保存")。暂存令牌 → 重跑 Upload::image。 $stoken = trim((string)($staged[0] ?? '')); $sf = $stoken !== '' ? \App\StagedUpload::read($familyId, (int)Auth::personId(), $stoken) : null; if ($sf !== null) { $up = Upload::image($familyId, [ 'name' => (string)$sf['name'], 'type' => (string)$sf['mime'], 'tmp_name' => (string)$sf['path'], 'error' => UPLOAD_ERR_OK, 'size' => (int)@filesize((string)$sf['path']), ], 'photo'); if ($up['ok']) { \App\StagedUpload::forget($stoken); } } } 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', '照片已删除。'); // ⚠️ v24 第 20 批 第 1 条:编辑页也能删照片了,所以按来路回去 —— // 从编辑页删的就回编辑页(否则用户一删就被甩回详情页,还得再点一次编辑)。 // 来路用表单里的 `back` 判断(由模板给出,白名单,免得开放重定向)。 $back = (string)($_POST['back'] ?? ''); if ($back === 'edit') { redirect('movies/edit', ['id' => $id]); } redirect('movies/show', ['id' => $id]); break; /** * 删掉海报 / 票根(v24 第 20 批 第 1 条)。 * * 为什么需要它:编辑页现在把已存的海报/票根**显示出来**了, * 用户看着一张不想要的图,唯一的动作就是"再选一张新的"—— * 而他可能只是**想去掉它**(海报库里没有更好的,删掉反而干净)。 * * ⚠️ 顺带把存储对象一起删(只清库不留孤儿文件,与 Movie::deletePhoto 同一口径)。 * ⚠️ 换图时(movies/update)服务端已经会删旧图,所以这里只管"只删不换"。 */ case 'movies/media_del': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $slot = (string)($_POST['slot'] ?? ''); $m = Movie::find($familyId, $id, (int)Auth::personId()); if (!$m) { flash('err', '找不到这条观影记录。'); redirect('movies'); } // 白名单:只认这两个槽位(表单可以被改,不能凭它去删别的字段/别的对象) $col = ['poster' => 'poster_key', 'ticket' => 'ticket_key'][$slot] ?? ''; if ($col === '') { flash('err', '不知道要删哪一样。'); redirect('movies/edit', ['id' => $id]); } $old = (string)($m[$col] ?? ''); Database::get()->prepare('UPDATE movie_details SET ' . $col . ' = NULL WHERE event_id = ?') ->execute([$id]); if ($old !== '') { \App\Storage\Storage::delete($familyId, $old); } flash('ok', ($slot === 'poster' ? '海报' : '票根') . '已删除。'); redirect('movies/edit', ['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'], // 拼好的简介(含导演/主演/年份/类型/片长/国家/评分 + 剧情原文)。 // 放在服务端拼、由前端填空 —— 见 Douban::summaryText 的注释: // 这份文本会**落库**(movie_details.info),所以拼法要可测、只有一处。 'summary' => Douban::summaryText( (array)$resDb['meta'], (string)($resDb['title'] ?? ''), true ), '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 ? '影院信息已更新。' : '影院已添加。'); // 用户 2026-10-04 第 11 批 #4:「管理影院按钮,改为新建影院,点击后弹框,添加新影院, // 不要跳转到管理影院的页面,操作被割裂了。」 // → 从**观影表单页**弹框新建时,表单带一个 back=movies/new, // 存完回到那里(而不是甩到影院管理页),并且带上新影院的 id 让下拉直接选中它。 // ⚠️ 只认白名单里的两个值,绝不把 POST 的字符串直接当跳转地址(开放重定向)。 $back = (string)($_POST['back'] ?? ''); if ($back === 'movies/new') { // 新建时要拿到刚插入的 id 才能"回来直接选中它"。 // ⚠️ Cinema::save() 返回的是**错误串**(空串=成功),不返回 id —— // 所以这里按 (place, name) 回查一次;它是唯一键,查得到就是刚那条。 $newId = $id; if ($newId <= 0) { $st = Database::get()->prepare( 'SELECT id FROM cinemas WHERE family_id = ? AND place = ? AND name = ? LIMIT 1' ); $st->execute([$familyId, trim((string)($_POST['place'] ?? '')), trim((string)($_POST['name'] ?? ''))]); $newId = (int)($st->fetchColumn() ?: 0); } redirect('movies/new', $newId > 0 ? ['cinema' => $newId] : []); } 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()); /* 🔴 v54(用户 2026-10-16 第 3 条):「在我们的旅程模块也实现一下」地图显示。 `attachSpots` 给每趟旅程算一个**代表坐标**(各段行程 from/to 的重心; 没有坐标就退到城市中心,离线表、不发网络请求)—— 地图视图用它打点。 ⚠️ `Trip::all()` 内部**已经**调过 `attachDerived()`(挂 `cities`), 所以这里**不能再调一次** —— 重复跑一遍那几条聚合查询是白费。 ⚠️ 它对**列表视图**也无害(只是多挂几个键,模板不读就不显示)。 */ Trip::attachSpots($rows); View::render('trips/index', [ 'title' => '旅程', 'rows' => $rows, 'total' => count($rows), 'keyword' => $keyword, /* 🔴 v54(第 5 条):默认看列表还是地图(每人一份,见 trips/display)。 */ 'tripDefaultView' => (string)UserSetting::get((int)Auth::personId(), 'trip_default_view', 'list'), ], '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' => [], /* 🔴 v41:直达 `trips/new` 时也渲染成**打开着的弹框**。 不打开的话,用户看到的是一张"空白页面"(内容全在 hidden 的弹框里)。 */ 'tpModal' => true, 'tpOpen' => true, ], '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'); } /* 🔴 v41:详情页的「编辑」按钮也改弹框(用户第 4 条把那排按钮列全了, 第 5/6 条要求弹窗化)。`?embed=1` → 只吐表单片段,由详情页弹框容器承载。 */ $tpEmbed = (string)($_GET['embed'] ?? '') === '1'; $tpArgs = [ 't' => $t, 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), 'errors' => [], 'tpEmbed' => $tpEmbed, 'tpModal' => !$tpEmbed, 'tpOpen' => true, 'tpReload' => 'tripListHost', ]; if ($tpEmbed) { header('Content-Type: text/html; charset=utf-8'); echo View::renderPartial('trips/form', $tpArgs); exit; } View::render('trips/form', ['title' => '编辑旅程'] + $tpArgs, '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'); } $d = [ 'title' => trim((string)($_POST['title'] ?? '')), // ⚠️ 用户第 15 批 第 2 条:活动**不收**起止时间与目的地 —— // 时间由各段旅程的起止算出来、城市由各段旅程的 place 去重得到。 // 表单里已经没有这些字段;这里不是"忘了读",是**故意不读**。 // ($startsAt / $endsAt 的 read_dt_input 一并删掉了, // 否则"表单没这两个字段 → 读出 null → 把库里老值冲成 NULL", // 会静默毁掉迁移前的数据。) 'note' => trim((string)($_POST['note'] ?? '')), 'summary' => trim((string)($_POST['summary'] ?? '')), // 用户 2026-10-03 第 4 条:旅程是否公开(只影响列表与日历,不影响能否直接打开) // 用户第 4 条:是否公开。 // ⚠️ 三种情况必须分清(少了任何一种都会错): // ① 表单**没这个字段**(程序化调用 / 老表单 / 迁移)→ 连键都不放, // 让 Trip::saveDetails 按"默认公开"处理; // ② 表单提交了 hidden=0(用户取消勾选)→ 明确的 0; // ③ 勾选 → '1'。 // 之前写成 `?? ''` 会把 ① 也变成"键存在且为空" → 被当成不公开, // 于是数据在列表里**静默消失**(实测:新建的旅程从列表与日历上都没了)。 // ⚠️ v24 第 16 批 用户第 10 条:建/编辑旅程的表单里**没有**同行的人了 // ("这些都是从行程里面总结出来的")。所以这里**不再收** attendees。 // ⚠️ 但**不能**就此把活动上那份名单清空:老数据里很多人的参与人 // 只登记在这一层(那时还没有行程级参与人),清掉就"人没了"。 // 所以"键不存在"时保持原样(Trip::saveAttendees 收到空数组会全删, // 这里刻意不传这个键)。新数据的参与人一律从行程里长出来。 ]; if (array_key_exists('is_public', $_POST)) { $d['is_public'] = (string)$_POST['is_public']; } $errors = []; if ($d['title'] === '') { $errors['title'] = '旅程名字不能为空'; } // 「回来不能早于出发」的校验一并删掉:时间不再由这里填, // 而是从各段旅程算 max/min —— 二者反了也不可能(min ≤ max 恒成立)。 // 封面图(编辑时换新图才动它;失败不挡保存,与观影海报同一个口径) $oldCover = $isEdit ? (string)($t['cover_key'] ?? '') : ''; $d['cover_key'] = $oldCover; $warns = []; // v24 第 14 批 用户第 3 条:题图按 **16:9 裁剪**(走 'cover' 槽位,不再是 // 'photo' 那种"只缩不裁"的自由槽位)。裁剪矩形由前端 Cropper 写成归一化 // "x,y,w,h";没裁(空串)时服务端按 16:9 居中裁。 $up = Upload::image($familyId, $_FILES['cover'] ?? [], 'cover', (string)($_POST['cover_crop'] ?? '')); if ($up['ok']) { $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); /* 🔴 v41(用户 2026-10-10):新建旅程改弹框后,**校验失败**要 把这同一个弹框重画回去(而不是整页跳走)。 判据用 `X-Requested-With`(与观影详情页编辑同一套约定), 回 200 + JSON `{ok:false, modal:'<片段>'}`。 */ if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') { header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => false, 'modal' => View::renderPartial('trips/form', [ 't' => $isEdit ? array_merge($t, $d) : null, 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), 'errors' => $errors, 'tpModal' => true, 'tpModalOnly' => false, // 要壳 'tpOpen' => true, // 强制成"弹框 + 打开" ]), ], JSON_UNESCAPED_UNICODE); exit; } View::render('trips/form', [ 'title' => $isEdit ? '编辑旅程' : '新建旅程', 't' => $isEdit ? array_merge($t, $d) : null, 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), 'errors' => $errors, // ⚠️ 整页直达时也要**打开弹框**:否则用户提交失败后跳到这一页 // 只会看到一张空白(所有提示都在弹框里)。 'tpModal' => true, 'tpOpen' => true, ], 'trips'); exit; } if ($isEdit) { Trip::update($familyId, $id, $d); Notification::syncForTarget( $familyId, Notification::TRIP, $id, (string)$d['title'], (int)Auth::personId(), (array)($d['attendees'] ?? []), 'trips/show', ['id' => $id] ); clear_old(); flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('trips/show', ['id' => $id]); } $newId = Trip::create($familyId, (int)Auth::personId(), $d); Notification::syncForTarget( $familyId, Notification::TRIP, $newId, (string)$d['title'], (int)Auth::personId(), (array)($d['attendees'] ?? []), 'trips/show', ['id' => $newId] ); clear_old(); flash('ok', '这个旅程记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); /* 🔴 v41(用户第 2 条):「确定完了之后,**动态刷新旅程这个列表页面**。」 ⇒ 从列表页弹框来的(`back=trips`)回**列表页**,让前端只换列表容器; 其余入口(如直达整页 `trips/new`)保持老行为:进详情页加行程。 */ if ((string)($_POST['back'] ?? '') === 'trips') { redirect('trips'); } 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, 'nodes' => Trip::nodes((int)$t['id'], $meId), // 用户第 2 条:题图右侧那五项统计(参与人/城市/天数/段数/花销) 'sm' => Trip::summary((int)$t['id'], $meId), ], 'trips'); break; // ---------------- 行程:新增 / 编辑(独立页面,v24 第 16 批 第 5/6/7 条)---------------- // // ⚠️ 用户第 5 条:「行程的编辑功能,点击后会把页面撑破。编辑行程,打开新的页面进行编辑。」 // 以前用的是详情页里的
    内联表单 —— 每个行程一份, // TripLog 那种十几组字段的表单内联一开就把页面撑到几屏长, // 而且 N 份表单的元素 id 互相撞、密码管理器逐个扫描还会卡。 // // 表单字段照 TripLog 的 nodes/form.php(用户第 7 条)。 case 'trips/node_new': case 'trips/node_edit': Auth::requireWrite(); $familyId = (int)Auth::familyId(); $isEdit = $r === 'trips/node_edit'; $t = null; $nd = null; $stage = 'plan'; if ($isEdit) { // ⚠️ 必须带上 $viewerId:Trip::node() 现在会一并挂上 members(第 19 批 第 5 条: // "编辑行程参与人是空的")。成员读取要在**看的人**视角下取昵称。 $nd = Trip::node((int)($_GET['id'] ?? 0), (int)Auth::personId()); if (!$nd) { flash('err', '找不到这段行程。'); redirect('trips'); } // ⚠️ 必须校验这段行程确实属于**本家的**某个旅程: // 否则知道 id 就能改别人家的行程(走的还是 Auth::requireWrite) $t = Trip::find($familyId, (int)$nd['event_id'], (int)Auth::personId()); if (!$t) { flash('err', '找不到这段行程。'); redirect('trips'); } $stage = (string)($nd['stage'] ?? 'plan'); } else { $t = Trip::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId()); if (!$t) { flash('err', '找不到这个旅程。'); redirect('trips'); } $stage = (string)($_GET['stage'] ?? 'plan'); if (!isset(Trip::NODE_STAGES[$stage]) || $stage === 'skipped') { $stage = 'plan'; // "未成行"是标记出来的,不是新建出来的 } } /* 🔴 v41(用户 2026-10-10 第 5/6 条):新增/编辑行程改为**弹框**。 `?embed=1`(详情页弹框里 fetch 时带的)→ 只吐**表单片段**(不带整页外壳), 由详情页的弹框容器塞进去。 ⚠️ 不带 embed 时仍渲染整页 —— 老链接(书签、旧入口)照样能用, 而且校验失败时整页重画也走这条路。 */ $embed = (string)($_GET['embed'] ?? '') === '1'; $ndArgs = [ 'ev' => $t, 'n' => $nd, 'stage' => $stage, 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), 'types' => NodeType::all($familyId), 'vehicles' => Vehicle::pickable($familyId), 'expenses' => $nd ? Expense::ofNode((int)$nd['id']) : [], 'errors' => [], /* 🔴 v41:两种形态由**路由明确指定**,模板不猜: · embed=1(详情页弹框 fetch 片段)→ 无页头、无弹框壳(只吐表单); · 直达整页(含校验失败重画)→ 有页头 + 弹框壳且**打开**。 */ 'nfEmbed' => $embed, 'nfModal' => !$embed, 'nfShell' => !$embed, 'nfOpen' => true, 'nfReload' => 'nodeListHost', ]; if ($embed) { header('Content-Type: text/html; charset=utf-8'); echo View::renderPartial('trips/node_form', $ndArgs); exit; } View::render('trips/node_form', ['title' => ($isEdit ? '编辑行程' : '添加行程') . ' · ' . (string)$t['title']] + $ndArgs, 'trips'); break; // ---------------- 花销(行程级,v24 第 16 批 第 2 条)---------------- case 'trips/expense_add': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $nodeId = (int)($_POST['node_id'] ?? 0); // 校验这段行程确实属于本家(顺着 nodes → events 查一遍) $st = Database::get()->prepare( "SELECT n.id FROM trip_nodes n JOIN events e ON e.id = n.event_id WHERE n.id = ? AND n.family_id = ? AND e.family_id = ?" ); $st->execute([$nodeId, $familyId, $familyId]); if ($st->fetchColumn() === false) { flash('err', '找不到这段行程。'); redirect('trips'); } $res = Expense::add($familyId, $nodeId, [ 'amount' => (string)($_POST['amount'] ?? ''), 'category' => (string)($_POST['category'] ?? ''), 'title' => (string)($_POST['title'] ?? ''), 'person_id' => (int)($_POST['person_id'] ?? 0), ]); flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '花销记下了。' : (string)$res['error']); redirect('trips/node_edit', ['id' => $nodeId]); break; case 'trips/expense_del': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $nodeId = (int)($_POST['node_id'] ?? 0); Expense::destroy($familyId, (int)($_POST['expense_id'] ?? 0)); flash('ok', '这笔花销删掉了。'); redirect('trips/node_edit', ['id' => $nodeId]); break; // ---------------- 旅程设置:车辆 / 出行方式 / 节点类型(用户第 8 条)---------------- /** * 🔴 v54(用户 2026-10-16 第 5 条):「里面的设置让用户自己配置: * **到底默认显示旅程列表,还是用地图显示**。」 * ⇒ 旅程「显示方式」设置页。 * * 存在 `user_settings.trip_default_view`(**每人一份**,与看板色/天气地点同一层) * —— 这是"我进来先看哪个",不是全家的偏好。 * * `?set=list|map` 是**列表页上那颗"设为默认"小链接**用的 —— 它直接改设置再回列表, * 省去"点进设置页 → 选 → 保存 → 再回来"四步(那正是用户抱怨过的那种绕)。 * ⚠️ 用 GET 传值只到"点一下就设默认"这种**幂等的个人偏好**为止: * 没有副作用、不影响别人;真正的表单(页面里那两个 radio)仍走 POST + csrf。 * ⚠️ 访客(canWrite=false)也能改 —— 它只影响自己看到的默认,不是数据变更。 */ case 'trips/display': $me = (int)Auth::personId(); $set = (string)($_GET['set'] ?? ''); if (in_array($set, ['list', 'map'], true)) { UserSetting::set($me, 'trip_default_view', $set); flash('ok', '已把「' . ($set === 'map' ? '地图' : '列表') . '」设为打开旅程时的默认视图。'); redirect('trips'); } View::render('trips/display', [ 'title' => '显示方式', 'current' => (string)UserSetting::get($me, 'trip_default_view', 'list'), ], 'trips'); break; case 'trips/display_save': Auth::requireLogin(); csrf_check(); $me = (int)Auth::personId(); $val = (string)($_POST['view'] ?? ''); if (!in_array($val, ['list', 'map'], true)) { flash('err', '没看懂要设成哪种显示方式。'); redirect('trips/display'); } UserSetting::set($me, 'trip_default_view', $val); flash('ok', '已保存:以后打开旅程默认看「' . ($val === 'map' ? '地图' : '列表') . '」。'); redirect('trips/display'); case 'trips/vehicles': $familyId = (int)Auth::familyId(); View::render('trips/vehicles', [ 'title' => '车辆管理', // ⚠️ v24 第 17 批 第 3 条:**不再传** ledger(财务里的车)—— // 用户原话「车辆下拉框,不需要出现什么财务里登记的车」。 // 模板里那一整块也删掉了。fromLedger() 方法仍在模型层保留 // (将来按车统计油费/保养要用它跟 vehicles.ledger_id 对上)。 'mine' => Vehicle::all($familyId), ], 'trips'); break; case 'trips/vehicles_save': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $res = Vehicle::save($familyId, $_POST, $id > 0 ? $id : null); flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '车辆已保存。' : (string)$res['error']); redirect('trips/vehicles'); break; case 'trips/vehicles_del': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $res = Vehicle::destroy($familyId, (int)($_POST['id'] ?? 0)); flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '车辆已删除。' : (string)$res['error']); redirect('trips/vehicles'); break; case 'trips/modes': $familyId = (int)Auth::familyId(); // "常用信息"的记忆:每种出行方式下用户常填的值(不是新增交通方式) View::render('trips/modes', [ 'title' => '出行方式', 'modes' => Trip::TRAVEL_MODES, 'memo' => json_decode((string)Setting::get($familyId, 'trip_mode_memo', '{}'), true) ?: [], ], 'trips'); break; case 'trips/modes_save': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $raw = (array)($_POST['memo'] ?? []); $out = []; foreach ($raw as $k => $v) { // 只认确实存在的出行方式,值限长 —— 表单可以改,不能凭它往设置里塞任意东西 if (!isset(Trip::TRAVEL_MODES[(string)$k])) { continue; } $v = mb_substr(trim((string)$v), 0, 60, 'UTF-8'); if ($v !== '') { $out[(string)$k] = $v; } } Setting::set($familyId, 'trip_mode_memo', json_encode($out, JSON_UNESCAPED_UNICODE)); flash('ok', '出行方式设置已保存。'); redirect('trips/modes'); break; case 'trips/types': $familyId = (int)Auth::familyId(); $types = NodeType::all($familyId); foreach ($types as &$ty) { $ty['used'] = NodeType::usedCount($familyId, (string)$ty['name']); } unset($ty); View::render('trips/types', [ 'title' => '节点类型', 'types' => $types, ], 'trips'); break; case 'trips/types_save': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $res = NodeType::save($familyId, $_POST, $id > 0 ? $id : null); flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '类型已保存。' : (string)$res['error']); redirect('trips/types'); break; case 'trips/types_del': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $res = NodeType::destroy($familyId, (int)($_POST['id'] ?? 0)); flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '类型已删除。' : (string)$res['error']); redirect('trips/types'); 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; /* ---- v24 第 21 批 第 4 条:**编辑行程绝不能悄悄改阶段** ---- 用户原话:「旅程模块,实际行程编辑后,点击保存,竟然又变成计划行程了, 这个不合理,实际行程永远是实际行程,不会再变回计划行程了。」 这条违规的直接原因(`stage[]` 与 `stage` 同名合并 → 解析成 "Array") 已经通过改字段名修掉了(见 `_stage_drop.php` 头部)。 但这里再上一道**语义保险**:阶段只由「新建」和详情页那三个显式动作 (完成 / 标记未成行 / 标记为实际 / 恢复为计划)决定 —— **编辑表单里根本没有让你改阶段的控件**,所以: · 新建:认表单里那个隐藏 `stage`(值由「+ 计划行程 / + 实际行程」按钮决定, 服务端在 trips/node_new 里按 `?stage=` 渲染进去); · 编辑:**一律沿用库里那一段自己的 stage**,不读表单。 这样即便将来又冒出某个字段名冲突、或有人往表单里加了个 `stage`, 也不可能把「实际」悄悄变回「计划」—— 那是最不该发生的静默数据损坏。 ⚠️ 想改阶段请走详情页的显式按钮(它们各自有独立路由,语义清楚、可确认)。 ⚠️ 新建这里的取值用 `!is_array` 兜一道:**同一个坑的通用防线** —— `stage` 一旦因为任何原因变成数组(比如将来又有个 `stage[]`), `(string)` 会得到 "Array" → `in_array` 为假 → 静默变成「计划」。 这里明确只接受标量,别的一律不认(宁可退回默认,也不把脏值写进库)。 */ $stageForSave = 'plan'; if ($isEdit) { $curNode = Trip::node($nid); $stageForSave = (string)($curNode['stage'] ?? 'plan'); } else { $pick = $_POST['stage'] ?? 'plan'; $stageForSave = is_array($pick) ? 'plan' : (string)$pick; } // v24 第 16 批:表单照 TripLog 的节点,字段全量(用户第 7 条)。 // ⚠️ 第 17 批 用户第 3 条把「角色标注」去掉了("这个功能去掉,不要了"), // 所以这里**不再读** roles;座位/登机口也不再从表单收(字段已从界面移除, // 数据库那两列保留,详见 Trip::saveNode 的处理)。 $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'), // 新建:认表单里选的「计划 / 实际」;**编辑:一律沿用库里的**(见上面那段说明) 'stage' => $stageForSave, 'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId), // ---- v21:交通(TripLog 同款)---- 'travel_mode' => (string)($_POST['travel_mode'] ?? ''), 'vehicle_id' => (int)($_POST['vehicle_id'] ?? 0), 'line_name' => trim((string)($_POST['line_name'] ?? '')), 'carrier' => trim((string)($_POST['carrier'] ?? '')), 'trip_no' => trim((string)($_POST['trip_no'] ?? '')), // ---- v22:出发地 / 到达地(地点名 + 详细地址 + 坐标 + 城市)---- // 用户第 17 批 第 3 条:「出发地和到达地,都用高德地图的接口,记录详细地址」。 // 城市由前端联想带回(`*_city`),带不回时 Trip::saveNode 里用 App\Geo 解析。 'from_place' => trim((string)($_POST['from_place'] ?? '')), 'to_place' => trim((string)($_POST['to_place'] ?? '')), 'from_addr' => trim((string)($_POST['from_addr'] ?? '')), 'to_addr' => trim((string)($_POST['to_addr'] ?? '')), 'from_lng' => trim((string)($_POST['from_lng'] ?? '')), 'from_lat' => trim((string)($_POST['from_lat'] ?? '')), 'to_lng' => trim((string)($_POST['to_lng'] ?? '')), 'to_lat' => trim((string)($_POST['to_lat'] ?? '')), 'from_city' => trim((string)($_POST['from_city'] ?? '')), 'to_city' => trim((string)($_POST['to_city'] ?? '')), ]; if ($d['title'] === '') { /* 🔴 v41(用户 2026-10-10 第 5/6 条):行程新增/编辑改成**弹框**后, 校验失败不能在弹框里"整页跳走"(用户会看到白页面、也不知道错在哪)。 ⇒ AJAX 请求(弹框提交)回 200 + JSON `{ok:false, modal:'<重画片段>'}`, 由详情页把这一段塞回弹框;非 AJAX(老式整页提交)保持原行为。 */ if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') { header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => false, 'error' => '这段行程得有个名字。', 'modal' => View::renderPartial('trips/node_form', [ 'ev' => $t, 'n' => $isEdit ? Trip::node($nid, (int)Auth::personId()) : null, 'stage' => (string)($_POST['stage'] ?? 'plan'), 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), 'types' => NodeType::all($familyId), 'vehicles' => Vehicle::pickable($familyId), 'expenses' => $isEdit ? Expense::ofNode($nid) : [], 'errors' => ['title' => '这段行程得有个名字。'], 'nfEmbed' => true, // 只吐表单片段(壳由详情页容器给) 'nfModal' => false, 'nfShell' => false, 'nfReload' => 'nodeListHost', ]), ], JSON_UNESCAPED_UNICODE); exit; } flash('err', '这段行程得有个名字。'); redirect('trips/show', ['id' => $tripId]); } $newId = Trip::saveNode($familyId, $tripId, $d, $isEdit ? $nid : null); $warns = attach_save_files($familyId, 'trip_node', $newId, 'photo', (int)Auth::personId()); /* 🔴 v30 第 5 条:**这里才是旅程里"把谁加进来"的日常入口** (活动级那个"同行的人"字段 v24 之后就没了,参与人从行程长出来)。 ⇒ 通知挂在**这一段行程**上(target_type='trip_node'),点进去直接落到那一段。 ⚠️ 用 `members` 而不是 `attendees`:表单字段名就是它(见上面 $d 的装配)。 */ Notification::syncForTarget( $familyId, 'trip_node', $newId, (string)$d['title'] . '(' . (string)($t['title'] ?? '旅程') . ')', (int)Auth::personId(), (array)($d['members'] ?? []), 'trips/show', ['id' => $tripId] ); flash('ok', ($isEdit ? '这段行程已保存。' : '行程已加进来。') . ($warns ? '(' . implode(';', $warns) . ')' : '')); // ⚠️ 用户第 5 条:编辑是**独立页面**,保存后回详情页(而不是留在表单上) redirect('trips/show', ['id' => $tripId], 'node-' . $newId); 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; // ---- 计划行程的三个动作(v24 第 13 批)---- // 用户原话:「如果是计划行程,则这个行程下面有几个按钮,用来进一步操作, // 一个是删除,如果是标记为删除,不要真的删除,只是做一个标记… // 另外一个按钮,用来记录实际的情况,按钮名字就叫修正… // 还有一个按钮,就是完成…这个行程直接就变成了实际行程。」 // // ⚠️ 三个动作都**只改状态、不真删** —— 这是用户明确要求的语义。 /** 「修正」:记一次实际执行情况(可无限次;行程仍是计划,要再点「标记为实际」) */ case 'trips/node_revise': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $tripId = (int)($_POST['trip_id'] ?? 0); if (!Trip::find($familyId, $tripId)) { flash('err', '找不到这个旅程。'); redirect('trips'); } $nid = (int)($_POST['id'] ?? 0); $seq = Trip::reviseNode($nid, [ 'starts_at' => (string)(read_dt_input('rev_starts_at') ?? ''), 'ends_at' => (string)(read_dt_input('rev_ends_at') ?? ''), 'place' => trim((string)($_POST['place'] ?? '')), 'detail' => trim((string)($_POST['detail'] ?? '')), 'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId), ], (int)Auth::personId()); flash('ok', $seq > 0 ? '已记录第 ' . $seq . ' 次修正。它仍是**计划行程**;' . '确认无误后点「标记为实际」即可转为实际行程。' : '修正没成功。'); redirect('trips/show', ['id' => $tripId]); break; /** 「标记为实际」:把(已经修正过的)计划行程正式转为实际行程 */ case 'trips/node_actual': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $tripId = (int)($_POST['trip_id'] ?? 0); if (!Trip::find($familyId, $tripId)) { flash('err', '找不到这个旅程。'); redirect('trips'); } Trip::markActual((int)($_POST['id'] ?? 0)); flash('ok', '已转为**实际行程** —— 它会出现在日历与首页的「往年今日」。'); redirect('trips/show', ['id' => $tripId]); break; /** 「完成」:实际与计划完全相同 → 直接变实际行程(不产生修正历史) */ case 'trips/node_done': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $tripId = (int)($_POST['trip_id'] ?? 0); if (!Trip::find($familyId, $tripId)) { flash('err', '找不到这个旅程。'); redirect('trips'); } Trip::completeNode((int)($_POST['id'] ?? 0)); flash('ok', '这一段已按计划完成,转为**实际行程**。'); redirect('trips/show', ['id' => $tripId]); break; /** 「标记未成行」:不真删,只打标记(不上日程、不进往年今日,但旅程里仍显示) */ case 'trips/node_skip': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $tripId = (int)($_POST['trip_id'] ?? 0); if (!Trip::find($familyId, $tripId)) { flash('err', '找不到这个旅程。'); redirect('trips'); } Trip::skipNode((int)($_POST['id'] ?? 0)); flash('ok', '已标记为未成行。它不再出现在日程与往年今日,但在这个旅程里仍然看得到。'); redirect('trips/show', ['id' => $tripId]); break; /** 撤销「未成行」→ 退回计划行程(点错了要能改回来) */ case 'trips/node_restore': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $tripId = (int)($_POST['trip_id'] ?? 0); if (!Trip::find($familyId, $tripId)) { flash('err', '找不到这个旅程。'); redirect('trips'); } Trip::restoreNode((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']); // v24 第 23 批 用户第 2 条:上面那张「总金额图」点类别标签后要换成**该类别的曲线**。 // 图是**服务端画 SVG**(零依赖,见 app/chart.php 的头),所以每一类都得 // 预先算好时间线 —— 点标签只是显隐切换,不用再打一次服务器(点了就变,无延迟)。 // ⚠️ 只在真出现该类别时才算:类别最多 10 个,但"只为用到的算"能省下 // 每个类别一次全表遍历(账目多时这个差别是能感觉到的)。 $sumNow = Ledger::summary($entries); $kindLines = []; foreach (array_keys((array)$sumNow['by_kind']) as $k) { $kindLines[(string)$k] = Ledger::timeline((int)$cur['id'], (string)$k); } View::render('ledger/index', [ 'title' => '财务', 'books' => $books, 'cur' => $cur, // ⚠️ `me` 必须传进去:模板里「挂在谁名下默认是自己」(用户第 8 条) // 要用它当默认选中值。漏传的话默认值会静默变成"不选" —— // 功能"看起来在",只是每次都要手动点一次选人框。 'me' => $me, 'entries' => $entries, 'summary' => $sumNow, 'shares' => Ledger::shares((int)$cur['id']), // 用户 2026-10-03 第 8 条:上面画"金额随时间怎么变",所以要历史。 'charts' => Ledger::charts((int)$cur['id']), 'timeline'=> Ledger::timeline((int)$cur['id']), 'kindLines' => $kindLines, 'people' => Person::roster($familyId, $me, 'member', ''), ], 'ledger'); break; /* 🔴 v24 第 26 批 用户第 1 条:**多选类别时,上面那张「总金额」图要按选中集合统计**。 * * 用户原话:「财务模块,多个标签勾选后,第一个图,总额那里,勾选一个会变化, * 勾选多个后,变成总数了。」 * * 上一批(第 25 批)把标签改成多选时,上图只保留了「恰好选一类」才切图的分支: * 选 1 类 → 显示该类曲线; * 选 2 类以上 → `sole` 为空 → **回落成"整本账"**(这正是用户看到的"变成总数了")。 * * 为什么不能在页面里把几条已渲染的曲线**加起来**:图是服务端画的 **SVG**, * 里面是一堆 `` 与坐标 —— 前端没法把两条折线"相加"成一条。 * (金额能加,但曲线形状加不出来:各自的时间刻度、各自的纵轴比例都不一样。) * * 所以做法是**按需现画**:前端把选中集合 POST 过来,这里用 * `Ledger::timeline($lid, $kinds)`(它已支持集合,见其 @param)现算一条曲线, * 用与页面**同一个** `chart_line()` 画成 SVG 回给前端替换。 * * ⚠️ 这里是 **GET**(只读、可缓存、可分享),所以不写 csrf —— * 它不改变任何状态。但仍要 **Auth::requireLogin()**:账本金额是家庭隐私。 * ⚠️ 只认**本账本真的有的类别**(拿 summary 的白名单过滤), * 否则 `?kinds=xxx` 可以随便塞字符串(虽然只会算不出东西,但没必要留口子)。 */ case 'ledger/total_chart': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $lid = (int)($_GET['book'] ?? 0); $book = Ledger::find($familyId, $lid, $me); if (!$book) { http_response_code(404); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => false, 'msg' => '账本不存在。'], JSON_UNESCAPED_UNICODE); exit; } /* 🔴🔴 v28 用户第 8 条:「财务,多选分类的时候,净资产 · 金额变化这个图表, 里面的数字还是不改变。」 根因:**前后端的参数形状对不上** —— · 前端(templates/ledger/index.php)发的是 `&kinds=deposit%2Chouse` (把选中集合拼成**一个逗号串**,因为 URLSearchParams 里一个 key 只能一个值); · 服务端这里写的是 `(array)($_GET['kinds'] ?? [])` —— 对字符串来说是 `['deposit,house']`(**一个元素**,值是带逗号的整串), 拿去和真实类别白名单求交 → 空 → 下面的 `if (!$kinds)` 分支把它当成"空集合" → **回落成"整本账"** → 图与数字当然不变。 ⇒ 修法:把逗号串**也切开**(两种形状都收:`kinds=a,b` 与 `kinds[]=a&kinds[]=b`)。 只收一种形状就会在下次换前端写法时再断一次 —— 收两种是一行代码的事。 ⚠️ 这与 `Ledger::timeline()` 的入参口径一致:它本来就同时接受字符串与数组。 */ $_rawKinds = $_GET['kinds'] ?? []; if (!is_array($_rawKinds)) { $_rawKinds = [$_rawKinds]; } $want = []; foreach ($_rawKinds as $_rk) { // 每个值再按逗号切一次 —— `a,b` 与 `a` 都能吃 foreach (preg_split('/[,\s]+/', (string)$_rk) ?: [] as $_one) { $_one = trim($_one); if ($_one !== '') { $want[$_one] = true; } } } $want = array_keys($want); // 白名单:只留下这本账里真实存在的类别 $present = array_map('strval', array_keys((array)Ledger::summary(Ledger::entries($lid))['by_kind'])); $kinds = array_values(array_intersect($want, $present)); if (!$kinds) { // 空集合 = 整本账(与前端 `picked.length === 0` 的语义一致) $tl = Ledger::timeline($lid); $label = '净资产'; $tkind = 'asset'; } else { $tl = Ledger::timeline($lid, $kinds); // 全选中的都是负债类 → 用绿线(与单类别那套配色一致) $allDebt = true; foreach ($kinds as $k) { if (!Ledger::isDebt($k)) { $allDebt = false; break; } } $label = count($kinds) === 1 ? Ledger::kindWord($kinds[0]) : count($kinds) . ' 类合计'; $tkind = $allDebt ? 'debt' : 'asset'; } header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: private, max-age=30'); echo json_encode([ 'ok' => true, 'svg' => $tl ? chart_line($tl, $label, $tkind, 900) : '', 'label' => $label, 'n' => count($tl), ], JSON_UNESCAPED_UNICODE); 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/reconcile': 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/reconcile') { // 用户 2026-10-03 第 8 条:盘点 = 记一次新金额,**旧值不被清空** // (落一行快照,见 Ledger::reconcile 的注释)。 $eid = (int)($_POST['id'] ?? 0); $ok = Ledger::reconcile( $lid, $eid, (string)($_POST['amount'] ?? ''), (string)($_POST['note'] ?? ''), $me ); flash($ok ? 'ok' : 'err', $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(); // 用户 2026-10-03 第 9 条:手动添加的与模块派生的**分开**,手动的在上。 $sp = Anniversary::split($familyId, $me); $people = Person::roster($familyId, $me, 'member', ''); // v24 第 25 批 用户第 2 条:「再加一列,关联人。列表中,人的前面,都加头像。」 // 模板按 id 取头像与称呼 → 这里先做成 id => 行 的映射(不必在模板里循环找人)。 $pmap = []; foreach ($people as $pp) { $pmap[(int)$pp['id']] = $pp; } /* 「接下来的日子」必须和下面两张表**用同一套可见性**: 否则别人记的、与你无关的那条会孤零零出现在最上面, 而下面「与我相关」「来自名册」里都找不到它(v62 实测踩到)。 ⚠️ 只筛这个页面;日历是全家共享视图,照旧全显示。 */ $upcoming = Anniversary::upcoming($familyId, $me, 60); if (!empty($sp['hiddenIds'])) { $hid = array_flip(array_map('intval', $sp['hiddenIds'])); $upcoming = array_values(array_filter($upcoming, static function ($u) use ($hid) { return !isset($hid[(int)($u['id'] ?? 0)]); })); } View::render('days/index', [ 'title' => '纪念日', 'custom' => $sp['custom'], 'derived' => $sp['derived'], // v62:别人记的、与我无关的条数(只在页面上给一句小字) 'hidden' => $sp['hidden'], 'upcoming' => $upcoming, 'kinds' => Anniversary::kinds($familyId), 'kindsFull' => Anniversary::kindsFull($familyId), 'kindCounts' => Anniversary::kindCounts($familyId), 'people' => $people, 'peopleMap' => $pmap, ], 'days'); break; /** * 纪念日分类设置页(v62 用户 2026-10-18 第 2 条)。 * * 用户原话:「设置按钮,点击后弹菜单,第一个菜单是分类设置,进去之后可以设置分类, * 每一类纪念日都有不同的图标……如果用户添加了自己的分类,就让用户自己选。」 * * ⇒ 与日程/观影/任务三个模块同一套形态:☰ 下拉 → 点条目 → 独立页面。 * * 表单是**一张**(改图标 / 改名 / 排序 / 删 / 加 全在里面),靠按钮的 name 区分动作: * save / del / move / add。为什么不用"每行一个小表单":那样改了 A 行图标再点 * B 行删除,A 行那次修改会被浏览器丢掉(只提交被点的那张表单)—— * "改了一半"是这类设置页最常见、也最难 debug 的投诉。 */ case 'days/settings': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); if (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') { Auth::requireWrite(); csrf_check(); $names = (array)($_POST['k_name'] ?? []); $icons = (array)($_POST['k_icon'] ?? []); // 用**原始下标**当键:空名字被跳过时下标也不能重排, // 否则「删第 3 行」会删到第 3 个非空行(两者不是一回事)。 $items = []; $tooLong = 0; foreach ($names as $i => $nm) { $nm = trim((string)$nm); if ($nm === '') { continue; } if (mb_strlen($nm, 'UTF-8') > 20) { $tooLong++; continue; } $items[(string)$i] = [ 'name' => $nm, 'icon' => (string)($icons[$i] ?? ''), ]; } $notice = '分类已保存。'; if (isset($_POST['del'])) { unset($items[(string)(int)$_POST['del']]); $notice = '分类已删除(已用它的纪念日不变,只是新建时不再出现)。'; } elseif (isset($_POST['move'])) { $mv = explode(':', (string)$_POST['move'], 2); $idx = (string)(int)($mv[0] ?? -1); $keys = array_keys($items); $pos = array_search($idx, $keys, true); if ($pos !== false) { $to = ($mv[1] ?? 'up') === 'up' ? $pos - 1 : $pos + 1; if ($to >= 0 && $to < count($keys)) { $tmp = $keys[$pos]; $keys[$pos] = $keys[$to]; $keys[$to] = $tmp; $reordered = []; foreach ($keys as $k) { $reordered[$k] = $items[$k]; } $items = $reordered; } } $notice = '顺序已调整。'; } elseif (isset($_POST['add'])) { $nn = trim((string)($_POST['new_name'] ?? '')); if ($nn === '') { flash('err', '新分类的名字是空的。'); redirect('days/settings'); } if (mb_strlen($nn, 'UTF-8') > 20) { flash('err', '分类名最多 20 个字。'); redirect('days/settings'); } $items[] = ['name' => $nn, 'icon' => (string)($_POST['new_icon'] ?? '')]; $notice = '分类已添加。'; } Anniversary::saveKinds($familyId, array_values($items)); if ($tooLong > 0) { flash('err', '有 ' . $tooLong . ' 个分类名超过 20 个字,已跳过。'); } flash('ok', $notice); redirect('days/settings'); } View::render('days/settings', [ 'title' => '纪念日分类', 'kindsFull' => Anniversary::kindsFull($familyId), 'kindCounts' => Anniversary::kindCounts($familyId), 'meId' => $me, ], 'days'); break; /** * 纪念日分类设置(v24 用户 2026-10-03 第 9 条)—— **老入口,保留兼容**。 * * v62 起前端的入口已经改成 `days/settings` 那一页(带图标)。这个 POST 端点留着: * ① 老页面/书签里还有提交到它的表单; * ② 反向验证脚本要能单独打它(证明"删一个分类"这条链路没断)。 * ⚠️ 它只处理**名字**(add / del / 整份 kinds)——新格式的图标由 saveKinds 兜底补。 */ case 'days/kinds': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); // 两个动作共用一个路由:加一个 / 删一个(表单里带 del 或 add) $kinds = Anniversary::kinds($familyId); $del = trim((string)($_POST['del'] ?? '')); if ($del !== '') { $kinds = array_values(array_filter($kinds, static fn($k) => (string)$k !== $del)); Anniversary::saveKinds($familyId, $kinds); flash('ok', '分类已删除(已用它的纪念日不变,只是新建时不再出现在下拉里)。'); redirect('days', [], 'anniv-kinds'); } $add = trim((string)($_POST['add'] ?? '')); if ($add !== '') { if (mb_strlen($add, 'UTF-8') > 20) { flash('err', '分类名太长了(最多 20 个字)。'); } else { $kinds[] = $add; Anniversary::saveKinds($familyId, $kinds); flash('ok', '分类已添加。'); } redirect('days', [], 'anniv-kinds'); } // 整份覆盖(设置面板里直接改一串) $list = (array)($_POST['kinds'] ?? []); Anniversary::saveKinds($familyId, $list); flash('ok', '分类已保存。'); redirect('days', [], 'anniv-kinds'); 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'); } // v24 第 25 批 用户第 2 条:编辑页要把**已关联的多个人**预勾选出来。 // 新表为空时回落到老列 person_id(升级过渡期的兜底)。 if ($editing) { $pids = Anniversary::peopleOf($editing); $editing['person_ids'] = $pids; } /* 🔴🔴 v43(用户 2026-10-11 第 2 条):「新建纪念日的时候,原来的页面还保存着。 但是点击"修改纪念日",怎么好像原来的页面全都没了?就是下面那一层页面没了, 这好像不太合理吧?」 真根因:**「修改」是 `` 整页跳转** —— 它套 layout, 渲染出的是"一张只有弹框的空白页",列表页被整个换掉 (正是用户说的"下面那一层页面没了")。而「新建」走的是列表页内嵌弹框, 两条路观感不一致。 ⇒ 修法:`days/edit` 支持 `?embed=1` —— **只吐弹框那一块**(不套 layout), 列表页 JS 拿它替换掉当前 `#anNewCard` 并打开。 这样"编辑"与"新建"共用**同一个弹框、同一个底层页面**。 (与 `trips/edit` 的 `?embed=1` 完全同一套约定。) ⚠️ 内嵌形态标 `$anModalOnly = true`:否则模板会给它打上 `data-an-standalone`, 关闭时去 `window.location.href` 跳页 —— 那正是要去掉的行为。 ⚠️ 非 embed 整页路径**保留**(直接访问 URL / 无 JS 兜底)。 */ $anArgs = [ 'editing' => $editing, 'kinds' => Anniversary::kinds($familyId), // v62:带图标的清单(下拉里用 emoji 打头,见 form.php) 'kindsFull' => Anniversary::kindsFull($familyId), 'people' => Person::roster($familyId, $me, 'member', ''), /* 🔴 v41(用户第 10 条):「新建纪念日,采用弹框形式。」 直达 `days/new` 时也渲染成**打开着的弹框** —— 否则用户看到的是一张空白页(内容全在 hidden 的弹框里)。 */ 'anModal' => true, 'anOpen' => true, ]; if ((string)($_GET['embed'] ?? '') === '1') { header('Content-Type: text/html; charset=utf-8'); echo View::renderPartial('days/form', $anArgs + ['anModalOnly' => true, 'anShell' => true]); exit; } View::render('days/form', ['title' => $id > 0 ? '编辑纪念日' : '新建纪念日'] + $anArgs, 'days'); break; case 'days/save': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); // v24 第 25 批 用户第 2 条:关联人改成**多值** `person_ids[]`。 // ⚠️ 兼容老表单:只传单个 `person_id` 时也认(见 Anniversary::save 的说明)。 $pids = (array)($_POST['person_ids'] ?? []); if (!$pids && isset($_POST['person_id'])) { $pids = [(int)$_POST['person_id']]; } Anniversary::save($familyId, [ 'title' => (string)($_POST['title'] ?? ''), 'repeat_type' => (string)($_POST['repeat_type'] ?? 'solar'), 'month' => (int)($_POST['month'] ?? 1), 'day' => (int)($_POST['day'] ?? 1), // 🔴 v53(用户 2026-10-16 第 1 条):年份 —— 用来算"第几个纪念日"。 // 留空就是 0/空串 ⇒ Anniversary::save 里落成 NULL(不猜年份)。 'year' => (int)($_POST['year'] ?? 0), 'is_leap' => !empty($_POST['is_leap']), 'kind' => (string)($_POST['kind'] ?? ''), 'note' => (string)($_POST['note'] ?? ''), 'person_ids' => array_map('intval', $pids), ], $id); flash('ok', $id > 0 ? '纪念日已保存。' : '纪念日已添加。'); /* 🔴 v81(用户 2026-10-22):「把原来模块里弹的那个框,现在拿到这边来。 目的就是为了在首页上就能快速完成一定工作,而不用进到模块里面。」 ⇒ 首页那张弹框提交时带 `back=home`,回首页(卡片上立刻能看到新加的)。 ⚠️ 白名单取值,绝不把 POST 的字符串直接当跳转地址(开放重定向)。 ⚠️ 编辑不从首页走(首页只有"新建"入口),所以 `$id > 0` 时不认这个参数。 */ if ($id <= 0 && (string)($_POST['back'] ?? '') === 'home') { redirect('home'); } 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 只认这三个,认不出就回落**默认视图**。 // ⚠️ 默认视图是**每人一份**的设置(用户第 5 条:设置里选月/周/天)。 // 但只在**没带 view 参数**时才生效 —— 手点了「周」按钮之后, // 那个选择必须优先,否则每次翻页都被默认值拽回去。 $viewIn = trim((string)($_GET['view'] ?? '')); if ($viewIn === '') { $view = Calendar::defaultView($me); } else { $view = in_array($viewIn, ['month', 'week', 'day'], true) ? $viewIn : '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); /* 🔴 v24 第 21 批 第 7 条:**打开日历顺手补一本「我的日程」**。 为什么必须在这儿补(而不是只改 DEFAULT_KINDS): `Calendar::seedDefaults()` 只在**安装**与**结构升级**两条路径上跑过, 线上这套库早就升完级了 → 老用户永远拿不到那一本。 结果:可写的普通本 0 本、"放在哪本"全是派生本、默认还选中排最前的 **观影** → 用户直接点「加上」建出来的是一条**观影记录**(建错东西)。 ⚠️ 幂等(owner_id + kind='custom' 有就不再补),一次请求只多一条 SELECT。 */ Calendar::ensurePersonalBook($familyId, $me); $cals = Calendar::mine($familyId, $me); // 用户第 4 条:哪些本子显示,改由设置里的复选框决定(以前是左侧那一栏)。 // 藏掉的本子**不参与取数**,而不是"取出来再 display:none" —— // 前端藏的话,关掉一本之后月视图的"+N 条"仍然按它算,数字对不上。 $hiddenCals = Calendar::hiddenCalendars($me); $visCals = []; foreach ($cals as $c) { if (!isset($hiddenCals[(int)$c['id']])) { $visCals[] = $c; } } 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, $visCals, $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, $visCals, $me, $from, $to); $grid = build_time_grid($events, $cols); } // 用户第 5 条:在日历里点一下就建日程,**开始时间 = 点的那一格**。 // 点空白处只带日期/时刻过来,标题等还是空的(不能替用户瞎填)。 $newAt = trim((string)($_GET['new_at'] ?? '')); /* ---- 当前时间红线(v24 第 20 批 用户第 6 条)---- 用户原话:「周视图和日视图,应该在视图中间显示一条红线…来表示当前时间 在视图上的刻度和位置。」 ⚠️ 位置在**服务端**算好(与 .tg-block 的 top/height 同一套算法: 列高固定 24 小时 → 1 分钟 = 100/1440 %),前端只负责"过一会儿自己挪一下"。 不在 CSS 里写死时间,也不在前端算 —— 算法只有一处,不会两边对不上。 */ $nowMinutes = ((int)date('G')) * 60 + (int)date('i'); $nowTop = round($nowMinutes / 1440 * 100, 4); View::render('calendar/index', [ 'title' => '日程', 'ym' => $ym, 'view' => $view, 'anchor' => $anchor, 'nowTop' => $nowTop, 'cols' => $view === 'month' ? [] : ($cols ?? []), 'weeks' => $view === 'month' ? $grid : [], 'tgrid' => $view === 'month' ? null : $grid, 'cals' => $cals, 'visCals'=> array_map('intval', array_column($visCals, 'id')), 'hiddenCals' => array_keys($hiddenCals), 'newAt' => $newAt, 'people' => Person::roster($familyId, $me, 'member', ''), ], 'calendar'); break; /** * 保存「日程显示设置」(v24 用户 2026-10-03 第 4、5 条)—— 每人一份。 * * 用户原话:「在右侧,也加一个设置按钮,设置中,可以设置哪些日历本显示, * 调整日历的颜色,导入日历等功能,都放在这个设置下。」 * 以及「在日程的设置中,可以设置默认显示的视图模式」。 * * 这一条只存两件**个人偏好**:默认视图 + 关掉哪几本。 * 颜色/新建/共享/导入仍在各自的既有路由上(它们改的是本子本身或数据,不是偏好), * 只是界面上统一收进了那个设置下拉里。 */ case 'calendar/prefs': Auth::requireLogin(); csrf_check(); $me = (int)Auth::personId(); Calendar::saveDefaultView($me, (string)($_POST['view'] ?? 'month')); // ⚠️ 复选框的语义是"**显示**",但库里存的是"关掉的"(见 hiddenCalendars 的注释)。 // 所以这里要把「没勾的」收集起来存。 // // ⚠️ 但**只有表单真的带了这部分字段时才动它**: // HTML 里"一个都没勾"会让浏览器整个省掉 `show` 这个键 —— // 与"这个请求压根没想改显隐"(比如将来某个页面只想改默认视图) // 在服务端看起来**一模一样**。 // 靠 `show_present` 这个隐藏标记把两者分开: // 没有它 = 不改显隐;有它 + 一个都没勾 = 用户确实想把本子全关掉。 // 不这么做的话,任何一次"只改默认视图"的提交都会静默把所有日历本藏掉。 if (!empty($_POST['show_present'])) { $onIds = array_map('intval', (array)($_POST['show'] ?? [])); $allIds = []; foreach (Calendar::mine((int)Auth::familyId(), $me) as $c) { $allIds[] = (int)$c['id']; } $off = []; foreach ($allIds as $cid) { if (!in_array($cid, $onIds, true)) { $off[] = $cid; } } Calendar::saveHiddenCalendars($me, $off); } flash('ok', '日程显示设置已保存。'); redirect('calendar', ['view' => (string)($_POST['back_view'] ?? 'month')]); break; /** * 旧地址 → 新地址(v24 第 22 批)。 * * 口令从「CalDAV 专用」升成「DAV 通用」(日历 + 通讯录共用一把,见 * Schema 版本 23 与 Auth::davIssue),管理入口也一并搬到**个人设置**页 * —— 它本来就是"我的凭据",跟着日历本页走会让只想要通讯录的人找不着。 * * ⚠️ 这两条**必须留着**:老书签、以及此前在页面里渲染过的表单地址都在这里, * 直接删掉的话点旧按钮会 404,用户看到的现象是"改了之后按钮没反应了"。 * 用 302 跳过去,两边都活着。 */ case 'calendar/caldav_token': case 'calendar/caldav_revoke': // ⚠️ 用第三个参数(anchor)而不是塞进 query:`redirect` 的签名是 // (route, query, anchor),把 '#dav' 当 query 传会变成 `?%23dav=`。 redirect('settings/me', [], 'dav'); break; /** * DAV 通用口令(v24 第 22 批):生成 / 重置 / 撤销。 * * 用户原话:「caldav和cardav,都增加一个创建口令的功能,这样,就使用用户名和口令 * 来同步,避免在第三方客户端进行同步时自己的密码泄露。」 * * ⚠️ 明文口令只在这**一次**跳转里带回页面显示(存 $_SESSION 一次性取用), * 库里只有 sha256。不这么做的话,要么明文落库(一旦泄露全暴露), * 要么用户永远看不到自己刚设的花口令。 * ⚠️ 生成与撤销共用 `settings/dav_plain` 这个槽位:撤销时**必须把它清掉**, * 否则"刚撤销完还显示着上一串口令",用户会以为撤销没生效。 */ case 'settings/dav_token': case 'settings/dav_revoke': $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); if ($r === 'settings/dav_token') { $_SESSION['dav_plain'] = Auth::davIssue($me); flash('ok', 'DAV 口令已生成(日历与通讯录共用这一把;旧口令立即失效,请在客户端里更新)。'); } else { Auth::davRevoke($me); unset($_SESSION['dav_plain']); flash('ok', 'DAV 口令已撤销,所有已同步的客户端(日历与通讯录)都会失去访问。'); } redirect('settings/me'); break; /* 🔴 v44(用户 2026-10-12 第 6 条):移动端 API 令牌的**签发 / 吊销**。 与 DAV 那两条同一个形态(明文进一次性会话槽位、只显示一次)。 */ /* 🔴🔴 v63(用户 2026-10-19 第 7 条):**手动签发令牌这条入口整条下线**。 用户原话:「个人设置中:手动签发接口令牌,这一部分删除,不允许用户修改, 不允许用户手动签发令牌,只能通过登录后,系统自动签发令牌。」 ⇒ `settings/api_token`(生成)**不再接受提交**:界面上那块已经删了; 若只删界面不删路由,它就变成一个"找不到按钮、但知道地址就能用"的后门 —— 那种入口比显式按钮危险。这里直接 302 回个人设置并给一句明确的话。 ⚠️ **`settings/api_revoke` 保留**:踢掉设备是用户该有的能力, 「设备管理」卡里那两个动作(移除一台 / 退出全部设备)还在用它。 换句话说:**签发只自动、吊销仍可用**,这条线才是用户要的形态。 ⚠️ 令牌机制本身(ApiToken 类、Bearer 校验、设备聚合)一个字没改。 */ case 'settings/api_token': Auth::requireLogin(); flash('err', '令牌由登录后自动签发,不能手动生成。要清掉某台设备,去「设备管理」移除它。'); redirect('settings/me'); break; case 'settings/api_revoke': $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); if ((int)($_POST['id'] ?? 0) > 0) { \App\ApiToken::revoke($me, (int)$_POST['id']); flash('ok', '这枚 API 令牌已吊销。'); } else { \App\ApiToken::revokeAll($me); unset($_SESSION['api_plain'], $_SESSION['api_plain_label']); flash('ok', '全部 API 令牌已吊销,所有 App 都需要重新登录。'); } redirect('settings/me', [], 'devices'); break; /* 🔴 v57(用户 2026-10-17):「设备管理」——把某一台设备远程退掉。 用户原话:「我们就允许我们的用户在个人设置页面,把其中的某一个客户端 远程给它退出,相当于它这个 Token 给它删掉。」 「这样的话,这个应用一旦打开,就发现这个 Token 已经失效了, 那么它就返回登录页面了。」 ⚠️ 移的是**设备**不是"令牌":同一台手机重登过 5 次,列表里只有 1 行, 点一次就该把它连着的那几枚全删掉(按 device_id 整组删,见 ApiToken 的 revokeDevice)。 ⚠️ 移除后那台设备**下一次请求就是 401**(服务端没有推送通道, "立刻失效"是下一次联网时生效 —— 网页上这句提示必须说实话)。 */ case 'settings/device_remove': $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $deviceId = trim((string)($_POST['device_id'] ?? '')); $n = \App\ApiToken::revokeDevice($me, $deviceId); if ($n > 0) { flash('ok', '已移除这台设备。它下次联网时会收到"令牌无效",并退回登录页。'); } else { flash('err', '没找到这台设备(可能已经被移除了)。'); } redirect('settings/me', [], 'devices'); break; /** * 日程「显示设置」独立页(v51 用户 2026-10-15 第 6 条)。 * * 原来「默认视图 + 显示哪些日历本」是**铺在设置下拉里**的一个表单; * 日历本一多,下拉就被撑到屏幕外面(用户原话:"下面的设置就看不到了, * 要拉下来才能看到")⇒ 挪成独立页面,下拉里只留一行「显示设置」。 * * ⚠️ 保存仍走既有的 `calendar/prefs`(一条没改)—— 它只是从"下拉里的表单" * 变成"这一页上的表单",提交契约、字段名、服务端逻辑完全不动。 */ /** * 显示设置 → **已并入「日历本」页**(v72 用户 2026-10-21)。 * * 用户原话:「日历设置里面日历本显示和日历管理可以放在一起啊。就是把日历本全都 * 列出来,然后显示不显示,加个复选框就行了。」 * * ⇒ v51 拆出来的 `calendar/display`(默认视图 + 显示哪些本子)与 * `calendar/calendars`(本子管理)**合并成一页**:一张清单、每行一个复选框。 * 那一页现在更完整(显隐 + 归属 + 权限 + 编辑/删除/共享都在一处)。 * * ⚠️ 这条路由**必须留着**(302 跳过去,不是删掉): * · 老书签、浏览器历史里还有 `?r=calendar/display`; * · `_settings_menu.php` 的四行菜单里「显示设置」曾经指向它; * · 直接删掉的话点旧链接会走"不存在的路由"兜底,用户看到的是"设置页没了"。 * ⇒ 与 `calendar/caldav_token` / `caldav_revoke` 那两条旧地址同样处理。 * * ⚠️ 查询串要**带过去**(`view`):跳转后「返回日程」还要回到用户原来那个视图, * 丢了 view 就一律变成月视图(用户改完设置回到日程,发现被换成月视图)。 */ case 'calendar/display': redirect('calendar/calendars', array_filter([ 'view' => (string)($_GET['view'] ?? ''), ], static fn($v) => $v !== '')); 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 第 22 批:口令改成**通用 DAV 口令**(日历 + 通讯录共用), // 管理入口也搬到了「个人设置 → DAV 同步」。所以这一页**不再处理口令表单** // —— 只显示它的状态与入口(点过去配置)。口令的明文仍走一次性会话槽位, // 但那一页在 settings/me,这一页读不到(也没必要读:这里只提示"去那边配")。 $cdHas = Auth::hasDav($me); $cdPlain = ''; $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, /* 🔴 v72(用户 2026-10-21):「日历设置里面日历本显示和日历管理可以放在一起。」 ⇒ 这一页现在同时管**显隐**(原 `calendar/display` 的全部内容) 与**本子管理**。所以这两项必须一起传进来: · `hiddenCals` —— 哪些本子没勾(原来是 display 页专属的数据); · `view` —— 回跳时保持当前视图(display 页本来就有)。 ⚠️ 别把 `calendar/display` 的取数逻辑复制一份到这里 —— 那边已经改成 302 跳本页(见该路由),单一来源。 */ 'hiddenCals' => array_keys(Calendar::hiddenCalendars($me)), 'view' => (string)($_GET['view'] ?? Calendar::defaultView($me)), '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; /** * 单本改色(v72 用户 2026-10-21)—— AJAX 专用。 * * 用户原话:「这个颜色选择是有问题的,点了之后是没反应的,就是预制的颜色 * 点了之后没反应的。」 * ⇒ 真根因不是"预置色没绑事件"这么一层:日历本页把"显隐"做成了**整页一次提交** * (`show[]`),而改色如果也走那张大表单,一次提交就会带上 N 个同名 `color` * 字段 ⇒ 服务端只拿最后一个 ⇒ 出现"改了这行、那行也变了"。 * 所以改色必须**单独一条请求**、只带一本。 * * ⚠️ 与 cal_update 的区别(两个都留着,别合并): * · `cal_update` —— 表单提交(改名字 + 改颜色),成功/失败都 `redirect` * (浏览器在页面级操作,跳回去看到 flash 是对的); * · `cal_color` —— AJAX 提交(**只改颜色**),一律回 **JSON** * (前端要据此决定"保留新颜色"还是"回滚成旧的")。 * 如果这里也 redirect,fetch 会把整页 HTML 当 json 解析失败 ⇒ 用户看到 * "改颜色没成功"但其实已经改好了(这类"假失败"最难受)。 * * 🔴 权限判据与 cal_update **一字不差**:必须是我自己的、且 kind='custom'。 * 共享给我的本子(哪怕是"可写")也改不了 —— 颜色是主人的东西 * (用户明确:「别人共享给我的,不让他编辑」)。 */ case 'calendar/cal_color': header('Content-Type: application/json; charset=utf-8'); $me = (int)Auth::personId(); // ⚠️ 这里**不能**用 csrf_check():它失败时输出纯文本并 419, // fetch 那边 `.json()` 会抛 ⇒ 只看到"网络不通",看不到真正原因。 $csrfOk = isset($_POST['_csrf'], $_SESSION['_csrf']) && hash_equals((string)$_SESSION['_csrf'], (string)$_POST['_csrf']); if (!$csrfOk) { http_response_code(419); echo json_encode(['ok' => false, 'error' => '页面已过期,刷新后重试'], JSON_UNESCAPED_UNICODE); exit; } if (!Auth::canWrite()) { http_response_code(403); echo json_encode(['ok' => false, 'error' => '只读账号不能改颜色'], JSON_UNESCAPED_UNICODE); exit; } $id = (int)($_POST['id'] ?? 0); $color = trim((string)($_POST['color'] ?? '')); $cal = Calendar::find($id); if (!$cal || (int)$cal['owner_id'] !== $me || (string)$cal['kind'] !== 'custom') { http_response_code(403); echo json_encode(['ok' => false, 'error' => '这本日历不能改颜色'], JSON_UNESCAPED_UNICODE); exit; } /* ⚠️ 颜色归一化交给 Calendar::update(内部会走 AvatarColor 的归一化并转大写)。 但 update 是"整本更新"(name + color 一起写)⇒ 这里把**原名**带回去, 否则改个颜色会把名字清成空串(那是个很容易漏的坑:update 的签名是 `update($id, $name, $color)`,不是"只更新传进来的字段")。 */ Calendar::update($id, (string)$cal['name'], $color); $saved = (string)Calendar::find($id)['color']; echo json_encode([ 'ok' => true, 'color' => $saved, ], JSON_UNESCAPED_UNICODE); exit; 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')); // v32 第 2 条:别人把日历本共享给我 → 给我发一条通知 // ⚠️ 放在**共享表写入之后**、按表现状重建(用户会反复改权限), // 不要在这里手写"给谁发"——见 Notification::syncShares 的说明。 Notification::syncShares((int)Auth::familyId(), Notification::CALENDAR, $id, $me); 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)); // v32 第 2 条:被移出共享 → 把他**未读**的那条通知一并撤销 //(已读的保留:那是他看过的历史,删了像"消息会自己消失") Notification::syncShares((int)Auth::familyId(), Notification::CALENDAR, $id, $me); flash('ok', '已取消共享。'); redirect('calendar/calendars'); break; case 'calendar/event_add': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); /* 🔴 v24 第 21 批 第 7 条:日历新建日程改成**无刷新**(用户原话: 「添加完成后,动态修改当前视图,不要刷新。」)。 同一个路由要同时服务两种调用者 → 必须认 X-Requested-With: · 页面里的 JS 带它 → 回 JSON(含新日程的颜色/时间/链接,前端自己插进日历); · 无 JS 的原生表单 → 照旧 302 回日历(渐进增强的兜底不变)。 ⚠️ 判据不能用"有没有某个参数"之类的猜测 —— 两条路的参数完全一样。 */ $ajax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') || (strpos((string)($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json') !== false); $jsonFail = static function (string $msg) use ($ajax, &$ym) { if ($ajax) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => false, 'msg' => $msg], JSON_UNESCAPED_UNICODE); exit; } flash('err', $msg); redirect('calendar', ['ym' => $ym]); }; 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); // v24 第 10 批 #4:农历 / 节假日(还有观影 / 旅行)是**派生只读本**, // 内容由别处算出来,不能往里写日程 —— 写了也不会显示(Calendar::month 只从 // kind='custom' 取 calendar_events),却会在库里留下一堆"看不见的日程"。 // 用户的要求是"不可删除",这里顺带把"不可写入"也钉死。 if ($cal && Calendar::isReadonlyKind((string)$cal['kind'])) { $jsonFail('「' . $cal['name'] . '」是系统日历,内容自动生成,不能往里加日程。'); } if (!$cal || !Calendar::canWrite($cal, $me)) { $jsonFail('这本日历你没有写权限。'); } // v24 第 5、6 条:起止各是一个「日期+时分秒」的完整时刻(_datetime.php 控件), // 不再拆成 date / end_date / start_time / end_time 四格。 $startsAt = read_dt_input('starts_at'); if ($startsAt === null) { $jsonFail('开始时间不对。'); } $endsAt = read_dt_input('ends_at'); $title = trim((string)($_POST['title'] ?? '')); if ($title === '') { $jsonFail('日程得有个标题。'); } $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']; /* 🔴 v24 第 21 批 第 7 条:观影 / 旅行两本是**可写**的 —— 往它们里面"加一条" 等于建一条真正的观影 / 旅程记录(只往 calendar_events 里塞一行的话, 日历里有、观影列表里没有)。 ⚠️ 这两段原来只 `redirect()`,AJAX 时会把 fetch 带着跳走: 客户端拿到的是一整页 HTML、`r.json()` 解析失败 → 退回原生提交 → 又整页刷新一次。所以这里也要**回 JSON**,让前端照旧"插进当前视图"。 ⚠️ AJAX 时**不再自动跳到观影/旅程详情页** —— 用户的原话是 "添加完成后,动态修改当前视图,不要刷新",跳走就等于离开了日历。 改在提示行里给一个可点的链接(想看详情随手点,不点也不打扰)。 */ if ($kind === 'movie' || $kind === 'trip') { if ($kind === 'movie') { $newId = Movie::create($familyId, $me, [ 'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note, 'attendees' => [$me], ]); $okMsg = '已加进观影,日历里也能看到了。'; $okHref = url('movies/show', ['id' => $newId]); } else { $newId = Trip::create($familyId, $me, [ 'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note, 'attendees' => [$me], ]); $okMsg = '已加进旅程,日历里也能看到了。'; $okHref = url('trips/show', ['id' => $newId]); } if ($ajax) { header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => true, 'msg' => $okMsg, 'goto' => $okHref, /* 前端只用来在提示行里放一个链接,不自动跳 */ 'event' => [ 'id' => (int)$newId, 'title' => $title, 'starts_at' => (string)$startsAt, 'ends_at' => (string)($endsAt ?? ''), 'all_day' => $allDay ? 1 : 0, 'cal' => (int)$cal['id'], 'color' => (string)($cal['color'] ?? ''), 'time' => ($allDay || $startsAt === '') ? '' : substr((string)$startsAt, 11, 5), 'href' => $okHref, ], ], JSON_UNESCAPED_UNICODE); exit; } flash('ok', $okMsg); redirect($kind === 'movie' ? 'movies/show' : 'trips/show', ['id' => $newId]); } $evLoc = trim((string)($_POST['location'] ?? '')); $evRrule = Repeat::build($_POST); $evId = Calendar::addEvent((int)$cal['id'], [ 'title' => $title, 'location' => $evLoc, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'all_day' => $allDay, // v24 第 20 批 第 8 条:提醒**多选** → 表单来的是数组 `reminder_minutes[]`。 // ⚠️ 不要再 `(string)` 强转 —— 那会得到 "Array" 字样(PHP 的经典坑), // Calendar::reminderMinutes() 收到它就一份都认不出来,提醒静默丢光。 // 直接原样把数组交下去,由 Calendar 那边做白名单 + 规范化。 'reminder_minutes' => ($_POST['reminder_minutes'] ?? ''), 'status' => (string)($_POST['status'] ?? 'confirmed'), 'transp' => !empty($_POST['transp']), // 用户 2026-10-03 第 5、6 条:重复规则(RFC 5545 RRULE 原文) 'rrule' => $evRrule, 'note' => $note, 'created_by' => $me, ]); $warns = attach_save_files($familyId, Calendar::ATTACH, $evId, 'attach', $me); /* v24 第 21 批 第 7 条:AJAX 时回**够前端把它插进当前视图**的那几个字段。 ⚠️ 字段必须与模板渲染那一行**完全一致**,否则"插进去的样子"与 "刷新一次之后的样子"会不同: · time —— 月视图小条前面的 `HH:MM`(全天不显示); · color —— 小条/时间块的底色(来自日历本,这正是用户要的"带上颜色"); · href —— 点它打开详情(与 Calendar::month 给的是同一个路由); · cal —— data-cal,前端/样式按它分组。 */ if ($ajax) { header('Content-Type: application/json; charset=utf-8'); /* 🔴 v70:「+N 条」点开的是**当天全部条目**浮层,数据来自服务端渲染好的 `#calDayData`。AJAX 新建一条之后必须**同步补进那份数据**,否则 "刚建的这条在 +N 条清单里看不到"(刷新一次又出现)。 ⇒ 这里直接回一份 `entry`(就是 `fb_entry_json()` 的产出), 前端原样塞进去 —— **不要**让前端自己按字段拼:漏一个 location 或把 occ/repeat 拼丢,浮层里的详情、删除就会和服务端渲染的不一样。 ⚠️ 字段与下面 `Calendar::month()` → `fb_entry_json()` 同一套口径。 */ $newDay = substr((string)$startsAt, 0, 10); $newTm = ($allDay || $startsAt === '') ? '' : substr((string)$startsAt, 11, 5); $newEtm = ($allDay || $endsAt === null || $endsAt === '') ? '' : substr((string)$endsAt, 11, 5); $newHref = url('calendar/event', ['id' => (int)$evId, 'd' => $newDay]); $newEntry = fb_entry_json([ 'title' => $title, 'date' => $newDay, 'time' => $newTm, 'end_time' => $newEtm, 'all_day' => $allDay, 'cal' => (int)$cal['id'], 'calname' => (string)($cal['name'] ?? ''), 'location' => $evLoc, 'note' => $note, 'kind' => 'custom', 'ref_id' => (int)$evId, 'repeat' => $evRrule, 'occ' => $newDay, '_can_write' => true, /* 刚在这本上建成功 ⇒ 当然可写 */ ], $newHref, url('calendar/event', ['id' => (int)$evId, 'embed' => 1])); echo json_encode([ 'ok' => true, 'msg' => '日程已加上。', 'warns' => $warns, 'event' => [ 'id' => (int)$evId, 'title' => $title, 'starts_at' => (string)$startsAt, 'ends_at' => (string)($endsAt ?? ''), 'all_day' => $allDay ? 1 : 0, 'cal' => (int)$cal['id'], 'calname' => (string)($cal['name'] ?? ''), 'color' => (string)($cal['color'] ?? ''), 'time' => $newTm, 'href' => $newHref, 'entry' => json_decode($newEntry, true), ], ], JSON_UNESCAPED_UNICODE); exit; } if ($warns) { flash('err', '有附件没有保存:' . implode(';', $warns)); } flash('ok', '日程已加上。'); /* 🔴 v82(用户 2026-10-23):「首页上的日程,……首页卡片上要有新建按钮」—— 从**首页**那张弹框提交的,回首页(卡片上立刻能看到刚建的那条)。 ⚠️ 默认仍回日程模块 —— 从日程页新建时,用户接着要看的是日历本身上 刚加的那一格,跳去首页反而看不到。 ⚠️ 白名单取值(只认 'home'),绝不把 POST 字符串直接当跳转地址 (开放重定向)—— 与 v81 的 `days/create`、`movies/create` 同一条约定。 ⚠️ 放在 AJAX 分支**之后**:AJAX 那条路自己早 exit 了,走不到这里。 */ if ((string)($_POST['back'] ?? '') === 'home') { redirect('home'); } 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']); /* 🔴 v43(用户 2026-10-11 第 6 条):条目详情浮层里点「编辑」时, `?embed=1` 只吐**编辑表单**(不套 layout、不带页头/删除), 由浮层塞进正文。与 days/edit、movies/edit、trips/node_edit 同一套。 */ $evArgs = [ 'title' => (string)$ev['title'], 'ev' => $ev, 'cal' => $cal, 'occ' => $occ, // ⚠️ 不要再传 'ym':模板自己从 $ev['starts_at'] 算(模板里那行更权威, // 传进来反而会在"改过日期后"与模板算出的值不一致)。 ]; if ((string)($_GET['embed'] ?? '') === '1') { header('Content-Type: text/html; charset=utf-8'); echo View::renderPartial('calendar/event', $evArgs + ['evEmbed' => true]); exit; } /* 🔴 v49(用户 2026-10-14 第 2 条):「点详情…让模态框变大,显示出来更多的信息」。 `?embed=detail` 只吐**只读详情块**(那两段 kv + 附件),不套 layout、不带按钮 —— 由浮层把它塞进正文并给面板加宽。与 `?embed=1`(编辑表单)同一条路, 只是取的形态不同(一个可编辑、一个只读)。 */ if ((string)($_GET['embed'] ?? '') === 'detail') { header('Content-Type: text/html; charset=utf-8'); echo View::renderPartial('calendar/event', $evArgs + ['evEmbed' => true, 'evDetail' => true]); exit; } View::render('calendar/event', $evArgs, '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, // v24 第 20 批 第 8 条:提醒**多选** → 表单来的是数组 `reminder_minutes[]`。 // ⚠️ 不要再 `(string)` 强转 —— 那会得到 "Array" 字样(PHP 的经典坑), // Calendar::reminderMinutes() 收到它就一份都认不出来,提醒静默丢光。 // 直接原样把数组交下去,由 Calendar 那边做白名单 + 规范化。 'reminder_minutes' => ($_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'); /* 🔴 v49(用户 2026-10-14 第 2 条):入口浮层的「删除」要**就地删、不刷页**。 同一个路由要服务两种调用者 ⇒ 必须认 `X-Requested-With`(与名册删除同一套约定): · 浮层的 JS 带它 → 回 JSON,前端自己把那一条从视图里摘掉; · 详情页底部那张原生表单**不带** → 照旧 flash + 302 回日历。 ⚠️ 每条**失败**也要回 JSON(且 msg 说清为什么)—— 可预期的拒绝(没权限、找不到)不该把用户甩到一个刷新过的页面上。 */ $ajax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') || (strpos((string)($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json') !== false); /* 结束这个动作:AJAX 回 JSON(并 `exit`),否则照旧 flash + 302。 ⚠️ 两条路**都终止请求** ⇒ 调用处不用再管"要不要 return"。 */ $done = static function (bool $ok, string $msg, array $extra = []) use ($ajax, $ym) { if ($ajax) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(array_merge(['ok' => $ok, 'msg' => $msg], $extra), JSON_UNESCAPED_UNICODE); exit; } flash($ok ? 'ok' : 'err', $msg); redirect('calendar', ['ym' => $ym]); }; $st = Database::get()->prepare( 'SELECT e.id, e.calendar_id, e.rrule, e.starts_at 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) { $done(false, '找不到这条日程。'); } $cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: []; if (!Calendar::canWrite($cal, $me)) { $done(false, '这本日历你没有写权限。'); } /* ==================== 重复日程的删除范围(v24 第 20 批 用户第 7 条)==================== 用户原话:「对于重复的日程,当点击删除的时候,应该提示是删除这一次, 还是全部都删除,还是删除这一次及以后的。」 ⚠️ 三档与**编辑**那三档(第 13 批做的 scope)刻意对齐,用户学一次就够: once = 只删这一次 → 在 calendar_event_skips 里记一天(**不是**删整条系列! 真删了就"全部都没了",正是用户要避免的); future = 删除这一次及以后 → 老系列截止到「这一次的前一天」; 这一步**必须**算出截止日并写进 RRULE,否则整条系列都会留着一个 "还没结束的重复",照样冒出来; all = 全部(整条删除,与以前一样)。 ⚠️ 范围信息从哪来:`scope`(用户在弹框里选的)+ `occ`(点开的是哪一天)。 两者缺一不可 —— 少了 occ 就定位不到"这一次"(重复日程每一天都是同一条记录 id)。 ⚠️ 不重复的日程直接按 all(没有"系列"可言),表单也不会给它选范围。 */ $scope = (string)($_POST['scope'] ?? 'all'); $occDay = substr(trim((string)($_POST['occ'] ?? '')), 0, 10); $rrule = (string)($ev['rrule'] ?? ''); $hasOcc = (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $occDay); if ($rrule !== '' && $hasOcc && $scope === 'once') { // 只删这一次:记一个"跳过这一天的例外",系列原样保留 Calendar::skipOnce($id, $occDay); $done(true, '只删了 ' . $occDay . ' 这一次;后面的照旧。', ['removed_occ' => $occDay]); // v49:前端只摘这一天,整条系列留着 } if ($rrule !== '' && $hasOcc && $scope === 'future') { // 这一次及以后:老系列截至「这一次的前一天」。 $rp = Repeat::parse($rrule); $cut = date('Y-m-d', strtotime($occDay . ' -1 day')); // ⚠️ 截断结果要重新拼 RRULE:只改 UNTIL、并清掉 COUNT // (COUNT 与 UNTIL 同时存在时以 COUNT 为准,不干掉它这次截断就失效)。 $rp['count'] = 0; $rp['until'] = $cut; // ⚠️ 边界:如果"这一天"就是系列的第一次(cut 落在首次之前), // 那"这一次及以后"等于**全部** —— 截断会产出一条永不发生的系列, // 留着纯属垃圾,所以那种情况直接整条删掉。 $firstDay = substr((string)$ev['starts_at'], 0, 10); if ($cut < $firstDay) { Calendar::deleteEvent($id); $done(true, '这一天就是第一次,「这一次及以后」等于整条删除。', ['removed_all' => true]); } $newRule = 'FREQ=' . strtoupper((string)$rp['freq']) . (!empty($rp['interval']) && (int)$rp['interval'] > 1 ? ';INTERVAL=' . (int)$rp['interval'] : '') . ($rp['freq'] === 'weekday' ? ';BYDAY=MO,TU,WE,TH,FR' : '') . ';UNTIL=' . str_replace('-', '', $cut) . 'T235959Z'; Database::get()->prepare('UPDATE calendar_events SET rrule = ?, sequence = sequence + 1 WHERE id = ?') ->execute([$newRule, $id]); /* ⚠️ "这一次及以后"改的是**系列**,但砍掉的部分是从 occDay 起 —— 前端最稳的做法是**整页重载**(视图里那条系列从这天起没了, 就地摘一条会漏掉后面那些)。`removed_forward` 就是给前端这个信号。 */ $done(true, $occDay . ' 及以后的那些都删了(之前的保持不变)。', ['removed_forward' => $occDay]); } // all(或本来就不重复):整条删除 Calendar::deleteEvent($id); $done(true, '日程已删除。', ['removed_all' => true]); 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'); } /* 🔴 v71(用户 2026-10-19):**不能**在这里 for 循环逐条 importEvent —— 那样同一 UID 下的覆盖行会先于主事件落库(ICS 里主事件不一定排第一), 或者反过来把主事件改掉。顺序由 `Calendar::importIcs()` 统一保证: 主事件 → 覆盖行 → EXDATE。 */ $stat = Calendar::importIcs($calId, $list, $me); $msg = '导入完成:新增 ' . $stat['added'] . ' 条,更新 ' . $stat['updated'] . ' 条。'; if ($stat['overrides'] > 0) { $msg .= '其中 ' . $stat['overrides'] . ' 条是重复日程里单独调整的某一次。'; } flash('ok', $msg); 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); // 用户 2026-10-03 第 10 条:按"最新新增 / 最近修改"排序,默认只显示最新 10 条。 // 原话:「应该按照最新的或者近期修改使用,进行排序,比如,只显示最新新增或者 // 修改或者使用过的 10 个,要不然太多了放不下。上面反正还有搜索呢。」 $sort = ($_GET['sort'] ?? '') === 'new' ? 'new' : 'used'; $limit = \App\Resource::LIST_LIMIT; // 搜索时不受"只显示 10 条"限制:用户是带着明确目标来找的, // 这时候还只给 10 条会让他以为"没搜到"。看全部也用 all=1 放开。 $showAll = !empty($_GET['all']) || $keyword !== ''; $rows = Resource::all($familyId, $me, $ownerId, $keyword, $sort, $showAll ? 0 : $limit); $totalAll = Resource::countVisible($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, 'sort' => $sort, 'limit' => $limit, 'totalAll'=> $totalAll, 'showAll' => $showAll, '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); /* 🔴 v41(用户 2026-10-10 第 9 条):「添加资源,采用弹窗形式,并在新建资源的 的时候,就确定共享人。」⇒ 表单里多了一个"共享给谁"的选人(`share_ids[]`), 在这里一次性落库(新建与编辑都收)。 ⚠️ 权限一律给 `read`:新建时最多只到"只读共享"。 要给人**可写**,去列表里那条资源的「共享」弹框单独设 (那里能选只读/可读写)。这样"新建时勾错"的代价最小。 ⚠️ owner 自己会被 `setShare` 自动跳过(见 Resource::setShare 的第一行), 所以"把自己也勾上"不会产生一条多余的共享记录。 */ $shareIds = filter_family_person_ids((array)($_POST['share_ids'] ?? []), $familyId); foreach ($shareIds as $sid) { Resource::setShare($rid, $ownerId, (int)$sid, 'read'); } if ($shareIds) { Notification::syncShares($familyId, Notification::RESOURCE, $rid, $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')); // v32 第 1+2 条:资源共享(顺带修掉"弹框里点共享没生效"之后, // 这里才真正会被走到)→ 按共享表现状给被共享的人发通知 Notification::syncShares($familyId, Notification::RESOURCE, $id, $me); 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)); // v32 第 2 条:撤销他未读的那条共享通知(已读的保留) Notification::syncShares($familyId, Notification::RESOURCE, $id, $me); 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(用户 2026-10-03 第 7 条):**列 = 用户自定义的看板本身**。 // 原话:「每个人自定义的看板,不是显示在现在的看板上面,而是直接代替现在的 // 待办和进行中,是看板,而不是看板的分类。」 // 所以不再有「待办 / 进行中」两列、也不再有顶上那个"切换看板"的横条 —— // 一列就是一个看板,都在同一屏上,一眼看全。 $bc = Task::boardColumns($familyId, $me); $cols = $bc['cols']; $total = 0; foreach ($cols as $list) { $total += count($list); } View::render('tasks/index', [ 'title' => '任务', 'cols' => $cols, 'boards' => $bc['boards'], 'categories' => Task::categories($familyId), 'total' => $total, '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 { // 🔴 v24 第 26 批 用户第 3 条:这一行表单现在同时提交**名字 + 颜色**。 // 颜色合法性在 Task::updateBoard 里收口(空 = 清除自定义、回到按名派生)。 // 这里不自己判断颜色格式 —— 两处各判一次必然走样。 Task::updateBoard($familyId, $id, $nm, (string)($_POST['color'] ?? '')); 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; /** * 首页「近期任务」的就地面板(v78 用户 2026-10-22)。 * * 用户原话:「网页端,首页点击一个任务后,除了展示信息,应该有相应的功能, * 可以修改状态:待办、进行中,可以挪动分组,可以拖动上传附件,可以添加评论, * 添加子任务等。其目地,是为了让用户不离开首页,就能对任务进行简单的处理。」 * * ⇒ 这个路由只吐**一块 HTML**(`templates/tasks/_panel.php`),由 * `entry_popup.js` 塞进条目浮层的正文里。动作全部走既有路由 * (status / move / create / comment_add / attach_add…),这里不新开写接口。 * * ⚠️ 找不到就回 404 纯文本:调用方(浮层)拿到非 200 会退回"只有详情"的形态, * 不会把一段错误页塞进面板。 */ case 'tasks/panel': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $t = Task::find($familyId, (int)($_GET['id'] ?? 0), $me); if (!$t) { http_response_code(404); header('Content-Type: text/plain; charset=utf-8'); echo '找不到这项任务。'; exit; } $people = Person::roster($familyId, $me, 'member', ''); $names = []; foreach ($people as $p) { $names[] = person_name($p); $nm = trim((string)($p['name'] ?? '')); if ($nm !== '') { $names[] = $nm; } } header('Content-Type: text/html; charset=utf-8'); echo View::renderPartial('tasks/_panel', [ 't' => $t, 'people' => $people, 'names' => array_values(array_unique(array_filter($names))), 'boards' => Task::boards($familyId), 'categories' => Task::categories($familyId), 'canWrite' => Auth::canWrite() && Task::canWrite($t, $me), 'meId' => $me, ]); exit; 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; /* 🔴 v43(用户 2026-10-11 第 6 条):条目详情浮层里点「编辑」→ 取这张表单片段。 ⚠️ 与 `tasks/show` 共用同一份 `_edit_form.php`(不许各写一张,那必然有一天不同步)。 */ case 'tasks/edit': Auth::requireWrite(); $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $t = Task::find($familyId, (int)($_GET['id'] ?? 0), $me); if (!$t || !Task::canWrite($t, $me)) { http_response_code(403); echo '这项任务不能改。'; exit; } $tkParents = []; foreach (['todo', 'doing', 'done'] as $sk) { foreach (Task::board($familyId, $me)[$sk] as $p) { if ((int)$p['id'] !== (int)$t['id']) { $tkParents[] = $p; } } } header('Content-Type: text/html; charset=utf-8'); echo View::renderPartial('tasks/_edit_form', [ 't' => $t, 'people' => Person::roster($familyId, $me, 'member', ''), 'parents' => $tkParents, 'boards' => Task::boards($familyId), 'categories' => Task::categories($familyId), 'tkEditEmbed' => true, // 浮层形态:保存键由浮层底栏给 ]); exit; 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 === '') { /* 🔴 v78:面板里"加子任务"是一条 AJAX 动作 —— 失败也得回面板, 否则用户被跳到看板(人在首页,却被扔去了另一个模块)。 */ if (task_panel_wanted()) { $panelId = (int)($_POST['parent_id'] ?? 0) ?: (int)($_POST['id'] ?? 0); task_panel_json($panelId, '任务得写清楚要做什么。', false); } 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), // v24 第 14 批 用户第 2 条:新建时可以**直接定状态**(表单里有这一项)。 // ⚠️ 编辑时**不**在这里改状态:状态由专门的 tasks/status 路由管 // (那个路由还要处理循环任务的"下一次也排上"),这里保持原值 —— // 否则"只改个标题"会把进行中的任务悄悄打回待办。 'status' => $isEdit ? (string)$old['status'] : (string)($_POST['status'] ?? 'todo'), ]; if ($d['assignee_id'] > 0 && !isset(Person::map($familyId)[$d['assignee_id']])) { $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', '任务已新建。'); } /* 🔴 v37 用户第 6 条第 3 点:「新建任务也和新建日程一样,新建时弹出一个框, 新建完之后刷新新建任务的**列表、看板**页面。」 ⇒ 从看板上的"新建任务"弹框提交时带 `back=tasks` → 回看板(刷新了看板)。 ⚠️ 白名单取值,绝不把 POST 的字符串直接当跳转地址(开放重定向)。 ⚠️ **只对新建生效**:编辑是从详情页进来的,回详情页才对 (子任务那个 inline-form 也不带 back → 照旧回详情页看子任务)。 ⚠️ 新建后不再回详情页的另一个好处:附件/共享在表单里已经填完了, 没必要让用户再看一遍同一份内容。 */ if (!$isEdit && (string)($_POST['back'] ?? '') === 'tasks') { redirect('tasks'); } /* 🔴 v78:首页那张「新建任务」弹框提交时带 `back=home` —— 回首页, 卡片上立刻能看到刚建的这条(用户是"不离开首页就把事办了")。 */ if (!$isEdit && (string)($_POST['back'] ?? '') === 'home') { redirect('home'); } /* 🔴 v78:面板里"加子任务"—— 回**父任务**的面板。 ⚠️ 不能回子任务自己(`$tid`):那等于把用户从"这件事"弹到"其中一小步", 而且面板里子任务不挂子任务,回过去看到的是一块没有被加进去的界面。 */ if (!$isEdit && task_panel_wanted()) { $panelId = (int)($_POST['parent_id'] ?? 0) > 0 ? (int)$_POST['parent_id'] : $tid; task_panel_json($panelId, '子任务已加上。'); } 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); // ⚠️ v24(用户 2026-10-03 第 7 条):看板的列现在是**用户自定义的看板**, // 所以"拖到另一列" = **换看板**(改 board_id),不是改 status。 // 两种搬法用一个路由:带 board 的走看板,带 status 的走状态 // (状态那条保留给任务详情页的"标记完成"和子任务用)。 if (array_key_exists('board', $_POST)) { Task::moveToBoard($familyId, $id, (int)$_POST['board'], $before, $me); /* 🔴 v78:面板里"挪动分组"(换看板)走这里 —— 回新面板,别跳走。 ⚠️ 提示语要说出**挪到哪儿**:看板名在服务端取(前端那排 chip 的文字 可能被用户改过名,用 POST 回来的 id 现查一次才是真的)。 */ if (task_panel_wanted()) { task_panel_json($id, '已挪到「' . Task::boardName($familyId, (int)$_POST['board']) . '」。'); } redirect('tasks'); break; } // 子任务只在 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; /** * 改一条任务的**状态**(v24 第 14 批 用户第 2 条)。 * * 用户原话:「任务模块,每个任务都应该有不同的状态,例如正在进行、已完成等。」 * → 看板卡片上的状态徽标点一下就走这里:待办 → 进行中 → 已完成 → 待办。 * * ⚠️ 与 tasks/move 的分工:move 管"搬到哪个看板/哪一列"(改 board_id 或带定位的 * status),这里只管"这件事做到哪一步"(改 status),不带位置信息 —— * 在看板上改状态不该顺手把卡片挪到列尾。 */ case 'tasks/status': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $status = (string)($_POST['status'] ?? ''); $t = Task::find($familyId, $id, $me); if (!$t || !Task::canWrite($t, $me)) { // v24 第 25 批 用户第 6 条:复选框在**任何地方**都能点(首页/看板/详情), // 所以这三处都可能发来 AJAX。失败也回 JSON,别让它落进"整页跳转"。 if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') { header('Content-Type: application/json; charset=utf-8', true, 403); echo json_encode(['ok' => false, 'msg' => '这项任务不能改。'], JSON_UNESCAPED_UNICODE); exit; } flash('err', '这项任务不能改。'); redirect('tasks'); } Task::setStatus($familyId, $id, $status); $word = Task::statusWord(Task::normalizeStatus($status)); $msg = ($status === 'done' && !empty($t['repeat_rule']) && ($t['due_at'] ?? '') !== '') ? '已标成「' . $word . '」,下一次的也排上了。' : '已标成「' . $word . '」。'; // v24 第 25 批:勾复选框 / 点状态徽标都走这条 —— // 带 X-Requested-With 时回 JSON(前端就地改那张卡,不刷页); // 普通表单提交照旧 302(没有 JS 时的兜底)。 // 🔴 v78:首页任务面板(`ajax_panel=1`)要的是**整块新面板**, // 它比"只回一个状态"多带了子任务进度、看板选中态等一整页的一致性。 // ⚠️ 面板里勾的是**子任务**,重画时要用 `panel_of`(父任务)而不是子任务自己。 if (task_panel_wanted()) { $panelId = (int)($_POST['panel_of'] ?? 0); task_panel_json($panelId > 0 ? $panelId : $id, $msg); } if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') { header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => true, 'id' => $id, 'status' => Task::normalizeStatus($status), 'word' => $word, 'msg' => $msg, ], JSON_UNESCAPED_UNICODE); exit; } flash('ok', $msg); // 无 JS 时的兜底回跳。三处复选框各自带自己的来源: // show=任务详情 / home=首页 / tasks(默认,看板)。 // ⚠️ v24 第 26 批 用户第 3 条顺带修:原来只认 'show',于是**首页**那颗 // 在没 JS 的浏览器上提交完会跳到任务看板(人在首页,却被扔去了另一个模块)。 // 首页现在传 back=home,这里认它。 $back = (string)($_POST['back'] ?? ''); if ($back === 'show') { redirect('tasks/show', ['id' => $id]); } elseif ($back === 'home') { redirect('home'); } redirect('tasks'); 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); if (task_panel_wanted()) { task_panel_json($id, '留言已发表。'); } flash('ok', '留言已发表。'); } else { if (task_panel_wanted()) { task_panel_json($id, '留言是空的。', false); } 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); // 内部只删自己的 if (task_panel_wanted()) { task_panel_json($taskId, '留言已删除。'); } 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); /* 🔴 v78:面板里"拖动上传"是**一个文件一条请求**(拖几张就是几次), 所以这里每次都要把最新面板回过去 —— 缩略图墙与"附件 n"随之长出来。 */ if (task_panel_wanted()) { task_panel_json($id, $warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。', !$warns); } 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); if (task_panel_wanted()) { task_panel_json($tid, '附件已删除。'); } 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; // ==================== 读书(v24 第 14 批 用户第 5 条) ==================== // ⚠️ 与观影 / 旅程不同,读书**不走 events 骨架**:一本书是"一段持续几个月的状态", // 不是"某一天发生的事"(详见 app/Book.php 的头注释)。真要上日历的那天是"读完那天"。 // // 入口就两个:书架(books)与一本书(books/show);阅读器(books/read)从书进去。 // 统计与笔记导入挂在读书页右上角的「☰」里,与观影同一套规矩。 case 'books': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); // ---- v24 第 25 批 用户第 8 条:「需要能够做到定期完成,自动同步」---- // // ⚠️ 触发点放在**读书列表页**(而不只是"微信读书导入"那一页): // 用户日常打开的是「读书」这个模块入口,导入页他一个月也去不了一次。 // 挂在导入页 = 自动同步基本永远不会被触发(等于没做)。 // ⚠️ 代价可忽略:maybeSync 内部先看间隔(6 小时),绝大多数请求只是 // 读一个 user_settings 时间戳,不联网。 // ⚠️ 失败静默(只记时间戳,下次再试)—— 外部依赖不能把书架页也拖慢/打挂。 // 🔴 v29 用户第 4 条:这里也改成**响应之后**再同步。 // 这条路由是用户日常最常打开的入口,"点进来要等"的感觉主要来自它。 // 🔴🔴 v47(用户 2026-10-13):「读书现在还是非常慢,进入这个页面刚开始非常慢」 // —— 真因是**会话锁**(见 WeRead::queueBackgroundSync 的长注释): // `fastcgi_finish_request()` 只断开客户端连接,**不释放 PHP 文件会话锁**; // 后台同步要跑 ~9 秒(真打微信读书接口), // 于是同一个人的**下一个请求**会一直等它跑完 ⇒ 下一个页面卡 9 秒。 // (线上实测:第 1 次 0.08s / 第 2 次 **8.73s** / 第 3 次 0.07s。) // ⇒ 排完后台任务后**立刻放锁**,再渲染。 // ⚠️ 放在渲染之前而不是只放在 shutdown 回调里:shutdown 回调不一定执行 // (客户端中断 / 服务器超时),那锁会一直挂到请求结束。 // ⚠️ 放锁前要用的都已经是普通变量($me / $familyId),此后不再读 $_SESSION ⇒ 安全。 if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) { WeRead::queueBackgroundSync($familyId, $me); // 排完就放锁:这一步是本轮"读书页慢"的真正修复点 if (session_status() === PHP_SESSION_ACTIVE) { session_write_close(); } } $f = [ 'status' => (string)($_GET['status'] ?? ''), 'category' => (string)($_GET['cat'] ?? ''), 'q' => (string)($_GET['q'] ?? ''), 'owner' => (int)($_GET['owner'] ?? 0), ]; View::render('books/index', [ 'title' => '读书', 'rows' => Book::all($familyId, $f), // ⚠️ 计数**不带筛选条件**(见 Book::statusCounts 的注释): // 否则点进某个状态后其它状态的数字全变 0,筛选条成了死路。 'counts' => Book::statusCounts($familyId), 'cats' => Book::categories($familyId), 'status' => $f['status'], 'cat' => $f['category'], 'q' => $f['q'], 'owner' => $f['owner'], 'people' => Person::roster($familyId, $me, 'member', ''), ], 'books'); break; case 'books/new': Auth::requireWrite(); View::render('books/form', [ 'title' => '添加图书', 'b' => null, 'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''), 'errors' => [], ], 'books'); break; case 'books/edit': Auth::requireWrite(); $familyId = (int)Auth::familyId(); $b = Book::find($familyId, (int)($_GET['id'] ?? 0)); if (!$b) { flash('err', '找不到这本书。'); redirect('books'); } View::render('books/form', [ 'title' => '编辑图书', 'b' => $b, 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), 'errors' => [], ], 'books'); break; case 'books/create': case 'books/update': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $isEdit = $r === 'books/update'; $id = $isEdit ? (int)($_POST['id'] ?? 0) : 0; $b = $isEdit ? Book::find($familyId, $id) : null; if ($isEdit && !$b) { flash('err', '找不到这本书。'); redirect('books'); } $errors = []; $warns = []; // ---- ① 图书文件:先存原文件,再解析出元数据与正文 ---- // ⚠️ 顺序不能反:文件是"本来就想要的"(下载 / 浏览器内嵌看 PDF 都靠它), // 解析只是锦上添花。解析失败也必须有一句人能看懂的话,不能静默。 $fileKey = $isEdit ? (string)($b['file_key'] ?? '') : ''; $fileName = $isEdit ? (string)($b['file_name'] ?? '') : ''; $fileFormat = $isEdit ? (string)($b['file_format'] ?? '') : ''; $fileSize = $isEdit ? (int)($b['file_size'] ?? 0) : 0; $parsed = null; $upFile = $_FILES['bookfile'] ?? []; if (isset($upFile['error']) && !is_array($upFile['error']) && (int)$upFile['error'] === UPLOAD_ERR_OK) { $origName = (string)($upFile['name'] ?? ''); $tmp = (string)($upFile['tmp_name'] ?? ''); $ext = strtolower((string)pathinfo($origName, PATHINFO_EXTENSION)); if (!in_array($ext, Book::FILE_EXT, true)) { $warns[] = '这个文件的格式不在允许范围内(' . implode(' / ', Book::FILE_EXT) . '),没有保存。'; } elseif ($tmp === '' || !is_file($tmp)) { $warns[] = '文件没有传上来(可能是太大了),其他信息已保存。'; } else { $safeExt = preg_replace('/[^a-z0-9]/', '', $ext) ?? ''; $newKey = 'books/' . bin2hex(random_bytes(12)) . ($safeExt !== '' ? '.' . $safeExt : ''); $fino = new \finfo(FILEINFO_MIME_TYPE); $mime = (string)$fino->file($tmp); $res = \App\Storage\Storage::putFile( $familyId, $tmp, $newKey, $mime !== '' ? $mime : 'application/octet-stream' ); if (!empty($res['ok'])) { if ($fileKey !== '' && $fileKey !== $newKey) { \App\Storage\Storage::delete($familyId, $fileKey); } $fileKey = $newKey; $fileName = $origName; $fileFormat = $ext; $fileSize = (int)@filesize($tmp); $p = BookParser::parse($tmp, $ext, $origName); if (empty($p['ok'])) { // 文件已经存好了 —— 解析失败不等于白传,如实说清楚 $warns[] = '文件存好了,但读不出内容:' . (string)($p['error'] ?? '未知原因'); } else { $parsed = $p; if (!empty($p['note'])) { $warns[] = (string)$p['note']; } } } else { $warns[] = '文件没有保存:' . (string)($res['error'] ?? '未知错误'); } } } // ---- ② 字段:手填优先,空着的才用解析出来的值补 ---- // ("解析出来的覆盖手填的"是最招人烦的一种自作聪明 —— 用户改了书名, // 再传一次文件又变回去,他会以为保存坏了。) $meta = is_array($parsed['meta'] ?? null) ? $parsed['meta'] : []; $mv = static function (string $k) use ($meta): string { return trim((string)($meta[$k] ?? '')); }; $titleIn = trim((string)($_POST['title'] ?? '')); if ($titleIn === '') { $titleIn = $mv('title'); } if ($titleIn === '') { $errors['title'] = '书名不能为空(上传 EPUB / TXT 会自动读出来)'; } $d = [ 'title' => $titleIn, 'subtitle' => trim((string)($_POST['subtitle'] ?? '')) ?: $mv('subtitle'), 'author' => trim((string)($_POST['author'] ?? '')) ?: $mv('author'), 'publisher' => trim((string)($_POST['publisher'] ?? '')) ?: $mv('publisher'), 'published' => trim((string)($_POST['published'] ?? '')) ?: $mv('published'), 'isbn' => trim((string)($_POST['isbn'] ?? '')) ?: $mv('isbn'), 'language' => trim((string)($_POST['language'] ?? '')) ?: $mv('language'), 'summary' => trim((string)($_POST['summary'] ?? '')) ?: $mv('summary'), 'translator' => trim((string)($_POST['translator'] ?? '')), 'category' => trim((string)($_POST['category'] ?? '')), 'tags' => trim((string)($_POST['tags'] ?? '')), 'pages' => (int)($_POST['pages'] ?? 0), 'status' => (string)($_POST['status'] ?? 'wish'), 'rating' => (string)($_POST['rating'] ?? ''), 'owner_id' => (int)($_POST['owner_id'] ?? 0), 'file_key' => $fileKey, 'file_name' => $fileName, 'file_format' => $fileFormat, 'file_size' => $fileSize, ]; // ---- ③ 封面:用户选的优先,其次用 EPUB 自带的 ---- $oldCover = $isEdit ? (string)($b['cover_key'] ?? '') : ''; $d['cover_key'] = $oldCover; $up = Upload::image($familyId, $_FILES['cover'] ?? [], 'bookcover', (string)($_POST['cover_crop'] ?? '')); if ($up['ok']) { $d['cover_key'] = (string)$up['file']; if ($oldCover !== '' && $oldCover !== $d['cover_key']) { \App\Storage\Storage::delete($familyId, $oldCover); } } elseif ((string)($up['error'] ?? '') !== '') { $warns[] = '封面没有保存:' . $up['error']; } elseif ($parsed !== null && !empty($parsed['cover']['tmp'])) { // 用户没另外选图 → 用书里自带的封面(Upload::image 只认 $_FILES 结构, // 这里按同样的形状喂一个进去,就能复用同一条"缩放 + 转 JPG"的链路)。 $ctmp = (string)$parsed['cover']['tmp']; $fake = [ 'name' => 'cover', 'type' => (string)($parsed['cover']['mime'] ?? 'image/jpeg'), 'tmp_name' => $ctmp, 'error' => UPLOAD_ERR_OK, 'size' => (int)@filesize($ctmp), ]; $up2 = Upload::image($familyId, $fake, 'bookcover', ''); if ($up2['ok']) { $d['cover_key'] = (string)$up2['file']; if ($oldCover !== '' && $oldCover !== $d['cover_key']) { \App\Storage\Storage::delete($familyId, $oldCover); } } } // 解析产生的临时封面文件用完即删(它是 BookParser 在 temp 下生成的) if ($parsed !== null && !empty($parsed['cover']['tmp'])) { @unlink((string)$parsed['cover']['tmp']); } if ($errors) { set_old($_POST); View::render('books/form', [ 'title' => $isEdit ? '编辑图书' : '添加图书', 'b' => $isEdit ? array_merge($b, $d) : null, 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), 'errors' => $errors, ], 'books'); exit; } if ($isEdit) { Book::update($familyId, $id, $d); // 重新上传了文件 → 章节按新文件整组替换(旧章节留着就是"两本书混在一起") if ($parsed !== null && is_array($parsed['chapters'] ?? null)) { Book::replaceChapters($id, $parsed['chapters']); } clear_old(); flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('books/show', ['id' => $id]); } $newId = Book::create($familyId, (int)Auth::personId(), $d); if ($parsed !== null && is_array($parsed['chapters'] ?? null)) { Book::replaceChapters($newId, $parsed['chapters']); } clear_old(); flash('ok', '这本书进书架了。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('books/show', ['id' => $newId]); break; case 'books/show': $familyId = (int)Auth::familyId(); $id = (int)($_GET['id'] ?? 0); $b = Book::find($familyId, $id); if (!$b) { flash('err', '找不到这本书。'); redirect('books'); } View::render('books/show', [ 'title' => (string)$b['title'], 'b' => $b, 'chapters' => Book::chapters($id), 'notes' => Book::notes($id, (int)Auth::personId()), ], 'books'); break; case 'books/read': $familyId = (int)Auth::familyId(); $id = (int)($_GET['id'] ?? 0); $b = Book::find($familyId, $id); if (!$b) { flash('err', '找不到这本书。'); redirect('books'); } if (empty($b['file_key'])) { flash('err', '这本书还没有上传文件,先到「编辑」里传一个。'); redirect('books/show', ['id' => $id]); } $chapters = Book::chapters($id); $total = count($chapters); $seq = (int)($_GET['seq'] ?? 0); if ($seq <= 0) { // 没指定 → 接着上次读到的地方(这就是"继续读"按钮能直接回原位的原因) $seq = max(1, (int)($b['progress'] ?? 0)); } $seq = max(1, min($seq, max(1, $total))); // ⚠️ 翻章就顺手记进度:让人手动记"我读到第几章"的阅读器没人会用。 if (Auth::canWrite() && $total > 0 && (int)($b['progress'] ?? 0) !== $seq) { Book::setProgress($familyId, $id, $seq); $b['progress'] = $seq; } View::render('books/read', [ 'title' => (string)$b['title'], 'b' => $b, 'chapters' => $chapters, 'chapter' => Book::chapter($id, $seq), 'seq' => $seq, ], 'books'); break; case 'books/status': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $b = Book::find($familyId, $id); if (!$b) { flash('err', '找不到这本书。'); redirect('books'); } $st = Book::normalizeStatus((string)($_POST['status'] ?? '')); Book::setStatus($familyId, $id, $st); flash('ok', '《' . (string)$b['title'] . '》标成「' . Book::statusWord($st) . '」了。'); // 列表上的一键 → 回列表;详情页 / 阅读页 → 回这本书 redirect((string)($_POST['back'] ?? '') === 'list' ? 'books' : 'books/show', ['id' => $id]); break; case 'books/destroy': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $b = Book::find($familyId, $id); if (!$b) { flash('err', '找不到这本书。'); redirect('books'); } Book::destroy($familyId, $id); flash('ok', '《' . (string)$b['title'] . '》已从书架移走。'); redirect('books'); break; case 'books/note_add': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $b = Book::find($familyId, $id); if (!$b) { flash('err', '找不到这本书。'); redirect('books'); } $nid = Book::addNote($familyId, $id, $me, [ 'kind' => (string)($_POST['kind'] ?? 'note'), 'chapter' => (string)($_POST['chapter'] ?? ''), 'content' => (string)($_POST['content'] ?? ''), 'source' => 'manual', ]); flash($nid > 0 ? 'ok' : 'err', $nid > 0 ? '记下了。' : '笔记是空的,没记。'); redirect('books/show', ['id' => $id], 'notes'); break; case 'books/note_del': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $noteId = (int)($_POST['note_id'] ?? 0); // 只能删自己的或无主的(导入进来的笔记没有归属,谁都能删) $st = Database::get()->prepare( 'SELECT n.book_id FROM book_notes n JOIN books b ON b.id = n.book_id WHERE n.id = ? AND b.family_id = ?' ); $st->execute([$noteId, $familyId]); $bid = (int)$st->fetchColumn(); Book::deleteNote($familyId, $noteId, $me); flash('ok', '这条笔记删掉了。'); if ($bid > 0) { redirect('books/show', ['id' => $bid], 'notes'); } redirect('books'); break; case 'books/stats': $familyId = (int)Auth::familyId(); View::render('books/stats', [ 'title' => '读书统计', 's' => Book::stats($familyId), ], 'books'); break; // ---- 微信读书导入(v24 第 24 批 用户第 5 条)---- // // 用户原话:「能不能导入微信的读书笔记,就是每一个人设置一下…授权一下」 // → 后续明确「用微信读书 api key」(官方 2026 已开放,见 App\WeRead 头部) // // ⚠️ Key 是**账号凭据**:加密落库、界面只回显掩码(同 DAV 口令那套思路)。 case 'books/weread': $me = (int)Auth::personId(); Auth::requireWrite(); $imported = null; if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { csrf_check(); $act = (string)($_POST['act'] ?? ''); if ($act === 'key_save') { $r = WeRead::saveKey($me, (string)($_POST['api_key'] ?? '')); flash($r['ok'] ? 'ok' : 'err', $r['ok'] ? 'API Key 已保存(加密存放,页面只显示掩码)。' : (string)$r['error']); redirect('books/weread'); } // v24 第 25 批 用户第 8 条:自动同步的总开关(默认开) if ($act === 'auto_toggle') { WeRead::setAutoSync($me, !WeRead::autoSyncOn($me)); flash('ok', WeRead::autoSyncOn($me) ? '已打开自动同步(打开读书页时顺带同步,约每 6 小时一次)。' : '已关闭自动同步,需要手动点「立即同步」。'); redirect('books/weread'); } // 用户第 8 条:手动「立即同步」(忽略间隔,立刻打一次接口) if ($act === 'sync_now') { $r = WeRead::maybeSync((int)Auth::familyId(), $me, true); if (!$r['ok']) { flash('err', '同步失败:' . (string)$r['error']); } else { flash('ok', '同步完成:新增 ' . (int)$r['books'] . ' 本书、' . (int)$r['covers'] . ' 张封面、' . (int)$r['notes'] . ' 条笔记。'); } redirect('books/weread'); } if ($act === 'fetch') { // 「拉取预览」:只取数据、**不落库**,让用户先看一眼再决定 // 🔴 v29 用户第 4 条:这里是**用户主动点的按钮**,所以仍然实时取 // ("我点一下就要看最新的"是明确预期,不能拿缓存糊弄)。 // 但取完**顺手落盘** —— 之后列表页/页头统计就走本地快照了。 $nb = WeRead::notebooks($me); if (!$nb['ok']) { flash('err', (string)$nb['error']); redirect('books/weread'); } WeRead::snapNotebooks($me, (array)$nb['books']); $preview = []; foreach (array_slice($nb['books'], 0, 40) as $b) { $nt = WeRead::notes($me, (string)$b['bookId']); // v24 第 25 批 用户第 8 条:预览里也把**封面**带出来, // 让用户选书时看得见是哪一本(原来只有一行文字)。 $info = WeRead::bookInfo($me, (string)$b['bookId']); $preview[] = [ 'title' => (string)$b['title'], 'author' => (string)$b['author'], 'bookId' => (string)$b['bookId'], 'cover' => $info['ok'] ? (string)($info['book']['cover'] ?? '') : '', 'count' => count((array)($nt['items'] ?? [])), 'items' => array_slice((array)($nt['items'] ?? []), 0, 3), 'local' => WeRead::matchLocalBook((int)Auth::familyId(), (string)$b['title']), ]; } View::render('books/weread', [ 'title' => '微信读书导入', 'preview' => $preview, 'nbCount' => count($nb['books']), 'stats' => WeRead::readStats($me, 'overall'), ], 'books'); break; } if ($act === 'import') { // 「真的导入」:只导**勾选的那些书**(用户在预览页里挑) // // ⚠️ v24 第 25 批 用户第 8 条:**不再自己写一遍导入逻辑** —— // 原来这里只建 title + author + status(于是封面永远是空的), // 现在整份交给 `WeRead::syncNow()`:它会把封面、简介、ISBN、 // 出版社、字数、进度**一次拿齐**,而且「只补空字段」。 // 两处各写一遍 = 迟早只修一处(这正是"封面看不到"的来路)。 $want = array_map('strval', (array)($_POST['pick'] ?? [])); if (!$want) { flash('err', '没有勾选任何书。'); redirect('books/weread'); } UserSetting::set($me, WeRead::SYNC_AT_KEY, '0'); // 强制真同步 $nb = WeRead::notebooks($me); if (!$nb['ok']) { flash('err', (string)$nb['error']); redirect('books/weread'); } $familyId = (int)Auth::familyId(); $sumBooks = $sumNotes = $sumCovers = 0; foreach ($nb['books'] as $b) { if (!in_array((string)$b['bookId'], $want, true)) { continue; } $title = trim((string)$b['title']); if ($title === '') { continue; } // 逐本走 syncNow 的那套「建书/补空」逻辑:把它当"只同步这一本"。 // ⚠️ syncNow 是按"有笔记的书"遍历的,这里已经有确定的 bookId, // 所以借它内部那两个入口(bookInfo + fetchCover)手动走一遍, // 保持与自动同步**完全同一条路径**(封面/元数据不会两样)。 $bid = WeRead::matchLocalBook($familyId, $title); $info = WeRead::bookInfo($me, (string)$b['bookId']); $meta = $info['ok'] ? $info['book'] : []; $coverKey = ''; if (!empty($meta['cover'])) { $coverKey = WeRead::fetchCover($familyId, (string)$meta['cover'], $title); if ($coverKey !== '') { $sumCovers++; } } if ($bid <= 0) { $bid = Book::create($familyId, $me, [ 'title' => $title, 'author' => (string)($meta['author'] ?? $b['author']), 'translator' => (string)($meta['translator'] ?? ''), 'publisher' => (string)($meta['publisher'] ?? ''), 'published' => (string)($meta['published'] ?? ''), 'isbn' => (string)($meta['isbn'] ?? ''), 'category' => (string)($meta['category'] ?? ''), 'summary' => (string)($meta['intro'] ?? ''), 'words' => (int)($meta['wordCount'] ?? 0), 'rating' => (int)($meta['rating'] ?? 0) ?: null, 'cover_key' => $coverKey, 'status' => 'reading', ]); if ($bid <= 0) { continue; } $sumBooks++; } elseif ($coverKey !== '') { // 已在书架且没封面 → 补上(用户第 8 条「封面图看不到」) $row = Book::find($familyId, $bid); if ($row && trim((string)($row['cover_key'] ?? '')) === '') { WeRead::patchBook($familyId, $bid, ['cover_key' => $coverKey]); } } $nt = WeRead::notes($me, (string)$b['bookId']); if ($nt['ok']) { $res = WeRead::store($bid, $me, (array)$nt['items']); $sumNotes += (int)$res['added']; } } flash('ok', '导入完成:新建 ' . $sumBooks . ' 本书、' . $sumCovers . ' 张封面、' . $sumNotes . ' 条笔记(重复的已自动跳过)。'); redirect('books'); } } // ---- GET:**不在渲染路径上同步**(v29 用户第 4 条)---- // // 🔴 用户原话:「读书还是慢。是不是可以做一个同步,然后落盘,这样就不用 // 每次都读取微信读书的数据了。」 // v28 只是给同步加了"总预算"(8 秒),但那 8 秒**仍然挡在渲染前面** // (这一段原来就在 View::render 上方)—— 到期那一次还是要等。 // ⇒ 现在两件事一起做: // ① 取数改走**本地快照**(`*Cached()`),永不联网; // ② 该同步时**排到响应之后**去跑(queueBackgroundSync),页面立刻出来。 $syncRes = ['ran' => false, 'books' => 0, 'notes' => 0, 'covers' => 0, 'error' => '']; if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) { WeRead::queueBackgroundSync((int)Auth::familyId(), $me); // 🔴 v47:排完就放会话锁(同 books 路由,见 WeRead::queueBackgroundSync 的长注释)。 // 不放的话,这台机器上"同一个人的下一个请求"会等到后台同步跑完 // (实测 9.2 秒)—— 用户会觉得"点进去要等好久"。 if (session_status() === PHP_SESSION_ACTIVE) { session_write_close(); } } View::render('books/weread', [ 'title' => '微信读书导入', 'preview' => $imported, 'nbCount' => 0, 'sync' => $syncRes, // ⚠️ 本地没有快照时(第一次用 / 刚换 Key)允许真取一次: // 否则页面会是一片空白,用户会以为"坏了"。 // 这是**首次**,之后每次访问都命中本地 —— 那才是常态。 'stats' => WeRead::readStatsCached($me, 'overall')['data'], 'snapAt' => WeRead::snapshotAt($me), ], 'books'); break; case 'books/notes_import': Auth::requireWrite(); // 同一条路由干两件事:GET 给粘贴框,POST 先解析出**预览**。 // ⚠️ 必须有预览这一步:解析是宽容的(宁可归类错也不丢字), // 直接落库的话,一段没书名的文字会变成一本"未命名的一本"—— // 看不见结果就写进去,是最容易让人以后再也不敢用这个功能的做法。 $raw = ''; $parsed = []; $source = 'weread'; if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { csrf_check(); $raw = (string)($_POST['raw'] ?? ''); $source = BookNoteImport::sourceOf((string)($_POST['source'] ?? '')); $parsed = BookNoteImport::parse($raw); } View::render('books/notes_import', [ 'title' => '导入读书笔记', 'raw' => $raw, 'parsed' => $parsed, 'source' => $source, ], 'books'); break; case 'books/notes_import_save': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $raw = (string)($_POST['raw'] ?? ''); $source = BookNoteImport::sourceOf((string)($_POST['source'] ?? '')); // ⚠️ **重新解析一遍**,不接前端传回来的"解析结果"—— // 表单可以改,信它就是让人往库里塞任意结构。 $parsed = BookNoteImport::parse($raw); $res = BookNoteImport::import($familyId, $me, $parsed, $source); if ((int)$res['added'] === 0 && (int)$res['created'] === 0) { flash('err', '这段里没认出能导入的笔记。检查一下是不是把书名行也贴上了。'); redirect('books/notes_import'); } flash('ok', '导入完成:新增 ' . (int)$res['created'] . ' 本书、' . (int)$res['added'] . ' 条笔记。'); redirect('books'); break; // ==================== 个人导航(v24 第 24 批 用户第 4 条,从 SimpleNav 搬进来)==================== // // ⚠️ 这一块与其它模块最大的不同:**导航展示页不需要登录**(用户明确要求), // 它的入口在 index.php 之外(`nav_show.php`),因为它必须在 bootstrap // 的"要求登录"之前就放行。这里的两条路由都是**管理端**(要登录)。 case 'nav': $me = (int)Auth::personId(); Nav::ensureSeed($me); // 第一次进来给一份默认导航(只在他一条板块都没有时) $gridGroups = Nav::groups($me, 'grid', true); $tabGroups = Nav::groups($me, 'tabs', true); $allGroups = Nav::groups($me); $sitesBy = Nav::sitesByGroup($me); $engines = Nav::engines($me); $settings = Nav::settings($me); $phrase = Nav::phrase($me); View::render('nav/index', [ 'title' => '导航', 'meId' => $me, 'gridGroups' => $gridGroups, 'tabGroups' => $tabGroups, 'allGroups' => $allGroups, 'sitesBy' => $sitesBy, 'engines' => $engines, 'navSet' => $settings, 'phrase' => $phrase, // 展示页地址(短语为空时模板里会提示去设置) // ⚠️ 用 `Nav::abs()` 而不是自己拼 `base_url`(v24 第 26 批顺带统一): // `abs()` 以**当前请求的 host** 为准,所以从 IP / 测试域名进来时 // 给出的地址也是那个入口的(写死 base_url 会把人指到另一个域上)。 'showUrl' => $phrase !== '' ? Nav::abs('/nav/') . '?q=' . rawurlencode($phrase) : '', // 我自己的头像 —— 展示页右上角要用(用户原话「头像就变成用户自己的头像」) 'meAvatar' => avatar_url(Person::find($me)['avatar'] ?? null), ], 'nav'); break; case 'nav/save': $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); // 一个路由干多件事(与 SimpleNav 的 admin/actions.php 同一个思路: // 那个文件就是按 ?action= 分派的)。这里用 act 区分,避免开十几条路由。 $act = (string)($_POST['act'] ?? ''); /* 🔴 v29 用户第 5 条:设置拆成四项之后,保存完要**回到刚才那一项**, 不能一律跳回「我的网址」—— 那会让人以为"设置没保存"(保存后位置变了)。 约定:表单里带一个 `back` 隐藏域(= 当前 `?set=` 的值)。 ⚠️ 白名单校验:`back` 只允许是那四个已知值,别的值一律当空 (否则它就是一个开源的重定向参数,能被塞任意 URL)。 ⚠️ 站点相关动作(site_save/delete…)默认 `back` 为空 → 回「我的网址」, 这正是不带 `set` 时的默认视图,符合直觉。 */ $backRaw = (string)($_POST['back'] ?? ''); $backSet = in_array($backRaw, ['addr', 'general', 'board', 'engine'], true) ? $backRaw : ''; // 拼"回去哪儿"。⚠️ 用 redirect() 的同一套 url() 拼法(路由 + 附加参数), // 别手写 'nav&set=xxx' 字符串 —— 那样与模板里 url('nav',['set'=>...]) 的 // 转义口径就可能不一致(本项目在"斜杠被 urlencode"上栽过一次)。 $navBackUrl = $backSet === '' ? url('nav') : url('nav', ['set' => $backSet]); $ok = static function (string $msg) use ($navBackUrl): void { // AJAX(导航管理页的拖拽排序/开关都是 fetch)→ 回 JSON;否则回 302 if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => true, 'msg' => $msg], JSON_UNESCAPED_UNICODE); exit; } flash('ok', $msg); header('Location: ' . $navBackUrl); exit; }; $bad = static function (string $msg) use ($navBackUrl): void { if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE); exit; } flash('err', $msg); header('Location: ' . $navBackUrl); exit; }; $gid = (int)($_POST['id'] ?? 0); switch ($act) { case 'group_save': $id = Nav::saveGroup($me, [ 'name' => (string)($_POST['name'] ?? ''), 'display' => (string)($_POST['display'] ?? 'grid'), 'is_visible' => !empty($_POST['is_visible']), ], $gid); $id > 0 ? $ok('板块已保存。') : $bad('板块得有个名字。'); break; case 'group_delete': Nav::removeGroup($me, $gid); $ok('板块已删除(里面的站点一起删了)。'); break; case 'group_toggle': Nav::toggleGroup($me, $gid); $ok('已切换显示。'); break; case 'group_reorder': // 拖拽后整组顺序一次提交(SimpleNav 的做法:整组归一化,间隙恢复整齐) // ⚠️ 同 site_reorder:不是数组就拒绝,别静默只改一项(见那边的说明)。 if (!is_array($_POST['ids'] ?? null)) { $bad('排序数据格式不对(应是一组 id)。'); } $ids = array_map('intval', (array)$_POST['ids']); $n = Nav::applySort('nav_groups', $me, $ids); $ok('顺序已保存(' . $n . ' 项)。'); break; case 'site_save': $url = Nav::normUrl((string)($_POST['url'] ?? '')); $id = Nav::saveSite($me, [ 'group_id' => (int)($_POST['group_id'] ?? 0), 'url' => $url, 'name' => (string)($_POST['name'] ?? ''), 'short_name' => (string)($_POST['short_name'] ?? ''), 'is_visible' => !empty($_POST['is_visible']), ], $gid); if ($id <= 0) { $bad('站点地址和所属板块都不能空。'); } /* 🔴 v24 第 26 批 用户第 4 条:**复用别人加过的站点**。 * * 用户原话:「输入 url 之后,如果其他用户添加了,他可以直接复用, * 复用的时候,自定义图标和简称,他可以自己再设置, * 设置好以后就固定了。」 * * 做法:表单里带一个 `reuse_key`(= 显示名|图标文件名)。 * ⚠️ 这里**重新查一遍**别人那条(不信任前端回传的内容)—— * 前端只被允许"指认是哪一条",绝不被允许"提供那条的内容"。 * 否则任何人都能凭一个伪造的 key 把任意的 name/icon 写进自己库里 * (比如塞一个 name 做 XSS 载荷 —— 展示页那份是 `e()` 转义过的, * 但"不要相信前端给的展示数据"这条规则不该有例外)。 * ⚠️ 复用只发生在**新建**(`$editSite` 为空)时:编辑一条已有站点时 * 又去抄别人,语义会很乱(是覆盖我刚改的简称吗?)。 * 编辑时 `reuse_key` 一律忽略。 */ $reuseKey = trim((string)($_POST['reuse_key'] ?? '')); $didReuse = false; if ($reuseKey !== '' && !empty($_POST['is_new'])) { foreach (Nav::sharedByUrl($me, $url, 20) as $cand) { $k = Nav::siteLabel($cand) . '|' . (string)($cand['icon'] ?? ''); if ($k !== $reuseKey) { continue; } // 用户在前端填的 简称/显示名 优先("他可以自己再设置"); // 没填的字段由 adoptSite 用别人的补上。 $didReuse = Nav::adoptSite($me, $id, $cand, (string)($_POST['name'] ?? ''), (string)($_POST['short_name'] ?? '')); break; } } // 抓元信息(名称 + 图标):用户勾了"顺便抓取"才做(联网、慢) // ⚠️ 复用过的那条**跳过抓取**:用户刚说"就用这个图标、以后固定" // (adoptSite 已把它标成 icon_custom=1),紧接着又按本站规则重抓一遍, // 等于当场把他的话推翻。(这也是"设置好以后就固定了"的一部分。) if (!$didReuse && !empty($_POST['do_fetch'])) { $meta = NavFetch::meta($url); $db = Database::get(); $newName = trim((string)($meta['name'] ?? '')); $file = NavFetch::fetchIcon($meta, $url); if ($file !== '') { // ⚠️ 抓来的图标**不算**自定义(icon_custom=0), // 下次「全部更新」可以覆盖它 —— 这正是 SimpleNav 的语义。 $db->prepare('UPDATE nav_sites SET icon = ?, icon_custom = 0, name = CASE WHEN name = \'\' OR name IS NULL THEN ? ELSE name END WHERE id = ? AND person_id = ?') ->execute([$file, $newName !== '' ? $newName : Nav::domainOf($url), $id, $me]); } } $ok('站点已保存。'); break; case 'site_delete': Nav::removeSite($me, $gid); $ok('站点已删除。'); break; case 'site_toggle': Nav::toggleSite($me, $gid); $ok('已切换显示。'); break; case 'site_reorder': // ⚠️ 此处**必须**把「不是数组」当成拒绝,不能静默将就: // 如果前端漏了 `ids[]` 的方括号(或有人手搓请求), // `$_POST['ids']` 会是**一个字符串**而不是数组。 // 直接 `(array)` 强转的话它变成 `['8,9,10']` → applySort 里 // `(int)'8,9,10'` = 8 → **只改了第一项却回「成功」**。 // (本轮实测:探针正好踩到这个组合,报了"排序成功但没落库"。) // 宁可回一句明确的话,也不要"看起来成功"。 $rawIds = $_POST['ids'] ?? null; if (!is_array($rawIds)) { $bad('排序数据格式不对(应是一组 id)。'); } $ids = array_map('intval', $rawIds); $to = (int)($_POST['group_id'] ?? 0); $n = Nav::applySort('nav_sites', $me, $ids, 'group_id', $to); $ok('顺序已保存(' . $n . ' 项)。'); break; case 'site_move': Nav::moveSite($me, $gid, (int)($_POST['group_id'] ?? 0)) ? $ok('已移动到别的板块。') : $bad('目标板块不存在。'); break; case 'engine_save': $id = Nav::saveEngine($me, $_POST, $gid); $id > 0 ? $ok('搜索引擎已保存。') : $bad('名称和搜索地址都不能空。'); break; case 'engine_delete': Nav::removeEngine($me, $gid); $ok('搜索引擎已删除。'); break; case 'engine_toggle': Nav::toggleEngine($me, $gid); $ok('已切换启用状态。'); break; /* 🔴 v24 第 26 批 用户第 4 条:「可以内置几个,让用户选择」。 老用户库里只有当年那几条引擎(`ensureSeed` 只在"一条都没有"时才灌), 所以给他们一个**一键补齐内置引擎**的入口。 ⚠️ 只补缺的,已有的(含用户改过的)一律不动 —— 见 Nav::addMissingEngines 的说明。 */ case 'engine_seed_add': $n = Nav::addMissingEngines($me); $ok($n > 0 ? ('已补上 ' . $n . ' 个内置搜索引擎。') : '内置搜索引擎都已经在列表里了,没有可补的。'); break; /* 🔴 v24 第 26 批 用户第 4 条(后半):新增站点时**按 URL 查可复用**。 ⚠️ 这是 AJAX 接口(前端在 URL 输入框失焦时问一次)。 做成 POST + csrf 而不是 GET:它带的是"我现在想加哪个 URL", 虽然是只读,但走同一个表单动作链更省事,且防了被当探测接口刷。 ⚠️ 回的是**展示信息**(名字/简称/图标 URL),**不含是谁加的**。 隐私边界见 Nav::sharedByUrl() 的注释。 */ case 'site_lookup': $u = (string)($_POST['url'] ?? ''); $rows = Nav::sharedByUrl($me, $u); $out = []; foreach ($rows as $r) { $out[] = [ 'name' => (string)($r['name'] ?? ''), 'short_name' => (string)($r['short_name'] ?? ''), 'label' => Nav::siteLabel($r), 'icon' => Nav::iconUrl((string)($r['icon'] ?? '')), // 复用时给前端一个"可点选"的标识(用显示名+图标即可, // ⚠️ 不能回 id —— 那是别人的行 id,暴露它没有必要) 'key' => Nav::siteLabel($r) . '|' . (string)($r['icon'] ?? ''), ]; } header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => true, 'items' => $out], JSON_UNESCAPED_UNICODE); exit; case 'engine_reorder': if (!is_array($_POST['ids'] ?? null)) { $bad('排序数据格式不对(应是一组 id)。'); } $ids = array_map('intval', (array)$_POST['ids']); $n = Nav::applySort('nav_engines', $me, $ids); $ok('顺序已保存(' . $n . ' 项)。'); break; case 'settings_save': Nav::setSetting($me, 'site_name', trim((string)($_POST['site_name'] ?? '我的导航'))); // ⚠️ 副标题**允许为空**,而且不能把空串当"未设置"回退成默认值 —— // SimpleNav 原文把这一条单列为 bug 现场("删了照样显示")。 Nav::setSetting($me, 'subtitle', trim((string)($_POST['subtitle'] ?? ''))); Nav::setSetting($me, 'primary_color', Nav::safeColor((string)($_POST['primary_color'] ?? ''), '#2F6BF7')); Nav::setSetting($me, 'default_engine', trim((string)($_POST['default_engine'] ?? 'bing'))); Nav::setSetting($me, 'open_blank', !empty($_POST['open_blank']) ? '1' : '0'); $sk = (string)($_POST['suggest_keys'] ?? 'both'); Nav::setSetting($me, 'suggest_keys', in_array($sk, ['arrows', 'punct', 'both'], true) ? $sk : 'both'); Nav::setSetting($me, 'collections_layout', ((string)($_POST['collections_layout'] ?? 'tabs')) === 'flat' ? 'flat' : 'tabs'); $ok('设置已保存。'); break; case 'phrase_save': $r = Nav::setPhrase($me, (string)($_POST['phrase'] ?? '')); $r['ok'] ? $ok('短语已保存,展示页地址已更新。') : $bad((string)$r['error']); break; case 'phrase_random': $r = Nav::setPhrase($me, Nav::randomPhrase()); $r['ok'] ? $ok('已换成随机短语。') : $bad((string)$r['error']); break; case 'fetch_all': // 「全部更新」:把每条站点的名称与图标重新抓一遍。 // ⚠️ 跳过 icon_custom=1 的(用户自己传的图标不许被覆盖)—— SimpleNav 的明确语义。 $n = 0; $db = Database::get(); foreach (Nav::sitesByGroup($me) as $sites) { foreach ($sites as $s) { $meta = NavFetch::meta((string)$s['url']); $name = trim((string)($meta['name'] ?? '')); $sets = []; $args = []; // 只补**空**的名字,不覆盖用户已经起好的(含自定义简称) if ($name !== '' && trim((string)$s['name']) === '') { $sets[] = 'name = ?'; $args[] = $name; } if (empty($s['icon_custom'])) { $file = NavFetch::fetchIcon($meta, (string)$s['url']); if ($file !== '') { $sets[] = 'icon = ?'; $args[] = $file; } } if ($sets) { $args[] = date('Y-m-d H:i:s'); $args[] = (int)$s['id']; $args[] = $me; $db->prepare('UPDATE nav_sites SET ' . implode(', ', $sets) . ', updated_at = ? WHERE id = ? AND person_id = ?') ->execute($args); } $n++; } } $ok('已更新 ' . $n . ' 条站点的名称与图标。'); break; default: $bad('未知操作。'); } break; /* ===================================================================== * 个人笔记(v27 用户 2026-10-07) * * 「参考思源和 obsidian,采用分块方式,支持图文混排,注意,要轻量级,别太复杂, * 兼顾笔记和标签功能,哦,再参考一下 flomo」 * * 路由就这几条(刻意少):列表(含搜索/标签筛选) / 表单(新建+编辑同页) / * 保存(create+update 合一) / 删除 / 图片上传。 * ⚠️ 新建与编辑走**同一个表单页**(`notes/form`):两者字段完全一样, * 分成两个模板只会让改一处忘一处(本仓在 resources/books 上已踩过)。 * ===================================================================== */ case 'notes': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $keyword = trim((string)($_GET['keyword'] ?? '')); $tag = trim((string)($_GET['tag'] ?? '')); $showAll = !empty($_GET['all']) || $keyword !== '' || $tag !== ''; $rows = Note::all($familyId, $me, $keyword, $tag, $showAll ? 0 : Note::LIST_LIMIT); View::render('notes/index', [ 'title' => '笔记', 'rows' => $rows, 'keyword' => $keyword, 'tag' => $tag, 'tagCloud' => Note::tagCloud($familyId, $me), 'totalAll' => Note::countVisible($familyId, $me, $keyword, $tag), 'showAll' => $showAll, 'limit' => Note::LIST_LIMIT, // 卡片上要显示"共享给几个人"(一次查完,别每条一次) 'shareN' => Note::shareCounts(array_map( static fn($r): int => (int)$r['id'], $rows)), ], 'notes'); break; /* 笔记页:**默认是阅读态**(v29 用户第 8 条),点「编辑」才进编辑态。 · `?id=` 看某条(阅读态,图文混排,附件在最下方) · `?id=&edit=1` 编辑某条 · 无 `id` 新建(直接就是编辑态 —— 还没有内容可"读") 🔴 用户原话:「笔记点进来,默认是阅读状态,图文混排,附件在最下方, 图文混排时,需要可以调整大小。点击编辑按钮,才进入编辑功能。」 ⇒ 这一条把 v27/v28 的"点进来就是编辑器"整个反过来: 阅读是主路径(笔记多数时间是被看的),编辑是次路径(少数时间被改)。 ⚠️ 权限:能看到就能进(阅读态);能不能**改**由 Note::canWrite 决定 (作者本人 或 共享时给了 write)。不能改就只看得到「编辑」按钮之外的 内容 —— 按钮不显示,服务端也会挡(两端都判,不只靠前端藏按钮)。 */ case 'notes/form': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $id = (int)($_GET['id'] ?? 0); // 新建必须能写;看/改已有笔记时**不 requireWrite**(访客也能看共享给他们的) if ($id <= 0) { Auth::requireWrite(); } $note = null; $perm = ''; if ($id > 0) { $note = Note::find($familyId, $id, $me); if ($note === null) { flash('err', '这条笔记看不到(可能已删除,或者没有共享给你)。'); redirect('notes'); } $perm = Note::permOf($note, $me); } $canEdit = $id <= 0 ? \App\Auth::canWrite() : Note::canWrite($note ?? [], $me); // `?edit=1` 且真的有写权限 → 编辑态;否则一律阅读态 $wantEdit = !empty($_GET['edit']); if ($id > 0 && $wantEdit && !$canEdit) { flash('err', '这条笔记你只有只读权限。'); redirect('notes/form', ['id' => $id]); } $editMode = $id <= 0 || $wantEdit; /* 🔴 v52(用户第 2 条):阅读态的**浏览器标题**也用笔记自己的名字 (与页面里那个 h1 保持一致;编辑态仍是"改笔记/写笔记")。 */ $noteHead = trim((string)($note['title'] ?? '')); View::render('notes/form', [ 'title' => $editMode ? ($id > 0 ? '改笔记' : '写笔记') : ($noteHead !== '' ? $noteHead : '看笔记'), 'editing' => $note, 'editMode' => $editMode, 'perm' => $perm, 'canEdit' => $canEdit, 'shares' => $id > 0 ? Note::shares($id) : [], 'tagSuggest' => Note::tagSuggest($familyId, $me), // 共享要"选人"(v29 第 9 条,与资源页同一个选人组件) 'people' => Person::roster($familyId, $me, 'member', ''), ], 'notes'); break; case 'notes/save': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); // 表单把块拼成 JSON 提交(`blocks` 字段)—— 块的增删排序本来就是个列表, // 拆成一堆并行数组(kind[]/content[])在服务端还得按索引对齐,容易错位。 $blocks = []; $rawBlocks = (string)($_POST['blocks'] ?? ''); if ($rawBlocks !== '') { $dec = json_decode($rawBlocks, true); if (is_array($dec)) { $blocks = $dec; } } // 兜底:没有 JS(或 JS 没跑到)时,退化成"一个正文textarea + 标题" // —— 这样纯服务端也能写出一条文字笔记,不会因为缺 JS 就完全不能用。 if (!$blocks) { $plain = (string)($_POST['body_plain'] ?? ''); if (trim($plain) !== '') { $blocks = [['kind' => 'text', 'content' => $plain]]; } } $payload = [ 'title' => (string)($_POST['title'] ?? ''), 'blocks' => $blocks, 'tags' => (string)($_POST['tags'] ?? ''), ]; $id = (int)($_POST['id'] ?? 0); if ($id > 0) { $done = Note::update($familyId, $id, $me, $payload); flash($done ? 'ok' : 'err', $done ? '改好了。' : '这条笔记你没有写权限。'); // 🔴 v29 第 8 条:保存后回到**阅读态**,让用户立刻看到改完的样子 // (而不是停在编辑器里,让人不确定到底存上没存上)。 redirect('notes/form', ['id' => $id]); } $nid = Note::create($familyId, $me, $payload); if ($nid > 0) { flash('ok', '记下了。'); redirect('notes/form', ['id' => $nid]); } flash('err', '什么都没写,没记。'); redirect('notes'); break; case 'notes/destroy': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $n = Note::find($familyId, $id, $me); // 🔴 v29:删除**只限作者本人** —— "可读写"给的是编辑权,不是销毁权 // (与资源的语义一致:写入受限、删除始终归本人/管理员)。 if (!$n || (int)$n['person_id'] !== $me) { flash('err', '只有作者本人能删这条笔记。'); redirect('notes'); } Note::destroy($familyId, $id, $me); flash('ok', '删掉了。'); redirect('notes'); break; /* 笔记底部附件(v28 用户第 12 条:「再加上附件上传功能,附件,都放在笔记最下面」)。 ⚠️ 只允许给**我自己的**笔记加附件:附件是挂在笔记上的东西, 共享出去的笔记别人只能看,不该能往里塞文件。 ⚠️ 复用全站统一的 `attach_save_files`(它处理多文件、类型判定、失败清单), 不自己遍历 $_FILES —— 各模块各写一遍必然有人漏掉"哪些字段是数组"。 */ /* ---- 共享(v29 用户第 9 条):选人 + 权限,与资源同一套 ---- 与资源的 resources/share 路由同一个形状:先设一条(人 + 只读/读写), 取消共享走另一条(unshare)。表单全页只有一份,前端填隐藏域提交。 */ case 'notes/share': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $pid = (int)($_POST['person_id'] ?? 0); $n = Note::find($familyId, $id, $me); // ⚠️ 只有**作者**能改共享(拿到 write 权限的人不该能把笔记再转给别人 —— // 那等于把作者的分享范围悄悄扩大)。 if (!$n || (int)$n['person_id'] !== $me) { flash('err', '只有作者本人能改共享设置。'); redirect('notes'); } if ($pid <= 0) { flash('err', '得先选一个要共享给谁。'); redirect('notes/form', ['id' => $id]); } Note::setShare($id, $me, $pid, (string)($_POST['perm'] ?? 'read')); // v32 第 2 条:别人把笔记共享给我 → 发一条通知(按共享表现状重建) Notification::syncShares($familyId, Notification::NOTE, $id, $me); flash('ok', '共享已更新。'); redirect('notes/form', ['id' => $id]); break; case 'notes/unshare': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $pid = (int)($_POST['person_id'] ?? 0); $n = Note::find($familyId, $id, $me); if (!$n || (int)$n['person_id'] !== $me) { flash('err', '只有作者本人能改共享设置。'); redirect('notes'); } Note::dropShare($id, $pid); // v32 第 2 条:撤销他未读的那条共享通知(已读的保留) Notification::syncShares($familyId, Notification::NOTE, $id, $me); flash('ok', '已取消共享。'); redirect('notes/form', ['id' => $id]); break; case 'notes/attach_add': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $n0 = Note::find($familyId, $id, $me); if (!$n0 || empty($n0['is_mine'])) { flash('err', '这条笔记不能改。'); redirect('notes'); } $warns = attach_save_files($familyId, Note::ATTACH, $id, 'file', $me); flash($warns ? 'err' : 'ok', $warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。'); redirect('notes/form', ['id' => $id]); break; case 'notes/attach_del': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $aid = (int)($_POST['id'] ?? 0); // ⚠️ 先按附件 id 找到它挂在**哪条笔记**上,再判那条笔记是不是我的 —— // 只信传进来的附件 id,等于谁都能构造请求删别人家的附件(资源页踩过)。 $st = Database::get()->prepare( 'SELECT target_id FROM attachments WHERE id = ? AND target_type = ?' ); $st->execute([$aid, Note::ATTACH]); $nid = (int)$st->fetchColumn(); $n0 = $nid > 0 ? Note::find($familyId, $nid, $me) : null; if (!$n0 || empty($n0['is_mine'])) { flash('err', '这个附件不能删。'); redirect('notes'); } \App\Attachment::remove($aid); flash('ok', '附件已删除。'); redirect('notes/form', ['id' => $nid]); break; /* 段落 Markdown 的**实时预览**(v28 用户第 12 条:"允许所见即所得编辑")。 ⚠️ 为什么预览走**服务端**而不是在前端再写一份渲染器: 两套渲染器必然漂移(前端显示成粗体、保存后却不是),而用户看到的 就是"预览",一旦不一致,他会以为保存坏了。 服务端只有一份 `App\Markdown`,预览与最终渲染保证同源。 ⚠️ 所以这段必须够轻:只做一段文本的行内+块级渲染,不碰数据库、不查权限 (不涉及任何数据,纯函数),前端再配合防抖调用。 ⚠️ 它仍然要求登录 + 写权限:不是安全所需(没有数据泄露),而是不给 "未登录也能拿这个当 Markdown 渲染器用"的口子。 */ case 'notes/preview': Auth::requireWrite(); csrf_check(); $src = (string)($_POST['md'] ?? ''); // 限长:预览是交互用的,不该被拿来转整本书 $src = mb_substr($src, 0, Note::MAX_TEXT, 'UTF-8'); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => true, 'html' => Markdown::render($src)], JSON_UNESCAPED_UNICODE); break; /* 图片块上传:走统一附件表(target_type='note')。 ⚠️ 为什么复用 attachments 而不在 notes 里另起一套: 图片的"存哪、怎么压缩、怎么通过 media.php 出去"这套逻辑全站只有一份, 每个模块各写一遍迟早出现"某个模块的图不压缩"或"路径拼错 404"。 ⚠️ 上传是**即时**的(写笔记时点加图就传),此刻笔记可能还不存在 —— 所以附件行先挂在 `target_type='note'` + `target_id=0` 上(草稿态), 前端拿到 `key` 后把它当一个 image 块随表单提交;笔记保存时 `Note::claimUploads()` 把这些草稿附件认领到真实 note_id 上。 ⚠️ 认领是**必须的**:不做的话草稿附件会永远挂在 target_id=0 上, 既不会被"删笔记时连图一起删"覆盖到,也会在附件表里越积越多。 */ case 'notes/upload': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); header('Content-Type: application/json; charset=utf-8'); if (empty($_FILES['img'])) { echo json_encode(['ok' => false, 'error' => '没有收到文件'], JSON_UNESCAPED_UNICODE); break; } $r = Attachment::save($familyId, Note::ATTACH, 0, $_FILES['img'], (int)Auth::personId()); if (empty($r['ok'])) { echo json_encode(['ok' => false, 'error' => (string)($r['error'] ?? '上传失败')], JSON_UNESCAPED_UNICODE); break; } // 取回刚存的那一行拿 key(Attachment 没有 find(),用 list 按 id 定位) $key = ''; foreach (Attachment::list(Note::ATTACH, 0) as $a) { if ((int)$a['id'] === (int)$r['id']) { $key = (string)$a['key']; break; } } echo json_encode([ 'ok' => $key !== '', 'key' => $key, 'id' => (int)$r['id'], 'url' => $key !== '' ? media_url($key) : '', ], JSON_UNESCAPED_UNICODE); break; /** * 🔴 v52(用户 2026-10-15 第 4 条):**只改一个块**(详情页的分段编辑)。 * * 与 `notes/save`(整条替换)分开是刻意的: * · 这条按 **块主键** 改一行,别的块一个字不动 ⇒ 用户要的"其他内容不影响"; * · 顺带避开"整组替换会换掉块 id"的问题(前端拿着的 data-block 会失效)。 * 返回 JSON(前端就地替换那一块的内容,不整页刷新)。 */ case 'notes/block': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); // ⚠️ 前端可能用 fetch 且带 `X-Requested-With`;这里**不看**那个头 —— // 分支只此一处用途,永远回 JSON(不像其它路由要兼顾整页提交)。 $noteId = (int)($_POST['note_id'] ?? 0); $blockId = (int)($_POST['block_id'] ?? 0); $kind = (string)($_POST['kind'] ?? 'text'); $content = (string)($_POST['content'] ?? ''); $row = \App\Note::updateBlock($familyId, $noteId, $blockId, $me, $kind, $content); if ($row === null) { http_response_code(403); echo json_encode(['ok' => false, 'msg' => '这一块改不了(没权限,或它已经不在了)。'], JSON_UNESCAPED_UNICODE); break; } echo json_encode(['ok' => true, 'block' => $row], JSON_UNESCAPED_UNICODE); break; default: http_response_code(404); View::render('error', [ 'title' => '页面不存在', 'message' => '找不到这一页。', ], 'home'); break; }