Files
FamilyBook/index.php
T
laoyang b5725b449a v24 第 6 轮:名册导入 / 两种备注 / 多段工作经历 / 家谱卡片与首字母检索修正
用户 2026-10-03 第二批(8 条)。逐条落点:

1. **名册导入**(新):支持 **vCard 3.0/4.0(.vcf)** 与 **CSV/TSV**(中英文表头都认)。
   新增 `app/ContactImport.php`(纯解析)+ `app/ContactImporter.php`(判重+落库)。
   判重按 **手机号(哈希,精确)→ 姓名(大小写无关)**;命中已有的人**只补空字段,
   绝不覆盖**(批量覆盖没法回退)。
   解析上要处理的三件事都处理了:**折行接回**(长名字/地址会被按字节折断)、
   转义还原(`\,` `\;` `\n`)、多值收全(TEL/EMAIL/ADR 可能出现多次)。
   UTF-16/GBK 的 vcf 也认(手机导出的常见编码)。
2. 成员详情页:右上角「移除」→ **「返回」**(回名册);「删除」移到页面**最下方**
   (在「开通登录账号」下面),并改成 `<details>` 两步确认 + 写清会连带删掉什么。
3. 编辑资料里的「这个人的一句话」**去掉** —— 那句话只能**本人**改。
   ⚠️ 落法是「这一栏只在改自己时出现」:用户要的是"别人不能替他写",
   而不是"这句没人能填";服务端也只在 `$isSelf` 时读表单,否则管理员一编辑就把它清空。
4. **工作单位 / 职务 / 职称可以有多段**(新表 `person_jobs`):表单是一组"经历行"
   (单位/职务/职称/起止年月/备注,可 +/×),详情页按"现在 + 历史"列出。
   `persons.org/title` 保留,语义变成"当前那段的快照"(列表页等只要"现在在哪儿"),
   升级时把老的 org/title 回填成一段经历。
5/备注分两种:「自我介绍」(`persons.note`,他本人写的、别人看得到)与
   **「我对他的私人备注」**(新表 `person_notes`,每人每对象一条,**只有写的人自己看得到**)。
   详情页有独立的一块;表单里只在"编辑别人"时出现。
6. 成员表单:**头像放到最前**、去掉「选择文件」按钮(点那张默认头像即选文件);
   表单标签统一 15px/600(原来 13–14px,和 12px 的灰字提示分不出层级)。
7. 名册列表:⋯ 前面加**「编辑」**按钮(改资料是高频动作,藏进 ⋯ 要点两下)。
8. 家谱:
   · **首字母检索 bug**:输入 `yy` 会搜出「岳父/洪勇」。根因是前端把
     **姓名与称呼的首字母拼成一串**再判子序列 —— 洪勇的称呼是"岳父" → `hy`+`yf`=`hyyf`
     → `yy` 跨字段命中。改成**逐字段独立判**(服务端本来就是逐字段判的,两边现在同口径)。
   · **卡片仍比头像宽**:头像是 **1:1(正方形)** 槽位,卡片里却写成 96×72 的长方形 +
     `object-fit: contain` → 照片被缩到 72 宽、左右各留 12px 白 → 看着"框比头像宽"。
     改成**正方形头像区 + `object-fit: cover`**(90×128 卡片,88×88 头像区)。
   · 右上角加**「我的家谱」**按钮(只在看别人的家谱时出现 —— 看自己的时候点它没意义)。

**测试新增**(都能变红,且做过反向验证):
- `_fb_import_test.py` 21 条:新人新建 / 手机号命中不重复建人 / **已有字段一个都不被覆盖** /
  缺的字段被补上 / 反复导入幂等 / 只给姓名也能命中 / 认不出的文件被明确拒绝,
  外加 ⑱ **"有单位但没有经历行"的人**(最容易漏的覆盖路径)。
- `_fb_roster_filter_test.js`(jsdom,用真实渲染出的 HTML)8 条:直击
  「输 yy 不该命中 岳父/洪勇」;反向验证时把实现改回拼接写法 → ① 立刻红。
- 改动过程中被测试抓出**两个真 bug**并已修:
  ① 导入时给"已有单位的人"也插经历 → `PersonJob::syncCurrent()` 回写把这人的单位**覆盖**了;
  ② `syncCurrent()` 在经历表为空时会把 `persons.org/title` **清空** ——
     而它被"编辑资料"这类常规路径调用一次就触发,属于**随手一保存就丢数据**。
     现在空表直接 return("经历表不是唯一的一份")。

**战绩**:e2e **775/0** · 导入 21/0 · 第 5 轮专项 91/0 · 家谱 34/0 · 几何 428/0 ·
移动端 688/0 · 交互 97/0 · 升级 126/0。
线上:schema **v15**、指纹一致、`person_jobs`/`person_notes` 已建、
6 条老的"工作单位"全部回填成经历、13 个文件 md5 与本地一致、错误日志无新增。
2026-10-03 19:27:14 +08:00

