Files
FamilyBook/index.php
T
laoyang 8f80f3e835 v24 第 2 轮:19 条清单收尾(附件预览/CalDAV/任务分类看板/全局选人/家谱/纪念日/财务)
用户 2026-10-02 提的 19 条需求,本轮完成剩下的 8 条 + CalDAV 服务端:

- ③ 附件:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;可预览的在本页
     弹层里看(不再新开标签页),渲染不了的点了直接下载;预览层右上角恒有下载按钮。
- ⑦b CalDAV:把「我的日历本」按 RFC 4791 只读子集分享出去(OPTIONS/PROPFIND/
     REPORT/GET,写方法一律 403)。用**专用口令**(库里只存 sha256),不是登录口令。
- ⑨⑩ 任务:分类与看板都由用户自建;已完成的任务从看板消失,改在「已完成」页
     按分类查看/搜索;设置入口在「新建任务」旁的 ☰。
- ⑪ 全局人员选择器:一个片段铺到观影/旅程/资源/日历共享/任务共享/财务共享;
     修掉了老组件「名字和拼音都搜不动」的 bug —— .att-item 自己声明了 display:flex,
     压过了浏览器 [hidden]{display:none},过滤根本没生效。
- ⑫ 家谱:头像改方形不再裁切;姻亲不在图上的卡片给「⇄ 姻亲」入口,
     以 TA 为中心重画 + 「返回我的家谱」。
- ⑬ 纪念日:阴历/阳历都能设且按对应历法重复;名册里每个人的生日默认可见
     (实时派生,不复制一份);全部作为全天日程进日历。
- ⑮ 财务:账本可共享,双方都能记账、都能看全部账目,资产−负债=净资产;
     金额一律以「分」存,不用浮点。

验证:端到端 724 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 24、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
2026-10-03 08:18:05 +08:00

3630 lines
154 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\Auth;
use App\Birthday;
use App\Calendar;
use App\Cinema;
use App\Crypto;
use App\Database;
use App\Douban;
use App\Family;
use App\Anniversary;
use App\Ics;
use App\Kinship;
use App\Ledger;
use App\Movie;
use App\MovieReview;
use App\Module;
use App\MovieStats;
use App\Nickname;
use App\Person;
use App\PersonGroup;
use App\PersonRoster;
use App\Relations;
use App\Resource;
use App\Schema;
use App\Setting;
use App\Task;
use App\Trip;
use App\Upload;
use App\View;
if (!Database::hasTables()) {
header('Location: install.php');
exit;
}
/**
* 一次性结构升级。
*
* ⚠️ 为什么要有这段:上线后的库不认识新加的列(birth_rank / is_placeholder)和
* 新的 guest 角色。靠人去手动 ALTER 迟早会漏,所以在每次请求的开头比一下版本号,
* 落后了就补一次 —— 平时只是一次字符串比较,几乎零成本。
*/
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) {
$have = (string)Setting::get($familyIdForSchema, 'schema_version', '1');
if ($have !== FB_SCHEMA_VERSION) {
try {
Schema::ensure();
Setting::set($familyIdForSchema, 'schema_version', FB_SCHEMA_VERSION);
} 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 丢弃;校验失败重画表单时还要用它回填)
*/
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),
], '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 点会变成一条压在最上面的怪东西。
* 单独挂在顶上的一行(跟群晖日历一样)。
*
* @return array{all_day:array,timed:array} all_day 按列分组;timed 按列分组,含 top/height
*/
function build_time_grid(array $events, array $cols): array
{
$dayIndex = [];
foreach ($cols as $i => $c) {
$dayIndex[(string)$c['date']] = $i;
}
$allDay = array_fill(0, count($cols), []);
$timed = array_fill(0, count($cols), []);
foreach ($events as $e) {
$ci = $dayIndex[(string)$e['date']] ?? null;
if ($ci === null) {
continue;
}
if (!empty($e['all_day'])) {
$allDay[$ci][] = $e;
continue;
}
$t = (string)($e['time'] ?? '');
// 有具体时刻才进时间格;没有时刻又不是全天的(如任务全天),按全天处理
if ($t === '' || !preg_match('/^(\d{2}):(\d{2})/', $t, $m)) {
$allDay[$ci][] = $e;
continue;
}
$startMin = ((int)$m[1]) * 60 + (int)$m[2];
// 没有结束时刻的按 30 分钟画一条最短的可见块(不然高度是 0,等于看不见)
$endMin = $startMin + 30;
$timed[$ci][] = $e + [
'top' => round($startMin / 1440 * 100, 4),
'height' => round(max(30, $endMin - $startMin) / 1440 * 100, 4),
];
}
return ['all_day' => $allDay, 'timed' => $timed];
}
/**
* 读任务的「截止日期(+ 可选时刻)」。
*
* ⚠️ 只填日期、不填时刻时存 00:00:00,而 00:00 在展示层与日历里都当作**全天**
* (与日程同一个口径):"某天之前做完"本来就没有具体几点。
*/
function read_due_input(): ?string
{
// v24 第 5 条:截止时刻与全局其它地方一样走 `_datetime.php`(日期 + 时/分/秒一起选,
// 精确到秒)。下面那段 due_date / due_time 只是老表单的兜底,别删 ——
// 旧的链接、书签、脚本还在按老名字提交,删了就静默变成"没有截止日期"。
$full = read_dt_input('due_at');
if ($full !== null) {
return $full;
}
$d = trim((string)($_POST['due_date'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
return null;
}
$t = trim((string)($_POST['due_time'] ?? ''));
return $d . ' ' . (preg_match('/^\d{2}:\d{2}$/', $t) ? $t . ':00' : '00:00:00');
}
/**
* 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。
*
* ⚠️ v24(用户 2026-10-02 第 19 条):原来同一天没有命中时会**退回同一个月**,
* 标题随之变成"往年这个月" —— 但用户要的就是**往年的今天**:
* "10 月 2 日"给我看"去年 10 月 8 日去看了场电影",这根本不是同一天的事,
* 凑出来的条目看着像回忆,其实只是同月的随机噪声,反而把"往年今日"的意味毁了。
* 现在只认同一天;没命中就明说"往年的这一天还没有记录"。
*
* @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容)
*/
function home_memories(int $familyId, int $limit = 6): array
{
$today = date('Y-m-d');
$year = (int)date('Y');
$sql = "SELECT e.id, e.title, e.starts_at, e.kind,
(SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS n
FROM events e
WHERE e.family_id = ? AND e.kind IN ('movie','trip') AND e.starts_at IS NOT NULL
AND YEAR(e.starts_at) < ?
AND DATE_FORMAT(e.starts_at, '%m-%d') = ?
ORDER BY e.starts_at DESC LIMIT " . (int)$limit;
$st = Database::get()->prepare($sql);
$st->execute([$familyId, $year, substr($today, 5, 5)]);
$rows = $st->fetchAll();
foreach ($rows as &$r) {
$r['years_ago'] = $year - (int)substr((string)$r['starts_at'], 0, 4);
$r['href'] = url($r['kind'] === 'trip' ? 'trips/show' : 'movies/show', ['id' => (int)$r['id']]);
}
unset($r);
return ['rows' => $rows, 'exact' => true];
}
/** 只认本家庭、确实存在的 person_id(表单可以改,不能凭它塞进别人家的人) */
function filter_family_person_ids(array $ids, int $familyId): array
{
$map = Person::map($familyId);
$out = [];
foreach ($ids as $v) {
$v = (int)$v;
if ($v > 0 && isset($map[$v]) && !in_array($v, $out, true)) {
$out[] = $v;
}
}
return $out;
}
switch ($r) {
// ==================== 登录 / 退出 ====================
case 'auth/login':
if (Auth::check()) {
redirect('home');
}
if ($method === 'POST') {
csrf_check();
$phone = trim((string)($_POST['phone'] ?? ''));
$pass = (string)($_POST['password'] ?? '');
if (Auth::login($phone, $pass)) {
redirect('home');
}
View::renderBare('login', [
'title' => '登录',
'familyName' => fb_config('site_name', '家书'),
'error' => '手机号或密码不对,或者账号还没开通/已被停用。',
'phone' => $phone,
]);
exit;
}
View::renderBare('login', [
'title' => '登录',
'familyName' => fb_config('site_name', '家书'),
]);
break;
case 'auth/logout':
if (isset($_GET['_csrf'], $_SESSION['_csrf'])
&& hash_equals($_SESSION['_csrf'], (string)$_GET['_csrf'])) {
Auth::logout();
}
redirect('auth/login');
break;
case 'auth/password':
Auth::requireLogin();
if ($method === 'POST') {
csrf_check();
$old = (string)($_POST['old_password'] ?? '');
$new = (string)($_POST['password'] ?? '');
$new2 = (string)($_POST['password2'] ?? '');
$st = Database::get()->prepare('SELECT password_hash FROM accounts WHERE id = ?');
$st->execute([Auth::id()]);
$hash = (string)$st->fetchColumn();
$err = null;
if (!password_verify($old, $hash)) {
$err = '当前密码不对';
} elseif ($new !== $new2) {
$err = '两次输入的新密码不一致';
} elseif (Account::validatePassword($new)) {
$err = implode(';', Account::validatePassword($new));
}
if ($err) {
View::render('auth/password', ['title' => '修改密码', 'error' => $err], 'home');
exit;
}
Account::setPassword((int)Auth::id(), $new);
flash('ok', '密码已更新,下次登录用新密码。');
redirect('home');
}
View::render('auth/password', ['title' => '修改密码'], 'home');
break;
// ==================== 首页(以我为中心) ====================
case 'home':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
// 「我的家人」= 以我为中心上下各三代那张家谱里的人。
// ⚠️ 早先只取「直接关系」(父母/配偶/子女/兄弟姐妹),于是**姻亲与祖孙都被漏掉**:
// 爱人登进来看不到「公公婆婆」,我看不到「岳父岳母」,爷爷奶奶也不在首页上。
// 这恰恰是「以个人为中心」最该体现的地方,所以改用家谱的节点集合。
$tree = Kinship::tree($graph, $me, $people, 3, 3);
$kinIds = array_diff(array_map('intval', array_keys($tree['nodes'])), [$me]);
$kin = [];
// 用 Person::all()(带账号信息)而不是 Person::map(),首页才能显示管理员/访客徽标
foreach (Person::all($familyId) as $p) {
$pid = (int)$p['id'];
if (in_array($pid, $kinIds, true)) {
$kin[$pid] = $p + ['label' => Kinship::label($graph, $me, $pid, $people)];
}
}
$birthdays = Person::upcomingBirthdays($familyId);
// 生日条上也标出称谓
foreach ($birthdays as &$b) {
$b['label'] = isset($people[(int)$b['id']])
? Kinship::label($graph, $me, (int)$b['id'], $people) : '';
// 名字优先用"我叫他什么" —— 同一个孩子,爸爸看到"儿子"、奶奶看到"小宝"
$b['shown'] = Nickname::display($me, $people[(int)$b['id']] ?? null) ?: $b['name'];
}
unset($b);
// ---- dashboard 的另外三块(用户第 13 条)----
// 未来 7 天的日程:自定义日程 + 观影 + 旅行 + 有截止时间的任务,一次汇总
$cals = Calendar::mine($familyId, $me);
$today = date('Y-m-d');
$soon = Calendar::month($familyId, $cals, $me, $today, date('Y-m-d', strtotime('+7 days')));
// 进行中的任务 + 已经过期的待办(**只有这些才值得占首页**;
// 没截止日期的待办放任务看板里看,堆到首页会变成一张长长的清单)
$board = Task::board($familyId, $me);
$tasks = $board['doing'];
foreach ($board['todo'] as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) {
$tasks[] = $t;
}
}
$memories = home_memories($familyId);
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),
], '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'];
}
unset($row);
// 按关系分组分栏。顺序用**当前用户自己的**(没自定义过就是默认顺序);
// 归到键 0 的那一堆是还没分类的人,模板单独放在最后一栏。
$grouped = [];
foreach ($rows as $row) {
$grouped[(int)($row['group_id'] ?? 0)][] = $row;
}
View::render('people/index', [
'title' => '家庭名册',
'people' => $rows,
'grouped'=> $grouped,
'groups' => PersonGroup::orderFor($familyId, $me),
'q' => $q,
'meId' => $me,
// 家里已经有人、但还不在我名册里 → 给一个「直接挑」的入口,
// 免得为了同一个人又录一遍(录两遍会变成两条记录,账号与亲属关系各存一份)
'pickable' => $q === '' ? count(PersonRoster::pickable($familyId, $me)) : 0,
], 'people');
break;
/**
* 名册卡片上的「调整分组」。
*
* 从 v5 起改的是**我自己那份名册**里的归类(person_roster.group_id),
* 不再动 persons —— 所以「我把张伟归到同事」不会让妈妈那边也变成同事。
* 单开一个路由而不是让人去「编辑成员」里改,是因为那张表很长
* (三个生日、身份证号、住址…),为了挪一个分组去翻半页是折磨。
* 而且分组**本来就只能在名册里改**(它是"我和他的关系")。
*/
case 'people/group':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改名册里的分组。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$gid = (int)($_POST['group_id'] ?? 0);
$p = Person::find($pid);
if (!$p || (int)$p['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
if (!PersonRoster::assignGroup($familyId, $me, $pid, $gid > 0 ? $gid : null)) {
flash('err', '这个人不在你的名册里,或者那个分组你用不了。');
redirect('people');
}
flash('ok', $gid > 0
? '已把「' . $p['name'] . '」归到「' . PersonGroup::name($familyId, $gid) . '」。'
: '已把「' . $p['name'] . '」改成未分类。');
redirect('people');
break;
/**
* 名册拖动排序 / 拖动换栏 —— 整版提交。
*
* 前端把每一栏(含「未分类」)的有序 id 列表发过来,服务端一次写完。
* 为什么不"移动一条算一次":拖拽的结果是一组**相对顺序**,
* 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。
* 没有 JS 也能用:卡片上的「调整分组」表单还在(只是没有拖拽那么顺手)。
*/
case 'people/reorder':
Auth::requireLogin();
// 拖动是 AJAX 发的(见 app.js)→ 出错也回 JSON 而不是 302:
// 不然那句 flash 会攒到"下一次刷新"才冒出来,看着像迟到的鬼影。
$isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest');
if (!Auth::canWrite()) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 403);
echo json_encode(['ok' => false, 'error' => 'readonly']);
exit;
}
flash('err', '你的身份是访客,不能调整名册顺序。');
redirect('people');
}
csrf_check();
$raw = (string)($_POST['layout'] ?? '');
$layout = json_decode($raw, true);
if (!is_array($layout)) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'invalid layout']);
exit;
}
flash('err', '没收到有效的顺序,请刷新页面再试一次。');
redirect('people');
}
$n = PersonRoster::applyLayout((int)Auth::familyId(), (int)Auth::personId(), $layout);
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => true, 'n' => $n], JSON_UNESCAPED_UNICODE);
exit;
}
flash('ok', $n > 0 ? '名册顺序已更新。' : '没有需要改动的。');
redirect('people');
break;
/**
* 「从家里已有的人里挑」—— 添加成员的第二个入口。
*
* 存在的理由:同一个人不该被录两遍。录两遍会变成两条 persons,
* 于是账号、亲属关系、生日各存一份,后面永远对不上。
* 所以只要人已经在这个家庭里(别人先录过了),就走这里把他**复用**进我的名册。
*/
case 'people/pick':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能往名册里加人。');
redirect('people');
}
$familyPick = PersonRoster::pickable($familyId, $me);
if ($method === 'POST') {
csrf_check();
$ids = array_map('intval', (array)($_POST['ids'] ?? []));
$gid = (int)($_POST['group_id'] ?? 0);
// 只认「确实可挑」的那些 id —— 表单可以改,不能凭它把别人家的拉进来
$ok = array_column($familyPick, 'id');
$ok = array_map('intval', $ok);
$n = 0;
foreach ($ids as $pid) {
if (!in_array($pid, $ok, true)) {
continue;
}
if (PersonRoster::ensure($familyId, $me, $pid, $gid > 0 ? $gid : null, 'self')) {
$n++;
}
}
flash($n > 0 ? 'ok' : 'err', $n > 0
? '已把 ' . $n . ' 个人加进你的名册。'
: '一个人都没加上 —— 先勾选要加的人。');
redirect('people');
}
View::render('people/pick', [
'title' => '从家里已有的人里挑',
'people' => $familyPick,
'groups' => PersonGroup::orderFor($familyId, $me),
'meId' => $me,
], 'people');
break;
/**
* 把一张名片分享到别人的名册里(需求原话:「我可以把一个人的名片,分享给一个用户,
* 则这个名片,自动出现在这个用户的名册中」)。
* 这只是"让他看得见",不改变任何资料本身;对方可以自己决定归到哪一组。
*/
case 'people/share':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能分享名片。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$to = (int)($_POST['to_id'] ?? 0);
$back = (int)($_POST['back'] ?? 0);
$p = Person::find($pid);
$t = Person::find($to);
if (!$p || !$t || (int)$p['family_id'] !== $familyId || (int)$t['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
if ($pid === $to) {
flash('err', '不用把名片分享给他自己。');
redirect('people/show', ['id' => $back > 0 ? $back : $pid]);
}
PersonRoster::share($familyId, $to, [$pid], 'shared');
flash('ok', '已把「' . $p['name'] . '」的名片放进「' . $t['name'] . '」的名册里。');
redirect('people/show', ['id' => $back > 0 ? $back : $pid]);
break;
/** 把一个人从**我的**名册里移掉(人还在名册与家谱里,只是我不再看到他) */
case 'people/forget':
Auth::requireLogin();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改名册。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$p = Person::find($pid);
if ($p && (int)$p['family_id'] === (int)Auth::familyId()) {
// v7:家谱关系带来的人不许「移走」——他在首页/家谱上照样看得见,
// 单独从名册藏掉只会把三处口径又搅回打架的状态。
// 想不再看到他,解除那条关系(名册条目会跟着自动收回)。
$src = (string)(PersonRoster::mine((int)Auth::familyId(), (int)Auth::personId())[$pid]['source'] ?? '');
if ($src === 'kin') {
flash('err', '「' . $p['name'] . '」是因为家谱关系自动进你名册的,不能单独移走;解除关系后他会自动移出。');
} else {
PersonRoster::remove((int)Auth::familyId(), (int)Auth::personId(), $pid);
flash('ok', '已把「' . $p['name'] . '」从你的名册里移走(他还在家里)。');
}
}
redirect('people');
break;
// ==================== 我自己的资料 / 个人设置 ====================
// 顶栏头像点开 →「个人设置」。访客也能改自己的资料 ——
// 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。
case 'people/me':
// 「我的资料」已并进「个人设置」(设置分两个维度:系统设置 / 个人设置)。
// 旧链接、老书签、以及别处写死的 people/me 一律跳过去,不再留第二个入口。
redirect('settings/me');
break;
case 'settings/me':
Auth::requireLogin();
$p = Person::find((int)Auth::personId());
if (!$p) {
flash('err', '找不到你自己的名册记录。');
redirect('home');
}
clear_old();
render_me_settings($p);
break;
/**
* 旧的「保存我的资料」入口。
*
* v5 起资料表单只剩一张(`templates/people/form.php`,提交到 people/update),
* 所以这两个地址不再处理表单 —— 只把还开着旧标签页的人送回个人设置,
* 免得一次提交撞上 404 让人以为"保存坏了"。
*/
case 'settings/me_save':
case 'people/me_save':
redirect('settings/me');
break;
/**
* 保存「导航条上显示哪些模块、什么顺序」(用户第 10 条,每人一份)。
*
* 表单同时可能带 `move=up:<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;
/**
* 保存模块主题色(用户第 11 条,**家庭一份**,所以只有管理员能改)。
*/
case 'settings/module_colors':
Auth::requireLogin();
csrf_check();
$familyId = (int)Auth::familyId();
if (!Auth::isAdmin()) {
flash('err', '模块主题色是全家共用的一套,只有管理员能改。');
redirect('settings/me');
}
if (!empty($_POST['reset'])) {
Module::saveColors($familyId, []);
flash('ok', '已恢复默认配色。');
redirect('settings/me', [], 'modules');
}
Module::saveColors($familyId, (array)($_POST['c'] ?? []));
flash('ok', '模块主题色已保存。刷新页面就能看到。');
redirect('settings/me', [], 'modules');
break;
// ==================== 分组的顺序 ====================
// 两套:「我自己的顺序」(只影响我看到的)与「公共分组的默认顺序」(全家兜底)。
// 靠表单里的 scope 字段区分 —— 同一个动作,落到两个地方。
case 'settings/groups/move':
Auth::requireLogin();
csrf_check();
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$id = (int)($_POST['id'] ?? 0);
$dir = ($_POST['dir'] ?? '') === 'up' ? 'up' : 'down';
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
if ($scope === 'public') {
Auth::requireAdmin();
}
$list = $scope === 'public'
? PersonGroup::forOwner($familyId, PersonGroup::PUBLIC_OWNER)
: PersonGroup::orderFor($familyId, $me);
$ids = array_map(fn($g) => (int)$g['id'], $list);
$at = array_search($id, $ids, true);
if ($at === false) {
flash('err', '找不到这个分组。');
redirect($scope === 'public' ? 'settings' : 'settings/me');
}
// 挪到边界就什么也不做(按钮在两端本来就是 disabled 的,
// 但地址栏里手敲一次也能到这儿,别让它把顺序搞乱)
$to = $dir === 'up' ? $at - 1 : $at + 1;
if ($to >= 0 && $to < count($ids)) {
[$ids[$at], $ids[$to]] = [$ids[$to], $ids[$at]];
if ($scope === 'public') {
PersonGroup::savePositions($familyId, PersonGroup::PUBLIC_OWNER, $ids);
} else {
PersonGroup::saveOrder($familyId, $me, $ids);
}
}
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
// ==================== 分组的增删改名 ====================
// 两套:公共的(管理员在系统设置里维护,全家共用)与我自己的(个人设置里,
// 只有自己看得见)。访客只读,给个明确的说法再回去,
// 而不是甩一张 403 页面(他明明是点了自己设置里的按钮)。
case 'settings/groups/add':
Auth::requireLogin();
csrf_check();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能加分组。');
redirect('settings/me');
}
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
$owner = PersonGroup::PUBLIC_OWNER;
if ($scope === 'public') {
Auth::requireAdmin();
} else {
$owner = (int)Auth::personId();
}
$res = PersonGroup::add((int)Auth::familyId(), (string)($_POST['name'] ?? ''), $owner);
flash($res['ok'] ? 'ok' : 'err', $res['ok']
? '已加上分组「' . trim((string)($_POST['name'] ?? '')) . '」。'
: (string)($res['error'] ?? '加不上。'));
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
case 'settings/groups/rename':
Auth::requireLogin();
csrf_check();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改分组。');
redirect('settings/me');
}
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
$owner = PersonGroup::PUBLIC_OWNER;
if ($scope === 'public') {
Auth::requireAdmin();
} else {
$owner = (int)Auth::personId();
}
$res = PersonGroup::rename((int)Auth::familyId(), (int)($_POST['id'] ?? 0),
(string)($_POST['name'] ?? ''), $owner);
flash($res['ok'] ? 'ok' : 'err', $res['ok']
? '分组名已改。'
: (string)($res['error'] ?? '改不了。'));
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
case 'settings/groups/delete':
Auth::requireLogin();
csrf_check();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能删分组。');
redirect('settings/me');
}
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
$owner = PersonGroup::PUBLIC_OWNER;
if ($scope === 'public') {
Auth::requireAdmin();
} else {
$owner = (int)Auth::personId();
}
$res = PersonGroup::delete((int)Auth::familyId(), (int)($_POST['id'] ?? 0), $owner);
if ($res['ok']) {
$n = (int)($res['moved'] ?? 0);
flash('ok', $n > 0
? '分组已删掉,那 ' . $n . ' 个人现在显示在「未分类」里,去名册重新归类即可。'
: '分组已删掉(那一组本来也没人)。');
} else {
flash('err', (string)($res['error'] ?? '删不了。'));
}
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
// ==================== 别人怎么称呼(只有我自己能定) ====================
case 'nickname/save':
Auth::requireLogin();
csrf_check();
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$target = (int)($_POST['target_id'] ?? 0);
$nick = (string)($_POST['nickname'] ?? '');
$t = Person::find($target);
$back = (string)($_POST['back'] ?? 'people/show');
if (!$t || (int)$t['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
Nickname::set($familyId, $me, $target, $nick);
flash('ok', trim($nick) === ''
? '已恢复成用姓名显示。'
: '记下了:你叫他「' . trim($nick) . '」。这只影响你自己看到的称呼。');
if ($back === 'settings/me' || $back === 'people/me') {
// 两个写法都认:老表单发的是 people/me(那一页现在是跳转)
redirect('settings/me');
}
redirect('people/show', ['id' => $target]);
break;
case 'people/new':
Auth::requireWrite();
clear_old();
View::render('people/form', [
'title' => '添加成员',
'person' => null,
'errors' => [],
'canEditAll' => true,
'isSelf' => false,
], 'people');
break;
case 'people/create':
Auth::requireWrite();
csrf_check();
$familyId = (int)Auth::familyId();
$d = [
'name' => trim((string)($_POST['name'] ?? '')),
'gender' => (string)($_POST['gender'] ?? ''),
'phone' => trim((string)($_POST['phone'] ?? '')),
'id_card' => trim((string)($_POST['id_card'] ?? '')),
'email' => trim((string)($_POST['email'] ?? '')),
'address' => trim((string)($_POST['address'] ?? '')),
'org' => trim((string)($_POST['org'] ?? '')),
'title' => trim((string)($_POST['title'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')),
'slogan' => trim((string)($_POST['slogan'] ?? '')),
'avatar' => '',
'kind' => 'member',
// ⚠️ 这两项必须显式从表单取回:表单里有,但漏了这一步就会被静默丢掉,
// 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
] + read_birthday_input();
$errors = Person::validate($d);
$contacts = read_contacts_input();
$ctErr = validate_contacts($contacts);
if ($ctErr !== '') {
$errors['contacts'] = $ctErr;
}
// ⚠️ 头像上传失败**不再挡住整条新增**(用户原话:「添加人员功能不能用了」——
// 早先一张 5MB 的手机原图就把整个人挡在名册外面,看起来像功能坏了)。
// 现在是:人先存进去,头像的事单独提示。
$up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
(string)($_POST['avatar_crop'] ?? ''));
$avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : '';
if ($errors) {
set_old($_POST);
View::render('people/form', [
'title' => '添加成员',
'person' => null,
'errors' => $errors,
'phoneValue' => $d['phone'],
'idCardValue'=> $d['id_card'],
'canEditAll' => true,
'isSelf' => false,
'contacts' => $contacts,
], 'people');
exit;
}
if ($up['ok']) {
$d['avatar'] = $up['file'];
}
try {
$id = Person::create($familyId, $d);
// 头像主色:上传时已算好,这里顺手存下(算不出就不写,走姓名兜底)
if ($up['ok']) {
Person::setAvatarColor($id, (string)($up['color'] ?? ''));
}
} catch (\PDOException $ex) {
set_old($_POST);
View::render('people/form', [
'title' => '添加成员',
'person' => null,
'errors' => ['phone' => '这个手机号已经在名册里了'],
'phoneValue' => $d['phone'],
'idCardValue'=> $d['id_card'],
'canEditAll' => true,
'isSelf' => false,
'contacts' => $contacts,
], 'people');
exit;
}
// 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。
// 分组:表单里选了就用它(用户第 12 条「增加名册时可选择所属分组」);
// ⚠️ 只认**我自己**的分组列表里的 id —— 表单可以改,不能凭它把人塞进
// 别人私有的分组、或根本不存在的分组里。
$gid = (int)($_POST['group_id'] ?? 0);
$okGids = array_map('intval', array_column(
PersonGroup::orderFor($familyId, (int)Auth::personId()), 'id'));
if ($gid > 0 && !in_array($gid, $okGids, true)) {
$gid = 0;
}
PersonRoster::ensure($familyId, (int)Auth::personId(), $id, $gid > 0 ? $gid : null, 'self');
Person::replaceContacts($id, $contacts);
clear_old();
if ($avatarWarn !== '') {
flash('ok', '已把「' . $d['name'] . '」加进名册。头像没有保存:' . $avatarWarn);
} else {
flash('ok', '已把「' . $d['name'] . '」加进名册。');
}
redirect('people/show', ['id' => $id]);
break;
/**
* 成员资料表单 —— **只有这一张**(需求:「编辑成员」与「改自己的资料」本来就是一件事)。
*
* 两个入口都指到这里:名册卡片/详情页的「编辑资料」、以及个人设置顶部的
* 「编辑我的资料」。区别只有一个:编辑**自己**时不要求能写(访客也能改自己的名字),
* 而编辑别人必须是能写的人。
*/
case 'people/edit':
$id = (int)($_GET['id'] ?? 0);
$isSelf = $id > 0 && $id === (int)Auth::personId();
if (!$isSelf) {
Auth::requireWrite();
}
$p = Person::find($id);
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
flash('err', '找不到这个人。');
redirect('people');
}
clear_old();
// 补充联系方式:kind=phone 存的是密文,编辑表单里要给本人(能写的人)看明文
$editContacts = Person::contactsOf($id);
foreach ($editContacts['phone'] as &$r) {
$r['value'] = Crypto::decrypt($r['value']);
}
unset($r);
View::render('people/form', [
'title' => $isSelf ? '我的资料' : '编辑成员',
'person' => $p,
'errors' => [],
'canEditAll' => Auth::canWrite(),
'isSelf' => $isSelf,
'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']),
'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']),
'contacts' => $editContacts,
], 'people');
break;
case 'people/update':
csrf_check();
$familyId = (int)Auth::familyId();
$id = (int)($_POST['id'] ?? 0);
$p = Person::find($id);
if (!$p || (int)$p['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
$isSelf = $id === (int)Auth::personId();
if (!$isSelf) {
Auth::requireWrite();
}
// 改别人(= 能写的人编名册):字段是完整的一套。
// 改自己(可能是访客):只允许动属于自己的那些 —— 与 Person::updateSelf 一一对应。
$canEditAll = Auth::canWrite();
$d = [
'name' => trim((string)($_POST['name'] ?? '')),
'gender' => (string)($_POST['gender'] ?? ''),
'phone' => $canEditAll ? trim((string)($_POST['phone'] ?? '')) : Crypto::decrypt((string)$p['phone_encrypted']),
'id_card' => $canEditAll ? trim((string)($_POST['id_card'] ?? '')) : Crypto::decrypt((string)$p['id_card_encrypted']),
'email' => trim((string)($_POST['email'] ?? '')),
'address' => trim((string)($_POST['address'] ?? '')),
'org' => trim((string)($_POST['org'] ?? '')),
'title' => trim((string)($_POST['title'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')),
'slogan' => trim((string)($_POST['slogan'] ?? '')),
'avatar' => (string)$p['avatar'],
'birth_rank' => $canEditAll ? trim((string)($_POST['birth_rank'] ?? '')) : ($p['birth_rank'] ?? ''),
'is_placeholder' => $canEditAll
? (!empty($_POST['is_placeholder']) ? 1 : 0)
: (int)($p['is_placeholder'] ?? 0),
] + read_birthday_input($p);
$errors = Person::validate($d);
$contacts = read_contacts_input();
$ctErr = validate_contacts($contacts);
if ($ctErr !== '') {
$errors['contacts'] = $ctErr;
}
// 同 people/create:头像失败不挡保存(见 create 分支里的说明)
$up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
(string)($_POST['avatar_crop'] ?? ''));
$avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : '';
if ($errors) {
set_old($_POST);
View::render('people/form', [
'title' => $isSelf ? '我的资料' : '编辑成员',
'person' => $p,
'errors' => $errors,
'canEditAll' => $canEditAll,
'isSelf' => $isSelf,
'phoneValue' => $d['phone'],
'idCardValue' => $d['id_card'],
'contacts' => $contacts,
], 'people');
exit;
}
if ($up['ok']) {
Upload::remove($familyId, (string)$p['avatar']);
$d['avatar'] = $up['file'];
// 换图就换色:新头像的主色马上覆盖旧的(算不出时保留旧值,不清空)
Person::setAvatarColor($id, (string)($up['color'] ?? ''));
}
try {
// 改自己且没有编名册的权限 → 只能动属于自己的字段(Person::updateSelf)。
// 这一条不是"防贼",是为了让访客也能改自己的名字,同时不会顺手把
// 手机号(= 登录凭据)或身份证号改掉。
if ($isSelf && !$canEditAll) {
Person::updateSelf($id, $d);
} else {
Person::update($id, $d);
}
} catch (\PDOException $ex) {
set_old($_POST);
View::render('people/form', [
'title' => $isSelf ? '我的资料' : '编辑成员',
'person' => $p,
'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'],
'canEditAll' => $canEditAll,
'isSelf' => $isSelf,
'phoneValue' => $d['phone'],
'idCardValue' => $d['id_card'],
'contacts' => $contacts,
], 'people');
exit;
}
Person::replaceContacts($id, $contacts);
if ((int)$p['id'] === (int)Auth::personId()) {
Auth::refreshAvatar((string)$d['avatar']);
// 姓名也可能改了 —— 不同步会话的话顶栏还是旧名字
$_SESSION['name'] = (string)$d['name'];
}
clear_old();
if ($avatarWarn !== '') {
flash('ok', '已保存。头像没有更换:' . $avatarWarn);
} else {
flash('ok', '已保存。');
}
redirect('people/show', ['id' => $id]);
break;
case 'people/show':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$id = (int)($_GET['id'] ?? 0);
$p = Person::findWithAccount($id, $me);
if (!$p || (int)$p['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
// 这个人在「我」眼里是谁,以及我与他之间的直接关系(可编辑的那些)
$tree = Kinship::tree($graph, $id, $people, 2, 2);
View::render('people/show', [
'title' => $p['name'],
'person' => $p,
'meId' => $me,
'people' => $people,
'graph' => $graph,
'labelToMe' => Kinship::label($graph, $me, $id, $people),
'isMe' => $id === $me,
// 以这个人为中心的两代亲属,供详情页展示
'theirTree' => $tree,
'theirRels' => Relations::forPerson($familyId, $id),
'options' => Person::options($familyId, $id, $me),
'kinReady' => Kinship::graph($familyId) !== [],
// 「我叫他什么」—— 只有我自己能改,改了也只影响我看到的
'myNickname' => $id === $me ? '' : Nickname::get($me, $id),
'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']),
'birthdays' => Birthday::describe($p),
'birthdayNext'=> Birthday::nextOccurrences($p, date('Y-m-d'), 400),
// 补充联系方式(vCard 对齐):kind=phone 的 value 是密文,模板里用 maskPhone 展示
'contacts' => Person::contactsOf($id),
// 名册(每人一份):他是不是在我的名册里、可以把他的名片分享给谁
'inMyRoster' => in_array($id, PersonRoster::visibleIds($familyId, $me), true),
], 'people');
break;
case 'people/destroy':
Auth::requireAdmin();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$p = Person::find($id);
if ($p && (int)$p['family_id'] === (int)Auth::familyId()) {
if ((int)$p['id'] === (int)Auth::personId()) {
flash('err', '不能把自己从名册里移除。');
redirect('people/show', ['id' => $id]);
}
// 不让最后一个管理员消失:系统里没人能管理家庭就锁死了
$acc = Account::findByPerson($id);
if ($acc && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。');
redirect('people/show', ['id' => $id]);
}
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
Person::delete($id);
flash('ok', '已从名册移除「' . $p['name'] . '」。');
}
redirect('people');
break;
// ==================== 账号 ====================
case 'accounts/create':
Auth::requireAdmin();
csrf_check();
$personId = (int)($_POST['person_id'] ?? 0);
$p = Person::find($personId);
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
flash('err', '找不到这个人。');
redirect('people');
}
if (Account::findByPerson($personId)) {
flash('err', '这个人的账号已经开通过了。');
redirect('people/show', ['id' => $personId]);
}
if (Crypto::decrypt((string)$p['phone_encrypted']) === '') {
flash('err', '要先填手机号,才能开通登录。');
redirect('people/show', ['id' => $personId]);
}
$pass = (string)($_POST['password'] ?? '');
$errs = Account::validatePassword($pass);
if ($errs) {
flash('err', implode(';', $errs));
redirect('people/show', ['id' => $personId]);
}
$r0 = (string)($_POST['role'] ?? '');
Account::create($personId, $pass, in_array($r0, Account::ROLES, true) ? $r0 : 'member');
// v7:多了一个「看家谱的人」——他的名册也要按树播种,一登录三处口径就是对的
PersonRoster::syncKinForFamily((int)Auth::familyId());
flash('ok', '账号已开通,用手机号就能登录。');
redirect('people/show', ['id' => $personId]);
break;
case 'accounts/role':
Auth::requireAdmin();
csrf_check();
$personId = (int)($_POST['person_id'] ?? 0);
$role = in_array(($r1 = (string)($_POST['role'] ?? '')), Account::ROLES, true) ? $r1 : 'member';
$p = Person::find($personId);
$acc = Account::findByPerson($personId);
if ($p && $acc) {
// ⚠️ 判的是 `$role !== 'admin'` 而不是 `$role === 'member'`:
// 早先只拦"降成家庭成员",于是把**最后一位管理员**降成访客能一路通过 ——
// 降完家里一个管理员都不剩,谁也进不了系统设置了。
// 允许改自己的权限(包括给自己降级),只在"会一个管理员都不剩"时拦。
if ($role !== 'admin' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
flash('err', '至少要留一位管理员 —— 把最后一位管理员降级,全家就没人能管理系统了。');
redirect('people/show', ['id' => $personId]);
}
Account::setRole((int)$acc['id'], $role);
flash('ok', '权限已更新。');
}
redirect('people/show', ['id' => $personId]);
break;
case 'accounts/status':
Auth::requireAdmin();
csrf_check();
$personId = (int)($_POST['person_id'] ?? 0);
$status = ($_POST['status'] ?? '') === 'active' ? 'active' : 'disabled';
$acc = Account::findByPerson($personId);
if ($acc) {
if ((int)$acc['person_id'] === (int)Auth::personId()) {
flash('err', '不能停用自己。');
redirect('people/show', ['id' => $personId]);
}
if ($status === 'disabled' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
flash('err', '至少要留一位管理员。');
redirect('people/show', ['id' => $personId]);
}
Account::setStatus((int)$acc['id'], $status);
flash('ok', $status === 'active' ? '账号已启用。' : '账号已停用。');
}
redirect('people/show', ['id' => $personId]);
break;
// ==================== 亲属关系 ====================
case 'kin/relate':
Auth::requireWrite();
csrf_check();
$familyId = (int)Auth::familyId();
$from = (int)($_POST['from_id'] ?? 0); // 我(或当前正在编辑的人)
$to = (int)($_POST['to_id'] ?? 0); // 对方
$kind = (string)($_POST['kind'] ?? '');
$role = (string)($_POST['role'] ?? '');
$ok = Person::find($from) && Person::find($to)
&& (int)Person::find($from)['family_id'] === $familyId
&& (int)Person::find($to)['family_id'] === $familyId;
if (!$ok) {
flash('err', '选的人不对。');
redirect('people');
}
// ⚠️⚠️ 方向极易写反,而且写反了**不会报任何错**:页面照常显示「关系已记录」,
// 只是把人记成了对方的父母 —— 家谱整个倒过来。
// 约定:表单里 from_id 是「当前打开的人」,也就是表单上写的那个「我」;
// kind=parent 表示「对方是我的父母」→ 所以我是 child、对方是 parent。
// Relations::addParent($familyId, $childId, $parentId, $role) 的第二个参数是**孩子**。
//
// ⚠️⚠️ 这里是**白名单**,不是 else 兜底。原先写成 `else { addSpouse(...) }`,
// 于是任何没被识别的 kind(大小写错、拼错、以后新增的种类)都会被**静默记成配偶**
// 且照样提示「关系已记录」——关系是错的,用户却以为成功了。
// 实测踩到:夹具传 kind=sibling(兄弟姐妹),库里落的全是 spouse,
// 于是「夫妻并排」「父母括号」这些断言看着在测,其实测的是一堆错关系。
// 兄弟姐妹本就不需要单独记 —— 有同一对父母就会自动认出来(见 Kinship::siblingIds)。
$kindMap = ['parent', 'child', 'spouse'];
if (!in_array($kind, $kindMap, true)) {
flash('err', '不认识这种关系,没有记录。兄弟姐妹不用单独填 —— 有同一对父母就会自动认出来。');
redirect('people/show', ['id' => $from]);
}
if ($kind === 'parent') {
// 对方是我的父母:我是孩子,对方是父母
$r = Relations::addParent($familyId, $from, $to, $role);
} elseif ($kind === 'child') {
// 对方是我的子女:对方是孩子,我是父母
$r = Relations::addParent($familyId, $to, $from, $role);
} else {
$r = Relations::addSpouse($familyId, $from, $to);
}
flash($r['ok'] ? 'ok' : 'err', $r['ok'] ? '关系已记录。' : $r['error']);
redirect('people/show', ['id' => $from]);
break;
case 'kin/unrelate':
Auth::requireWrite();
csrf_check();
$familyId = (int)Auth::familyId();
$id = (int)($_POST['id'] ?? 0);
$who = (int)($_POST['person_id'] ?? 0);
$kinRemoved = Relations::remove($familyId, $id);
// v7:关系没了,因这条关系自动进两端名册的条目也收回 —— 把这件事说破,
// 不然用户会以为名册里的人凭空消失了
flash('ok', $kinRemoved
? '这条关系已解除,两人名册里因此自动加入的对方也一并移出了。'
: '这条关系已解除。');
redirect('people/show', ['id' => $who]);
break;
// ==================== 访客邀请 ====================
case 'accounts/invite':
Auth::requireAdmin();
clear_old();
// ⚠️ 访客建出来是 kind='contact'(不进家谱),而 Person::all() 默认只取 'member' ——
// 早先这里没传 kind,于是"已有访客"永远列不出人来(页面看着像是从没开通过)。
View::render('people/invite', [
'title' => '邀请访客',
'people' => Person::all((int)Auth::familyId(), '', 'contact', (int)Auth::personId()),
'options' => Person::options((int)Auth::familyId()),
], 'people');
break;
case 'accounts/invite_create':
Auth::requireAdmin();
csrf_check();
$familyId = (int)Auth::familyId();
$name = trim((string)($_POST['name'] ?? ''));
$phone = trim((string)($_POST['phone'] ?? ''));
$pass = (string)($_POST['password'] ?? '');
$note = trim((string)($_POST['note'] ?? ''));
$errors = [];
if ($name === '') {
$errors['name'] = '请填写对方的名字';
}
if (!preg_match('/^\+?[0-9\-]{6,20}$/', $phone)) {
$errors['phone'] = '手机号格式看起来不对';
}
$pw = Account::validatePassword($pass);
if ($pw) {
$errors['password'] = implode(';', $pw);
}
if (!$errors) {
try {
// 访客也进名册(kind=contact,与家庭成员区分开),但不参与家谱
$pid = Person::create($familyId, [
'name' => $name, 'gender' => '', 'avatar' => '', 'phone' => $phone,
'id_card' => '', 'email' => '', 'address' => '', 'note' => $note,
'slogan' => '', 'kind' => 'contact',
]);
Account::create($pid, $pass, 'guest');
// 我邀的访客也进**我的**名册(名册是每人一份的;访客自己那份由
// Account::create 给他补上他自己)
PersonRoster::ensure($familyId, (int)Auth::personId(), $pid, null, 'self');
clear_old();
flash('ok', '访客「' . $name . '」已开通,把手机号和密码告诉他就能登录。');
redirect('accounts/invite');
} catch (\PDOException $ex) {
$errors['phone'] = '这个手机号已经有人用了';
}
}
set_old($_POST);
View::render('people/invite', [
'title' => '邀请访客',
'people' => Person::all($familyId, '', 'contact', (int)Auth::personId()),
'options' => Person::options($familyId),
'errors' => $errors,
], 'people');
break;
// ==================== 设置 ====================
case 'settings':
Auth::requireAdmin();
$dbVersion = '未知';
try {
$dbVersion = Database::get()->query('SELECT VERSION()')->fetchColumn();
} catch (\Throwable $ex) {
// 拿不到版本不影响页面
}
$rawKey = (string)fb_config('app_key', '');
$acc = Account::roleCounts();
// 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET)
$diag = $_SESSION['_oss_diag'] ?? null;
unset($_SESSION['_oss_diag']);
// 公共分组(owner_id = 0)—— 管理员在这里维护全家共用的那一套。
// 人数按"全家名册"统计(这里要看的是"这一组里全家有多少人",不是我个人那份)。
View::render('settings', [
'title' => '设置',
'family' => Family::find((int)Auth::familyId()),
'dbVersion' => $dbVersion,
'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'] ?? ''))
);
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'] ?? '')),
'attendees' => filter_family_person_ids((array)($_POST['attendees'] ?? []), $familyId),
];
$errors = [];
if ($d['title'] === '') {
$errors['title'] = '旅程名字不能为空';
} elseif ($d['starts_at'] !== '' && $d['ends_at'] !== ''
&& strcmp($d['ends_at'], $d['starts_at']) < 0) {
$errors['ends_at'] = '回来的时间不能早于出发';
}
// 封面图(编辑时换新图才动它;失败不挡保存,与观影海报同一个口径)
$oldCover = $isEdit ? (string)($t['cover_key'] ?? '') : '';
$d['cover_key'] = $oldCover;
$warns = [];
$up = Upload::image($familyId, $_FILES['cover'] ?? [], 'photo');
if ($up['ok']) {
$d['cover_key'] = (string)$up['file'];
if ($oldCover !== '' && $oldCover !== $d['cover_key']) {
\App\Storage\Storage::delete($familyId, $oldCover);
}
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = '封面图没有保存:' . $up['error'];
}
if ($errors) {
set_old($_POST);
View::render('trips/form', [
'title' => $isEdit ? '编辑旅程' : '记一趟旅程',
't' => $isEdit ? array_merge($t, $d) : null,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => $errors,
], 'trips');
exit;
}
if ($isEdit) {
Trip::update($familyId, $id, $d);
clear_old();
flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('trips/show', ['id' => $id]);
}
$newId = Trip::create($familyId, (int)Auth::personId(), $d);
clear_old();
flash('ok', '这趟旅程记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('trips/show', ['id' => $newId]);
break;
case 'trips/show':
$familyId = (int)Auth::familyId();
$meId = (int)Auth::personId();
$t = Trip::find($familyId, (int)($_GET['id'] ?? 0), $meId);
if (!$t) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
View::render('trips/show', [
'title' => (string)$t['title'],
't' => $t,
'photos' => $t['photos'],
'nodes' => Trip::nodes((int)$t['id'], $meId),
'people' => Person::roster($familyId, $meId, 'member', ''),
], 'trips');
break;
case 'trips/destroy':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
Trip::destroy($familyId, $id);
flash('ok', '这趟旅程已删除。');
redirect('trips');
break;
case 'trips/photo_add':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
$warns = attach_save_files($familyId, 'trip', $id, 'photo', (int)Auth::personId());
if ($warns) {
flash('err', '有照片没有保存:' . implode(';', $warns));
} else {
flash('ok', '照片已加上。');
}
redirect('trips/show', ['id' => $id]);
break;
case 'trips/photo_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
\App\Attachment::remove((int)($_POST['photo_id'] ?? 0));
flash('ok', '照片已删除。');
redirect('trips/show', ['id' => $id]);
break;
// ---- 行程节点(计划 / 执行双轨)----
case 'trips/node_add':
case 'trips/node_update':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
$t = Trip::find($familyId, $tripId);
if (!$t) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
$isEdit = $r === 'trips/node_update';
$nid = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
$d = [
'title' => trim((string)($_POST['title'] ?? '')),
'detail' => trim((string)($_POST['detail'] ?? '')),
'place' => trim((string)($_POST['place'] ?? '')),
'starts_at' => (string)(read_dt_input('starts_at') ?? ''),
'ends_at' => (string)(read_dt_input('ends_at') ?? ''),
'node_type' => (string)($_POST['node_type'] ?? 'other'),
'status' => (string)($_POST['status'] ?? 'planned'),
'category' => (string)($_POST['category'] ?? 'plan'),
'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId),
];
if ($d['title'] === '') {
flash('err', '这一段得有个名字。');
redirect('trips/show', ['id' => $tripId]);
}
$newId = Trip::saveNode($familyId, $tripId, $d, $isEdit ? $nid : null);
$warns = attach_save_files($familyId, 'trip_node', $newId, 'attach', (int)Auth::personId());
flash('ok', $isEdit ? '这一段已保存。' : '已加一段。'
. ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('trips/show', ['id' => $tripId]);
break;
case 'trips/node_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这趟旅程。');
redirect('trips');
}
Trip::deleteNode((int)($_POST['id'] ?? 0));
flash('ok', '这一段已删除。');
redirect('trips/show', ['id' => $tripId]);
break;
// ==================== 日程 / 日历(v12) ====================
// 自定义日程落在 calendar_events;**观影与旅行两本是派生的**(从 events 实时汇总,
// 不复制),给这两本"加日程"= 真的建一条观影 / 旅程记录。
/**
* 财务(v24 第 15 条):账本 + 账目 + 共享。
*
* 用户原话要的是「一个可以共享的家庭总账」:先有账本,账本共享给某人,
* 双方都能往里记账、都能看到全部账目,最后算出总数。
* ⚠️ 金额一律以**分**存取(见 Ledger::toCents 的注释),显示时再转元。
*/
case 'ledger':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$books = Ledger::mine($familyId, $me);
// 一本都没有 → 自动建一本(不然新用户进来一片空白,也不知道要先"创建家庭")
if (!$books) {
Ledger::ensureDefault($familyId, $me);
$books = Ledger::mine($familyId, $me);
}
$lid = (int)($_GET['book'] ?? 0);
$cur = null;
foreach ($books as $b) {
if ($lid > 0 && (int)$b['id'] === $lid) {
$cur = $b;
break;
}
}
if (!$cur) {
$cur = $books[0];
}
$entries = Ledger::entries((int)$cur['id']);
View::render('ledger/index', [
'title' => '财务',
'books' => $books,
'cur' => $cur,
'entries' => $entries,
'summary' => Ledger::summary($entries),
'shares' => Ledger::shares((int)$cur['id']),
'people' => Person::roster($familyId, $me, 'member', ''),
], 'ledger');
break;
case 'ledger/book_add':
case 'ledger/book_rename':
case 'ledger/book_del':
case 'ledger/entry_add':
case 'ledger/entry_update':
case 'ledger/entry_del':
case 'ledger/share':
case 'ledger/unshare':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$lid = (int)($_POST['ledger_id'] ?? 0);
$act = (string)($_GET['r'] ?? '');
$book = $lid > 0 ? Ledger::find($familyId, $lid, $me) : null;
if ($act === 'ledger/book_add') {
$id = Ledger::create($familyId, $me, (string)($_POST['name'] ?? ''));
flash('ok', '账本已创建。');
redirect('ledger', ['book' => $id]);
}
if (!$book) {
flash('err', '找不到这本账。');
redirect('ledger');
}
if (!Ledger::canWrite($book)) {
flash('err', '这本账你只有只读权限。');
redirect('ledger', ['book' => $lid]);
}
if ($act === 'ledger/book_rename') {
Ledger::rename($familyId, $lid, (string)($_POST['name'] ?? ''));
flash('ok', '账本已改名。');
} elseif ($act === 'ledger/book_del') {
Ledger::remove($familyId, $lid);
flash('ok', '账本已删除。');
redirect('ledger');
} elseif ($act === 'ledger/entry_add') {
Ledger::addEntry($lid, [
'kind' => (string)($_POST['kind'] ?? 'other'),
'title' => (string)($_POST['title'] ?? ''),
'amount' => (string)($_POST['amount'] ?? ''),
'note' => (string)($_POST['note'] ?? ''),
'person_id' => (int)($_POST['person_id'] ?? 0),
], $me);
flash('ok', '账目已添加。');
} elseif ($act === 'ledger/entry_update') {
Ledger::updateEntry($lid, (int)($_POST['id'] ?? 0), [
'kind' => (string)($_POST['kind'] ?? 'other'),
'title' => (string)($_POST['title'] ?? ''),
'amount' => (string)($_POST['amount'] ?? ''),
'note' => (string)($_POST['note'] ?? ''),
'person_id' => (int)($_POST['person_id'] ?? 0),
]);
flash('ok', '账目已保存。');
} elseif ($act === 'ledger/entry_del') {
Ledger::removeEntry($lid, (int)($_POST['id'] ?? 0));
flash('ok', '账目已删除。');
} elseif ($act === 'ledger/share') {
$pid = (int)($_POST['person_id'] ?? 0);
if ($pid > 0 && isset(Person::map($familyId)[$pid])) {
Ledger::setShare($lid, $pid, (string)($_POST['perm'] ?? 'read'));
flash('ok', '已共享。');
} else {
flash('err', '请选择要共享给的人。');
}
} else {
Ledger::unshare($lid, (int)($_POST['person_id'] ?? 0));
flash('ok', '已取消共享。');
}
redirect('ledger', ['book' => $lid]);
break;
/**
* 纪念日(v24 第 13 条):阴历/阳历都行;名册里每个人的生日默认可见
* (生日本身就是纪念日的一种,且**实时派生**、不复制一份)。
* 所有纪念日都会作为**全天日程**出现在日历里(见 Calendar::month 那段)。
*/
case 'days':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
View::render('days/index', [
'title' => '纪念日',
'rows' => Anniversary::all($familyId, $me),
'upcoming' => Anniversary::upcoming($familyId, $me, 60),
'people' => Person::roster($familyId, $me, 'member', ''),
], 'days');
break;
case 'days/new':
case 'days/edit':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$id = (int)($_GET['id'] ?? 0);
$editing = $id > 0 ? Anniversary::find($familyId, $id) : null;
if ($id > 0 && !$editing) {
flash('err', '找不到这条纪念日。');
redirect('days');
}
View::render('days/form', [
'title' => $id > 0 ? '编辑纪念日' : '新建纪念日',
'editing' => $editing,
'people' => Person::roster($familyId, $me, 'member', ''),
], 'days');
break;
case 'days/save':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
Anniversary::save($familyId, [
'title' => (string)($_POST['title'] ?? ''),
'repeat_type' => (string)($_POST['repeat_type'] ?? 'solar'),
'month' => (int)($_POST['month'] ?? 1),
'day' => (int)($_POST['day'] ?? 1),
'is_leap' => !empty($_POST['is_leap']),
'kind' => (string)($_POST['kind'] ?? ''),
'note' => (string)($_POST['note'] ?? ''),
'person_id' => (int)($_POST['person_id'] ?? 0),
], $id);
flash('ok', $id > 0 ? '纪念日已保存。' : '纪念日已添加。');
redirect('days');
break;
case 'days/destroy':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
Anniversary::remove($familyId, (int)($_POST['id'] ?? 0));
flash('ok', '纪念日已删除。');
redirect('days');
break;
case 'calendar':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// v24 第 4 条:月 / 周 / 天三种看法。view 只认这三个,认不出就回落月视图。
$view = (string)($_GET['view'] ?? 'month');
if (!in_array($view, ['month', 'week', 'day'], true)) {
$view = 'month';
}
$anchor = trim((string)($_GET['d'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $anchor)) {
$anchor = date('Y-m-d');
}
$ym = substr($anchor, 0, 7);
$cals = Calendar::mine($familyId, $me);
if ($view === 'month') {
$gym = trim((string)($_GET['ym'] ?? ''));
if (preg_match('/^\d{4}-\d{2}$/', $gym)) {
$ym = $gym;
}
[$from, $to] = month_grid_range($ym);
$events = Calendar::month($familyId, $cals, $me, $from, $to);
$grid = build_month_grid($ym, $events);
} else {
// 周视图 7 列、天视图 1 列,共用同一个时间网格
$cols = day_columns($anchor, $view === 'week' ? 7 : 1);
$from = $cols[0]['date'];
$to = $cols[count($cols) - 1]['date'];
$events = Calendar::month($familyId, $cals, $me, $from, $to);
$grid = build_time_grid($events, $cols);
}
View::render('calendar/index', [
'title' => '日程',
'ym' => $ym,
'view' => $view,
'anchor' => $anchor,
'cols' => $view === 'month' ? [] : ($cols ?? []),
'weeks' => $view === 'month' ? $grid : [],
'tgrid' => $view === 'month' ? null : $grid,
'cals' => $cals,
'people' => Person::roster($familyId, $me, 'member', ''),
], 'calendar');
break;
/**
* CalDAV 专用口令(v24 第 7 条后半):生成 / 重置 / 撤销。
*
* ⚠️ 明文口令只在这**一次**跳转里带回页面显示(存 $_SESSION 一次性取用),
* 库里只有 sha256。不这么做的话,要么明文落库(一旦泄露全暴露),
* 要么用户永远看不到自己刚设的花口令。
*/
case 'calendar/caldav_token':
case 'calendar/caldav_revoke':
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
if ($r === 'calendar/caldav_token') {
$_SESSION['caldav_plain'] = Auth::caldavIssue($me);
flash('ok', 'CalDAV 口令已生成(旧口令立即失效,请在客户端里更新)。');
} else {
Auth::caldavRevoke($me);
flash('ok', 'CalDAV 口令已撤销,所有已订阅的客户端会失去访问。');
}
redirect('calendar/calendars');
break;
case 'calendar/calendars':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$all = Calendar::mine($familyId, $me);
$mine = [];
$shared = [];
foreach ($all as $c) {
if ((int)$c['shared'] === 1) {
$shared[] = $c;
} else {
$mine[] = $c;
}
}
$shares = [];
foreach ($mine as $c) {
$shares[(int)$c['id']] = Calendar::shares((int)$c['id']);
}
// v24 第 7 条后半:CalDAV 专用口令的状态(明文只在刚生成那一次给用户看)
$cdHas = Auth::hasCalDav($me);
$cdPlain = (string)($_SESSION['caldav_plain'] ?? '');
unset($_SESSION['caldav_plain']);
$editing = null;
$editId = (int)($_GET['edit'] ?? 0);
if ($editId > 0) {
foreach ($mine as $c) {
if ((int)$c['id'] === $editId && (string)$c['kind'] === 'custom') {
$editing = $c;
}
}
}
View::render('calendar/calendars', [
'title' => '日历本',
'cals' => $mine,
'shared' => $shared,
'shares' => $shares,
'editing' => $editing,
'people' => Person::roster($familyId, $me, 'member', ''),
'cdHas' => $cdHas,
'cdPlain' => $cdPlain,
], 'calendar');
break;
case 'calendar/cal_add':
case 'calendar/cal_update':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$name = trim((string)($_POST['name'] ?? ''));
$color = trim((string)($_POST['color'] ?? ''));
if ($name === '') {
flash('err', '日历本得有个名字。');
redirect('calendar/calendars');
}
if ($r === 'calendar/cal_update') {
$id = (int)($_POST['id'] ?? 0);
// 只改自己的自定义本:预置的观影/旅行本不能改名换色(内容是从模块派生的)
$cal = Calendar::find($id);
if (!$cal || (int)$cal['owner_id'] !== $me || (string)$cal['kind'] !== 'custom') {
flash('err', '这本日历不能改。');
redirect('calendar/calendars');
}
Calendar::update($id, $name, $color);
flash('ok', '日历本已更新。');
} else {
Calendar::create($familyId, $me, $name, $color);
flash('ok', '日历本已新建。');
}
redirect('calendar/calendars');
break;
case 'calendar/cal_del':
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$cal = Calendar::find($id);
if (!$cal || (int)$cal['owner_id'] !== $me) {
flash('err', '这本日历不能删。');
redirect('calendar/calendars');
}
Calendar::delete($id); // 内部只删 kind='custom'
flash('ok', '日历本已删除。');
redirect('calendar/calendars');
break;
case 'calendar/cal_share':
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$cal = Calendar::find($id);
$target = (int)($_POST['person_id'] ?? 0);
if (!$cal || (int)$cal['owner_id'] !== $me || $target <= 0 || $target === $me) {
flash('err', '不能这样共享。');
redirect('calendar/calendars');
}
Calendar::setShare($id, $target, (string)($_POST['perm'] ?? 'read'));
flash('ok', '已共享。');
redirect('calendar/calendars');
break;
case 'calendar/cal_unshare':
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$cal = Calendar::find($id);
if (!$cal || (int)$cal['owner_id'] !== $me) {
flash('err', '不能这样取消共享。');
redirect('calendar/calendars');
}
Calendar::dropShare($id, (int)($_POST['person_id'] ?? 0));
flash('ok', '已取消共享。');
redirect('calendar/calendars');
break;
case 'calendar/event_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$ym = trim((string)($_POST['ym'] ?? ''));
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
$calId = (int)($_POST['calendar_id'] ?? 0);
// ⚠️ 走 mineOne 而不是 Calendar::find():要带 perm,共享本的可读写才判得对
$cal = Calendar::mineOne($familyId, $me, $calId);
if (!$cal || !Calendar::canWrite($cal, $me)) {
flash('err', '这本日历你没有写权限。');
redirect('calendar', ['ym' => $ym]);
}
// v24 第 5、6 条:起止各是一个「日期+时分秒」的完整时刻(_datetime.php 控件),
// 不再拆成 date / end_date / start_time / end_time 四格。
$startsAt = read_dt_input('starts_at');
if ($startsAt === null) {
flash('err', '开始时间不对。');
redirect('calendar', ['ym' => $ym]);
}
$endsAt = read_dt_input('ends_at');
$title = trim((string)($_POST['title'] ?? ''));
if ($title === '') {
flash('err', '日程得有个标题。');
redirect('calendar', ['ym' => $ym]);
}
$allDay = !empty($_POST['all_day']);
if ($allDay) {
// 全天日程:时刻部分一律归零(跨天的全天日程截止时刻含到当天末尾)
$startsAt = substr($startsAt, 0, 10) . ' 00:00:00';
$endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null;
}
$note = trim((string)($_POST['note'] ?? ''));
// ⚠️ 观影 / 旅行两本是派生的:往这两本里加日程 = 建一条真正的观影 / 旅程记录,
// 而不是往 calendar_events 里塞一行(那样日历里有、观影列表里没有)。
$kind = (string)$cal['kind'];
if ($kind === 'movie') {
$newId = Movie::create($familyId, $me, [
'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
'attendees' => [$me],
]);
flash('ok', '已加进观影,日历里也能看到了。');
redirect('movies/show', ['id' => $newId]);
}
if ($kind === 'trip') {
$newId = Trip::create($familyId, $me, [
'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
'attendees' => [$me],
]);
flash('ok', '已加进旅程,日历里也能看到了。');
redirect('trips/show', ['id' => $newId]);
}
$evId = Calendar::addEvent((int)$cal['id'], [
'title' => $title,
'location' => trim((string)($_POST['location'] ?? '')),
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'all_day' => $allDay,
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
'status' => (string)($_POST['status'] ?? 'confirmed'),
'transp' => !empty($_POST['transp']),
'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);
View::render('calendar/event', [
'title' => (string)$ev['title'],
'ev' => $ev,
'cal' => $cal,
], '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');
$st = Database::get()->prepare(
'SELECT e.id, e.calendar_id, 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();
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;
}
Calendar::updateEvent($id, [
'title' => trim((string)($_POST['title'] ?? '')),
'location' => trim((string)($_POST['location'] ?? '')),
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'all_day' => $allDay,
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
'status' => (string)($_POST['status'] ?? 'confirmed'),
'transp' => !empty($_POST['transp']),
'note' => trim((string)($_POST['note'] ?? '')),
]);
$warns = attach_save_files($familyId, Calendar::ATTACH, $id, 'attach', $me);
if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns));
}
flash('ok', '日程已保存。');
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
break;
case 'calendar/event_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$ym = trim((string)($_POST['ym'] ?? ''));
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
$st = Database::get()->prepare(
'SELECT e.id, e.calendar_id FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
WHERE e.id = ? AND c.family_id = ?'
);
$st->execute([$id, $familyId]);
$ev = $st->fetch();
if ($ev) {
$cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: [];
if (Calendar::canWrite($cal, $me)) {
Calendar::deleteEvent($id);
flash('ok', '日程已删除。');
} else {
flash('err', '这本日历你没有写权限。');
}
}
redirect('calendar', ['ym' => $ym]);
break;
// ==================== ics 导出 / 导入(v24 第 7 条)====================
case 'calendar/export':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$calId = (int)($_GET['id'] ?? 0);
$cal = Calendar::mineOne($familyId, $me, $calId);
if (!$cal) {
flash('err', '找不到这本日历。');
redirect('calendar');
}
$rows = Calendar::exportRows($cal, $familyId, $me);
$ics = Ics::export($rows, (string)$cal['name']);
// ⚠️ 文件名要用**本子名 + 日期**,别用中文之外的怪字符:
// Content-Disposition 的非 ASCII 文件名各家浏览器编码方式不一样,
// 最稳的做法是给一个 ASCII 兜底名(filename=)再给一个 UTF-8 的(filename*=)。
$ascii = 'calendar-' . (int)$cal['id'] . '-' . date('Ymd') . '.ics';
$utf8 = rawurlencode((string)$cal['name'] . '-' . date('Y-m-d') . '.ics');
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $ascii . '"; filename*=UTF-8\'\'' . $utf8);
header('Content-Length: ' . strlen($ics));
echo $ics;
exit;
case 'calendar/import':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$calId = (int)($_POST['calendar_id'] ?? 0);
$cal = Calendar::mineOne($familyId, $me, $calId);
if (!$cal || !Calendar::canWrite($cal, $me)) {
flash('err', '这本日历你没有写权限。');
redirect('calendar/calendars');
}
// ⚠️ 只能导进**自定义本**:观影 / 旅行两本是派生的(内容是 events / trip_nodes),
// 往里塞 calendar_events 会出现"日历里有、观影列表里没有"的孤儿。
if ((string)$cal['kind'] !== 'custom') {
flash('err', '只能往自定义日历本里导入。观影、旅行两本是从记录里自动汇总的。');
redirect('calendar/calendars');
}
if (empty($_FILES['ics']) || (int)($_FILES['ics']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
flash('err', '没收到 .ics 文件。');
redirect('calendar/calendars');
}
$text = (string)file_get_contents((string)$_FILES['ics']['tmp_name']);
$list = Ics::parse($text);
if (!$list) {
flash('err', '这个文件里没有找到任何日程(VEVENT)。');
redirect('calendar/calendars');
}
$added = 0;
$updated = 0;
foreach ($list as $v) {
if (($v['starts_at'] ?? null) === null) {
continue; // 没有 DTSTART 的 VEVENT 是坏数据,跳过而不是写成 1970 年
}
if (trim((string)$v['title']) === '') {
$v['title'] = '(无标题日程)';
}
if (Calendar::importEvent($calId, $v, $me) === 'added') {
$added++;
} else {
$updated++;
}
}
flash('ok', '导入完成:新增 ' . $added . ' 条,更新 ' . $updated . ' 条。');
redirect('calendar/calendars');
break;
case 'calendar/attach_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$aid = (int)($_POST['attach_id'] ?? 0);
$ev = Calendar::event($id);
if ($ev && Calendar::canWriteEvent($ev, $me)) {
// ⚠️ 附件 id 与宿主 id 一起判:只按 attach_id 删,就能用别的日程的
// 附件 id 把人家文件删掉(宿主对不上就静默放行是很典型的越权)。
$st = Database::get()->prepare(
'SELECT id FROM attachments WHERE id = ? AND target_type = ? AND target_id = ?'
);
$st->execute([$aid, Calendar::ATTACH, $id]);
if ($st->fetchColumn() !== false) {
Attachment::remove($aid);
flash('ok', '附件已删除。');
}
}
redirect('calendar/event', ['id' => $id]);
break;
// ==================== 资源(v12) ====================
// 挂在**某个人名下**的东西(证件 / 照片 / 账号),可共享给别人(只读或读写)。
case 'resources':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$keyword = trim((string)($_GET['keyword'] ?? ''));
$ownerId = (int)($_GET['owner'] ?? 0);
$rows = Resource::all($familyId, $me, $ownerId, $keyword);
// 附件与共享一次取齐(列表里每条都要显示"几个附件""共享给谁")
$attachs = [];
$shares = [];
foreach ($rows as $r) {
$attachs[(int)$r['id']] = \App\Attachment::list(Resource::ATTACH, (int)$r['id']);
$shares[(int)$r['id']] = Resource::shares((int)$r['id']);
}
$editing = null;
$editId = (int)($_GET['edit'] ?? 0);
if ($editId > 0) {
$editing = Resource::find($familyId, $editId, $me);
if ($editing !== null && !Resource::canWrite($editing, $me)) {
$editing = null; // 只读的不给编辑(表单照旧收起)
}
}
View::render('resources/index', [
'title' => '资源',
'rows' => $rows,
'attachs' => $attachs,
'shares' => $shares,
'editing' => $editing,
'keyword' => $keyword,
'ownerId' => $ownerId,
'people' => Person::roster($familyId, $me, 'member', ''),
], 'resources');
break;
case 'resources/create':
case 'resources/update':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$isEdit = $r === 'resources/update';
$id = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
$d = [
'title' => trim((string)($_POST['title'] ?? '')),
'content' => trim((string)($_POST['content'] ?? '')),
];
if ($d['title'] === '') {
flash('err', '资源得有个标题。');
redirect('resources');
}
$ownerId = (int)($_POST['owner_id'] ?? 0);
$map = Person::map($familyId);
if (!isset($map[$ownerId])) {
$ownerId = $me; // 表单可以改,但不能指到不存在的人身上
}
if ($isEdit) {
$old = Resource::find($familyId, $id, $me);
if (!$old || !Resource::canWrite($old, $me)) {
flash('err', '这条资源不能改。');
redirect('resources');
}
Resource::update($familyId, $id, $d);
// 允许改归属人(记错了挂在谁名下)
if ((int)$old['owner_id'] !== $ownerId) {
Database::get()->prepare('UPDATE resources SET owner_id = ? WHERE id = ? AND family_id = ?')
->execute([$ownerId, $id, $familyId]);
}
$rid = $id;
flash('ok', '资源已保存。');
} else {
$rid = Resource::create($familyId, $ownerId, $me, $d);
flash('ok', '资源已新建。');
}
$warns = attach_save_files($familyId, Resource::ATTACH, $rid, 'attach', $me);
if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns));
}
redirect('resources');
break;
case 'resources/attach_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$r0 = Resource::find($familyId, $id, $me);
if (!$r0 || !Resource::canWrite($r0, $me)) {
flash('err', '这条资源不能改。');
redirect('resources');
}
$warns = attach_save_files($familyId, Resource::ATTACH, $id, 'attach', $me);
flash($warns ? 'err' : 'ok', $warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。');
redirect('resources');
break;
case 'resources/attach_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$aid = (int)($_POST['id'] ?? 0);
// ⚠️ 要按"这条附件属于哪条资源"找到宿主再判权限:只传附件 id 的话,
// 谁都能构造一个请求删掉别人家的附件。
$st = Database::get()->prepare('SELECT target_id FROM attachments WHERE id = ? AND target_type = ?');
$st->execute([$aid, Resource::ATTACH]);
$tid = (int)$st->fetchColumn();
$r0 = $tid > 0 ? Resource::find($familyId, $tid, $me) : null;
if (!$r0 || !Resource::canWrite($r0, $me)) {
flash('err', '这个附件不能删。');
redirect('resources');
}
\App\Attachment::remove($aid);
flash('ok', '附件已删除。');
redirect('resources');
break;
case 'resources/share':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$r0 = Resource::find($familyId, $id, $me);
if (!$r0 || !Resource::canWrite($r0, $me)) {
flash('err', '这条资源不能共享。');
redirect('resources');
}
$target = (int)($_POST['person_id'] ?? 0);
if ($target > 0 && isset(Person::map($familyId)[$target])) {
Resource::setShare($id, (int)$r0['owner_id'], $target, (string)($_POST['perm'] ?? 'read'));
flash('ok', '已共享。');
}
redirect('resources');
break;
case 'resources/unshare':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$r0 = Resource::find($familyId, $id, $me);
if ($r0 && Resource::canWrite($r0, $me)) {
Resource::dropShare($id, (int)($_POST['person_id'] ?? 0));
flash('ok', '已取消共享。');
}
redirect('resources');
break;
case 'resources/destroy':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$r0 = Resource::find($familyId, $id, $me);
if (!$r0 || !Resource::canWrite($r0, $me)) {
flash('err', '这条资源不能删。');
redirect('resources');
}
Resource::destroy($familyId, $id);
flash('ok', '资源已删除。');
redirect('resources');
break;
// ==================== 任务(v12) ====================
case 'tasks':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// v24 第 10 条:看板由用户自己建(家务 / 装修 / …),顶上有个切换条
$boards = Task::boards($familyId);
$bid = (int)($_GET['board'] ?? 0);
$board = Task::board($familyId, $me, $bid);
View::render('tasks/index', [
'title' => '任务',
'board' => $board,
'boards' => $boards,
'curBoard' => $bid > 0 ? $bid : Task::ensureDefaultBoard($familyId),
'categories' => Task::categories($familyId),
'total' => count($board['todo']) + count($board['doing']),
'people' => Person::roster($familyId, $me, 'member', ''),
], 'tasks');
break;
/**
* 任务设置(v24 第 9、10 条):分类 / 看板 / 已完成的归档。
* 入口在「新建任务」旁边那个 ☰ 下拉里,与观影的 ☰ 是同一套做法。
*/
case 'tasks/settings':
$familyId = (int)Auth::familyId();
View::render('tasks/settings', [
'title' => '任务设置',
'boards' => Task::boards($familyId),
'categories' => Task::categories($familyId),
], 'tasks');
break;
/** 已完成的任务(按分类查看 / 按关键词搜) */
case 'tasks/done':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$cid = (int)($_GET['cat'] ?? 0);
$kw = trim((string)($_GET['kw'] ?? ''));
View::render('tasks/done', [
'title' => '已完成',
'rows' => Task::doneList($familyId, $me, $cid, $kw),
'categories' => Task::categories($familyId),
'cat' => $cid,
'kw' => $kw,
], 'tasks');
break;
case 'tasks/board_add':
case 'tasks/board_update':
case 'tasks/board_del':
case 'tasks/cat_add':
case 'tasks/cat_update':
case 'tasks/cat_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$nm = trim((string)($_POST['name'] ?? ''));
$act = (string)($_GET['r'] ?? '');
if ($act === 'tasks/board_add') {
if ($nm === '') {
flash('err', '看板名不能为空。');
} else {
Task::createBoard($familyId, $nm);
flash('ok', '看板已添加。');
}
} elseif ($act === 'tasks/board_update') {
if ($nm === '') {
flash('err', '看板名不能为空。');
} else {
Task::renameBoard($familyId, $id, $nm);
flash('ok', '看板已改名。');
}
} elseif ($act === 'tasks/board_del') {
Task::deleteBoard($familyId, $id);
flash('ok', '看板已删除,里头的任务挪到了默认看板。');
} elseif ($act === 'tasks/cat_add') {
if ($nm === '') {
flash('err', '分类名不能为空。');
} else {
Task::createCategory($familyId, $nm);
flash('ok', '分类已添加。');
}
} elseif ($act === 'tasks/cat_update') {
if ($nm === '') {
flash('err', '分类名不能为空。');
} else {
Task::renameCategory($familyId, $id, $nm);
flash('ok', '分类已改名。');
}
} else {
Task::deleteCategory($familyId, $id);
flash('ok', '分类已删除,用它标过的任务变成「未分类」。');
}
redirect('tasks/settings');
break;
case 'tasks/show':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$t = Task::find($familyId, (int)($_GET['id'] ?? 0), $me);
if (!$t) {
flash('err', '找不到这项任务。');
redirect('tasks');
}
// 可选的父任务 = 我能看到的**顶层**任务(不含自己)
$parents = [];
foreach (['todo', 'doing', 'done'] as $sk) {
foreach (Task::board($familyId, $me)[$sk] as $p) {
if ((int)$p['id'] !== (int)$t['id']) {
$parents[] = $p;
}
}
}
View::render('tasks/show', [
'title' => (string)$t['title'],
't' => $t,
'people' => Person::roster($familyId, $me, 'member', ''),
'parents' => $parents,
// v24 第 9、10 条:详情页的编辑表单里也能改分类与看板
'boards' => Task::boards($familyId),
'categories' => Task::categories($familyId),
], 'tasks');
break;
case 'tasks/create':
case 'tasks/update':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$isEdit = $r === 'tasks/update';
$id = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
$old = $isEdit ? Task::find($familyId, $id, $me) : null;
if ($isEdit && (!$old || !Task::canWrite($old, $me))) {
flash('err', '这项任务不能改。');
redirect('tasks');
}
$title = trim((string)($_POST['title'] ?? ''));
if ($title === '') {
flash('err', '任务得写清楚要做什么。');
redirect('tasks');
}
$d = [
'title' => $title,
'note' => trim((string)($_POST['note'] ?? '')),
'due_at' => (string)(read_due_input() ?? ''),
'repeat_rule' => trim((string)($_POST['repeat_rule'] ?? '')),
'parent_id' => (int)($_POST['parent_id'] ?? 0),
'assignee_id' => (int)($_POST['assignee_id'] ?? 0),
// v24 第 9、10 条:分类与看板。Task 侧会校验归属(不认别的家庭的 id)。
'category_id' => (int)($_POST['category_id'] ?? 0),
'board_id' => (int)($_POST['board_id'] ?? 0),
'status' => $isEdit ? (string)$old['status'] : 'todo',
];
if ($d['assignee_id'] > 0 && !isset(Person::map($familyId)[$d['assignee_id']])) {
$d['assignee_id'] = 0;
}
if ($isEdit) {
Task::update($familyId, $id, $d);
$tid = $id;
flash('ok', '任务已保存。');
} else {
$tid = Task::create($familyId, $me, $d);
foreach (filter_family_person_ids((array)($_POST['shares'] ?? []), $familyId) as $pid) {
Task::setShare($tid, $pid, 'write');
}
$warns = attach_save_files($familyId, Task::ATTACH, $tid, 'attach', $me);
if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns));
}
flash('ok', '任务已新建。');
}
redirect('tasks/show', ['id' => $tid]);
break;
case 'tasks/move':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$status = (string)($_POST['status'] ?? '');
$t = Task::find($familyId, $id, $me);
if (!$t || !Task::canWrite($t, $me)) {
flash('err', '这项任务不能改。');
redirect('tasks');
}
$before = (int)($_POST['before'] ?? 0);
// 子任务只在 todo / done 之间切(看板里没有子任务的列,给它做定位没意义)
if (!empty($t['parent_id'])) {
$status = $status === 'done' ? 'done' : 'todo';
Task::setStatus($familyId, $id, $status);
} else {
Task::moveTo($familyId, $id, $status, $before);
}
if ($status === 'done' && !empty($t['repeat_rule']) && ($t['due_at'] ?? '') !== '') {
flash('ok', '已完成,下一次的也排上了。');
} elseif ($status === 'done') {
flash('ok', '已完成。');
}
redirect('tasks/show', ['id' => $id]);
break;
case 'tasks/comment_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$t = Task::find($familyId, $id, $me);
if (!$t) {
flash('err', '找不到这项任务。');
redirect('tasks');
}
$cid = Task::addComment($id, $me, (string)($_POST['content'] ?? ''));
if ($cid > 0) {
attach_save_files($familyId, 'task_comment', $cid, 'attach', $me);
flash('ok', '留言已发表。');
} else {
flash('err', '留言是空的。');
}
redirect('tasks/show', ['id' => $id]);
break;
case 'tasks/comment_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$taskId = (int)($_POST['task_id'] ?? 0);
Task::deleteComment((int)($_POST['id'] ?? 0), $me); // 内部只删自己的
flash('ok', '留言已删除。');
redirect('tasks/show', ['id' => $taskId]);
break;
case 'tasks/attach_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$t = Task::find($familyId, $id, $me);
if (!$t || !Task::canWrite($t, $me)) {
flash('err', '这项任务不能改。');
redirect('tasks');
}
$warns = attach_save_files($familyId, Task::ATTACH, $id, 'attach', $me);
flash($warns ? 'err' : 'ok', $warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。');
redirect('tasks/show', ['id' => $id]);
break;
case 'tasks/attach_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$aid = (int)($_POST['id'] ?? 0);
$st = Database::get()->prepare('SELECT target_id FROM attachments WHERE id = ? AND target_type = ?');
$st->execute([$aid, Task::ATTACH]);
$tid = (int)$st->fetchColumn();
$t = $tid > 0 ? Task::find($familyId, $tid, $me) : null;
if (!$t || !Task::canWrite($t, $me)) {
flash('err', '这个附件不能删。');
redirect('tasks');
}
\App\Attachment::remove($aid);
flash('ok', '附件已删除。');
redirect('tasks/show', ['id' => $tid]);
break;
case 'tasks/share':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$t = Task::find($familyId, $id, $me);
if (!$t || !Task::canWrite($t, $me)) {
flash('err', '这项任务不能共享。');
redirect('tasks');
}
$target = (int)($_POST['person_id'] ?? 0);
if ($target > 0 && isset(Person::map($familyId)[$target])) {
Task::setShare($id, $target, (string)($_POST['perm'] ?? 'write'));
flash('ok', '已共享。');
}
redirect('tasks/show', ['id' => $id]);
break;
case 'tasks/unshare':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$t = Task::find($familyId, $id, $me);
if ($t && Task::canWrite($t, $me)) {
Task::dropShare($id, (int)($_POST['person_id'] ?? 0));
flash('ok', '已取消共享。');
}
redirect('tasks/show', ['id' => $id]);
break;
case 'tasks/destroy':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$t = Task::find($familyId, $id, $me);
if (!$t || !Task::canWrite($t, $me)) {
flash('err', '这项任务不能删。');
redirect('tasks');
}
Task::destroy($familyId, $id);
flash('ok', '任务已删除。');
redirect('tasks');
break;
default:
http_response_code(404);
View::render('error', [
'title' => '页面不存在',
'message' => '找不到这一页。',
], 'home');
break;
}