4206 lines
186 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 家书 FamilyBook · 前端控制器
*
* 路由走 query-string(index.php?r=模块/动作),不依赖 Apache 重写,
* 换服务器、换 Nginx 都不用改配置。
*/
declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Account;
use App\Attachment;
use App\Auth;
use App\Birthday;
use App\Calendar;
use App\Cinema;
use App\ContactImport;
use App\ContactImporter;
use App\Crypto;
use App\Database;
use App\Douban;
use App\Family;
use App\Geo;
use App\Home;
use App\HomeMods;
use App\Anniversary;
use App\Ics;
use App\Kinship;
use App\Ledger;
use App\Movie;
use App\MovieReview;
use App\Module;
use App\MovieStats;
use App\Nickname;
use App\Person;
use App\PersonGroup;
use App\PersonJob;
use App\PersonNote;
use App\PersonQuery;
use App\PersonRoster;
use App\Pinyin;
use App\Relations;
use App\Repeat;
use App\Resource;
use App\Schema;
use App\Setting;
use App\Task;
use App\Trip;
use App\Upload;
use App\UserSetting;
use App\View;
if (!Database::hasTables()) {
header('Location: install.php');
exit;
}
/**
* 一次性结构升级。
*
* ⚠️ 为什么要有这段:上线后的库不认识新加的列(birth_rank / is_placeholder)和
* 新的 guest 角色。靠人去手动 ALTER 迟早会漏,所以在每次请求的开头比一下版本号,
* 落后了就补一次 —— 平时只是一次字符串比较,几乎零成本。
*/
// 仅用于**显示**(设置页/日志里"库结构是哪一版")。判断要不要升级请看
// Schema::needsUpgrade() —— 它比的是源码指纹。
const FB_SCHEMA_VERSION = Schema::VERSION;
if (Database::hasTables()) {
$familyIdForSchema = isset($_SESSION['family_id']) ? (int)$_SESSION['family_id'] : 0;
// 登录前还没有 family_id,用首个家庭兜底(本系统是单家庭部署)
if ($familyIdForSchema === 0) {
try {
$familyIdForSchema = (int)(Database::get()->query('SELECT MIN(id) FROM families')->fetchColumn() ?: 0);
} catch (\Throwable $ex) {
$familyIdForSchema = 0;
}
}
if ($familyIdForSchema > 0) {
// ⚠️ 判据是 **Schema 源码指纹**,不是版本号 —— 见 Schema::fingerprint() 的说明。
// 版本号靠人记得改,本项目已因此翻车一次(2026-10-03:两批结构变更都没改
// VERSION → 线上判定"已是最新" → 整段升级被跳过 → Calendar::month() 500)。
// 指纹改成"改了 Schema 就跑",不再依赖任何人的记性。
if (Schema::needsUpgrade($familyIdForSchema)) {
try {
Schema::ensure();
Schema::markUpgraded($familyIdForSchema);
} catch (\Throwable $ex) {
// 升级失败不能把整站挡住;下次请求会再试。
// ⚠️ 但**必须留下痕迹**:这里曾经是空的 catch,结果线上结构升级
// 从 2026-09-30 起一次都没跑完(报错被吞、版本号永远停在 2),
// 而首页/名册都 200,看着完全正常 —— 直到「个人设置 500」才暴露。
// 一行 error_log 就能省掉几小时排查,别再删掉它。
error_log('[familybook] Schema::ensure() 失败(已跳过,下次请求重试):'
. $ex->getMessage() . ' @ ' . $ex->getFile() . ':' . $ex->getLine());
if (!empty($GLOBALS['config']['debug'])) {
throw $ex;
}
}
}
}
}
$r = isset($_GET['r']) ? trim((string)$_GET['r'], '/') : 'home';
$r = $r === '' ? 'home' : $r;
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$publicRoutes = ['auth/login'];
if (!Auth::check() && !in_array($r, $publicRoutes, true)) {
redirect('auth/login');
}
/**
* 从表单里读「三个生日」。
*
* 为什么要有 `birthday_source` 这个显式字段:
* 阳历生日与农历生日说的是**同一个人的同一天**,不能各填各的(会自相矛盾)。
* 用户手填时必然是在其中一边操作,所以让他明确"我这次填的是哪一个",
* 服务端据此**反推另一个** —— 不依赖 JS,也就不会有"JS 没跑到就静默存错"。
* 兼容:source=solar 但阳历留空、农历有值 → 按农历算(用户只填了一边)。
*/
function read_birthday_input(array $existing = []): array
{
$source = (($_POST['birthday_source'] ?? 'solar') === 'lunar') ? 'lunar' : 'solar';
$solar = (string)(read_dt_input('birthday_solar') ?? '');
$lunar = (string)(read_dt_input('birthday_lunar') ?? '');
if ($source === 'solar' && $solar === '' && $lunar !== '') {
$source = 'lunar';
}
return [
'birthday_source' => $source,
'birthday_solar' => $solar,
'birthday_lunar' => $lunar,
'birthday_lunar_leap' => !empty($_POST['birthday_lunar_leap']) ? 1 : 0,
// 库里已有的身份证生日要**带回来**:它是"只进不出"的,不能被一次提交清掉
'birthday_id_card' => (string)($existing['birthday_id_card'] ?? ''),
];
}
/**
* 从表单解析「补充联系方式」(vCard 对齐,v6 起)。
*
* 每类两个平行数组:ct_<kind>_label[] / ct_<kind>_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 用 `<input type="month">`,值就是 'YYYY-MM' —— 正好是库里存的格式,
* 不需要再做一次"日期 → 年月"的换算(少一处能写错的地方)。
*
* @return array<int,array{org:string,title:string,rank_name:string,start_ym:string,end_ym:string,note:string}>
*/
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' && !preg_match('/^\+?[0-9\- ]{6,25}$/', $v)) {
return '第 ' . $no . ' 个补充电话的格式看起来不对:' . $v;
}
}
}
return '';
}
/**
* 渲染「个人设置」页(settings/me)。
*
* 从 v5 起这一页只剩两件事:**我怎么称呼家人**、**我的分组**。
* 「我的资料」表单不在这里 —— 它和「成员资料」是同一张表单
* (`templates/people/form.php`,两条入口都指过去),页面顶部给一个按钮过去。
* 这是用户明确要求的:「编辑成员」和「修改自己的资料」本来就是一件事,
* 两套表单必然填出两种口径。
*
* @param array $person 当前登录者自己的名册记录
* @param array $errors 字段名 => 错误文案(保留给"校验失败就地重画"用)
*/
function render_me_settings(array $person, array $errors = []): void
{
$familyId = (int)Auth::familyId();
$me = (int)$person['id'];
View::render('settings/me', [
'title' => '个人设置',
'person' => $person,
'errors' => $errors,
// 我怎么称呼别人(视角属性,只有我自己能定)
'nicknames' => Nickname::mineWithNames($familyId, $me),
'options' => Person::options($familyId, $me, $me),
'birthdays' => Birthday::describe($person),
// 我的分组 = 公共的 + 我自己加的,顺序是我的(user_settings.group_order)
'groups' => PersonGroup::orderFor($familyId, $me),
'groupCounts' => PersonGroup::counts($familyId, $me),
'rosterCount' => PersonRoster::countFor($familyId, $me),
// 用户 2026-10-03 第 3 条:首页上显示哪些模块、什么顺序(每人一份)
'homeOrder' => HomeMods::orderedForPicker($me),
'homeOn' => HomeMods::forPerson($me),
// 用户 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),
], 'settings');
}
/**
* 上传并挂一组附件到某个宿主(旅程照片 / 资源附件 / 任务附件…全走这一处)。
*
* 同时吃「单文件」与「多文件」两种表单字段:`<input type="file" name="x">`
* 与 `<input type="file" name="x" multiple>`(后者 PHP 会把每个子项变成数组)。
* 逐个交给 App\Attachment::save —— 一张失败不该连累其它几张(照片多选尤其明显)。
*
* @return string[] 失败原因(空数组 = 全部成功)
*/
function attach_save_files(int $familyId, string $type, int $targetId, string $field, int $uploaderId): array
{
$files = [];
if (!empty($_FILES[$field]) && is_array($_FILES[$field])) {
$f = $_FILES[$field];
if (isset($f['name']) && is_array($f['name'])) {
foreach ($f['name'] as $i => $_n) {
$files[] = [
'name' => $f['name'][$i],
'type' => $f['type'][$i] ?? '',
'tmp_name' => $f['tmp_name'][$i] ?? '',
'error' => $f['error'][$i] ?? UPLOAD_ERR_NO_FILE,
'size' => $f['size'][$i] ?? 0,
];
}
} elseif (isset($f['name'])) {
$files[] = $f;
}
}
$warns = [];
foreach ($files as $f) {
$res = \App\Attachment::save($familyId, $type, $targetId, $f, $uploaderId);
if (empty($res['ok']) && (string)($res['error'] ?? '') !== '') {
$warns[] = (string)($f['name'] ?: '附件') . ':' . $res['error'];
}
}
return $warns;
}
/**
* 月视图网格的起止日期(**含前后补白的整周**)。
*
* ⚠️ 网格必须按整周补白,否则"这个月 1 号是周三"时第一行会空出 3 格而没法画 ——
* 补白的日子要一起取日程(跨周/跨月的日程也落在网格里)。
* @return array{0:string,1:string} ['Y-m-d', 'Y-m-d']
*/
function month_grid_range(string $ym): array
{
$firstTs = strtotime($ym . '-01');
if ($firstTs === false) {
$firstTs = strtotime(date('Y-m') . '-01');
}
$w = (int)date('w', $firstTs); // 0=周日 … 6=周六
$start = strtotime('-' . $w . ' days', $firstTs);
$cells = $w + (int)date('t', $firstTs);
$rows = (int)ceil($cells / 7);
$endTs = strtotime('+' . ($rows * 7 - 1) . ' days', $start);
return [date('Y-m-d', $start), date('Y-m-d', $endTs ?: $start)];
}
/**
* 把一段日期里的日程摆成 6×7 的月网格。
*
* @return array 周 => [7 格],每格 ['date','day','in_month','is_today','events']
*/
function build_month_grid(string $ym, array $events): array
{
[$from, $to] = month_grid_range($ym);
$byDay = [];
foreach ($events as $e) {
$byDay[(string)$e['date']][] = $e;
}
$weeks = [];
$cur = $from;
for ($r = 0; $r < 6; $r++) {
$row = [];
for ($d = 0; $d < 7; $d++) {
$row[] = [
'date' => $cur,
'day' => (int)date('j', strtotime($cur)),
'in_month' => substr($cur, 0, 7) === $ym,
'is_today' => $cur === date('Y-m-d'),
'events' => $byDay[$cur] ?? [],
];
$cur = date('Y-m-d', strtotime($cur . ' +1 day'));
}
$weeks[] = $row;
if (strcmp($cur, $to) > 0) {
break;
}
}
return $weeks;
}
/**
* 周视图 / 天视图的日期列(用户 2026-10-02 第 4 条)。
*
* 周视图固定 7 天;天视图就 1 天 —— 两者共用同一个"按小时铺的时间网格",
* 只是列数不同,所以放在一个函数里。
*
* @return array<int, array{date:string,w:int,day:int,is_today:bool}>
*/
function day_columns(string $anchor, int $span): array
{
$ts = strtotime($anchor);
if ($ts === false) {
$ts = time();
}
$w = (int)date('w', $ts); // 0=周日 … 6=周六
$mon = strtotime('-' . (($w + 6) % 7) . ' days', $ts); // 周一开头
$out = [];
for ($i = 0; $i < max(1, $span); $i++) {
$d = date('Y-m-d', strtotime('+' . $i . ' days', $mon));
$out[] = [
'date' => $d,
'w' => (int)date('w', strtotime($d)),
'day' => (int)date('j', strtotime($d)),
'is_today' => $d === date('Y-m-d'),
];
}
return $out;
}
/**
* 把日程摆进「小时 × 日期列」的时间网格(周视图 / 天视图共用)。
*
* 与月视图的不同:这里**按时段定位**(top/height 用百分比),
* 所以"09:15 的 20 分钟会"在格子里就是一小条,而不是跟"9 点的会"挤成一样高。
*
* ⚠️ 全天日程不进时间格 —— 它们没有时段,硬塞进 0 点会变成一条压在最上面的怪东西。
* 单独挂在顶上的一行(跟群晖日历一样)。
*
* 用户 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];
// 结束时刻:有就按真实时长;没有就画一条 30 分钟的最短可见块
// (高度是 0 等于看不见,那比"画得不准"更糟)。
$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;
}
}
$spanMin = $endMin === null ? 30 : max(15, $endMin - $startMin);
// ⚠️ 上限到 24:00:超出的部分已经在网格外面,硬画出去会把整列撑长
$spanMin = min($spanMin, 1440 - $startMin);
$timed[$ci][] = $e + [
'top' => round($startMin / 1440 * 100, 4),
'height' => round($spanMin / 1440 * 100, 4),
];
}
return ['all_day' => $allDay, 'timed' => $timed];
}
/**
* 读任务的「截止日期(+ 可选时刻)」。
*
* ⚠️ 只填日期、不填时刻时存 00:00:00,而 00:00 在展示层与日历里都当作**全天**
* (与日程同一个口径):"某天之前做完"本来就没有具体几点。
*/
function read_due_input(): ?string
{
// v24 第 5 条:截止时刻与全局其它地方一样走 `_datetime.php`(日期 + 时/分/秒一起选,
// 精确到秒)。下面那段 due_date / due_time 只是老表单的兜底,别删 ——
// 旧的链接、书签、脚本还在按老名字提交,删了就静默变成"没有截止日期"。
$full = read_dt_input('due_at');
if ($full !== null) {
return $full;
}
$d = trim((string)($_POST['due_date'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
return null;
}
$t = trim((string)($_POST['due_time'] ?? ''));
return $d . ' ' . (preg_match('/^\d{2}:\d{2}$/', $t) ? $t . ':00' : '00:00:00');
}
/**
* 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。
*
* ⚠️ v24(用户 2026-10-02 第 19 条):原来同一天没有命中时会**退回同一个月**,
* 标题随之变成"往年这个月" —— 但用户要的就是**往年的今天**:
* "10 月 2 日"给我看"去年 10 月 8 日去看了场电影",这根本不是同一天的事,
* 凑出来的条目看着像回忆,其实只是同月的随机噪声,反而把"往年今日"的意味毁了。
* 现在只认同一天;没命中就明说"往年的这一天还没有记录"。
*
* @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容)
*/
function home_memories(int $familyId, int $limit = 6): array
{
$today = date('Y-m-d');
$year = (int)date('Y');
$sql = "SELECT e.id, e.title, e.starts_at, e.kind,
(SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS n
FROM events e
WHERE e.family_id = ? AND e.kind IN ('movie','trip') AND e.starts_at IS NOT NULL
AND YEAR(e.starts_at) < ?
AND DATE_FORMAT(e.starts_at, '%m-%d') = ?
ORDER BY e.starts_at DESC LIMIT " . (int)$limit;
$st = Database::get()->prepare($sql);
$st->execute([$familyId, $year, substr($today, 5, 5)]);
$rows = $st->fetchAll();
foreach ($rows as &$r) {
$r['years_ago'] = $year - (int)substr((string)$r['starts_at'], 0, 4);
$r['href'] = url($r['kind'] === 'trip' ? 'trips/show' : 'movies/show', ['id' => (int)$r['id']]);
}
unset($r);
return ['rows' => $rows, 'exact' => true];
}
/** 只认本家庭、确实存在的 person_id(表单可以改,不能凭它塞进别人家的人) */
function filter_family_person_ids(array $ids, int $familyId): array
{
$map = Person::map($familyId);
$out = [];
foreach ($ids as $v) {
$v = (int)$v;
if ($v > 0 && isset($map[$v]) && !in_array($v, $out, true)) {
$out[] = $v;
}
}
return $out;
}
switch ($r) {
// ==================== 登录 / 退出 ====================
case 'auth/login':
if (Auth::check()) {
redirect('home');
}
if ($method === 'POST') {
csrf_check();
$phone = trim((string)($_POST['phone'] ?? ''));
$pass = (string)($_POST['password'] ?? '');
if (Auth::login($phone, $pass)) {
redirect('home');
}
View::renderBare('login', [
'title' => '登录',
'familyName' => fb_config('site_name', '家书'),
'error' => '手机号或密码不对,或者账号还没开通/已被停用。',
'phone' => $phone,
]);
exit;
}
View::renderBare('login', [
'title' => '登录',
'familyName' => fb_config('site_name', '家书'),
]);
break;
case 'auth/logout':
if (isset($_GET['_csrf'], $_SESSION['_csrf'])
&& hash_equals($_SESSION['_csrf'], (string)$_GET['_csrf'])) {
Auth::logout();
}
redirect('auth/login');
break;
case 'auth/password':
Auth::requireLogin();
if ($method === 'POST') {
csrf_check();
$old = (string)($_POST['old_password'] ?? '');
$new = (string)($_POST['password'] ?? '');
$new2 = (string)($_POST['password2'] ?? '');
$st = Database::get()->prepare('SELECT password_hash FROM accounts WHERE id = ?');
$st->execute([Auth::id()]);
$hash = (string)$st->fetchColumn();
$err = null;
if (!password_verify($old, $hash)) {
$err = '当前密码不对';
} elseif ($new !== $new2) {
$err = '两次输入的新密码不一致';
} elseif (Account::validatePassword($new)) {
$err = implode(';', Account::validatePassword($new));
}
if ($err) {
View::render('auth/password', ['title' => '修改密码', 'error' => $err], 'home');
exit;
}
Account::setPassword((int)Auth::id(), $new);
flash('ok', '密码已更新,下次登录用新密码。');
redirect('home');
}
View::render('auth/password', ['title' => '修改密码'], 'home');
break;
// ==================== 首页(以我为中心) ====================
case 'home':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
// 「我的家人」= 以我为中心上下各三代那张家谱里的人。
// ⚠️ 早先只取「直接关系」(父母/配偶/子女/兄弟姐妹),于是**姻亲与祖孙都被漏掉**:
// 爱人登进来看不到「公公婆婆」,我看不到「岳父岳母」,爷爷奶奶也不在首页上。
// 这恰恰是「以个人为中心」最该体现的地方,所以改用家谱的节点集合。
$tree = Kinship::tree($graph, $me, $people, 3, 3);
$kinIds = array_diff(array_map('intval', array_keys($tree['nodes'])), [$me]);
$kin = [];
// 用 Person::all()(带账号信息)而不是 Person::map(),首页才能显示管理员/访客徽标
foreach (Person::all($familyId) as $p) {
$pid = (int)$p['id'];
if (in_array($pid, $kinIds, true)) {
$kin[$pid] = $p + ['label' => Kinship::label($graph, $me, $pid, $people)];
}
}
$birthdays = Person::upcomingBirthdays($familyId);
// 生日条上也标出称谓
foreach ($birthdays as &$b) {
$b['label'] = isset($people[(int)$b['id']])
? Kinship::label($graph, $me, (int)$b['id'], $people) : '';
// 名字优先用"我叫他什么" —— 同一个孩子,爸爸看到"儿子"、奶奶看到"小宝"
$b['shown'] = Nickname::display($me, $people[(int)$b['id']] ?? null) ?: $b['name'];
}
unset($b);
// ---- dashboard 的另外三块(用户第 13 条)----
// 未来 7 天的日程:自定义日程 + 观影 + 旅行 + 有截止时间的任务,一次汇总
$cals = Calendar::mine($familyId, $me);
$today = date('Y-m-d');
$soon = Calendar::month($familyId, $cals, $me, $today, date('Y-m-d', strtotime('+7 days')));
// 进行中的任务 + 已经过期的待办(**只有这些才值得占首页**;
// 没截止日期的待办放任务看板里看,堆到首页会变成一张长长的清单)
$board = Task::board($familyId, $me);
$tasks = $board['doing'];
foreach ($board['todo'] as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) {
$tasks[] = $t;
}
}
$memories = home_memories($familyId);
// ---- 用户 2026-10-03 第 3 条:首页要显示哪些模块、什么顺序(每人一份)----
$homeMods = HomeMods::forPerson($me);
$moduleCards = Home::moduleCards($familyId, $me, $soon, $tasks);
// 统计信息块:随机问候 + 今日天气 + 近七天的数字
$weekEnd = date('Y-m-d', strtotime('+7 days'));
$st7 = Database::get()->prepare(
"SELECT COUNT(*) FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
WHERE c.family_id = ?
AND DATE(e.starts_at) BETWEEN ? AND ?
AND (c.owner_id = ? OR EXISTS (
SELECT 1 FROM calendar_shares sh
WHERE sh.calendar_id = c.id AND sh.person_id = ?))"
);
$st7->execute([$familyId, $today, $weekEnd, $me, $me]);
$ev7 = (int)$st7->fetchColumn();
$todoN = count($board['todo']);
$lateN = 0;
foreach ([$board['todo'], $board['doing']] as $col) {
foreach ($col as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) {
$lateN++;
}
}
}
$meName = Nickname::display($me, $people[$me] ?? null) ?: (string)($people[$me]['name'] ?? '');
$greeting = Home::greeting($meName, $today, count($soon), $lateN);
// 天气:地点在**个人设置**里(v24 用户 2026-10-03 第 1 条),每人一份。
// 三级回落都在这一个调用里:自定义经纬度 → 内置城市表 → 不显示。
$weatherLoc = Home::weatherLocation($me, $familyId);
$weather = $weatherLoc === null ? null
: Home::weather($familyId, $weatherLoc['lat'], $weatherLoc['lon'], $weatherLoc['place']);
View::render('home', [
'title' => '首页',
'family' => Family::find($familyId),
'me' => $people[$me] ?? null,
'meId' => $me,
'kin' => $kin,
'allCount' => count($people),
'relCount' => Relations::count($familyId),
'birthdays' => $birthdays,
'soon' => $soon,
'tasks' => $tasks,
'memories' => $memories,
// 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页)
'mySlogan' => trim((string)($people[$me]['slogan'] ?? '')),
'myNicknames'=> Nickname::mapFor($me),
// 用户第 3 条新增
'greeting' => $greeting,
'weekday' => Home::weekday($today),
'weather' => $weather,
'stat7' => ['events' => $ev7, 'todos' => $todoN, 'overdue' => $lateN],
'homeModules'=> $homeMods,
'moduleCards'=> $moduleCards,
], 'home');
break;
// ==================== 家谱(以我为中心) ====================
case 'kin':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
// ⚠️ v15:家谱模板不再吃 Kinship::tree() 的产物(那套 units/generations 是给
// 旧画法用的),改由模板里调 Kinship::familyMap() 现算。这里就不再白算一遍。
// v24 第 12 条:可以按某个人为中心重画(姻亲不在主图里时点卡片上的入口进来)。
// ⚠️ 「看的是谁的家谱」与「我登录着」是两件事:$meId 决定称呼视角,$focusId 决定画谁的树。
$focusId = (int)($_GET['focus'] ?? 0);
if ($focusId <= 0 || !isset($people[$focusId])) {
$focusId = $me;
}
View::render('kin/tree', [
'title' => $focusId === $me ? '我的家谱' : ((string)($people[$focusId]['name'] ?? '') . ' 的家谱'),
'meId' => $me,
'focusId' => $focusId,
'people' => $people,
'graph' => $graph,
], 'kin');
break;
// ==================== 名册 ====================
case 'people':
$q = trim((string)($_GET['q'] ?? ''));
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// ⚠️ 用 roster() 而不是 all():名册是「每人一份」——
// 只列我加的人 + 别人分享给我的(v5 起,见 App\PersonRoster)。
$rows = Person::roster($familyId, $me, 'member', $q);
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
// 名册里也标出「我该怎么称呼他」
foreach ($rows as &$row) {
$row['label'] = Kinship::label($graph, $me, (int)$row['id'], $people);
$row['shown'] = Nickname::display($me, $row) ?: (string)$row['name'];
// 用户 2026-10-03 第 3 条:列表要显示手机号(密文解密)与生日。
// 解密与生日格式化统一走 PersonQuery(见该类的类注释:
// 名册 / 挑人 / 高级搜索必须**同一套判据**,否则同一句搜索在不同页面结果不同)。
$row['phone_plain'] = PersonQuery::phonePlain($row);
[$row['birthday_show'], $row['birthday_kind']] = PersonQuery::birthdayOf($row);
// 首字母供前端"输入即筛选"用(filter.js 对 data-initials 做子序列匹配)
$row['initials'] = Pinyin::initials((string)$row['name'] . ' ' . (string)($row['my_nickname'] ?? ''));
}
unset($row);
// 按关系分组分栏。顺序用**当前用户自己的**(没自定义过就是默认顺序);
// 归到键 0 的那一堆是还没分类的人,模板单独放在最后一栏。
$grouped = [];
foreach ($rows as $row) {
$grouped[(int)($row['group_id'] ?? 0)][] = $row;
}
View::render('people/index', [
'title' => '家庭名册',
'people' => $rows,
'grouped'=> $grouped,
'groups' => PersonGroup::orderFor($familyId, $me),
'q' => $q,
'meId' => $me,
// 家里已经有人、但还不在我名册里 → 给一个「直接挑」的入口,
// 免得为了同一个人又录一遍(录两遍会变成两条记录,账号与亲属关系各存一份)
'pickable' => $q === '' ? count(PersonRoster::pickable($familyId, $me)) : 0,
], 'people');
break;
/**
* 名册卡片上的「调整分组」。
*
* 从 v5 起改的是**我自己那份名册**里的归类(person_roster.group_id),
* 不再动 persons —— 所以「我把张伟归到同事」不会让妈妈那边也变成同事。
* 单开一个路由而不是让人去「编辑成员」里改,是因为那张表很长
* (三个生日、身份证号、住址…),为了挪一个分组去翻半页是折磨。
* 而且分组**本来就只能在名册里改**(它是"我和他的关系")。
*/
case 'people/group':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改名册里的分组。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$gid = (int)($_POST['group_id'] ?? 0);
$p = Person::find($pid);
if (!$p || (int)$p['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
if (!PersonRoster::assignGroup($familyId, $me, $pid, $gid > 0 ? $gid : null)) {
flash('err', '这个人不在你的名册里,或者那个分组你用不了。');
redirect('people');
}
flash('ok', $gid > 0
? '已把「' . $p['name'] . '」归到「' . PersonGroup::name($familyId, $gid) . '」。'
: '已把「' . $p['name'] . '」改成未分类。');
redirect('people');
break;
/**
* 名册拖动排序 / 拖动换栏 —— 整版提交。
*
* 前端把每一栏(含「未分类」)的有序 id 列表发过来,服务端一次写完。
* 为什么不"移动一条算一次":拖拽的结果是一组**相对顺序**,
* 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。
* 没有 JS 也能用:卡片上的「调整分组」表单还在(只是没有拖拽那么顺手)。
*/
case 'people/reorder':
Auth::requireLogin();
// 拖动是 AJAX 发的(见 app.js)→ 出错也回 JSON 而不是 302:
// 不然那句 flash 会攒到"下一次刷新"才冒出来,看着像迟到的鬼影。
$isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest');
if (!Auth::canWrite()) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 403);
echo json_encode(['ok' => false, 'error' => 'readonly']);
exit;
}
flash('err', '你的身份是访客,不能调整名册顺序。');
redirect('people');
}
csrf_check();
$raw = (string)($_POST['layout'] ?? '');
$layout = json_decode($raw, true);
if (!is_array($layout)) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'invalid layout']);
exit;
}
flash('err', '没收到有效的顺序,请刷新页面再试一次。');
redirect('people');
}
$n = PersonRoster::applyLayout((int)Auth::familyId(), (int)Auth::personId(), $layout);
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => true, 'n' => $n], JSON_UNESCAPED_UNICODE);
exit;
}
flash('ok', $n > 0 ? '名册顺序已更新。' : '没有需要改动的。');
redirect('people');
break;
/**
* 名册导入(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' => '导入联系人',
'groups' => PersonGroup::orderFor($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);
$res = ContactImporter::run($familyId, $me, $records, $groupId);
$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),
'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);
// 只认「确实可挑」的那些 id —— 表单可以改,不能凭它把别人家的拉进来
$ok = array_column($familyPick, 'id');
$ok = array_map('intval', $ok);
$n = 0;
foreach ($ids as $pid) {
if (!in_array($pid, $ok, true)) {
continue;
}
if (PersonRoster::ensure($familyId, $me, $pid, $gid > 0 ? $gid : null, 'self')) {
$n++;
}
}
flash($n > 0 ? 'ok' : 'err', $n > 0
? '已把 ' . $n . ' 个人加进你的名册。'
: '一个人都没加上 —— 先勾选要加的人。');
redirect('people');
}
View::render('people/pick', [
'title' => '从家里已有的人里挑',
'people' => $familyPick,
'groups' => PersonGroup::orderFor($familyId, $me),
'meId' => $me,
], 'people');
break;
/**
* 把一张名片分享到别人的名册里(需求原话:「我可以把一个人的名片,分享给一个用户,
* 则这个名片,自动出现在这个用户的名册中」)。
* 这只是"让他看得见",不改变任何资料本身;对方可以自己决定归到哪一组。
*/
case 'people/share':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能分享名片。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$to = (int)($_POST['to_id'] ?? 0);
$back = (int)($_POST['back'] ?? 0);
$p = Person::find($pid);
$t = Person::find($to);
if (!$p || !$t || (int)$p['family_id'] !== $familyId || (int)$t['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
if ($pid === $to) {
flash('err', '不用把名片分享给他自己。');
redirect('people/show', ['id' => $back > 0 ? $back : $pid]);
}
PersonRoster::share($familyId, $to, [$pid], 'shared');
flash('ok', '已把「' . $p['name'] . '」的名片放进「' . $t['name'] . '」的名册里。');
redirect('people/show', ['id' => $back > 0 ? $back : $pid]);
break;
/** 把一个人从**我的**名册里移掉(人还在名册与家谱里,只是我不再看到他) */
case 'people/forget':
Auth::requireLogin();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改名册。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$p = Person::find($pid);
if ($p && (int)$p['family_id'] === (int)Auth::familyId()) {
// v7:家谱关系带来的人不许「移走」——他在首页/家谱上照样看得见,
// 单独从名册藏掉只会把三处口径又搅回打架的状态。
// 想不再看到他,解除那条关系(名册条目会跟着自动收回)。
$src = (string)(PersonRoster::mine((int)Auth::familyId(), (int)Auth::personId())[$pid]['source'] ?? '');
if ($src === 'kin') {
flash('err', '「' . $p['name'] . '」是因为家谱关系自动进你名册的,不能单独移走;解除关系后他会自动移出。');
} else {
PersonRoster::remove((int)Auth::familyId(), (int)Auth::personId(), $pid);
flash('ok', '已把「' . $p['name'] . '」从你的名册里移走(他还在家里)。');
}
}
redirect('people');
break;
// ==================== 我自己的资料 / 个人设置 ====================
// 顶栏头像点开 →「个人设置」。访客也能改自己的资料 ——
// 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。
case 'people/me':
// 「我的资料」已并进「个人设置」(设置分两个维度:系统设置 / 个人设置)。
// 旧链接、老书签、以及别处写死的 people/me 一律跳过去,不再留第二个入口。
redirect('settings/me');
break;
case 'settings/me':
Auth::requireLogin();
$p = Person::find((int)Auth::personId());
if (!$p) {
flash('err', '找不到你自己的名册记录。');
redirect('home');
}
clear_old();
render_me_settings($p);
break;
/**
* 旧的「保存我的资料」入口。
*
* v5 起资料表单只剩一张(`templates/people/form.php`,提交到 people/update),
* 所以这两个地址不再处理表单 —— 只把还开着旧标签页的人送回个人设置,
* 免得一次提交撞上 404 让人以为"保存坏了"。
*/
case 'settings/me_save':
case 'people/me_save':
redirect('settings/me');
break;
/**
* 保存「导航条上显示哪些模块、什么顺序」(用户第 10 条,每人一份)。
*
* 表单同时可能带 `move=up:<key>` / `move=down:<key>`(▲▼ 按钮)—— 那是
* **不用 JS 也能排序**的兜底:先在当前顺序里跟邻居换一位,再一起存。
* 拖动则由前端直接搬 DOM(每个 <li> 自带 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;
// ==================== 分组的顺序 ====================
// 两套:「我自己的顺序」(只影响我看到的)与「公共分组的默认顺序」(全家兜底)。
// 靠表单里的 scope 字段区分 —— 同一个动作,落到两个地方。
case 'settings/groups/move':
Auth::requireLogin();
csrf_check();
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$id = (int)($_POST['id'] ?? 0);
$dir = ($_POST['dir'] ?? '') === 'up' ? 'up' : 'down';
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
if ($scope === 'public') {
Auth::requireAdmin();
}
$list = $scope === 'public'
? PersonGroup::forOwner($familyId, PersonGroup::PUBLIC_OWNER)
: PersonGroup::orderFor($familyId, $me);
$ids = array_map(fn($g) => (int)$g['id'], $list);
$at = array_search($id, $ids, true);
if ($at === false) {
flash('err', '找不到这个分组。');
redirect($scope === 'public' ? 'settings' : 'settings/me');
}
// 挪到边界就什么也不做(按钮在两端本来就是 disabled 的,
// 但地址栏里手敲一次也能到这儿,别让它把顺序搞乱)
$to = $dir === 'up' ? $at - 1 : $at + 1;
if ($to >= 0 && $to < count($ids)) {
[$ids[$at], $ids[$to]] = [$ids[$to], $ids[$at]];
if ($scope === 'public') {
PersonGroup::savePositions($familyId, PersonGroup::PUBLIC_OWNER, $ids);
} else {
PersonGroup::saveOrder($familyId, $me, $ids);
}
}
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
// ==================== 分组的增删改名 ====================
// 两套:公共的(管理员在系统设置里维护,全家共用)与我自己的(个人设置里,
// 只有自己看得见)。访客只读,给个明确的说法再回去,
// 而不是甩一张 403 页面(他明明是点了自己设置里的按钮)。
case 'settings/groups/add':
Auth::requireLogin();
csrf_check();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能加分组。');
redirect('settings/me');
}
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
$owner = PersonGroup::PUBLIC_OWNER;
if ($scope === 'public') {
Auth::requireAdmin();
} else {
$owner = (int)Auth::personId();
}
$res = PersonGroup::add((int)Auth::familyId(), (string)($_POST['name'] ?? ''), $owner);
flash($res['ok'] ? 'ok' : 'err', $res['ok']
? '已加上分组「' . trim((string)($_POST['name'] ?? '')) . '」。'
: (string)($res['error'] ?? '加不上。'));
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
case 'settings/groups/rename':
Auth::requireLogin();
csrf_check();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改分组。');
redirect('settings/me');
}
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
$owner = PersonGroup::PUBLIC_OWNER;
if ($scope === 'public') {
Auth::requireAdmin();
} else {
$owner = (int)Auth::personId();
}
$res = PersonGroup::rename((int)Auth::familyId(), (int)($_POST['id'] ?? 0),
(string)($_POST['name'] ?? ''), $owner);
flash($res['ok'] ? 'ok' : 'err', $res['ok']
? '分组名已改。'
: (string)($res['error'] ?? '改不了。'));
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
case 'settings/groups/delete':
Auth::requireLogin();
csrf_check();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能删分组。');
redirect('settings/me');
}
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
$owner = PersonGroup::PUBLIC_OWNER;
if ($scope === 'public') {
Auth::requireAdmin();
} else {
$owner = (int)Auth::personId();
}
$res = PersonGroup::delete((int)Auth::familyId(), (int)($_POST['id'] ?? 0), $owner);
if ($res['ok']) {
$n = (int)($res['moved'] ?? 0);
flash('ok', $n > 0
? '分组已删掉,那 ' . $n . ' 个人现在显示在「未分类」里,去名册重新归类即可。'
: '分组已删掉(那一组本来也没人)。');
} else {
flash('err', (string)($res['error'] ?? '删不了。'));
}
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
// ==================== 别人怎么称呼(只有我自己能定) ====================
case 'nickname/save':
Auth::requireLogin();
csrf_check();
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$target = (int)($_POST['target_id'] ?? 0);
$nick = (string)($_POST['nickname'] ?? '');
$t = Person::find($target);
$back = (string)($_POST['back'] ?? 'people/show');
if (!$t || (int)$t['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
Nickname::set($familyId, $me, $target, $nick);
flash('ok', trim($nick) === ''
? '已恢复成用姓名显示。'
: '记下了:你叫他「' . trim($nick) . '」。这只影响你自己看到的称呼。');
if ($back === 'settings/me' || $back === 'people/me') {
// 两个写法都认:老表单发的是 people/me(那一页现在是跳转)
redirect('settings/me');
}
redirect('people/show', ['id' => $target]);
break;
case 'people/new':
Auth::requireWrite();
clear_old();
View::render('people/form', [
'title' => '添加成员',
'person' => null,
'errors' => [],
'canEditAll' => true,
'isSelf' => false,
// 工作经历先给一行空白(第 4 条),省得还要点「再加一段」
'jobRows' => [[]],
'myNote' => '',
], 'people');
break;
case 'people/create':
Auth::requireWrite();
csrf_check();
$familyId = (int)Auth::familyId();
$d = [
'name' => trim((string)($_POST['name'] ?? '')),
'gender' => (string)($_POST['gender'] ?? ''),
'phone' => trim((string)($_POST['phone'] ?? '')),
'id_card' => trim((string)($_POST['id_card'] ?? '')),
'email' => trim((string)($_POST['email'] ?? '')),
'address' => trim((string)($_POST['address'] ?? '')),
'org' => trim((string)($_POST['org'] ?? '')),
'title' => trim((string)($_POST['title'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')),
// 新增的人还没有"我的这一句话"(那是他自己的东西,由他之后自己填)
'slogan' => '',
// 工作经历(第 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;
}
// ⚠️ 头像上传失败**不再挡住整条新增**(用户原话:「添加人员功能不能用了」——
// 早先一张 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'] ?? '')),
], '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'] ?? '')),
], 'people');
exit;
}
// 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。
// 分组:表单里选了就用它(用户第 12 条「增加名册时可选择所属分组」);
// ⚠️ 只认**我自己**的分组列表里的 id —— 表单可以改,不能凭它把人塞进
// 别人私有的分组、或根本不存在的分组里。
$gid = (int)($_POST['group_id'] ?? 0);
$okGids = array_map('intval', array_column(
PersonGroup::orderFor($familyId, (int)Auth::personId()), 'id'));
if ($gid > 0 && !in_array($gid, $okGids, true)) {
$gid = 0;
}
PersonRoster::ensure($familyId, (int)Auth::personId(), $id, $gid > 0 ? $gid : null, 'self');
Person::replaceContacts($id, $contacts);
// 工作经历:整组替换(第 4 条)。空的那组会把这段经历清空 —— 与联系人同一口径。
PersonJob::replace($id, (array)($d['jobs'] ?? []));
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()),
], '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'] ?? '')),
'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,
], 'people');
exit;
}
if ($up['ok']) {
Upload::remove($familyId, (string)$p['avatar']);
$d['avatar'] = $up['file'];
// 换图就换色:新头像的主色马上覆盖旧的(算不出时保留旧值,不清空)
Person::setAvatarColor($id, (string)($up['color'] ?? ''));
}
try {
// 改自己且没有编名册的权限 → 只能动属于自己的字段(Person::updateSelf)。
// 这一条不是"防贼",是为了让访客也能改自己的名字,同时不会顺手把
// 手机号(= 登录凭据)或身份证号改掉。
if ($isSelf && !$canEditAll) {
Person::updateSelf($id, $d);
} else {
Person::update($id, $d);
}
} catch (\PDOException $ex) {
set_old($_POST);
View::render('people/form', [
'title' => $isSelf ? '我的资料' : '编辑成员',
'person' => $p,
'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'],
'canEditAll' => $canEditAll,
'isSelf' => $isSelf,
'phoneValue' => $d['phone'],
'idCardValue' => $d['id_card'],
'contacts' => $contacts,
], 'people');
exit;
}
Person::replaceContacts($id, $contacts);
// 工作经历:整组替换(第 4 条)。空的那组会把这段经历清空 —— 与联系人同一口径。
PersonJob::replace($id, (array)($d['jobs'] ?? []));
// 「我对他的私人备注」(第 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']);
}
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);
if ($p && (int)$p['family_id'] === (int)Auth::familyId()) {
if ((int)$p['id'] === (int)Auth::personId()) {
flash('err', '不能把自己从名册里移除。');
redirect('people/show', ['id' => $id]);
}
// 不让最后一个管理员消失:系统里没人能管理家庭就锁死了
$acc = Account::findByPerson($id);
if ($acc && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。');
redirect('people/show', ['id' => $id]);
}
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
Person::delete($id);
flash('ok', '已从名册移除「' . $p['name'] . '」。');
}
redirect('people');
break;
// ==================== 账号 ====================
case 'accounts/create':
Auth::requireAdmin();
csrf_check();
$personId = (int)($_POST['person_id'] ?? 0);
$p = Person::find($personId);
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
flash('err', '找不到这个人。');
redirect('people');
}
if (Account::findByPerson($personId)) {
flash('err', '这个人的账号已经开通过了。');
redirect('people/show', ['id' => $personId]);
}
if (Crypto::decrypt((string)$p['phone_encrypted']) === '') {
flash('err', '要先填手机号,才能开通登录。');
redirect('people/show', ['id' => $personId]);
}
$pass = (string)($_POST['password'] ?? '');
$errs = Account::validatePassword($pass);
if ($errs) {
flash('err', implode(';', $errs));
redirect('people/show', ['id' => $personId]);
}
$r0 = (string)($_POST['role'] ?? '');
Account::create($personId, $pass, in_array($r0, Account::ROLES, true) ? $r0 : 'member');
// v7:多了一个「看家谱的人」——他的名册也要按树播种,一登录三处口径就是对的
PersonRoster::syncKinForFamily((int)Auth::familyId());
flash('ok', '账号已开通,用手机号就能登录。');
redirect('people/show', ['id' => $personId]);
break;
case 'accounts/role':
Auth::requireAdmin();
csrf_check();
$personId = (int)($_POST['person_id'] ?? 0);
$role = in_array(($r1 = (string)($_POST['role'] ?? '')), Account::ROLES, true) ? $r1 : 'member';
$p = Person::find($personId);
$acc = Account::findByPerson($personId);
if ($p && $acc) {
// ⚠️ 判的是 `$role !== 'admin'` 而不是 `$role === 'member'`:
// 早先只拦"降成家庭成员",于是把**最后一位管理员**降成访客能一路通过 ——
// 降完家里一个管理员都不剩,谁也进不了系统设置了。
// 允许改自己的权限(包括给自己降级),只在"会一个管理员都不剩"时拦。
if ($role !== 'admin' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
flash('err', '至少要留一位管理员 —— 把最后一位管理员降级,全家就没人能管理系统了。');
redirect('people/show', ['id' => $personId]);
}
Account::setRole((int)$acc['id'], $role);
flash('ok', '权限已更新。');
}
redirect('people/show', ['id' => $personId]);
break;
case 'accounts/status':
Auth::requireAdmin();
csrf_check();
$personId = (int)($_POST['person_id'] ?? 0);
$status = ($_POST['status'] ?? '') === 'active' ? 'active' : 'disabled';
$acc = Account::findByPerson($personId);
if ($acc) {
if ((int)$acc['person_id'] === (int)Auth::personId()) {
flash('err', '不能停用自己。');
redirect('people/show', ['id' => $personId]);
}
if ($status === 'disabled' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
flash('err', '至少要留一位管理员。');
redirect('people/show', ['id' => $personId]);
}
Account::setStatus((int)$acc['id'], $status);
flash('ok', $status === 'active' ? '账号已启用。' : '账号已停用。');
}
redirect('people/show', ['id' => $personId]);
break;
// ==================== 亲属关系 ====================
case 'kin/relate':
Auth::requireWrite();
csrf_check();
$familyId = (int)Auth::familyId();
$from = (int)($_POST['from_id'] ?? 0); // 我(或当前正在编辑的人)
$to = (int)($_POST['to_id'] ?? 0); // 对方
$kind = (string)($_POST['kind'] ?? '');
$role = (string)($_POST['role'] ?? '');
$ok = Person::find($from) && Person::find($to)
&& (int)Person::find($from)['family_id'] === $familyId
&& (int)Person::find($to)['family_id'] === $familyId;
if (!$ok) {
flash('err', '选的人不对。');
redirect('people');
}
// ⚠️⚠️ 方向极易写反,而且写反了**不会报任何错**:页面照常显示「关系已记录」,
// 只是把人记成了对方的父母 —— 家谱整个倒过来。
// 约定:表单里 from_id 是「当前打开的人」,也就是表单上写的那个「我」;
// kind=parent 表示「对方是我的父母」→ 所以我是 child、对方是 parent。
// Relations::addParent($familyId, $childId, $parentId, $role) 的第二个参数是**孩子**。
//
// ⚠️⚠️ 这里是**白名单**,不是 else 兜底。原先写成 `else { addSpouse(...) }`,
// 于是任何没被识别的 kind(大小写错、拼错、以后新增的种类)都会被**静默记成配偶**
// 且照样提示「关系已记录」——关系是错的,用户却以为成功了。
// 实测踩到:夹具传 kind=sibling(兄弟姐妹),库里落的全是 spouse,
// 于是「夫妻并排」「父母括号」这些断言看着在测,其实测的是一堆错关系。
// 兄弟姐妹本就不需要单独记 —— 有同一对父母就会自动认出来(见 Kinship::siblingIds)。
$kindMap = ['parent', 'child', 'spouse'];
if (!in_array($kind, $kindMap, true)) {
flash('err', '不认识这种关系,没有记录。兄弟姐妹不用单独填 —— 有同一对父母就会自动认出来。');
redirect('people/show', ['id' => $from]);
}
if ($kind === 'parent') {
// 对方是我的父母:我是孩子,对方是父母
$r = Relations::addParent($familyId, $from, $to, $role);
} elseif ($kind === 'child') {
// 对方是我的子女:对方是孩子,我是父母
$r = Relations::addParent($familyId, $to, $from, $role);
} else {
$r = Relations::addSpouse($familyId, $from, $to);
}
flash($r['ok'] ? 'ok' : 'err', $r['ok'] ? '关系已记录。' : $r['error']);
redirect('people/show', ['id' => $from]);
break;
case 'kin/unrelate':
Auth::requireWrite();
csrf_check();
$familyId = (int)Auth::familyId();
$id = (int)($_POST['id'] ?? 0);
$who = (int)($_POST['person_id'] ?? 0);
$kinRemoved = Relations::remove($familyId, $id);
// v7:关系没了,因这条关系自动进两端名册的条目也收回 —— 把这件事说破,
// 不然用户会以为名册里的人凭空消失了
flash('ok', $kinRemoved
? '这条关系已解除,两人名册里因此自动加入的对方也一并移出了。'
: '这条关系已解除。');
redirect('people/show', ['id' => $who]);
break;
// ==================== 访客邀请 ====================
case 'accounts/invite':
Auth::requireAdmin();
clear_old();
// ⚠️ 访客建出来是 kind='contact'(不进家谱),而 Person::all() 默认只取 'member' ——
// 早先这里没传 kind,于是"已有访客"永远列不出人来(页面看着像是从没开通过)。
View::render('people/invite', [
'title' => '邀请访客',
'people' => Person::all((int)Auth::familyId(), '', 'contact', (int)Auth::personId()),
'options' => Person::options((int)Auth::familyId()),
], 'people');
break;
case 'accounts/invite_create':
Auth::requireAdmin();
csrf_check();
$familyId = (int)Auth::familyId();
$name = trim((string)($_POST['name'] ?? ''));
$phone = trim((string)($_POST['phone'] ?? ''));
$pass = (string)($_POST['password'] ?? '');
$note = trim((string)($_POST['note'] ?? ''));
$errors = [];
if ($name === '') {
$errors['name'] = '请填写对方的名字';
}
if (!preg_match('/^\+?[0-9\-]{6,20}$/', $phone)) {
$errors['phone'] = '手机号格式看起来不对';
}
$pw = Account::validatePassword($pass);
if ($pw) {
$errors['password'] = implode(';', $pw);
}
if (!$errors) {
try {
// 访客也进名册(kind=contact,与家庭成员区分开),但不参与家谱
$pid = Person::create($familyId, [
'name' => $name, 'gender' => '', 'avatar' => '', 'phone' => $phone,
'id_card' => '', 'email' => '', 'address' => '', 'note' => $note,
'slogan' => '', 'kind' => 'contact',
]);
Account::create($pid, $pass, 'guest');
// 我邀的访客也进**我的**名册(名册是每人一份的;访客自己那份由
// Account::create 给他补上他自己)
PersonRoster::ensure($familyId, (int)Auth::personId(), $pid, null, 'self');
clear_old();
flash('ok', '访客「' . $name . '」已开通,把手机号和密码告诉他就能登录。');
redirect('accounts/invite');
} catch (\PDOException $ex) {
$errors['phone'] = '这个手机号已经有人用了';
}
}
set_old($_POST);
View::render('people/invite', [
'title' => '邀请访客',
'people' => Person::all($familyId, '', 'contact', (int)Auth::personId()),
'options' => Person::options($familyId),
'errors' => $errors,
], 'people');
break;
// ==================== 设置 ====================
case 'settings':
Auth::requireAdmin();
$dbVersion = '未知';
try {
$dbVersion = Database::get()->query('SELECT VERSION()')->fetchColumn();
} catch (\Throwable $ex) {
// 拿不到版本不影响页面
}
$rawKey = (string)fb_config('app_key', '');
$acc = Account::roleCounts();
// 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET)
$diag = $_SESSION['_oss_diag'] ?? null;
unset($_SESSION['_oss_diag']);
// 公共分组(owner_id = 0)—— 管理员在这里维护全家共用的那一套。
// 人数按"全家名册"统计(这里要看的是"这一组里全家有多少人",不是我个人那份)。
View::render('settings', [
'title' => '设置',
'family' => Family::find((int)Auth::familyId()),
'dbVersion' => $dbVersion,
'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:<id>'(重跑迁移的幂等锚点)。
/** 观影瀑布流(v9,MovieLog 同款海报墙;带关键词搜索)。 */
case 'movies':
$familyId = (int)Auth::familyId();
$keyword = trim((string)($_GET['keyword'] ?? ''));
$rows = Movie::all($familyId, $keyword, (int)Auth::personId());
View::render('movies/index', [
'title' => '观影记录',
'rows' => $rows,
'total' => count($rows),
'keyword' => $keyword,
], 'movies');
break;
case 'movies/show':
$familyId = (int)Auth::familyId();
$meId = (int)Auth::personId();
$m = Movie::find($familyId, (int)($_GET['id'] ?? 0), $meId);
if (!$m) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
$rvError = '';
View::render('movies/show', [
'title' => (string)$m['title'],
'm' => $m,
'reviews' => MovieReview::forEvent((int)$m['id']),
'rvStats' => MovieReview::stats((int)$m['id']),
'myReview' => MovieReview::mine((int)$m['id'], $meId),
'meId' => $meId,
'rvError' => $rvError,
'feelCat' => MovieReview::CATALOG,
], 'movies');
break;
case 'movies/new':
Auth::requireWrite();
View::render('movies/form', [
'title' => '记一场电影',
'm' => null,
'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''),
'cinemas' => Cinema::all((int)Auth::familyId()),
'errors' => [],
], 'movies');
break;
case 'movies/edit':
Auth::requireWrite();
$familyId = (int)Auth::familyId();
$m = Movie::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId());
if (!$m) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
View::render('movies/form', [
'title' => '编辑观影记录',
'm' => $m,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'cinemas' => Cinema::all($familyId),
'errors' => [],
], 'movies');
break;
/** 收集表单里和观影有关的字段(create / update 共用一份)。 */
case 'movies/create':
case 'movies/update':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$isEdit = $r === 'movies/update';
$id = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
$m = $isEdit ? Movie::find($familyId, $id) : null;
if ($isEdit && !$m) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
// 起止时刻精确到秒(_datetime.php 控件 → read_dt_input)
$startsAt = read_dt_input('starts_at');
$endsAt = read_dt_input('ends_at');
$cinemaId = (int)($_POST['cinema_id'] ?? 0);
if ($cinemaId > 0 && !Cinema::find($familyId, $cinemaId)) {
$cinemaId = 0; // 表单可改:只认本家的影院
}
$d = [
'title' => trim((string)($_POST['title'] ?? '')),
'starts_at' => (string)($startsAt ?? ''),
'ends_at' => (string)($endsAt ?? ''),
'location' => trim((string)($_POST['location'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')),
'info' => trim((string)($_POST['info'] ?? '')),
'cinema_id' => $cinemaId,
// 同行的人:只认「确实存在且属于这个家」的 id —— 表单可以改,
// 不能凭它把别人家的成员挂进自己家的观影记录里(会顺着详情页漏人名)。
'attendees' => array_map('intval', (array)($_POST['attendees'] ?? [])),
];
// 豆瓣元数据(v10):表单隐藏字段带 JSON,解码后只认白名单键、逐字段限长,
// 防手搓请求往库里塞垃圾。整体一个数组交给模型存 douban_meta。
$doubanMeta = [];
$metaRaw = trim((string)($_POST['douban_meta'] ?? ''));
if ($metaRaw !== '') {
$metaDecoded = json_decode($metaRaw, true);
if (is_array($metaDecoded)) {
foreach (['douban_id', 'douban_url', 'year', 'genre', 'runtime', 'director',
'actors', 'plot', 'rating', 'country', 'alias'] as $mk) {
if (isset($metaDecoded[$mk]) && is_scalar($metaDecoded[$mk])) {
$mv = trim((string)$metaDecoded[$mk]);
if ($mv !== '') {
$doubanMeta[$mk] = mb_substr($mv, 0, 2000, 'UTF-8');
}
}
}
}
}
$d['douban_meta'] = $doubanMeta;
$_familyPeople = Person::map($familyId);
$d['attendees'] = array_values(array_filter($d['attendees'],
static fn($pid) => isset($_familyPeople[$pid])));
$errors = [];
if ($d['title'] === '') {
$errors['title'] = '片名不能为空';
} elseif ($d['starts_at'] !== '' && $d['ends_at'] !== ''
&& strcmp($d['ends_at'], $d['starts_at']) < 0) {
$errors['ends_at'] = '散场时间不能早于开场时间';
}
// ⚠️ 图片上传失败**不挡保存**(与头像同一思路:一条记录因为一张 5MB 原图
// 存不进去,看起来像功能坏了)。现在是先存记录,图片的事单独提示。
// 海报 / 票根:free 槽位,不需要裁剪框;编辑时换新图就顺带删掉旧对象。
$warns = [];
foreach (['poster', 'ticket'] as $slot) {
$up = Upload::image($familyId, $_FILES[$slot] ?? [], $slot);
if ($up['ok']) {
$oldKey = $isEdit ? (string)($m[$slot === 'poster' ? 'poster_key' : 'ticket_key'] ?? '') : '';
$d[$slot === 'poster' ? 'poster_key' : 'ticket_key'] = (string)$up['file'];
if ($oldKey !== '' && $oldKey !== (string)$up['file']) {
\App\Storage\Storage::delete($familyId, $oldKey);
}
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = ($slot === 'poster' ? '海报' : '票根') . '没有保存:' . $up['error'];
} elseif ($isEdit) {
// 没传新图 → 保留库里的旧值
$d[$slot === 'poster' ? 'poster_key' : 'ticket_key'] = (string)($m[$slot === 'poster' ? 'poster_key' : 'ticket_key'] ?? '');
}
}
// 豆瓣抓取的海报(v10):前端抓取成功时把存储键放进了隐藏 poster_key。
// 用户**自己选的文件优先**($_FILES['poster'] 真有文件就不覆盖);
// 键必须是 posters/ 下的本站存储键,编辑时换用新键就顺带删掉旧对象。
$dbPosterKey = trim((string)($_POST['poster_key'] ?? ''));
$posterFileGiven = isset($_FILES['poster']['error']) && !is_array($_FILES['poster']['error'])
&& (int)$_FILES['poster']['error'] === UPLOAD_ERR_OK;
if (!$posterFileGiven && $dbPosterKey !== ''
&& preg_match('#^posters/[A-Za-z0-9._-]+$#', $dbPosterKey)) {
if (($d['poster_key'] ?? '') !== $dbPosterKey) {
$oldPosterKey = $isEdit ? (string)($m['poster_key'] ?? '') : '';
$d['poster_key'] = $dbPosterKey;
if ($oldPosterKey !== '' && $oldPosterKey !== $dbPosterKey) {
\App\Storage\Storage::delete($familyId, $oldPosterKey);
}
}
}
// 现场照片(多选,仅新增表单提供;编辑页在详情页里逐张补/删)
if (!$isEdit) {
$photoFiles = [];
if (!empty($_FILES['photo']) && is_array($_FILES['photo']['name'])) {
foreach ($_FILES['photo']['name'] as $i => $_n) {
$photoFiles[] = [
'name' => $_FILES['photo']['name'][$i],
'type' => $_FILES['photo']['type'][$i],
'tmp_name' => $_FILES['photo']['tmp_name'][$i],
'error' => $_FILES['photo']['error'][$i],
'size' => $_FILES['photo']['size'][$i],
];
}
}
$photoKeys = [];
foreach ($photoFiles as $f) {
$up = Upload::image($familyId, $f, 'photo');
if ($up['ok']) {
$photoKeys[] = (string)$up['file'];
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = '有一张照片没有保存:' . $up['error'];
}
}
$d['photos'] = $photoKeys;
}
if ($errors) {
set_old($_POST);
View::render('movies/form', [
'title' => $isEdit ? '编辑观影记录' : '记一场电影',
'm' => $m,
'people' => Person::roster($familyId, $me, 'member', ''),
'cinemas' => Cinema::all($familyId),
'errors' => $errors,
], 'movies');
exit;
}
if ($isEdit) {
Movie::update($familyId, $id, $d);
clear_old();
flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('movies/show', ['id' => $id]);
} else {
$newId = Movie::create($familyId, $me, $d);
clear_old();
flash('ok', '已记下这场电影。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('movies/show', ['id' => $newId]);
}
break;
/** 编辑页补传一张现场照片(详情页里的小表单,一次一张)。 */
case 'movies/photo_add':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Movie::find($familyId, $id)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
$up = Upload::image($familyId, $_FILES['photo'] ?? [], 'photo');
if ($up['ok']) {
// savePhotos 是整组替换 —— 现有键取出来再追加一张,位置接着排
$keys = array_map('strval', array_column(Movie::photos($id), 'key'));
$keys[] = (string)$up['file'];
Movie::savePhotos($id, $keys);
flash('ok', '照片已加上。');
} else {
flash('err', '照片没有保存:' . (string)($up['error'] ?: '没选文件'));
}
redirect('movies/show', ['id' => $id]);
break;
case 'movies/photo_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$pid = (int)($_POST['photo_id'] ?? 0);
if (!Movie::find($familyId, $id)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
Movie::deletePhoto($familyId, $id, $pid);
flash('ok', '照片已删除。');
redirect('movies/show', ['id' => $id]);
break;
case 'movies/destroy':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Movie::find($familyId, $id)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
Movie::destroy($familyId, $id);
flash('ok', '这条观影记录已删除。');
redirect('movies');
break;
// ---------------- 豆瓣自动补充(v10,MovieLog fetch_movie 同源迁移) ----------------
/** 片名联想 / 缩略图代理 / 抓取详情+海报入库。全部回 JSON(thumb 回图片字节)。 */
case 'movies/douban':
$familyId = (int)Auth::familyId();
$act = (string)($_GET['action'] ?? '');
if (!Auth::check()) {
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => '未登录或登录已过期,请重新登录后再试'], JSON_UNESCAPED_UNICODE);
break;
}
if ($act === 'suggest') {
header('Content-Type: application/json; charset=utf-8');
$res = Douban::suggest((string)($_GET['q'] ?? ''));
echo json_encode($res, JSON_UNESCAPED_UNICODE);
break;
}
if ($act === 'thumb') {
// 浏览器不能直连豆瓣图片(防盗链),由服务端代取;只读、不落盘
$img = Douban::thumb((string)($_GET['u'] ?? ''));
if ($img === null) {
http_response_code(404);
break;
}
header('Content-Type: ' . $img['mime']);
header('Content-Length: ' . strlen($img['body']));
header('Cache-Control: public, max-age=86400');
echo $img['body'];
break;
}
if ($act !== '') {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => '不支持的 action'], JSON_UNESCAPED_UNICODE);
break;
}
// 默认:抓取详情 + 海报入库(写存储,访客不行)。JSON 错误而非 302,
// 前端才能在会话过期/无权限时给出明确提示。
header('Content-Type: application/json; charset=utf-8');
if (!Auth::canWrite()) {
http_response_code(403);
echo json_encode(['error' => '访客账号没有写权限'], JSON_UNESCAPED_UNICODE);
break;
}
$idDb = trim((string)($_GET['id'] ?? ''));
$qDb = trim((string)($_GET['q'] ?? ''));
if ($idDb !== '') {
$resDb = Douban::fetchById($idDb);
} elseif ($qDb !== '') {
$resDb = Douban::fetchByTitle($qDb);
} else {
$resDb = ['ok' => false, 'error' => '请输入电影名称'];
}
if (!$resDb['ok']) {
echo json_encode(['error' => $resDb['error']], JSON_UNESCAPED_UNICODE);
break;
}
// 海报:下载字节 → poster 槽位管线(裁 2:3、缩放、转 JPEG、Storage 统一出口)
$posterKey = '';
$posterShow = '';
if (($resDb['poster_url'] ?? '') !== '') {
$bytes = Douban::downloadPoster((string)$resDb['poster_url']);
if ($bytes !== '') {
$tmp = tempnam(sys_get_temp_dir(), 'fbdb');
if ($tmp !== false) {
file_put_contents($tmp, $bytes);
$up = Upload::image($familyId, ['error' => UPLOAD_ERR_OK, 'tmp_name' => $tmp], 'poster');
@unlink($tmp);
if ($up['ok']) {
$posterKey = (string)$up['file'];
$posterShow = \App\Storage\Storage::url($familyId, $posterKey);
}
}
}
}
// 清掉上一次抓取但没被采用的海报(每次「自动获取」都下一张,不清就堆孤儿)
$oldPoster = trim((string)($_GET['old_poster'] ?? ''));
if ($oldPoster !== '' && $oldPoster !== $posterKey
&& preg_match('#^posters/[A-Za-z0-9._-]+$#', $oldPoster)) {
$stRef = Database::get()->prepare('SELECT COUNT(*) FROM movie_details WHERE poster_key = ?');
$stRef->execute([$oldPoster]);
if ((int)$stRef->fetchColumn() === 0) {
\App\Storage\Storage::delete($familyId, $oldPoster);
}
}
echo json_encode([
'ok' => true,
'source' => 'douban',
'title' => (string)($resDb['title'] ?? ''),
'meta' => $resDb['meta'],
'poster_key' => $posterKey,
'poster' => $posterShow,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
break;
// ---------------- 影院管理(v9,MovieLog 同款) ----------------
/** 影院管理单页:上面加/编表单,下面列表。GET 带参数时进入编辑态。 */
case 'movies/cinemas':
$familyId = (int)Auth::familyId();
$editId = (int)($_GET['edit'] ?? 0);
$editing = $editId > 0 ? Cinema::find($familyId, $editId) : null;
View::render('movies/cinemas', [
'title' => '影院管理',
'cinemas' => Cinema::all($familyId),
'editing' => $editing,
], 'movies');
break;
case 'movies/cinemas_save':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$editing = $id > 0 ? Cinema::find($familyId, $id) : null;
if ($id > 0 && !$editing) {
flash('err', '找不到这家影院。');
redirect('movies/cinemas');
}
$err = Cinema::save(
$familyId,
$id,
trim((string)($_POST['place'] ?? '')),
trim((string)($_POST['name'] ?? '')),
trim((string)($_POST['address'] ?? '')) !== '' ? trim((string)($_POST['address'] ?? '')) : null,
Cinema::normalizeColor((string)($_POST['color'] ?? '#888888'))
);
if ($err !== '') {
flash('err', $err);
redirect('movies/cinemas', $id > 0 ? ['edit' => $id] : []);
}
flash('ok', $id > 0 ? '影院信息已更新。' : '影院已添加。');
redirect('movies/cinemas');
break;
case 'movies/cinemas_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (Cinema::find($familyId, $id)) {
Cinema::destroy($familyId, $id);
flash('ok', '影院已删除,关联观影记录的影院信息已置空。');
} else {
flash('err', '找不到这家影院。');
}
redirect('movies/cinemas');
break;
// ---------------- 统计 / 高级搜索 / 年度报告(v11,从 MovieLog 迁入) ----------------
// 三个页面都挂在观影模块「设置」按钮下(顶栏不再单独放影院/统计入口)。
// 只读,不需要 requireWrite —— 访客也能看统计,与观影列表同一口径。
/** 统计仪表板 */
case 'movies/stats':
$familyId = (int)Auth::familyId();
View::render('movies/stats', [
'title' => '观影统计',
'ov' => MovieStats::overview($familyId),
'byCinema' => MovieStats::byCinema($familyId),
'byPerson' => MovieStats::byPerson($familyId, (int)Auth::personId()),
'byMonth' => MovieStats::byMonth($familyId, 12),
'byYear' => MovieStats::byYear($familyId),
], 'movies');
break;
/** 高级搜索(组合条件;条件全空时不查,页面提示先设置条件) */
case 'movies/search':
$familyId = (int)Auth::familyId();
$p = [
'title' => trim((string)($_GET['title'] ?? '')),
'cinema_id' => trim((string)($_GET['cinema_id'] ?? '')),
'person_id' => trim((string)($_GET['person_id'] ?? '')),
'date_from' => trim((string)($_GET['date_from'] ?? '')),
'date_to' => trim((string)($_GET['date_to'] ?? '')),
];
$hasCond = false;
foreach ($p as $v) {
if ($v !== '' && $v !== '0') {
$hasCond = true;
break;
}
}
View::render('movies/search', [
'title' => '高级搜索',
'cinemas' => Cinema::all($familyId),
'persons' => Person::all($familyId, '', 'member', (int)Auth::personId()),
'p' => $p,
'hasCond' => $hasCond,
'rows' => $hasCond ? MovieStats::search($familyId, $p, (int)Auth::personId()) : [],
], 'movies');
break;
/** 年度报告(以登录者为中心) */
case 'movies/report':
$familyId = (int)Auth::familyId();
$meId = (int)Auth::personId();
$years = MovieStats::years($familyId);
$year = (int)($_GET['year'] ?? 0);
if ($year <= 0 || !in_array($year, $years, true)) {
$year = $years ? (int)$years[0] : (int)date('Y');
}
$people = Person::map($familyId);
View::render('movies/report', [
'title' => $year . ' 年度观影报告',
'year' => $year,
'years' => $years ?: [$year],
'stats' => MovieStats::annual($familyId, $meId, $year),
'meName' => (string)($people[$meId]['name'] ?? '我'),
], 'movies');
break;
// ---------------- 评价与讨论(v9,MovieLog 同款) ----------------
// 评价人是「登录账号背后的人」,与同行观影人无关;访客只读(requireWrite)。
case 'movies/review_save':
$familyId = (int)Auth::familyId();
$meId = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$eventId = (int)($_POST['id'] ?? 0);
$m = Movie::find($familyId, $eventId);
if (!$m) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
$my = MovieReview::mine($eventId, $meId);
$err = MovieReview::save(
$eventId, $familyId, $meId,
(int)($_POST['rating'] ?? 0),
trim((string)($_POST['content'] ?? '')),
array_values((array)($_POST['feelings'] ?? [])),
$my !== null
);
if ($err !== '') {
flash('err', $err);
} else {
flash('ok', $my !== null ? '评价已更新。' : '评价已发布。');
}
redirect('movies/show', ['id' => $eventId], 'reviews');
break;
case 'movies/review_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$eventId = (int)($_POST['id'] ?? 0);
if (!Movie::find($familyId, $eventId)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
if (!MovieReview::deleteMine((int)($_POST['review_id'] ?? 0), (int)Auth::personId())) {
flash('err', '只能删除自己的评价。');
} else {
flash('ok', '评价已删除。');
}
redirect('movies/show', ['id' => $eventId], 'reviews');
break;
case 'movies/comment_add':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$eventId = (int)($_POST['id'] ?? 0);
$reviewId = (int)($_POST['review_id'] ?? 0);
$parentId = (int)($_POST['parent_id'] ?? 0);
if (!Movie::find($familyId, $eventId)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
$res = MovieReview::addComment(
$reviewId, (int)Auth::personId(),
trim((string)($_POST['content'] ?? '')),
array_values((array)($_POST['feelings'] ?? [])),
$parentId
);
if (is_string($res)) {
flash('err', $res);
} else {
flash('ok', $parentId > 0 ? '回复已发表。' : '评论已发表。');
}
redirect('movies/show', ['id' => $eventId], 'reviews');
break;
case 'movies/comment_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$eventId = (int)($_POST['id'] ?? 0);
if (!Movie::find($familyId, $eventId)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
if (!MovieReview::deleteComment((int)($_POST['comment_id'] ?? 0), (int)Auth::personId())) {
flash('err', '只能删除自己的评论。');
} else {
flash('ok', '评论已删除。');
}
redirect('movies/show', ['id' => $eventId], 'reviews');
break;
// ==================== 旅程(v12,TripLog 迁入) ====================
// 与观影共用 events 骨架(kind='trip'):一次出行 = 一个有起止、有人、会上日历的事件。
// 旅程特有的(封面 / 小结)放 trip_details,行程段落放 trip_nodes(计划 / 执行双轨)。
case 'trips':
$familyId = (int)Auth::familyId();
$keyword = trim((string)($_GET['keyword'] ?? ''));
$rows = Trip::all($familyId, $keyword);
Trip::attachAttendees($rows, (int)Auth::personId());
View::render('trips/index', [
'title' => '旅程',
'rows' => $rows,
'total' => count($rows),
'keyword' => $keyword,
], 'trips');
break;
case 'trips/new':
Auth::requireWrite();
View::render('trips/form', [
'title' => '记一趟旅程',
't' => null,
// 新建时默认勾上「我自己」—— 一趟旅程里几乎总有我,少点一下
'presetAttendees' => [(int)Auth::personId()],
'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''),
'errors' => [],
], 'trips');
break;
case 'trips/edit':
Auth::requireWrite();
$familyId = (int)Auth::familyId();
$t = Trip::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId());
if (!$t) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
View::render('trips/form', [
'title' => '编辑旅程',
't' => $t,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => [],
], 'trips');
break;
case 'trips/create':
case 'trips/update':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$isEdit = $r === 'trips/update';
$id = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
$t = $isEdit ? Trip::find($familyId, $id) : null;
if ($isEdit && !$t) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
$startsAt = read_dt_input('starts_at');
$endsAt = read_dt_input('ends_at');
$d = [
'title' => trim((string)($_POST['title'] ?? '')),
'starts_at' => (string)($startsAt ?? ''),
'ends_at' => (string)($endsAt ?? ''),
'location' => trim((string)($_POST['location'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')),
'summary' => trim((string)($_POST['summary'] ?? '')),
// 用户 2026-10-03 第 4 条:旅程是否公开(只影响列表与日历,不影响能否直接打开)
// 用户第 4 条:是否公开。
// ⚠️ 三种情况必须分清(少了任何一种都会错):
// ① 表单**没这个字段**(程序化调用 / 老表单 / 迁移)→ 连键都不放,
// 让 Trip::saveDetails 按"默认公开"处理;
// ② 表单提交了 hidden=0(用户取消勾选)→ 明确的 0;
// ③ 勾选 → '1'。
// 之前写成 `?? ''` 会把 ① 也变成"键存在且为空" → 被当成不公开,
// 于是数据在列表里**静默消失**(实测:新建的旅程从列表与日历上都没了)。
'attendees' => filter_family_person_ids((array)($_POST['attendees'] ?? []), $familyId),
];
if (array_key_exists('is_public', $_POST)) {
$d['is_public'] = (string)$_POST['is_public'];
}
$errors = [];
if ($d['title'] === '') {
$errors['title'] = '旅程名字不能为空';
} elseif ($d['starts_at'] !== '' && $d['ends_at'] !== ''
&& strcmp($d['ends_at'], $d['starts_at']) < 0) {
$errors['ends_at'] = '回来的时间不能早于出发';
}
// 封面图(编辑时换新图才动它;失败不挡保存,与观影海报同一个口径)
$oldCover = $isEdit ? (string)($t['cover_key'] ?? '') : '';
$d['cover_key'] = $oldCover;
$warns = [];
$up = Upload::image($familyId, $_FILES['cover'] ?? [], 'photo');
if ($up['ok']) {
$d['cover_key'] = (string)$up['file'];
if ($oldCover !== '' && $oldCover !== $d['cover_key']) {
\App\Storage\Storage::delete($familyId, $oldCover);
}
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = '封面图没有保存:' . $up['error'];
}
if ($errors) {
set_old($_POST);
View::render('trips/form', [
'title' => $isEdit ? '编辑旅程' : '记一趟旅程',
't' => $isEdit ? array_merge($t, $d) : null,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => $errors,
], 'trips');
exit;
}
if ($isEdit) {
Trip::update($familyId, $id, $d);
clear_old();
flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('trips/show', ['id' => $id]);
}
$newId = Trip::create($familyId, (int)Auth::personId(), $d);
clear_old();
flash('ok', '这趟旅程记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('trips/show', ['id' => $newId]);
break;
case 'trips/show':
$familyId = (int)Auth::familyId();
$meId = (int)Auth::personId();
$t = Trip::find($familyId, (int)($_GET['id'] ?? 0), $meId);
if (!$t) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
View::render('trips/show', [
'title' => (string)$t['title'],
't' => $t,
'photos' => $t['photos'],
'nodes' => Trip::nodes((int)$t['id'], $meId),
'people' => Person::roster($familyId, $meId, 'member', ''),
], 'trips');
break;
case 'trips/destroy':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
Trip::destroy($familyId, $id);
flash('ok', '这趟旅程已删除。');
redirect('trips');
break;
case 'trips/photo_add':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
$warns = attach_save_files($familyId, 'trip', $id, 'photo', (int)Auth::personId());
if ($warns) {
flash('err', '有照片没有保存:' . implode(';', $warns));
} else {
flash('ok', '照片已加上。');
}
redirect('trips/show', ['id' => $id]);
break;
case 'trips/photo_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
\App\Attachment::remove((int)($_POST['photo_id'] ?? 0));
flash('ok', '照片已删除。');
redirect('trips/show', ['id' => $id]);
break;
// ---- 行程节点(计划 / 执行双轨)----
case 'trips/node_add':
case 'trips/node_update':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
$t = Trip::find($familyId, $tripId);
if (!$t) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
$isEdit = $r === 'trips/node_update';
$nid = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
$d = [
'title' => trim((string)($_POST['title'] ?? '')),
'detail' => trim((string)($_POST['detail'] ?? '')),
'place' => trim((string)($_POST['place'] ?? '')),
'starts_at' => (string)(read_dt_input('starts_at') ?? ''),
'ends_at' => (string)(read_dt_input('ends_at') ?? ''),
'node_type' => (string)($_POST['node_type'] ?? 'other'),
'status' => (string)($_POST['status'] ?? 'planned'),
'category' => (string)($_POST['category'] ?? 'plan'),
'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId),
];
if ($d['title'] === '') {
flash('err', '这一段得有个名字。');
redirect('trips/show', ['id' => $tripId]);
}
$newId = Trip::saveNode($familyId, $tripId, $d, $isEdit ? $nid : null);
$warns = attach_save_files($familyId, 'trip_node', $newId, 'attach', (int)Auth::personId());
flash('ok', $isEdit ? '这一段已保存。' : '已加一段。'
. ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('trips/show', ['id' => $tripId]);
break;
case 'trips/node_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
Trip::deleteNode((int)($_POST['id'] ?? 0));
flash('ok', '这一段已删除。');
redirect('trips/show', ['id' => $tripId]);
break;
// ==================== 日程 / 日历(v12) ====================
// 自定义日程落在 calendar_events;**观影与旅行两本是派生的**(从 events 实时汇总,
// 不复制),给这两本"加日程"= 真的建一条观影 / 旅程记录。
/**
* 财务(v24 第 15 条):账本 + 账目 + 共享。
*
* 用户原话要的是「一个可以共享的家庭总账」:先有账本,账本共享给某人,
* 双方都能往里记账、都能看到全部账目,最后算出总数。
* ⚠️ 金额一律以**分**存取(见 Ledger::toCents 的注释),显示时再转元。
*/
case 'ledger':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$books = Ledger::mine($familyId, $me);
// 一本都没有 → 自动建一本(不然新用户进来一片空白,也不知道要先"创建家庭")
if (!$books) {
Ledger::ensureDefault($familyId, $me);
$books = Ledger::mine($familyId, $me);
}
$lid = (int)($_GET['book'] ?? 0);
$cur = null;
foreach ($books as $b) {
if ($lid > 0 && (int)$b['id'] === $lid) {
$cur = $b;
break;
}
}
if (!$cur) {
$cur = $books[0];
}
$entries = Ledger::entries((int)$cur['id']);
View::render('ledger/index', [
'title' => '财务',
'books' => $books,
'cur' => $cur,
// ⚠️ `me` 必须传进去:模板里「挂在谁名下默认是自己」(用户第 8 条)
// 要用它当默认选中值。漏传的话默认值会静默变成"不选" ——
// 功能"看起来在",只是每次都要手动点一次选人框。
'me' => $me,
'entries' => $entries,
'summary' => Ledger::summary($entries),
'shares' => Ledger::shares((int)$cur['id']),
// 用户 2026-10-03 第 8 条:上面画"金额随时间怎么变",所以要历史。
'charts' => Ledger::charts((int)$cur['id']),
'timeline'=> Ledger::timeline((int)$cur['id']),
'people' => Person::roster($familyId, $me, 'member', ''),
], 'ledger');
break;
case 'ledger/book_add':
case 'ledger/book_rename':
case 'ledger/book_del':
case 'ledger/entry_add':
case 'ledger/entry_update':
case 'ledger/entry_del':
case 'ledger/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);
View::render('days/index', [
'title' => '纪念日',
'custom' => $sp['custom'],
'derived' => $sp['derived'],
'upcoming' => Anniversary::upcoming($familyId, $me, 60),
'kinds' => Anniversary::kinds($familyId),
'kindCounts' => Anniversary::kindCounts($familyId),
'people' => Person::roster($familyId, $me, 'member', ''),
], 'days');
break;
/**
* 纪念日分类设置(v24 用户 2026-10-03 第 9 条)。
*
* 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」
*
* 分类清单是**家庭级**的(全家共用一套,不然「结婚纪念日」在两个人屏幕上
* 是两套叫法,筛选/看的时候对不上)。所以放在 settings 而不是 user_settings。
*/
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');
}
View::render('days/form', [
'title' => $id > 0 ? '编辑纪念日' : '新建纪念日',
'editing' => $editing,
'kinds' => Anniversary::kinds($familyId),
'people' => Person::roster($familyId, $me, 'member', ''),
], 'days');
break;
case 'days/save':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
Anniversary::save($familyId, [
'title' => (string)($_POST['title'] ?? ''),
'repeat_type' => (string)($_POST['repeat_type'] ?? 'solar'),
'month' => (int)($_POST['month'] ?? 1),
'day' => (int)($_POST['day'] ?? 1),
'is_leap' => !empty($_POST['is_leap']),
'kind' => (string)($_POST['kind'] ?? ''),
'note' => (string)($_POST['note'] ?? ''),
'person_id' => (int)($_POST['person_id'] ?? 0),
], $id);
flash('ok', $id > 0 ? '纪念日已保存。' : '纪念日已添加。');
redirect('days');
break;
case 'days/destroy':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
Anniversary::remove($familyId, (int)($_POST['id'] ?? 0));
flash('ok', '纪念日已删除。');
redirect('days');
break;
case 'calendar':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// v24 第 4 条:月 / 周 / 天三种看法。view 只认这三个,认不出就回落**默认视图**。
// ⚠️ 默认视图是**每人一份**的设置(用户第 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);
$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'] ?? ''));
View::render('calendar/index', [
'title' => '日程',
'ym' => $ym,
'view' => $view,
'anchor' => $anchor,
'cols' => $view === 'month' ? [] : ($cols ?? []),
'weeks' => $view === 'month' ? $grid : [],
'tgrid' => $view === 'month' ? null : $grid,
'cals' => $cals,
'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;
/**
* CalDAV 专用口令(v24 第 7 条后半):生成 / 重置 / 撤销。
*
* ⚠️ 明文口令只在这**一次**跳转里带回页面显示(存 $_SESSION 一次性取用),
* 库里只有 sha256。不这么做的话,要么明文落库(一旦泄露全暴露),
* 要么用户永远看不到自己刚设的花口令。
*/
case 'calendar/caldav_token':
case 'calendar/caldav_revoke':
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
if ($r === 'calendar/caldav_token') {
$_SESSION['caldav_plain'] = Auth::caldavIssue($me);
flash('ok', 'CalDAV 口令已生成(旧口令立即失效,请在客户端里更新)。');
} else {
Auth::caldavRevoke($me);
flash('ok', 'CalDAV 口令已撤销,所有已订阅的客户端会失去访问。');
}
redirect('calendar/calendars');
break;
case 'calendar/calendars':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$all = Calendar::mine($familyId, $me);
$mine = [];
$shared = [];
foreach ($all as $c) {
if ((int)$c['shared'] === 1) {
$shared[] = $c;
} else {
$mine[] = $c;
}
}
$shares = [];
foreach ($mine as $c) {
$shares[(int)$c['id']] = Calendar::shares((int)$c['id']);
}
// v24 第 7 条后半:CalDAV 专用口令的状态(明文只在刚生成那一次给用户看)
$cdHas = Auth::hasCalDav($me);
$cdPlain = (string)($_SESSION['caldav_plain'] ?? '');
unset($_SESSION['caldav_plain']);
$editing = null;
$editId = (int)($_GET['edit'] ?? 0);
if ($editId > 0) {
foreach ($mine as $c) {
if ((int)$c['id'] === $editId && (string)$c['kind'] === 'custom') {
$editing = $c;
}
}
}
View::render('calendar/calendars', [
'title' => '日历本',
'cals' => $mine,
'shared' => $shared,
'shares' => $shares,
'editing' => $editing,
'people' => Person::roster($familyId, $me, 'member', ''),
'cdHas' => $cdHas,
'cdPlain' => $cdPlain,
], 'calendar');
break;
case 'calendar/cal_add':
case 'calendar/cal_update':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$name = trim((string)($_POST['name'] ?? ''));
$color = trim((string)($_POST['color'] ?? ''));
if ($name === '') {
flash('err', '日历本得有个名字。');
redirect('calendar/calendars');
}
if ($r === 'calendar/cal_update') {
$id = (int)($_POST['id'] ?? 0);
// 只改自己的自定义本:预置的观影/旅行本不能改名换色(内容是从模块派生的)
$cal = Calendar::find($id);
if (!$cal || (int)$cal['owner_id'] !== $me || (string)$cal['kind'] !== 'custom') {
flash('err', '这本日历不能改。');
redirect('calendar/calendars');
}
Calendar::update($id, $name, $color);
flash('ok', '日历本已更新。');
} else {
Calendar::create($familyId, $me, $name, $color);
flash('ok', '日历本已新建。');
}
redirect('calendar/calendars');
break;
case 'calendar/cal_del':
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$cal = Calendar::find($id);
if (!$cal || (int)$cal['owner_id'] !== $me) {
flash('err', '这本日历不能删。');
redirect('calendar/calendars');
}
Calendar::delete($id); // 内部只删 kind='custom'
flash('ok', '日历本已删除。');
redirect('calendar/calendars');
break;
case 'calendar/cal_share':
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$cal = Calendar::find($id);
$target = (int)($_POST['person_id'] ?? 0);
if (!$cal || (int)$cal['owner_id'] !== $me || $target <= 0 || $target === $me) {
flash('err', '不能这样共享。');
redirect('calendar/calendars');
}
Calendar::setShare($id, $target, (string)($_POST['perm'] ?? 'read'));
flash('ok', '已共享。');
redirect('calendar/calendars');
break;
case 'calendar/cal_unshare':
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$cal = Calendar::find($id);
if (!$cal || (int)$cal['owner_id'] !== $me) {
flash('err', '不能这样取消共享。');
redirect('calendar/calendars');
}
Calendar::dropShare($id, (int)($_POST['person_id'] ?? 0));
flash('ok', '已取消共享。');
redirect('calendar/calendars');
break;
case 'calendar/event_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$ym = trim((string)($_POST['ym'] ?? ''));
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
$calId = (int)($_POST['calendar_id'] ?? 0);
// ⚠️ 走 mineOne 而不是 Calendar::find():要带 perm,共享本的可读写才判得对
$cal = Calendar::mineOne($familyId, $me, $calId);
if (!$cal || !Calendar::canWrite($cal, $me)) {
flash('err', '这本日历你没有写权限。');
redirect('calendar', ['ym' => $ym]);
}
// v24 第 5、6 条:起止各是一个「日期+时分秒」的完整时刻(_datetime.php 控件),
// 不再拆成 date / end_date / start_time / end_time 四格。
$startsAt = read_dt_input('starts_at');
if ($startsAt === null) {
flash('err', '开始时间不对。');
redirect('calendar', ['ym' => $ym]);
}
$endsAt = read_dt_input('ends_at');
$title = trim((string)($_POST['title'] ?? ''));
if ($title === '') {
flash('err', '日程得有个标题。');
redirect('calendar', ['ym' => $ym]);
}
$allDay = !empty($_POST['all_day']);
if ($allDay) {
// 全天日程:时刻部分一律归零(跨天的全天日程截止时刻含到当天末尾)
$startsAt = substr($startsAt, 0, 10) . ' 00:00:00';
$endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null;
}
$note = trim((string)($_POST['note'] ?? ''));
// ⚠️ 观影 / 旅行两本是派生的:往这两本里加日程 = 建一条真正的观影 / 旅程记录,
// 而不是往 calendar_events 里塞一行(那样日历里有、观影列表里没有)。
$kind = (string)$cal['kind'];
if ($kind === 'movie') {
$newId = Movie::create($familyId, $me, [
'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
'attendees' => [$me],
]);
flash('ok', '已加进观影,日历里也能看到了。');
redirect('movies/show', ['id' => $newId]);
}
if ($kind === 'trip') {
$newId = Trip::create($familyId, $me, [
'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
'attendees' => [$me],
]);
flash('ok', '已加进旅程,日历里也能看到了。');
redirect('trips/show', ['id' => $newId]);
}
$evId = Calendar::addEvent((int)$cal['id'], [
'title' => $title,
'location' => trim((string)($_POST['location'] ?? '')),
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'all_day' => $allDay,
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
'status' => (string)($_POST['status'] ?? 'confirmed'),
'transp' => !empty($_POST['transp']),
// 用户 2026-10-03 第 5、6 条:重复规则(RFC 5545 RRULE 原文)
'rrule' => Repeat::build($_POST),
'note' => $note,
'created_by' => $me,
]);
$warns = attach_save_files($familyId, Calendar::ATTACH, $evId, 'attach', $me);
if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns));
}
flash('ok', '日程已加上。');
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
break;
case 'calendar/event':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$id = (int)($_GET['id'] ?? 0);
$st = Database::get()->prepare(
'SELECT e.*, c.owner_id, c.name AS cal_name, c.color AS cal_color, c.kind AS cal_kind
FROM calendar_events e JOIN calendars c ON c.id = e.calendar_id
WHERE e.id = ? AND c.family_id = ?'
);
$st->execute([$id, $familyId]);
$ev = $st->fetch();
if (!$ev) {
flash('err', '找不到这条日程。');
redirect('calendar');
}
$ev['_can_write'] = Calendar::canWriteEvent($ev, $me);
// 用户第 5 条:从日历点进来时会带 ?d=(那一次是哪天),
// 编辑重复日程时用它定位"仅此一次 / 这一次及以后"。
$occ = trim((string)($_GET['d'] ?? ''));
$occ = preg_match('/^\d{4}-\d{2}-\d{2}$/', $occ) ? $occ : '';
// 编辑页要回填"哪些天被跳过了"(跳过的那次不再出现在日历里)
$ev['skips'] = Calendar::skipsOf((int)$ev['id']);
View::render('calendar/event', [
'title' => (string)$ev['title'],
'ev' => $ev,
'cal' => $cal,
'occ' => $occ,
// ⚠️ 不要再传 'ym':模板自己从 $ev['starts_at'] 算(模板里那行更权威,
// 传进来反而会在"改过日期后"与模板算出的值不一致)。
], 'calendar');
break;
case 'calendar/event_update':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$ym = trim((string)($_POST['ym'] ?? ''));
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
// ⚠️ 这里要把**整行**取回来(含 rrule / starts_at / 各字段):
// 改重复日程时,"仅此一次 / 这一次及以后"都要拿旧内容来裁剪或复制,
// 只取 id 的话还得再查一遍(而且容易漏字段)。
$st = Database::get()->prepare(
'SELECT e.*, c.owner_id FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
WHERE e.id = ? AND c.family_id = ?'
);
$st->execute([$id, $familyId]);
$ev = $st->fetch();
$ev2 = $ev; // 旧内容(scope 分支要用,别被后面的 $d 覆盖)
if (!$ev) {
flash('err', '找不到这条日程。');
redirect('calendar', ['ym' => $ym]);
}
$cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: [];
if (!Calendar::canWrite($cal, $me)) {
flash('err', '这本日历你没有写权限。');
redirect('calendar', ['ym' => $ym]);
}
// v24 第 5、6 条:起止各是一个完整时刻(精确到秒),见上面 event_add 的说明。
$startsAt = read_dt_input('starts_at');
if ($startsAt === null) {
flash('err', '开始时间不对。');
redirect('calendar', ['ym' => $ym]);
}
$endsAt = read_dt_input('ends_at');
$allDay = !empty($_POST['all_day']);
if ($allDay) {
$startsAt = substr($startsAt, 0, 10) . ' 00:00:00';
$endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null;
}
$d = [
'title' => trim((string)($_POST['title'] ?? '')),
'location' => trim((string)($_POST['location'] ?? '')),
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'all_day' => $allDay,
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
'status' => (string)($_POST['status'] ?? 'confirmed'),
'transp' => !empty($_POST['transp']),
'rrule' => Repeat::build($_POST),
'note' => trim((string)($_POST['note'] ?? '')),
];
/* ---- 用户 2026-10-03 第 5 条:改一条**重复**日程时,先问"改哪些" ----
scope 三档:
all = 整条系列都按新内容改(直接 update 本行,rrule 也跟着改)
future = 这一次**以及以后**的:把原系列截止到"这一次"的前一天,
再新建一条从这一天开始的系列 —— 这样过去的历史原样保留,
以后的全部采用新内容(与 iOS/群晖日历的"此事件及后续"一致)
once = 仅这一次:原系列里跳掉这一天,另建一条只发生一次(detachOnce)
⚠️ 不重复的日程直接按 all 处理(没有"系列"可言)。 */
$scope = (string)($_POST['scope'] ?? 'all');
$occDay = substr(trim((string)($_POST['occ'] ?? '')), 0, 10);
$rruleOld = (string)($ev2['rrule'] ?? '');
if (in_array($scope, ['once', 'future'], true)
&& $rruleOld !== ''
&& preg_match('/^\d{4}-\d{2}-\d{2}$/', $occDay)) {
if ($scope === 'once') {
// 仅这一次:原系列跳掉那天 + 落一条独立单次
Calendar::detachOnce(
['id' => $id, 'calendar_id' => (int)$ev['calendar_id']],
$occDay, $d, $me
);
flash('ok', '只改了这一次。');
} else {
// 这一次及以后:老系列截到前一天,新系列从这天起
$rp = Repeat::parse($rruleOld);
$cut = date('Y-m-d', strtotime($occDay . ' -1 day'));
$rp['until'] = $cut;
$rp['count'] = 0;
// 老系列:只保留到 $cut,并**移除**原来的 days 定义之外的东西
Calendar::updateEvent($id, [
'title' => (string)($ev2['title'] ?? ''),
'location' => (string)($ev2['location'] ?? ''),
'starts_at' => (string)$ev2['starts_at'],
'ends_at' => $ev2['ends_at'] ?: null,
'all_day' => (int)($ev2['all_day'] ?? 0) === 1,
'reminder_minutes' => $ev2['reminder_minutes'] ?? '',
'status' => (string)($ev2['status'] ?? 'confirmed'),
'transp' => (int)($ev2['transp'] ?? 0) === 1,
'rrule' => 'FREQ=' . strtoupper((string)$rp['freq'])
. ($rp['freq'] === 'weekday' ? ';BYDAY=MO,TU,WE,TH,FR' : '')
. ';UNTIL=' . str_replace('-', '', $cut) . 'T235959Z',
'note' => (string)($ev2['note'] ?? ''),
]);
// 新系列:从 $occDay 起,用用户改后的内容
$d2 = $d;
$offsetDays = (int)floor((strtotime(substr((string)$d['starts_at'], 0, 10))
- strtotime(substr((string)$ev2['starts_at'], 0, 10))) / 86400);
$d2['starts_at'] = $occDay . ' ' . substr((string)$d['starts_at'], 11, 8);
$d2['ends_at'] = $d['ends_at'] !== null ? $occDay . ' ' . substr((string)$d['ends_at'], 11, 8) : null;
unset($offsetDays);
$d2['created_by'] = $me;
Calendar::addEvent((int)$ev['calendar_id'], $d2);
flash('ok', '这一次及以后的都改好了(过去的保持不变)。');
}
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
}
// all(或本来就不重复):整条按新内容改
Calendar::updateEvent($id, $d);
$warns = attach_save_files($familyId, Calendar::ATTACH, $id, 'attach', $me);
if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns));
}
flash('ok', '日程已保存。');
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
break;
case 'calendar/event_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$ym = trim((string)($_POST['ym'] ?? ''));
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
$st = Database::get()->prepare(
'SELECT e.id, e.calendar_id FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
WHERE e.id = ? AND c.family_id = ?'
);
$st->execute([$id, $familyId]);
$ev = $st->fetch();
if ($ev) {
$cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: [];
if (Calendar::canWrite($cal, $me)) {
Calendar::deleteEvent($id);
flash('ok', '日程已删除。');
} else {
flash('err', '这本日历你没有写权限。');
}
}
redirect('calendar', ['ym' => $ym]);
break;
// ==================== ics 导出 / 导入(v24 第 7 条)====================
case 'calendar/export':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$calId = (int)($_GET['id'] ?? 0);
$cal = Calendar::mineOne($familyId, $me, $calId);
if (!$cal) {
flash('err', '找不到这本日历。');
redirect('calendar');
}
$rows = Calendar::exportRows($cal, $familyId, $me);
$ics = Ics::export($rows, (string)$cal['name']);
// ⚠️ 文件名要用**本子名 + 日期**,别用中文之外的怪字符:
// Content-Disposition 的非 ASCII 文件名各家浏览器编码方式不一样,
// 最稳的做法是给一个 ASCII 兜底名(filename=)再给一个 UTF-8 的(filename*=)。
$ascii = 'calendar-' . (int)$cal['id'] . '-' . date('Ymd') . '.ics';
$utf8 = rawurlencode((string)$cal['name'] . '-' . date('Y-m-d') . '.ics');
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $ascii . '"; filename*=UTF-8\'\'' . $utf8);
header('Content-Length: ' . strlen($ics));
echo $ics;
exit;
case 'calendar/import':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$calId = (int)($_POST['calendar_id'] ?? 0);
$cal = Calendar::mineOne($familyId, $me, $calId);
if (!$cal || !Calendar::canWrite($cal, $me)) {
flash('err', '这本日历你没有写权限。');
redirect('calendar/calendars');
}
// ⚠️ 只能导进**自定义本**:观影 / 旅行两本是派生的(内容是 events / trip_nodes),
// 往里塞 calendar_events 会出现"日历里有、观影列表里没有"的孤儿。
if ((string)$cal['kind'] !== 'custom') {
flash('err', '只能往自定义日历本里导入。观影、旅行两本是从记录里自动汇总的。');
redirect('calendar/calendars');
}
if (empty($_FILES['ics']) || (int)($_FILES['ics']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
flash('err', '没收到 .ics 文件。');
redirect('calendar/calendars');
}
$text = (string)file_get_contents((string)$_FILES['ics']['tmp_name']);
$list = Ics::parse($text);
if (!$list) {
flash('err', '这个文件里没有找到任何日程(VEVENT)。');
redirect('calendar/calendars');
}
$added = 0;
$updated = 0;
foreach ($list as $v) {
if (($v['starts_at'] ?? null) === null) {
continue; // 没有 DTSTART 的 VEVENT 是坏数据,跳过而不是写成 1970 年
}
if (trim((string)$v['title']) === '') {
$v['title'] = '(无标题日程)';
}
if (Calendar::importEvent($calId, $v, $me) === 'added') {
$added++;
} else {
$updated++;
}
}
flash('ok', '导入完成:新增 ' . $added . ' 条,更新 ' . $updated . ' 条。');
redirect('calendar/calendars');
break;
case 'calendar/attach_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$aid = (int)($_POST['attach_id'] ?? 0);
$ev = Calendar::event($id);
if ($ev && Calendar::canWriteEvent($ev, $me)) {
// ⚠️ 附件 id 与宿主 id 一起判:只按 attach_id 删,就能用别的日程的
// 附件 id 把人家文件删掉(宿主对不上就静默放行是很典型的越权)。
$st = Database::get()->prepare(
'SELECT id FROM attachments WHERE id = ? AND target_type = ? AND target_id = ?'
);
$st->execute([$aid, Calendar::ATTACH, $id]);
if ($st->fetchColumn() !== false) {
Attachment::remove($aid);
flash('ok', '附件已删除。');
}
}
redirect('calendar/event', ['id' => $id]);
break;
// ==================== 资源(v12) ====================
// 挂在**某个人名下**的东西(证件 / 照片 / 账号),可共享给别人(只读或读写)。
case 'resources':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$keyword = trim((string)($_GET['keyword'] ?? ''));
$ownerId = (int)($_GET['owner'] ?? 0);
// 用户 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);
if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns));
}
redirect('resources');
break;
case 'resources/attach_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$r0 = Resource::find($familyId, $id, $me);
if (!$r0 || !Resource::canWrite($r0, $me)) {
flash('err', '这条资源不能改。');
redirect('resources');
}
$warns = attach_save_files($familyId, Resource::ATTACH, $id, 'attach', $me);
flash($warns ? 'err' : 'ok', $warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。');
redirect('resources');
break;
case 'resources/attach_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$aid = (int)($_POST['id'] ?? 0);
// ⚠️ 要按"这条附件属于哪条资源"找到宿主再判权限:只传附件 id 的话,
// 谁都能构造一个请求删掉别人家的附件。
$st = Database::get()->prepare('SELECT target_id FROM attachments WHERE id = ? AND target_type = ?');
$st->execute([$aid, Resource::ATTACH]);
$tid = (int)$st->fetchColumn();
$r0 = $tid > 0 ? Resource::find($familyId, $tid, $me) : null;
if (!$r0 || !Resource::canWrite($r0, $me)) {
flash('err', '这个附件不能删。');
redirect('resources');
}
\App\Attachment::remove($aid);
flash('ok', '附件已删除。');
redirect('resources');
break;
case 'resources/share':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$r0 = Resource::find($familyId, $id, $me);
if (!$r0 || !Resource::canWrite($r0, $me)) {
flash('err', '这条资源不能共享。');
redirect('resources');
}
$target = (int)($_POST['person_id'] ?? 0);
if ($target > 0 && isset(Person::map($familyId)[$target])) {
Resource::setShare($id, (int)$r0['owner_id'], $target, (string)($_POST['perm'] ?? 'read'));
flash('ok', '已共享。');
}
redirect('resources');
break;
case 'resources/unshare':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$r0 = Resource::find($familyId, $id, $me);
if ($r0 && Resource::canWrite($r0, $me)) {
Resource::dropShare($id, (int)($_POST['person_id'] ?? 0));
flash('ok', '已取消共享。');
}
redirect('resources');
break;
case 'resources/destroy':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$r0 = Resource::find($familyId, $id, $me);
if (!$r0 || !Resource::canWrite($r0, $me)) {
flash('err', '这条资源不能删。');
redirect('resources');
}
Resource::destroy($familyId, $id);
flash('ok', '资源已删除。');
redirect('resources');
break;
// ==================== 任务(v12) ====================
case 'tasks':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// v24(用户 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 {
Task::renameBoard($familyId, $id, $nm);
flash('ok', '看板已改名。');
}
} elseif ($act === 'tasks/board_del') {
Task::deleteBoard($familyId, $id);
flash('ok', '看板已删除,里头的任务挪到了默认看板。');
} elseif ($act === 'tasks/cat_add') {
if ($nm === '') {
flash('err', '分类名不能为空。');
} else {
Task::createCategory($familyId, $nm);
flash('ok', '分类已添加。');
}
} elseif ($act === 'tasks/cat_update') {
if ($nm === '') {
flash('err', '分类名不能为空。');
} else {
Task::renameCategory($familyId, $id, $nm);
flash('ok', '分类已改名。');
}
} else {
Task::deleteCategory($familyId, $id);
flash('ok', '分类已删除,用它标过的任务变成「未分类」。');
}
redirect('tasks/settings');
break;
case 'tasks/show':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$t = Task::find($familyId, (int)($_GET['id'] ?? 0), $me);
if (!$t) {
flash('err', '找不到这项任务。');
redirect('tasks');
}
// 可选的父任务 = 我能看到的**顶层**任务(不含自己)
$parents = [];
foreach (['todo', 'doing', 'done'] as $sk) {
foreach (Task::board($familyId, $me)[$sk] as $p) {
if ((int)$p['id'] !== (int)$t['id']) {
$parents[] = $p;
}
}
}
View::render('tasks/show', [
'title' => (string)$t['title'],
't' => $t,
'people' => Person::roster($familyId, $me, 'member', ''),
'parents' => $parents,
// v24 第 9、10 条:详情页的编辑表单里也能改分类与看板
'boards' => Task::boards($familyId),
'categories' => Task::categories($familyId),
], 'tasks');
break;
case 'tasks/create':
case 'tasks/update':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$isEdit = $r === 'tasks/update';
$id = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
$old = $isEdit ? Task::find($familyId, $id, $me) : null;
if ($isEdit && (!$old || !Task::canWrite($old, $me))) {
flash('err', '这项任务不能改。');
redirect('tasks');
}
$title = trim((string)($_POST['title'] ?? ''));
if ($title === '') {
flash('err', '任务得写清楚要做什么。');
redirect('tasks');
}
$d = [
'title' => $title,
'note' => trim((string)($_POST['note'] ?? '')),
'due_at' => (string)(read_due_input() ?? ''),
'repeat_rule' => trim((string)($_POST['repeat_rule'] ?? '')),
'parent_id' => (int)($_POST['parent_id'] ?? 0),
'assignee_id' => (int)($_POST['assignee_id'] ?? 0),
// v24 第 9、10 条:分类与看板。Task 侧会校验归属(不认别的家庭的 id)。
'category_id' => (int)($_POST['category_id'] ?? 0),
'board_id' => (int)($_POST['board_id'] ?? 0),
'status' => $isEdit ? (string)$old['status'] : 'todo',
];
if ($d['assignee_id'] > 0 && !isset(Person::map($familyId)[$d['assignee_id']])) {
$d['assignee_id'] = 0;
}
if ($isEdit) {
Task::update($familyId, $id, $d);
$tid = $id;
flash('ok', '任务已保存。');
} else {
$tid = Task::create($familyId, $me, $d);
foreach (filter_family_person_ids((array)($_POST['shares'] ?? []), $familyId) as $pid) {
Task::setShare($tid, $pid, 'write');
}
$warns = attach_save_files($familyId, Task::ATTACH, $tid, 'attach', $me);
if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns));
}
flash('ok', '任务已新建。');
}
redirect('tasks/show', ['id' => $tid]);
break;
case 'tasks/move':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$status = (string)($_POST['status'] ?? '');
$t = Task::find($familyId, $id, $me);
if (!$t || !Task::canWrite($t, $me)) {
flash('err', '这项任务不能改。');
redirect('tasks');
}
$before = (int)($_POST['before'] ?? 0);
// ⚠️ 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);
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;
case 'tasks/comment_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$t = Task::find($familyId, $id, $me);
if (!$t) {
flash('err', '找不到这项任务。');
redirect('tasks');
}
$cid = Task::addComment($id, $me, (string)($_POST['content'] ?? ''));
if ($cid > 0) {
attach_save_files($familyId, 'task_comment', $cid, 'attach', $me);
flash('ok', '留言已发表。');
} else {
flash('err', '留言是空的。');
}
redirect('tasks/show', ['id' => $id]);
break;
case 'tasks/comment_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$taskId = (int)($_POST['task_id'] ?? 0);
Task::deleteComment((int)($_POST['id'] ?? 0), $me); // 内部只删自己的
flash('ok', '留言已删除。');
redirect('tasks/show', ['id' => $taskId]);
break;
case 'tasks/attach_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$t = Task::find($familyId, $id, $me);
if (!$t || !Task::canWrite($t, $me)) {
flash('err', '这项任务不能改。');
redirect('tasks');
}
$warns = attach_save_files($familyId, Task::ATTACH, $id, 'attach', $me);
flash($warns ? 'err' : 'ok', $warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。');
redirect('tasks/show', ['id' => $id]);
break;
case 'tasks/attach_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$aid = (int)($_POST['id'] ?? 0);
$st = Database::get()->prepare('SELECT target_id FROM attachments WHERE id = ? AND target_type = ?');
$st->execute([$aid, Task::ATTACH]);
$tid = (int)$st->fetchColumn();
$t = $tid > 0 ? Task::find($familyId, $tid, $me) : null;
if (!$t || !Task::canWrite($t, $me)) {
flash('err', '这个附件不能删。');
redirect('tasks');
}
\App\Attachment::remove($aid);
flash('ok', '附件已删除。');
redirect('tasks/show', ['id' => $tid]);
break;
case 'tasks/share':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$t = Task::find($familyId, $id, $me);
if (!$t || !Task::canWrite($t, $me)) {
flash('err', '这项任务不能共享。');
redirect('tasks');
}
$target = (int)($_POST['person_id'] ?? 0);
if ($target > 0 && isset(Person::map($familyId)[$target])) {
Task::setShare($id, $target, (string)($_POST['perm'] ?? 'write'));
flash('ok', '已共享。');
}
redirect('tasks/show', ['id' => $id]);
break;
case 'tasks/unshare':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$t = Task::find($familyId, $id, $me);
if ($t && Task::canWrite($t, $me)) {
Task::dropShare($id, (int)($_POST['person_id'] ?? 0));
flash('ok', '已取消共享。');
}
redirect('tasks/show', ['id' => $id]);
break;
case 'tasks/destroy':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$t = Task::find($familyId, $id, $me);
if (!$t || !Task::canWrite($t, $me)) {
flash('err', '这项任务不能删。');
redirect('tasks');
}
Task::destroy($familyId, $id);
flash('ok', '任务已删除。');
redirect('tasks');
break;
default:
http_response_code(404);
View::render('error', [
'title' => '页面不存在',
'message' => '找不到这一页。',
], 'home');
break;
}