Files
FamilyBook/index.php
T
laoyang 6852b4ee2c v28 第 28 批(12 条):名册分组改标签 / 资源合并为两按钮 / 首页定位与主题色 / 笔记富文本双模式
其中 4 条是**上批遗漏的真 bug**(1/4/8/9),根因已逐条定位:

1. 首页日程没定位到红线 —— 真根因:刻度区间**不含当前时刻** → $showNow=false
   → 红线**整个不渲染**,而定位脚本用同一个越界比例算目标,被 clamp 到底部。
   修:把当前小时并进区间端点(现在必然落在区间内)。
2. 「看整月」→「更多日程」+ 日程主题色;3. 「手头在做」→「近期任务」、
   「任务看板」→「全部任务」+ 任务主题色(注入 --hd-c,不再写死白底)。
4. 任务复选框提示还在 —— 真根因:**JS 在点击后**给状态徽标挂 title
   (`badge.setAttribute('title',…)`),上批只去了静态渲染,静态断言永远抓不到。
5. 名册「分组」→「标签」(用户:一个人可能既是小学同学又是初中同学,没法分组):
   新增 person_tags(每人一份、一人多标签),名册改**平铺 + 顶部标签筛选**;
   🔴 迁移用**一次性哨兵**(第一版用 NOT EXISTS,实测被"用户清空标签"绕过 → 旧分组名被灌回)。
6. 名册卡片按钮加语义底色。
7. 资源四按钮 → **编辑 / 删除**两颗:三块内容进一个带页签的弹框(信息/附件/共享)。
8. 财务多选数字不变 —— 真根因:前端发 `kinds=a,b`(逗号串),服务端 `(array)` 收成
   单元素 → 求交为空 → **静默回落整本账**。修:两种形状都收。
9. 微信读书偶发很慢 —— 真根因:syncNow 扫 60 本 × 每本 1~3 次网关调用(单次超时 20s),
   全同步阻塞在列表页请求里。修:总时长预算 + 单次超时 8s + 自动/手动分批。
10. 新增全局可见的联想接口库(6 个实测可用来源)+ 三种新解析类型。
11. 导航右上角「设置」:默认进"添加网址 + 全部网址",四类设置收进 ?set=1 视图。
12. 笔记:段落/清单支持 Markdown(自研渲染器,**不允许原始 HTML**),
    实时预览走服务端(前后端只一份规则);底部附件上传;清单多级;
    列表改**定尺寸卡片网格**(溢出显示摘要、图片缩放下方);按用户要求**去掉标签侧栏**。
2026-10-08 02:20:19 +08:00

6557 lines
314 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 家书 FamilyBook · 前端控制器
*
* 路由走 query-string(index.php?r=模块/动作),不依赖 Apache 重写,
* 换服务器、换 Nginx 都不用改配置。
*/
declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Account;
use App\Attachment;
use App\Auth;
use App\Birthday;
use App\Book;
use App\BookNoteImport;
use App\BookParser;
use App\Calendar;
use App\Cinema;
use App\ContactImport;
use App\ContactImporter;
use App\Crypto;
use App\Database;
use App\Douban;
use App\Expense;
use App\Family;
use App\Geo;
use App\Home;
use App\HomeMods;
use App\Holiday;
use App\Anniversary;
use App\Ics;
use App\Kinship;
use App\Ledger;
use App\Lunar;
use App\Markdown;
use App\Movie;
use App\MovieReview;
use App\Module;
use App\MovieStats;
use App\Nav;
use App\NavFetch;
use App\NodeType;
use App\Nickname;
use App\Note;
use App\Person;
use App\PersonGroup;
use App\PersonJob;
use App\PersonNote;
use App\PersonQuery;
use App\PersonRoster;
use App\PersonTag;
use App\Pinyin;
use App\Relations;
use App\Repeat;
use App\Resource;
use App\Schema;
use App\Setting;
use App\Task;
use App\Trip;
use App\Upload;
use App\UserSetting;
use App\Vehicle;
use App\View;
use App\WeRead;
if (!Database::hasTables()) {
header('Location: install.php');
exit;
}
/**
* 一次性结构升级。
*
* ⚠️ 为什么要有这段:上线后的库不认识新加的列(birth_rank / is_placeholder)和
* 新的 guest 角色。靠人去手动 ALTER 迟早会漏,所以在每次请求的开头比一下版本号,
* 落后了就补一次 —— 平时只是一次字符串比较,几乎零成本。
*/
// 仅用于**显示**(设置页/日志里"库结构是哪一版")。判断要不要升级请看
// Schema::needsUpgrade() —— 它比的是源码指纹。
const FB_SCHEMA_VERSION = Schema::VERSION;
if (Database::hasTables()) {
$familyIdForSchema = isset($_SESSION['family_id']) ? (int)$_SESSION['family_id'] : 0;
// 登录前还没有 family_id,用首个家庭兜底(本系统是单家庭部署)
if ($familyIdForSchema === 0) {
try {
$familyIdForSchema = (int)(Database::get()->query('SELECT MIN(id) FROM families')->fetchColumn() ?: 0);
} catch (\Throwable $ex) {
$familyIdForSchema = 0;
}
}
if ($familyIdForSchema > 0) {
// ⚠️ 判据是 **Schema 源码指纹**,不是版本号 —— 见 Schema::fingerprint() 的说明。
// 版本号靠人记得改,本项目已因此翻车一次(2026-10-03:两批结构变更都没改
// VERSION → 线上判定"已是最新" → 整段升级被跳过 → Calendar::month() 500)。
// 指纹改成"改了 Schema 就跑",不再依赖任何人的记性。
if (Schema::needsUpgrade($familyIdForSchema)) {
try {
Schema::ensure();
Schema::markUpgraded($familyIdForSchema);
} catch (\Throwable $ex) {
// 升级失败不能把整站挡住;下次请求会再试。
// ⚠️ 但**必须留下痕迹**:这里曾经是空的 catch,结果线上结构升级
// 从 2026-09-30 起一次都没跑完(报错被吞、版本号永远停在 2),
// 而首页/名册都 200,看着完全正常 —— 直到「个人设置 500」才暴露。
// 一行 error_log 就能省掉几小时排查,别再删掉它。
error_log('[familybook] Schema::ensure() 失败(已跳过,下次请求重试):'
. $ex->getMessage() . ' @ ' . $ex->getFile() . ':' . $ex->getLine());
if (!empty($GLOBALS['config']['debug'])) {
throw $ex;
}
}
}
}
}
$r = isset($_GET['r']) ? trim((string)$_GET['r'], '/') : 'home';
$r = $r === '' ? 'home' : $r;
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$publicRoutes = ['auth/login'];
if (!Auth::check() && !in_array($r, $publicRoutes, true)) {
redirect('auth/login');
}
/**
* 从表单里读「三个生日」。
*
* 为什么要有 `birthday_source` 这个显式字段:
* 阳历生日与农历生日说的是**同一个人的同一天**,不能各填各的(会自相矛盾)。
* 用户手填时必然是在其中一边操作,所以让他明确"我这次填的是哪一个",
* 服务端据此**反推另一个** —— 不依赖 JS,也就不会有"JS 没跑到就静默存错"。
* 兼容:source=solar 但阳历留空、农历有值 → 按农历算(用户只填了一边)。
*/
function read_birthday_input(array $existing = []): array
{
$source = (($_POST['birthday_source'] ?? 'solar') === 'lunar') ? 'lunar' : 'solar';
$solar = (string)(read_dt_input('birthday_solar') ?? '');
$lunar = (string)(read_dt_input('birthday_lunar') ?? '');
if ($source === 'solar' && $solar === '' && $lunar !== '') {
$source = 'lunar';
}
return [
'birthday_source' => $source,
'birthday_solar' => $solar,
'birthday_lunar' => $lunar,
'birthday_lunar_leap' => !empty($_POST['birthday_lunar_leap']) ? 1 : 0,
// 库里已有的身份证生日要**带回来**:它是"只进不出"的,不能被一次提交清掉
'birthday_id_card' => (string)($existing['birthday_id_card'] ?? ''),
];
}
/**
* 从表单解析「补充联系方式」(vCard 对齐,v6 起)。
*
* 每类两个平行数组:ct_<kind>_label[] / ct_<kind>_value[] ——
* 平行数组而不是二维键名(ct[phone][0][value]),是因为 label 与 value
* 分列在两个 input 里,PHP 自动按数字补齐下标,zip 起来最直观。
*
* @return string[][][] kind => 行列表(label/value,value 保留原样,空行不丢 ——
* 交给 Person::replaceContacts 丢弃;校验失败重画表单时还要用它回填)
*/
/**
* 从表单读「工作经历」那组行(用户 2026-10-03 第 4 条)。
*
* 表单长这样(与「补充联系方式」同一套平行数组做法):
* job_org[] / job_title[] / job_rank[] / job_start[] / job_end[] / job_note[]
* 浏览器会按同一下标自动补齐,zip 起来最直观;空行由 PersonJob::replace 丢掉。
*
* start/end 用 `<input type="month">`,值就是 'YYYY-MM' —— 正好是库里存的格式,
* 不需要再做一次"日期 → 年月"的换算(少一处能写错的地方)。
*
* @return array<int,array{org:string,title:string,rank_name:string,start_ym:string,end_ym:string,note:string}>
*/
function read_jobs_input(): array
{
$org = (array)($_POST['job_org'] ?? []);
$title = (array)($_POST['job_title'] ?? []);
$rank = (array)($_POST['job_rank'] ?? []);
$start = (array)($_POST['job_start'] ?? []);
$end = (array)($_POST['job_end'] ?? []);
$note = (array)($_POST['job_note'] ?? []);
$n = max(count($org), count($title), count($rank), count($start), count($end), count($note));
$out = [];
for ($i = 0; $i < $n; $i++) {
$out[] = [
'org' => (string)($org[$i] ?? ''),
'title' => (string)($title[$i] ?? ''),
'rank_name' => (string)($rank[$i] ?? ''),
'start_ym' => (string)($start[$i] ?? ''),
'end_ym' => (string)($end[$i] ?? ''),
'note' => (string)($note[$i] ?? ''),
];
}
return $out;
}
function read_contacts_input(): array
{
$out = [];
foreach (['phone', 'email', 'address', 'url'] as $kind) {
$labels = $_POST['ct_' . $kind . '_label'] ?? [];
$values = $_POST['ct_' . $kind . '_value'] ?? [];
if (!is_array($labels) || !is_array($values)) {
$out[$kind] = [];
continue;
}
$rows = [];
$n = max(count($labels), count($values));
for ($i = 0; $i < $n; $i++) {
$rows[] = ['label' => (string)($labels[$i] ?? ''), 'value' => trim((string)($values[$i] ?? ''))];
}
$out[$kind] = $rows;
}
return $out;
}
/** 补充联系方式的宽松校验(主手机号/主邮箱的校验在 Person::validate 里) */
function validate_contacts(array $contacts): string
{
foreach ($contacts as $kind => $rows) {
foreach ($rows as $i => $row) {
$v = trim((string)($row['value'] ?? ''));
if ($v === '') {
continue;
}
$no = $i + 1;
if ($kind === 'email' && !filter_var($v, FILTER_VALIDATE_EMAIL)) {
return '第 ' . $no . ' 个补充邮箱的格式看起来不对:' . $v;
}
if ($kind === 'phone' && !preg_match('/^\+?[0-9\- ]{6,25}$/', $v)) {
return '第 ' . $no . ' 个补充电话的格式看起来不对:' . $v;
}
}
}
return '';
}
/**
* 渲染「个人设置」页(settings/me)。
*
* 从 v5 起这一页只剩两件事:**我怎么称呼家人**、**我的分组**。
* 「我的资料」表单不在这里 —— 它和「成员资料」是同一张表单
* (`templates/people/form.php`,两条入口都指过去),页面顶部给一个按钮过去。
* 这是用户明确要求的:「编辑成员」和「修改自己的资料」本来就是一件事,
* 两套表单必然填出两种口径。
*
* @param array $person 当前登录者自己的名册记录
* @param array $errors 字段名 => 错误文案(保留给"校验失败就地重画"用)
*/
function render_me_settings(array $person, array $errors = []): void
{
$familyId = (int)Auth::familyId();
$me = (int)$person['id'];
View::render('settings/me', [
'title' => '个人设置',
'person' => $person,
'errors' => $errors,
// 我怎么称呼别人(视角属性,只有我自己能定)
'nicknames' => Nickname::mineWithNames($familyId, $me),
'options' => Person::options($familyId, $me, $me),
'birthdays' => Birthday::describe($person),
// 我的分组 = 公共的 + 我自己加的,顺序是我的(user_settings.group_order)
'groups' => PersonGroup::orderFor($familyId, $me),
'groupCounts' => PersonGroup::counts($familyId, $me),
'rosterCount' => PersonRoster::countFor($familyId, $me),
// 用户 2026-10-03 第 3 条:首页上显示哪些模块、什么顺序(每人一份)
'homeOrder' => HomeMods::orderedForPicker($me),
'homeOn' => HomeMods::forPerson($me),
// 用户 2026-10-03 第 1 条:我所在的城市(决定首页那格天气)。
// 同时把"查到的是哪儿"带过去,好让用户当场确认没填错(而不是回首页才发现)。
'weatherCity' => (string)UserSetting::get($me, 'weather_city', ''),
'weatherLat' => (string)UserSetting::get($me, 'weather_lat', ''),
'weatherLon' => (string)UserSetting::get($me, 'weather_lon', ''),
'weatherAt' => Home::weatherLocation($me, $familyId),
// v24 第 22 批:DAV 通用口令(日历 CalDAV + 通讯录 CardDAV 共用一把)。
// ⚠️ 明文只从**一次性会话槽位**取、取完立刻 unset —— 用户刷新页面就看不到了,
// 这是刻意的:口令只该在那一次被复制走(页面上也这么写)。
'davHas' => Auth::hasDav($me),
'davPlain' => (string)($_SESSION['dav_plain'] ?? ''),
'davLastUsed' => Auth::davLastUsed($me),
'davBase' => rtrim((string)fb_config('base_url', ''), '/'),
// 通讯录同步范围要和名册页**完全一致**(用户 2026-10-06 第 22 批确认):
// 名册页看到谁就同步谁。这里把人数带过去,用户当场就能对上。
'davRosterN' => PersonRoster::countFor($familyId, $me),
], 'settings');
// ⚠️ 取完即清:明文口令只该被看到一次(刷新页面就没了)。
// 放在 View::render **之后** —— 早了就拿不到值了。
unset($_SESSION['dav_plain']);
}
/**
* 上传并挂一组附件到某个宿主(旅程照片 / 资源附件 / 任务附件…全走这一处)。
*
* 同时吃「单文件」与「多文件」两种表单字段:`<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
{
// v24 第 20 批 第 4 条:先把**拖动即时上传**的暂存文件认领到这个条目上。
// 用户原话:「拖动一个进去,就开始上传…最后统一保存即可。」
// 那些文件在拖动时就已经传到服务器了(upload_stage.php),但**没有挂到任何条目**;
// 保存时(这里)才真正认领 —— 于是"立刻上传"与"最后统一保存"两件事都成立。
// ⚠️ 认领放在临时文件之前:两者最终都进同一张附件表、按 position 顺序排,
// 先认领的在前(也就是"拖动加进来的先出现"),符合操作顺序。
//
// 🔴 v24 第 21 批 第 4 条:字段名从 `stage` 改成 **`stage_token`**。
// 原来叫 `stage` 时,它和行程表单里那个隐藏的 `stage`(plan/actual)
// **同名** —— PHP 解析表单会把两者合并进同一个 `$_POST['stage']`,
// 而且因为上传那侧带 `[]`(追加记号),合并结果是**数组**。
// 于是 `trips/node_update` 里 `(string)($_POST['stage'] ?? 'plan')`
// 拿到 "Array" → 不在 ['plan','actual'] 里 → 静默退回 'plan'。
// 症状:拖过图片的"实际行程"一保存就变回计划行程(不拖图时正常 → 时好时坏)。
// ⚠️ 改字段名是唯一正确的修法:同名合并是 PHP 的表单语义,
// 在路由里加"是数组就取第一个"只会把这个坑留给下一次加字段。
// 详细来龙去脉见 templates/_stage_drop.php 头部。
$staged = (array)($_POST['stage_token'] ?? []);
$stageWarns = [];
if ($staged) {
$res = \App\StagedUpload::claim($familyId, $uploaderId, $staged, $type, $targetId);
$stageWarns = (array)($res['warns'] ?? []);
}
// 顺手清掉过期没认领的暂存(不必挂定时任务)
\App\StagedUpload::sweep();
$files = [];
if (!empty($_FILES[$field]) && is_array($_FILES[$field])) {
$f = $_FILES[$field];
if (isset($f['name']) && is_array($f['name'])) {
foreach ($f['name'] as $i => $_n) {
$files[] = [
'name' => $f['name'][$i],
'type' => $f['type'][$i] ?? '',
'tmp_name' => $f['tmp_name'][$i] ?? '',
'error' => $f['error'][$i] ?? UPLOAD_ERR_NO_FILE,
'size' => $f['size'][$i] ?? 0,
];
}
} elseif (isset($f['name'])) {
$files[] = $f;
}
}
$warns = $stageWarns ?? [];
foreach ($files as $f) {
$res = \App\Attachment::save($familyId, $type, $targetId, $f, $uploaderId);
if (empty($res['ok']) && (string)($res['error'] ?? '') !== '') {
$warns[] = (string)($f['name'] ?: '附件') . ':' . $res['error'];
}
}
return $warns;
}
/**
* 月视图网格的起止日期(**含前后补白的整周**)。
*
* ⚠️ 网格必须按整周补白,否则"这个月 1 号是周三"时第一行会空出 3 格而没法画 ——
* 补白的日子要一起取日程(跨周/跨月的日程也落在网格里)。
* @return array{0:string,1:string} ['Y-m-d', 'Y-m-d']
*/
function month_grid_range(string $ym): array
{
$firstTs = strtotime($ym . '-01');
if ($firstTs === false) {
$firstTs = strtotime(date('Y-m') . '-01');
}
// ⚠️ v24 第 11 批 #6(用户原话「月视图,从周一开始。而不是周日。」):
// 这里原来是"周日开头"——$w 直接当回退天数(周日=0 → 不回退)。
// 现在改成**周一开头**:把周日(0)折算成 6、其余减 1,回退到本周一。
// ⚠️ 月网格与周视图必须用**同一套**起算规则,否则同一天在两张视图里
// 落在不同的列上,用户会以为日程"跳"了。
$wSun = (int)date('w', $firstTs); // 0=周日 … 6=周六
$w = ($wSun + 6) % 7; // 0=周一 … 6=周日
$start = strtotime('-' . $w . ' days', $firstTs);
$cells = $w + (int)date('t', $firstTs);
$rows = (int)ceil($cells / 7);
$endTs = strtotime('+' . ($rows * 7 - 1) . ' days', $start);
return [date('Y-m-d', $start), date('Y-m-d', $endTs ?: $start)];
}
/**
* 把一段日期里的日程摆成 6×7 的月网格。
*
* @return array 周 => [7 格],每格 ['date','day','in_month','is_today','events']
*/
function build_month_grid(string $ym, array $events): array
{
[$from, $to] = month_grid_range($ym);
$byDay = [];
foreach ($events as $e) {
$byDay[(string)$e['date']][] = $e;
}
$weeks = [];
$cur = $from;
for ($r = 0; $r < 6; $r++) {
$row = [];
for ($d = 0; $d < 7; $d++) {
$row[] = [
'date' => $cur,
'day' => (int)date('j', strtotime($cur)),
'in_month' => substr($cur, 0, 7) === $ym,
'is_today' => $cur === date('Y-m-d'),
'events' => $byDay[$cur] ?? [],
];
$cur = date('Y-m-d', strtotime($cur . ' +1 day'));
}
$weeks[] = $row;
if (strcmp($cur, $to) > 0) {
break;
}
}
return $weeks;
}
/**
* 周视图 / 天视图的日期列(用户 2026-10-02 第 4 条)。
*
* 周视图固定 7 天;天视图就 1 天 —— 两者共用同一个"按小时铺的时间网格",
* 只是列数不同,所以放在一个函数里。
*
* @return array<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();
}
// ⚠️ v24 第 10 批 #5(用户报「日视图点后一天/前一天没反应」):
// 这里原来**无条件**把日期吸到"这一周的周一"—— 周视图要这样(7 列从周一排),
// 但天视图只有 1 列,吸过去就等于**永远显示那一周的周一**:
// d=2026-10-04(周日)→ 列 09-28;d=2026-09-30(周三)→ 列 09-28;
// 只有 d 本来就是周一时才"看着正常"。而页头标题用的是 $anchor(正确),
// 于是症状正是"标题变了、日程区域纹丝不动"。
$start = $ts;
if ($span > 1) {
$w = (int)date('w', $ts); // 0=周日 … 6=周六
$start = strtotime('-' . (($w + 6) % 7) . ' days', $ts); // 周一开头(仅周视图)
}
$out = [];
for ($i = 0; $i < max(1, $span); $i++) {
$d = date('Y-m-d', strtotime('+' . $i . ' days', $start));
$out[] = [
'date' => $d,
'w' => (int)date('w', strtotime($d)),
'day' => (int)date('j', strtotime($d)),
'is_today' => $d === date('Y-m-d'),
];
}
return $out;
}
/**
* 把日程摆进「小时 × 日期列」的时间网格(周视图 / 天视图共用)。
*
* 与月视图的不同:这里**按时段定位**(top/height 用百分比),
* 所以"09:15 的 20 分钟会"在格子里就是一小条,而不是跟"9 点的会"挤成一样高。
*
* ⚠️ 全天日程不进时间格 —— 它们没有时段,硬塞进 0 点会变成一条压在最上面的怪东西。
* 单独挂在顶上的一行(跟群晖日历一样)。
*
* 用户 2026-10-03 第 5 条:「日程色块的高度不对,应该是从开始时间到结束时间,
* 高度随着日程的时间而不同才对。」
* 旧实现把**所有**日程都写成「30 分钟高」(`$endMin = $startMin + 30`),
* 于是三小时的会议和十五分钟的站立会同高 —— 一眼看不出"哪段时间被占住了",
* 而"时间网格"的全部意义就在于把时长画出来。
* 现在:有 ends_at 就按真实时长;没填结束才算"最短可见块"(30 分钟)。
*
* @return array{all_day:array,timed:array} all_day 按列分组;timed 按列分组,含 top/height
*/
function build_time_grid(array $events, array $cols): array
{
$dayIndex = [];
foreach ($cols as $i => $c) {
$dayIndex[(string)$c['date']] = $i;
}
$allDay = array_fill(0, count($cols), []);
$timed = array_fill(0, count($cols), []);
foreach ($events as $e) {
$ci = $dayIndex[(string)$e['date']] ?? null;
if ($ci === null) {
continue;
}
if (!empty($e['all_day'])) {
$allDay[$ci][] = $e;
continue;
}
$t = (string)($e['time'] ?? '');
// 有具体时刻才进时间格;没有时刻又不是全天的(如任务全天),按全天处理
if ($t === '' || !preg_match('/^(\d{2}):(\d{2})/', $t, $m)) {
$allDay[$ci][] = $e;
continue;
}
$startMin = ((int)$m[1]) * 60 + (int)$m[2];
// 结束时刻:有就按真实时长;没有就画一条 30 分钟的最短可见块
// (高度是 0 等于看不见,那比"画得不准"更糟)。
$et = (string)($e['end_time'] ?? '');
$endMin = null;
if ($et !== '' && preg_match('/^(\d{2}):(\d{2})/', $et, $m2)) {
$endMin = ((int)$m2[1]) * 60 + (int)$m2[2];
if ($endMin <= $startMin) {
// 结束时刻不在开始之后(跨天日程的第二天、或用户填反了)→ 当没有结束
$endMin = null;
}
}
$spanMin = $endMin === null ? 30 : max(15, $endMin - $startMin);
// ⚠️ 上限到 24:00:超出的部分已经在网格外面,硬画出去会把整列撑长
$spanMin = min($spanMin, 1440 - $startMin);
$timed[$ci][] = $e + [
'top' => round($startMin / 1440 * 100, 4),
'height' => round($spanMin / 1440 * 100, 4),
];
}
return ['all_day' => $allDay, 'timed' => $timed];
}
/**
* 读任务的「截止日期(+ 可选时刻)」。
*
* ⚠️ 只填日期、不填时刻时存 00:00:00,而 00:00 在展示层与日历里都当作**全天**
* (与日程同一个口径):"某天之前做完"本来就没有具体几点。
*/
function read_due_input(): ?string
{
// v24 第 5 条:截止时刻与全局其它地方一样走 `_datetime.php`(日期 + 时/分/秒一起选,
// 精确到秒)。下面那段 due_date / due_time 只是老表单的兜底,别删 ——
// 旧的链接、书签、脚本还在按老名字提交,删了就静默变成"没有截止日期"。
$full = read_dt_input('due_at');
if ($full !== null) {
return $full;
}
$d = trim((string)($_POST['due_date'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
return null;
}
$t = trim((string)($_POST['due_time'] ?? ''));
return $d . ' ' . (preg_match('/^\d{2}:\d{2}$/', $t) ? $t . ':00' : '00:00:00');
}
/**
* 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。
*
* ⚠️ v24(用户 2026-10-02 第 19 条):原来同一天没有命中时会**退回同一个月**,
* 标题随之变成"往年这个月" —— 但用户要的就是**往年的今天**:
* "10 月 2 日"给我看"去年 10 月 8 日去看了场电影",这根本不是同一天的事,
* 凑出来的条目看着像回忆,其实只是同月的随机噪声,反而把"往年今日"的意味毁了。
* 现在只认同一天;没命中就明说"往年的这一天还没有记录"。
*
* @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容)
*/
function home_memories(int $familyId, int $limit = 6): array
{
$today = date('Y-m-d');
$year = (int)date('Y');
$md = substr($today, 5, 5);
$rows = [];
// ① 观影:照旧(events kind=movie)
$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 = 'movie' 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, $md]);
foreach ($st->fetchAll() as $r) {
$rows[] = $r;
}
// ② 旅程:改成看**实际行程**(trip_nodes stage='actual'),不是整趟旅程。
// 用户 2026-10-05:「实际行程,都会出现在日历和首页的往年今日中」,
// 而"标记未成行 / 仍是计划"的都不该出现("就权当没有这个日程")。
// ⚠️ 用 starts_at 而不是 events.starts_at —— 实际行程的时间才是"那天真的走了"。
$sql2 = "SELECT n.id, n.title, n.starts_at, 'trip_node' AS kind,
(SELECT COUNT(*) FROM trip_node_members m WHERE m.node_id = n.id) AS n,
e.id AS trip_id, e.title AS trip_title, n.node_type
FROM trip_nodes n
JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
LEFT JOIN trip_details d ON d.event_id = e.id
WHERE n.family_id = ? AND n.stage = 'actual' AND n.starts_at IS NOT NULL
AND COALESCE(d.is_public, 1) = 1
AND YEAR(n.starts_at) < ?
AND DATE_FORMAT(n.starts_at, '%m-%d') = ?
ORDER BY n.starts_at DESC LIMIT " . (int)$limit;
$st2 = Database::get()->prepare($sql2);
$st2->execute([$familyId, $year, $md]);
foreach ($st2->fetchAll() as $r) {
// 标题带上"是哪趟旅程的哪一段",否则只看到一个"栈桥看海"不知道是何时的
$r['title'] = (string)$r['trip_title'] . ' · ' . (string)$r['title'];
$rows[] = $r;
}
// 合并后按时间倒序,取前 N
usort($rows, static fn(array $a, array $b): int => strcmp((string)$b['starts_at'], (string)$a['starts_at']));
$rows = array_slice($rows, 0, $limit);
foreach ($rows as &$r) {
$r['years_ago'] = $year - (int)substr((string)$r['starts_at'], 0, 4);
if ($r['kind'] === 'trip_node') {
$r['href'] = url('trips/show', ['id' => (int)$r['trip_id']]) . '#node-' . (int)$r['id'];
} else {
$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) {
/**
* 暂存上传(v24 第 20 批 用户第 4 条)。
*
* 用户原话:「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,
* 拖动一个进去,就开始上传,还可以继续拖动,继续上传,最后统一保存即可。」
*
* ⚠️ 真正的实现放在**独立文件** `upload_stage.php` 里,这里只做一次转发:
* · 那个端点要自己控制响应头(纯 JSON)、自己的错误处理
* (任何 PHP 警告被 display_errors 打成 HTML,前端 JSON.parse 就炸);
* · 放在独立文件里也和 media.php 同一个思路:**自成一条出入口,便于审阅**。
* ⚠️ 转发必须把这次请求的**全部**上下文原样交给它($_FILES/$_POST 是超全局,
* 自动带过去;但 __DIR__ 与相对 require 的基准会变,所以先 chdir 到站点根)。
* ⚠️ `require` 之后脚本**不会回来**(那个文件自己 exit),所以这里不必写 break;
* 但语法上留着更清楚"这里就是终点"。
*/
case 'upload/stage':
chdir(__DIR__);
require __DIR__ . '/upload_stage.php';
break;
// ==================== 登录 / 退出 ====================
case 'auth/login':
if (Auth::check()) {
redirect('home');
}
if ($method === 'POST') {
csrf_check();
$phone = trim((string)($_POST['phone'] ?? ''));
$pass = (string)($_POST['password'] ?? '');
if (Auth::login($phone, $pass)) {
redirect('home');
}
View::renderBare('login', [
'title' => '登录',
'familyName' => fb_config('site_name', '家书'),
'error' => '手机号或密码不对,或者账号还没开通/已被停用。',
'phone' => $phone,
]);
exit;
}
View::renderBare('login', [
'title' => '登录',
'familyName' => fb_config('site_name', '家书'),
]);
break;
case 'auth/logout':
if (isset($_GET['_csrf'], $_SESSION['_csrf'])
&& hash_equals($_SESSION['_csrf'], (string)$_GET['_csrf'])) {
Auth::logout();
}
redirect('auth/login');
break;
case 'auth/password':
Auth::requireLogin();
if ($method === 'POST') {
csrf_check();
$old = (string)($_POST['old_password'] ?? '');
$new = (string)($_POST['password'] ?? '');
$new2 = (string)($_POST['password2'] ?? '');
$st = Database::get()->prepare('SELECT password_hash FROM accounts WHERE id = ?');
$st->execute([Auth::id()]);
$hash = (string)$st->fetchColumn();
$err = null;
if (!password_verify($old, $hash)) {
$err = '当前密码不对';
} elseif ($new !== $new2) {
$err = '两次输入的新密码不一致';
} elseif (Account::validatePassword($new)) {
$err = implode(';', Account::validatePassword($new));
}
if ($err) {
View::render('auth/password', ['title' => '修改密码', 'error' => $err], 'home');
exit;
}
Account::setPassword((int)Auth::id(), $new);
flash('ok', '密码已更新,下次登录用新密码。');
redirect('home');
}
View::render('auth/password', ['title' => '修改密码'], 'home');
break;
// ==================== 首页(以我为中心) ====================
case 'home':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
// 「我的家人」= 以我为中心上下各三代那张家谱里的人。
// ⚠️ 早先只取「直接关系」(父母/配偶/子女/兄弟姐妹),于是**姻亲与祖孙都被漏掉**:
// 爱人登进来看不到「公公婆婆」,我看不到「岳父岳母」,爷爷奶奶也不在首页上。
// 这恰恰是「以个人为中心」最该体现的地方,所以改用家谱的节点集合。
$tree = Kinship::tree($graph, $me, $people, 3, 3);
$kinIds = array_diff(array_map('intval', array_keys($tree['nodes'])), [$me]);
$kin = [];
// 用 Person::all()(带账号信息)而不是 Person::map(),首页才能显示管理员/访客徽标
foreach (Person::all($familyId) as $p) {
$pid = (int)$p['id'];
if (in_array($pid, $kinIds, true)) {
$kin[$pid] = $p + ['label' => Kinship::label($graph, $me, $pid, $people)];
}
}
$birthdays = Person::upcomingBirthdays($familyId);
// 生日条上也标出称谓
foreach ($birthdays as &$b) {
$b['label'] = isset($people[(int)$b['id']])
? Kinship::label($graph, $me, (int)$b['id'], $people) : '';
// 名字优先用"我叫他什么" —— 同一个孩子,爸爸看到"儿子"、奶奶看到"小宝"
$b['shown'] = Nickname::display($me, $people[(int)$b['id']] ?? null) ?: $b['name'];
}
unset($b);
// ---- dashboard 的另外三块(用户第 13 条)----
// 未来 7 天的日程:自定义日程 + 观影 + 旅行 + 有截止时间的任务,一次汇总
$cals = Calendar::mine($familyId, $me);
$today = date('Y-m-d');
$soon = Calendar::month($familyId, $cals, $me, $today, date('Y-m-d', strtotime('+7 days')));
// 用户 2026-10-04 第 11 批 #1:「首页,未来七天,不显示农历」。
// ⚠️ 农历是**每天一条**的派生条目(节假日也一样),七天就是七八行"廿一 / 廿二…",
// 把真正要看的日程全挤下去了 —— 它在月视图里当"日历刻度"很有用,
// 在首页这种"接下来要做什么"的清单里纯属噪音。
// 节假日保留("这周六放假"是有信息量的),农历整类去掉。
$soon = array_values(array_filter($soon, static function (array $e): bool {
return (string)($e['kind'] ?? '') !== 'lunar';
}));
// 进行中的任务 + 已经过期的待办(**只有这些才值得占首页**;
// 没截止日期的待办放任务看板里看,堆到首页会变成一张长长的清单)
//
// ⚠️ v24 第 14 批 用户第 2 条:**按状态取,不看板取**。
// 原话:「首页上显示的任务,是和任务的状态有关,而不是和它在那个看板有关」。
// 原来调的是 Task::board() —— 它只取**默认看板**,于是建在别的看板上的
// 「进行中」永远上不了首页(用户看到的就是"我明明在做,首页却没有")。
$tasks = Task::byStatus($familyId, $me, 'doing');
foreach (Task::byStatus($familyId, $me, 'todo') as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) {
$tasks[] = $t;
}
}
$memories = home_memories($familyId);
// ---- 用户 2026-10-03 第 3 条:首页要显示哪些模块、什么顺序(每人一份)----
$homeMods = HomeMods::forPerson($me);
$moduleCards = Home::moduleCards($familyId, $me, $soon, $tasks);
// 统计信息块:随机问候 + 今日天气 + 近七天的数字
$weekEnd = date('Y-m-d', strtotime('+7 days'));
$st7 = Database::get()->prepare(
"SELECT COUNT(*) FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
WHERE c.family_id = ?
AND DATE(e.starts_at) BETWEEN ? AND ?
AND (c.owner_id = ? OR EXISTS (
SELECT 1 FROM calendar_shares sh
WHERE sh.calendar_id = c.id AND sh.person_id = ?))"
);
$st7->execute([$familyId, $today, $weekEnd, $me, $me]);
$ev7 = (int)$st7->fetchColumn();
// ⚠️ 同上(用户第 2 条):这两个数字也是**按状态、跨看板**算的。
// $board 在首页已经不再使用(它只取默认看板)—— 留着它只会让统计与
// 上面的清单对不上("清单上有 3 件,卡片却说 1 件在做")。
$todoN = count(Task::byStatus($familyId, $me, 'todo'));
$lateN = 0;
foreach ($tasks as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) {
$lateN++;
}
}
$meName = Nickname::display($me, $people[$me] ?? null) ?: (string)($people[$me]['name'] ?? '');
$greeting = Home::greeting($meName, $today, count($soon), $lateN);
// ---- v24 第 24 批 用户第 2 条:信息面板要的那几样 ----
// 「今天是几月几日?星期几?农历几月几日?如果有节气/重要节日/纪念日/生日,
// 在这里显示;紧接着下面显示今天一共有多少条日程、几件待办正在进行。」
$lunarToday = Lunar::solarToLunar((int)date('Y'), (int)date('n'), (int)date('j'));
$lunarLine = $lunarToday === null ? '' : Lunar::label(
(int)$lunarToday['year'], (int)$lunarToday['month'], (int)$lunarToday['day'],
!empty($lunarToday['is_leap']));
// 抬头要的是「农历几月几日」,不要"农历二〇二六年"那一段(面板上塞不下)
$lunarShort = $lunarToday === null ? '' : (
Lunar::monthName((int)$lunarToday['month'], !empty($lunarToday['is_leap']))
. Lunar::dayName((int)$lunarToday['day']));
$todayMarks = Home::todayMarks($familyId, $me, $today);
// 今天的日程(v24 第 24 批 用户第 3 条:左侧那块要"今天的日程,日视图那样")
// ⚠️ 与「未来七天」不同:这里**只要今天**,而且要按时间排好(日视图从上往下就是时间序)。
$todayEvents = array_values(array_filter($soon, static function (array $e) use ($today): bool {
return (string)($e['date'] ?? '') === $today;
}));
usort($todayEvents, static function (array $a, array $b): int {
// 全天事件(无时间)排最前 —— 日视图的习惯就是这样
$ta = (string)($a['time'] ?? '');
$tb = (string)($b['time'] ?? '');
if ($ta === '' && $tb !== '') { return -1; }
if ($tb === '' && $ta !== '') { return 1; }
return strcmp($ta, $tb);
});
// 天气:地点在**个人设置**里(v24 用户 2026-10-03 第 1 条),每人一份。
// 三级回落都在这一个调用里:自定义经纬度 → 内置城市表 → 不显示。
$weatherLoc = Home::weatherLocation($me, $familyId);
$weather = $weatherLoc === null ? null
: Home::weather($familyId, $weatherLoc['lat'], $weatherLoc['lon'], $weatherLoc['place']);
View::render('home', [
'title' => '首页',
'family' => Family::find($familyId),
'me' => $people[$me] ?? null,
'meId' => $me,
'meName' => $meName,
'kin' => $kin,
'allCount' => count($people),
'relCount' => Relations::count($familyId),
'birthdays' => $birthdays,
'soon' => $soon,
'todayEvents'=> $todayEvents,
'tasks' => $tasks,
'memories' => $memories,
// 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页)
'mySlogan' => trim((string)($people[$me]['slogan'] ?? '')),
'myNicknames'=> Nickname::mapFor($me),
// 用户第 3 条新增
'greeting' => $greeting,
'weekday' => Home::weekday($today),
'weather' => $weather,
'stat7' => ['events' => $ev7, 'todos' => $todoN, 'overdue' => $lateN],
// v24 第 24 批:信息面板
'todayLabel' => date('n 月 j 日'),
'lunarShort' => $lunarShort,
'lunarLine' => $lunarLine,
'todayMarks' => $todayMarks,
'today' => $today,
'homeModules'=> $homeMods,
'moduleCards'=> $moduleCards,
], 'home');
break;
// ==================== 家谱(以我为中心) ====================
case 'kin':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
// ⚠️ v15:家谱模板不再吃 Kinship::tree() 的产物(那套 units/generations 是给
// 旧画法用的),改由模板里调 Kinship::familyMap() 现算。这里就不再白算一遍。
// v24 第 12 条:可以按某个人为中心重画(姻亲不在主图里时点卡片上的入口进来)。
// ⚠️ 「看的是谁的家谱」与「我登录着」是两件事:$meId 决定称呼视角,$focusId 决定画谁的树。
$focusId = (int)($_GET['focus'] ?? 0);
if ($focusId <= 0 || !isset($people[$focusId])) {
$focusId = $me;
}
View::render('kin/tree', [
'title' => $focusId === $me ? '我的家谱' : ((string)($people[$focusId]['name'] ?? '') . ' 的家谱'),
'meId' => $me,
'focusId' => $focusId,
'people' => $people,
'graph' => $graph,
], 'kin');
break;
// ==================== 名册 ====================
case 'people':
$q = trim((string)($_GET['q'] ?? ''));
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// ⚠️ 用 roster() 而不是 all():名册是「每人一份」——
// 只列我加的人 + 别人分享给我的(v5 起,见 App\PersonRoster)。
$rows = Person::roster($familyId, $me, 'member', $q);
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
// 名册里也标出「我该怎么称呼他」
foreach ($rows as &$row) {
$row['label'] = Kinship::label($graph, $me, (int)$row['id'], $people);
$row['shown'] = Nickname::display($me, $row) ?: (string)$row['name'];
// 用户 2026-10-03 第 3 条:列表要显示手机号(密文解密)与生日。
// 解密与生日格式化统一走 PersonQuery(见该类的类注释:
// 名册 / 挑人 / 高级搜索必须**同一套判据**,否则同一句搜索在不同页面结果不同)。
$row['phone_plain'] = PersonQuery::phonePlain($row);
// 用户 2026-10-04 第 3 条:阴历和阳历**都**列出来,且只显示月和日
$row['birthdays'] = PersonQuery::birthdaysOf($row);
// 首字母供前端"输入即筛选"用(filter.js 对 data-initials 做子序列匹配)
$row['initials'] = Pinyin::initials((string)$row['name'] . ' ' . (string)($row['my_nickname'] ?? ''));
}
unset($row);
// 🔴🔴 v28 用户第 5 条:「名册的分组,用下来不太好,我感觉应该改成打标签,
// 就是说,一个人,他可能并不是只在一个分组,例如,他既是我的小学同学,
// 又是我的初中同学,就没法分组,改成标签,似乎更合理。」
// ⇒ 不再**按分组分栏**(`$grouped`),改成:
// · 名册**平铺**成一张列表(带拖动排序,用户明确要求保留);
// · 顶部一条**标签筛选**(`?tag=xxx`),点哪个看哪个;
// · 每张卡片上挂着他自己的那几个标签(一个人可以有好几个)。
// ⚠️ 仍然传 `groups`:这就是"标签的候选来源"(用户可以在卡片上选已有的标签),
// 它本身没废 —— 只是从"分栏依据"降级成"可选的标签词表"。
$tagFilter = trim((string)($_GET['tag'] ?? ''));
$tagsOf = PersonTag::mapFor($familyId, $me);
// 按标签筛选:留在这个标签里的人(`?tag=` 为空 = 全部)
if ($tagFilter !== '') {
$rows = array_values(array_filter($rows,
static fn(array $r): bool => in_array($tagFilter, $tagsOf[(int)$r['id']] ?? [], true)));
}
foreach ($rows as &$row) {
$row['tags'] = $tagsOf[(int)$row['id']] ?? [];
}
unset($row);
// v24 第 9 批 #5:千人名册的 HTML 有 3~4 MB。本机 php -S 与部分虚拟机
// 都不会替 PHP 输出开 gzip,原样吐 3.8 MB 又慢又费流量;
// 这里在渲染前挂 ob_gzhandler(自动按 Accept-Encoding 协商,
// 不支持 gzip 的客户端拿到的还是原文)。压 3.8 MB 约 60~90ms,
// 换来 10 倍体积差;media.php / dav 的二进制输出不走这条路。
if (($_SERVER['HTTP_ACCEPT_ENCODING'] ?? '') !== ''
&& stripos((string)$_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== false) {
ob_start('ob_gzhandler');
}
View::render('people/index', [
'title' => '家庭名册',
'people' => $rows,
// v28:`$grouped`(按分栏分堆)已不再需要 —— 名册平铺(见上面注释)。
// 不再传它,模板也不再读(多传一个变量迟早有人用它,把分栏又带回来)。
'groups' => PersonGroup::orderFor($familyId, $me),
// v28 用户第 5 条:标签(筛选条 + 卡片上的标签 + 编辑标签时的候选)
'tagCloud' => PersonTag::cloud($familyId, $me),
'tagFilter' => $tagFilter,
'q' => $q,
'meId' => $me,
// 家里已经有人、但还不在我名册里 → 给一个「直接挑」的入口,
// 免得为了同一个人又录一遍(录两遍会变成两条记录,账号与亲属关系各存一份)
'pickable' => $q === '' ? count(PersonRoster::pickable($familyId, $me)) : 0,
// v24 第 22 批:CardDAV 那张卡要知道"口令配了没有"(决定显示"去生成"还是"去管理")。
// ⚠️ 这里**不传明文** —— 明文只走一次性会话槽位、只在「个人设置」那页显示一次;
// 名册页能反复看到明文就等于口令多了一个泄露面。
'davHas' => \App\Auth::hasDav($me),
], 'people');
break;
/**
* 名册卡片上的「调整分组」。
*
* 从 v5 起改的是**我自己那份名册**里的归类(person_roster.group_id),
* 不再动 persons —— 所以「我把张伟归到同事」不会让妈妈那边也变成同事。
* 单开一个路由而不是让人去「编辑成员」里改,是因为那张表很长
* (三个生日、身份证号、住址…),为了挪一个分组去翻半页是折磨。
* 而且分组**本来就只能在名册里改**(它是"我和他的关系")。
*/
case 'people/group':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// v24 第 25 批 用户第 1 条:「名册模块在列表中,对列表中的某一个人进行分组,
// 也不应该刷新界面,而是应该动态调整页面。」
// → 与 people/reorder 同一套做法:**AJAX 请求回 JSON**(前端就地挪卡片),
// 普通表单提交仍走 302 兜底(没有 JS 时照样能用)。
$isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest');
if (!Auth::canWrite()) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 403);
echo json_encode(['ok' => false, 'error' => 'readonly',
'msg' => '你的身份是访客,不能改名册里的分组。'], JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', '你的身份是访客,不能改名册里的分组。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$gid = (int)($_POST['group_id'] ?? 0);
$p = Person::find($pid);
if (!$p || (int)$p['family_id'] !== $familyId) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 404);
echo json_encode(['ok' => false, 'msg' => '找不到这个人。'], JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', '找不到这个人。');
redirect('people');
}
if (!PersonRoster::assignGroup($familyId, $me, $pid, $gid > 0 ? $gid : null)) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 200);
echo json_encode(['ok' => false,
'msg' => '这个人不在你的名册里,或者那个分组你用不了。'],
JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', '这个人不在你的名册里,或者那个分组你用不了。');
redirect('people');
}
$gname = $gid > 0 ? PersonGroup::name($familyId, $gid) : '';
$msg = $gid > 0
? '已把「' . $p['name'] . '」归到「' . $gname . '」。'
: '已把「' . $p['name'] . '」改成未分类。';
if ($isAjax) {
// ⚠️ 前端需要**分组名**去改那颗按钮上的文字("改成未分类"时是空串)
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'pid' => $pid,
'gid' => $gid,
'gname' => $gname,
'msg' => $msg,
], JSON_UNESCAPED_UNICODE);
exit;
}
flash('ok', $msg);
redirect('people');
break;
/**
* 名册卡片上的「标签」(v28 用户第 5 条:分组 → 标签)。
*
* 与 people/group 同一套做法(AJAX 回 JSON 就地更新 / 普通提交 302 兜底),
* 只是把"选一个分组"换成"整组替换他的标签"。
*
* ⚠️ 标签是**每人一份**的(owner_id = 我),改它只影响我自己的名册 ——
* 我给他打「球友」不该让全家的名册都多出这个词。
*/
case 'people/tags':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest');
if (!Auth::canWrite()) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 403);
echo json_encode(['ok' => false, 'msg' => '你的身份是访客,不能改名册标签。'],
JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', '你的身份是访客,不能改名册标签。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$p = Person::find($pid);
if (!$p || (int)$p['family_id'] !== $familyId) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 404);
echo json_encode(['ok' => false, 'msg' => '找不到这个人。'], JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', '找不到这个人。');
redirect('people');
}
// ⚠️ 只允许给**在我名册里的人**打标签 ——
// 否则会给一份"看不见的名册条目"写上标签,用户永远看不到、也删不掉。
if (!isset(PersonRoster::visibleIds($familyId, $me)[$pid])) {
$msg = '这个人不在你的名册里。';
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 200);
echo json_encode(['ok' => false, 'msg' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', $msg);
redirect('people');
}
$saved = PersonTag::setFor($familyId, $me, $pid, (string)($_POST['tags'] ?? ''));
$msg = $saved
? '标签已改成:' . implode('、', array_map(static fn($t): string => '#' . $t, $saved)) . '。'
: '标签已清空。';
if ($isAjax) {
// ⚠️ 前端要用这个数组去改卡片上那颗按钮的文字(空数组 = 显示「标签」)
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'pid' => $pid,
'tags' => $saved,
'msg' => $msg,
], JSON_UNESCAPED_UNICODE);
exit;
}
flash('ok', $msg);
redirect('people');
break;
/**
* 名册拖动排序 —— 整版提交。
*
* 🔴 v28:名册已**平铺**(用户第 5 条:分组→标签),所以这里只剩"排序"这一件事,
* 不再有"换栏"。前端发上来的是一个不含 `g` 的栏(见 app.js 的 save),
* 服务端 `applyLayout` 据此只更新 sort_order、不碰 group_id。
*
* 前端把有序 id 列表发过来,服务端一次写完。
* 为什么不"移动一条算一次":拖拽的结果是一组**相对顺序**,
* 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。
*/
case 'people/reorder':
Auth::requireLogin();
// 拖动是 AJAX 发的(见 app.js)→ 出错也回 JSON 而不是 302:
// 不然那句 flash 会攒到"下一次刷新"才冒出来,看着像迟到的鬼影。
$isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest');
if (!Auth::canWrite()) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 403);
echo json_encode(['ok' => false, 'error' => 'readonly']);
exit;
}
flash('err', '你的身份是访客,不能调整名册顺序。');
redirect('people');
}
csrf_check();
$raw = (string)($_POST['layout'] ?? '');
$layout = json_decode($raw, true);
if (!is_array($layout)) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'invalid layout']);
exit;
}
flash('err', '没收到有效的顺序,请刷新页面再试一次。');
redirect('people');
}
$n = PersonRoster::applyLayout((int)Auth::familyId(), (int)Auth::personId(), $layout);
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => true, 'n' => $n], JSON_UNESCAPED_UNICODE);
exit;
}
flash('ok', $n > 0 ? '名册顺序已更新。' : '没有需要改动的。');
redirect('people');
break;
/**
* 名册导入(v24 用户 2026-10-03 第 1 条)。
*
* 用户原话:「需要增加一个导入功能,可以导入常见的联系人格式,例如 vcf 等。
* 导入时,如果名字或手机号在已有的名册中存在,则导入其他信息,
* 如果这些信息在系统中已经存在,则只导入其他信息。」
*
* 流程:上传文件 → ContactImport::parse()(纯解析)→ ContactImporter::run()(判重+落库)。
* 判重与"只补空不覆盖"的规则都在 ContactImporter 里,这里只负责收文件、报告结果。
*/
case 'people/import':
Auth::requireWrite();
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if ($method !== 'POST') {
View::render('people/import', [
'title' => '导入联系人',
'groups' => PersonGroup::orderFor($familyId, $me),
'report' => null,
], 'people');
break;
}
csrf_check();
$f = $_FILES['contacts'] ?? null;
if (!$f || (int)($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
flash('err', '没收到文件(或上传失败)。');
redirect('people/import');
}
// ⚠️ 不去校验扩展名/类型:手机导出的 vcf 常被改名成 .txt 或 .csv,
// 按扩展名拦会把正常文件挡在外面。判据用**内容**(见 ContactImport::parse)。
$raw = (string)file_get_contents((string)$f['tmp_name']);
if (trim($raw) === '') {
flash('err', '文件是空的。');
redirect('people/import');
}
$records = ContactImport::parse($raw, (string)($f['name'] ?? ''));
if (!$records) {
flash('err', '没从这份文件里认出任何联系人。支持 .vcf(vCard)与 .csv/.txt'
. '(表头含 姓名/手机/邮箱 即可)。');
redirect('people/import');
}
$groupId = (int)($_POST['group_id'] ?? 0);
$res = ContactImporter::run($familyId, $me, $records, $groupId);
$msg = '导入完成:新增 ' . $res['added'] . ' 人,补充信息 ' . $res['updated'] . ' 人';
if ($res['skipped'] > 0) {
$msg .= ',' . $res['skipped'] . ' 条无需改动';
}
flash('ok', $msg . '。');
View::render('people/import', [
'title' => '导入联系人',
'groups' => PersonGroup::orderFor($familyId, $me),
'report' => $res,
], 'people');
break;
/**
* 「从家里已有的人里挑」—— 添加成员的第二个入口。
*
* 存在的理由:同一个人不该被录两遍。录两遍会变成两条 persons,
* 于是账号、亲属关系、生日各存一份,后面永远对不上。
* 所以只要人已经在这个家庭里(别人先录过了),就走这里把他**复用**进我的名册。
*/
case 'people/pick':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能往名册里加人。');
redirect('people');
}
$familyPick = PersonRoster::pickable($familyId, $me);
if ($method === 'POST') {
csrf_check();
$ids = array_map('intval', (array)($_POST['ids'] ?? []));
$gid = (int)($_POST['group_id'] ?? 0);
// 只认「确实可挑」的那些 id —— 表单可以改,不能凭它把别人家的拉进来
$ok = array_column($familyPick, 'id');
$ok = array_map('intval', $ok);
$n = 0;
foreach ($ids as $pid) {
if (!in_array($pid, $ok, true)) {
continue;
}
if (PersonRoster::ensure($familyId, $me, $pid, $gid > 0 ? $gid : null, 'self')) {
$n++;
}
}
flash($n > 0 ? 'ok' : 'err', $n > 0
? '已把 ' . $n . ' 个人加进你的名册。'
: '一个人都没加上 —— 先勾选要加的人。');
redirect('people');
}
View::render('people/pick', [
'title' => '从家里已有的人里挑',
'people' => $familyPick,
'groups' => PersonGroup::orderFor($familyId, $me),
'meId' => $me,
], 'people');
break;
/**
* 把一张名片分享到别人的名册里(需求原话:「我可以把一个人的名片,分享给一个用户,
* 则这个名片,自动出现在这个用户的名册中」)。
* 这只是"让他看得见",不改变任何资料本身;对方可以自己决定归到哪一组。
*/
case 'people/share':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能分享名片。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$to = (int)($_POST['to_id'] ?? 0);
$back = (int)($_POST['back'] ?? 0);
$p = Person::find($pid);
$t = Person::find($to);
if (!$p || !$t || (int)$p['family_id'] !== $familyId || (int)$t['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
if ($pid === $to) {
flash('err', '不用把名片分享给他自己。');
redirect('people/show', ['id' => $back > 0 ? $back : $pid]);
}
PersonRoster::share($familyId, $to, [$pid], 'shared');
flash('ok', '已把「' . $p['name'] . '」的名片放进「' . $t['name'] . '」的名册里。');
redirect('people/show', ['id' => $back > 0 ? $back : $pid]);
break;
/** 把一个人从**我的**名册里移掉(人还在名册与家谱里,只是我不再看到他) */
case 'people/forget':
Auth::requireLogin();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改名册。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$p = Person::find($pid);
if ($p && (int)$p['family_id'] === (int)Auth::familyId()) {
// v7:家谱关系带来的人不许「移走」——他在首页/家谱上照样看得见,
// 单独从名册藏掉只会把三处口径又搅回打架的状态。
// 想不再看到他,解除那条关系(名册条目会跟着自动收回)。
$src = (string)(PersonRoster::mine((int)Auth::familyId(), (int)Auth::personId())[$pid]['source'] ?? '');
if ($src === 'kin') {
flash('err', '「' . $p['name'] . '」是因为家谱关系自动进你名册的,不能单独移走;解除关系后他会自动移出。');
} else {
PersonRoster::remove((int)Auth::familyId(), (int)Auth::personId(), $pid);
flash('ok', '已把「' . $p['name'] . '」从你的名册里移走(他还在家里)。');
}
}
redirect('people');
break;
// ==================== 我自己的资料 / 个人设置 ====================
// 顶栏头像点开 →「个人设置」。访客也能改自己的资料 ——
// 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。
case 'people/me':
// 「我的资料」已并进「个人设置」(设置分两个维度:系统设置 / 个人设置)。
// 旧链接、老书签、以及别处写死的 people/me 一律跳过去,不再留第二个入口。
redirect('settings/me');
break;
case 'settings/me':
Auth::requireLogin();
$p = Person::find((int)Auth::personId());
if (!$p) {
flash('err', '找不到你自己的名册记录。');
redirect('home');
}
clear_old();
render_me_settings($p);
break;
/**
* 旧的「保存我的资料」入口。
*
* v5 起资料表单只剩一张(`templates/people/form.php`,提交到 people/update),
* 所以这两个地址不再处理表单 —— 只把还开着旧标签页的人送回个人设置,
* 免得一次提交撞上 404 让人以为"保存坏了"。
*/
case 'settings/me_save':
case 'people/me_save':
redirect('settings/me');
break;
/**
* 保存「导航条上显示哪些模块、什么顺序」(用户第 10 条,每人一份)。
*
* 表单同时可能带 `move=up:<key>` / `move=down:<key>`(▲▼ 按钮)—— 那是
* **不用 JS 也能排序**的兜底:先在当前顺序里跟邻居换一位,再一起存。
* 拖动则由前端直接搬 DOM(每个 <li> 自带 order[],搬 li 就等于搬顺序)。
*/
case 'settings/nav_save':
Auth::requireLogin();
csrf_check();
$me = (int)Auth::personId();
$order = array_values(array_map('strval', (array)($_POST['order'] ?? [])));
$on = array_map('strval', (array)($_POST['on'] ?? []));
// ⚠️ 只收认识的 key,且**按 order 的顺序**取交集(顺序就是用户拖出来的顺序)
$keys = array_values(array_filter(
$order,
static fn($k) => Module::exists($k) && in_array($k, $on, true)
));
$move = (string)($_POST['move'] ?? '');
if (strpos($move, ':') !== false) {
[$dir, $key] = explode(':', $move, 2);
$dir = $dir === 'up' ? 'up' : 'down';
// ▲▼ 作用于**全部模块**的排列(含未勾选的),否则关掉的那些会被挤走
$all = array_values(array_filter($order, static fn($k) => Module::exists($k)));
foreach (Module::keys() as $k) {
if (!in_array($k, $all, true)) {
$all[] = $k;
}
}
$i = array_search($key, $all, true);
if ($i !== false) {
$j = $dir === 'up' ? (int)$i - 1 : (int)$i + 1;
if ($j >= 0 && $j < count($all)) {
[$all[$i], $all[$j]] = [$all[$j], $all[$i]];
}
}
$keys = array_values(array_filter($all, static fn($k) => in_array($k, $on, true)));
}
if (!$keys) {
$keys = Module::DEFAULT_NAV; // 一个都不勾 = 没配过,退回默认(不然导航条会空掉)
}
Module::saveNav($me, $keys);
flash('ok', '导航条已更新。');
redirect('settings/me', [], 'modules');
break;
/**
* 保存「首页上显示哪些模块、什么顺序」(用户 2026-10-03 第 3 条,每人一份)。
*
* 与导航条那套(settings/nav_save)**同一套交互**:勾选 + 拖动/▲▼ 排序。
* 用同一个表单字段名(order[] / on[] / move),前端那段拖拽脚本也就能共用。
*/
case 'settings/home_save':
Auth::requireLogin();
csrf_check();
$me = (int)Auth::personId();
$order = array_values(array_map('strval', (array)($_POST['order'] ?? [])));
$on = array_map('strval', (array)($_POST['on'] ?? []));
// 「▲▼」按钮(没有 JS 也能排序):在当前顺序里跟邻居换一位
$move = (string)($_POST['move'] ?? '');
if ($move !== '' && strpos($move, ':') !== false) {
[$dir, $key] = explode(':', $move, 2);
$all = HomeMods::orderedForPicker($me);
$pos = array_search($key, $all, true);
if ($pos !== false) {
$swap = $dir === 'up' ? $pos - 1 : $pos + 1;
if ($swap >= 0 && $swap < count($all)) {
[$all[$pos], $all[$swap]] = [$all[$swap], $all[$pos]];
}
// 换完之后 order 用新顺序、on 保持原勾选状态
$order = $all;
}
}
$keys = array_values(array_filter(
$order,
static fn($k) => Module::exists($k) && in_array($k, $on, true)
));
HomeMods::save($me, $keys);
flash('ok', '首页显示已保存。');
redirect('settings/me', [], 'home-mods');
break;
/**
* 保存「我所在的城市」(v24 用户 2026-10-03 第 1 条)—— **每人一份**。
*
* 用户原话:「天气这个这样吧,让用户在个人设置中填写自己的城市。」
*
* 三段式:
* ① 填了城市名 → 用内置表 app/Geo 查坐标(不联网,见 Geo 的类注释);
* 查不到就**明说**查不到,不猜(猜错城市比不显示天气更糟)。
* ② 内置表没有的地方 → 允许直接填经纬度(镇 / 村 / 海外)。
* ③ 都不填 → 首页不显示天气那一块(不是错误,只是没配)。
*
* ⚠️ 存的键挂在 **user_settings**(每人一份)而不是家庭 settings 表:
* 一家人可能分处两个城市(孩子在外地),天气是"我看到的那一份天空",
* 家庭级的设置一改全家都变,正好是这里要避免的。
*/
case 'settings/weather':
Auth::requireLogin();
csrf_check();
$me = (int)Auth::personId();
$city = trim((string)($_POST['city'] ?? ''));
$latIn = trim((string)($_POST['lat'] ?? ''));
$lonIn = trim((string)($_POST['lon'] ?? ''));
UserSetting::set($me, 'weather_city', mb_substr($city, 0, 30, 'UTF-8'));
// 坐标只收合法数字,非法值一律当"没填" ——
// 一个手滑的字母会让首页每次请求都白白打一次外部接口。
$okNum = static fn(string $v): string =>
($v !== '' && is_numeric($v) && (float)$v >= -180 && (float)$v <= 180) ? $v : '';
UserSetting::set($me, 'weather_lat', $okNum($latIn));
UserSetting::set($me, 'weather_lon', $okNum($lonIn));
if ($city !== '' && Geo::lookup($city) === null && ($latIn === '' || $lonIn === '')) {
flash('err', '没有「' . $city . '」这座城市的坐标。换个写法(如「山东青岛」),'
. '或者直接在下面填经纬度。');
} else {
flash('ok', '天气地点已保存。');
}
redirect('settings/me', [], 'weather');
break;
/**
* 保存模块主题色(用户第 11 条,**家庭一份**,所以只有管理员能改)。
*/
case 'settings/module_colors':
Auth::requireLogin();
csrf_check();
$familyId = (int)Auth::familyId();
if (!Auth::isAdmin()) {
flash('err', '模块主题色是全家共用的一套,只有管理员能改。');
redirect('settings/me');
}
if (!empty($_POST['reset'])) {
Module::saveColors($familyId, []);
flash('ok', '已恢复默认配色。');
redirect('settings/me', [], 'modules');
}
Module::saveColors($familyId, (array)($_POST['c'] ?? []));
flash('ok', '模块主题色已保存。刷新页面就能看到。');
redirect('settings/me', [], 'modules');
break;
// ==================== 分组的顺序 ====================
// 两套:「我自己的顺序」(只影响我看到的)与「公共分组的默认顺序」(全家兜底)。
// 靠表单里的 scope 字段区分 —— 同一个动作,落到两个地方。
case 'settings/groups/move':
Auth::requireLogin();
csrf_check();
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$id = (int)($_POST['id'] ?? 0);
$dir = ($_POST['dir'] ?? '') === 'up' ? 'up' : 'down';
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
if ($scope === 'public') {
Auth::requireAdmin();
}
$list = $scope === 'public'
? PersonGroup::forOwner($familyId, PersonGroup::PUBLIC_OWNER)
: PersonGroup::orderFor($familyId, $me);
$ids = array_map(fn($g) => (int)$g['id'], $list);
$at = array_search($id, $ids, true);
if ($at === false) {
flash('err', '找不到这个分组。');
redirect($scope === 'public' ? 'settings' : 'settings/me');
}
// 挪到边界就什么也不做(按钮在两端本来就是 disabled 的,
// 但地址栏里手敲一次也能到这儿,别让它把顺序搞乱)
$to = $dir === 'up' ? $at - 1 : $at + 1;
if ($to >= 0 && $to < count($ids)) {
[$ids[$at], $ids[$to]] = [$ids[$to], $ids[$at]];
if ($scope === 'public') {
PersonGroup::savePositions($familyId, PersonGroup::PUBLIC_OWNER, $ids);
} else {
PersonGroup::saveOrder($familyId, $me, $ids);
}
}
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
// ==================== 分组的增删改名 ====================
// 两套:公共的(管理员在系统设置里维护,全家共用)与我自己的(个人设置里,
// 只有自己看得见)。访客只读,给个明确的说法再回去,
// 而不是甩一张 403 页面(他明明是点了自己设置里的按钮)。
case 'settings/groups/add':
Auth::requireLogin();
csrf_check();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能加分组。');
redirect('settings/me');
}
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
$owner = PersonGroup::PUBLIC_OWNER;
if ($scope === 'public') {
Auth::requireAdmin();
} else {
$owner = (int)Auth::personId();
}
$res = PersonGroup::add((int)Auth::familyId(), (string)($_POST['name'] ?? ''), $owner);
flash($res['ok'] ? 'ok' : 'err', $res['ok']
? '已加上分组「' . trim((string)($_POST['name'] ?? '')) . '」。'
: (string)($res['error'] ?? '加不上。'));
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
case 'settings/groups/rename':
Auth::requireLogin();
csrf_check();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改分组。');
redirect('settings/me');
}
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
$owner = PersonGroup::PUBLIC_OWNER;
if ($scope === 'public') {
Auth::requireAdmin();
} else {
$owner = (int)Auth::personId();
}
$res = PersonGroup::rename((int)Auth::familyId(), (int)($_POST['id'] ?? 0),
(string)($_POST['name'] ?? ''), $owner);
flash($res['ok'] ? 'ok' : 'err', $res['ok']
? '分组名已改。'
: (string)($res['error'] ?? '改不了。'));
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
case 'settings/groups/delete':
Auth::requireLogin();
csrf_check();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能删分组。');
redirect('settings/me');
}
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
$owner = PersonGroup::PUBLIC_OWNER;
if ($scope === 'public') {
Auth::requireAdmin();
} else {
$owner = (int)Auth::personId();
}
$res = PersonGroup::delete((int)Auth::familyId(), (int)($_POST['id'] ?? 0), $owner);
if ($res['ok']) {
$n = (int)($res['moved'] ?? 0);
flash('ok', $n > 0
? '分组已删掉,那 ' . $n . ' 个人现在显示在「未分类」里,去名册重新归类即可。'
: '分组已删掉(那一组本来也没人)。');
} else {
flash('err', (string)($res['error'] ?? '删不了。'));
}
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
// ==================== 别人怎么称呼(只有我自己能定) ====================
case 'nickname/save':
Auth::requireLogin();
csrf_check();
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$target = (int)($_POST['target_id'] ?? 0);
$nick = (string)($_POST['nickname'] ?? '');
$t = Person::find($target);
$back = (string)($_POST['back'] ?? 'people/show');
if (!$t || (int)$t['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
Nickname::set($familyId, $me, $target, $nick);
flash('ok', trim($nick) === ''
? '已恢复成用姓名显示。'
: '记下了:你叫他「' . trim($nick) . '」。这只影响你自己看到的称呼。');
if ($back === 'settings/me' || $back === 'people/me') {
// 两个写法都认:老表单发的是 people/me(那一页现在是跳转)
redirect('settings/me');
}
redirect('people/show', ['id' => $target]);
break;
case 'people/new':
Auth::requireWrite();
clear_old();
View::render('people/form', [
'title' => '添加成员',
'person' => null,
'errors' => [],
'canEditAll' => true,
'isSelf' => false,
// 工作经历先给一行空白(第 4 条),省得还要点「再加一段」
'jobRows' => [[]],
'myNote' => '',
], 'people');
break;
case 'people/create':
Auth::requireWrite();
csrf_check();
$familyId = (int)Auth::familyId();
$d = [
'name' => trim((string)($_POST['name'] ?? '')),
'gender' => (string)($_POST['gender'] ?? ''),
'phone' => trim((string)($_POST['phone'] ?? '')),
'id_card' => trim((string)($_POST['id_card'] ?? '')),
'email' => trim((string)($_POST['email'] ?? '')),
'address' => trim((string)($_POST['address'] ?? '')),
// 用户 2026-10-04 第 4 条:地址是**结构化五级**(国家/省/市/区/详细),
// 不再是一整串。四个下拉提交的是标准名而不是代码(见 Person::addrOrNull)。
'addr_country' => trim((string)($_POST['addr_country'] ?? '')),
'addr_province' => trim((string)($_POST['addr_province'] ?? '')),
'addr_city' => trim((string)($_POST['addr_city'] ?? '')),
'addr_district' => trim((string)($_POST['addr_district'] ?? '')),
'org' => trim((string)($_POST['org'] ?? '')),
'title' => trim((string)($_POST['title'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')),
// 新增的人还没有"我的这一句话"(那是他自己的东西,由他之后自己填)
'slogan' => '',
// 工作经历(第 4 条):一组行,整组替换;见 PersonJob::replace
'jobs' => read_jobs_input(),
'avatar' => '',
'kind' => 'member',
// ⚠️ 这两项必须显式从表单取回:表单里有,但漏了这一步就会被静默丢掉,
// 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
] + read_birthday_input();
$errors = Person::validate($d);
$contacts = read_contacts_input();
$ctErr = validate_contacts($contacts);
if ($ctErr !== '') {
$errors['contacts'] = $ctErr;
}
// ⚠️ 头像上传失败**不再挡住整条新增**(用户原话:「添加人员功能不能用了」——
// 早先一张 5MB 的手机原图就把整个人挡在名册外面,看起来像功能坏了)。
// 现在是:人先存进去,头像的事单独提示。
$up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
(string)($_POST['avatar_crop'] ?? ''));
$avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : '';
if ($errors) {
set_old($_POST);
View::render('people/form', [
'title' => '添加成员',
'person' => null,
'errors' => $errors,
'phoneValue' => $d['phone'],
'idCardValue'=> $d['id_card'],
'canEditAll' => true,
'isSelf' => false,
'contacts' => $contacts,
// 校验失败要把用户刚填的工作经历**原样回显**(第 4 条),
// 私备注同理(第 5 条)—— 别让他重填一遍。
'jobRows' => (array)($d['jobs'] ?? []),
'myNote' => trim((string)($_POST['my_note'] ?? '')),
], 'people');
exit;
}
if ($up['ok']) {
$d['avatar'] = $up['file'];
}
try {
$id = Person::create($familyId, $d);
// 头像主色:上传时已算好,这里顺手存下(算不出就不写,走姓名兜底)
if ($up['ok']) {
Person::setAvatarColor($id, (string)($up['color'] ?? ''));
}
} catch (\PDOException $ex) {
set_old($_POST);
View::render('people/form', [
'title' => '添加成员',
'person' => null,
'errors' => ['phone' => '这个手机号已经在名册里了'],
'phoneValue' => $d['phone'],
'idCardValue'=> $d['id_card'],
'canEditAll' => true,
'isSelf' => false,
'contacts' => $contacts,
// 校验失败要把用户刚填的工作经历**原样回显**(第 4 条),
// 私备注同理(第 5 条)—— 别让他重填一遍。
'jobRows' => (array)($d['jobs'] ?? []),
'myNote' => trim((string)($_POST['my_note'] ?? '')),
], 'people');
exit;
}
// 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。
// 分组:表单里选了就用它(用户第 12 条「增加名册时可选择所属分组」);
// ⚠️ 只认**我自己**的分组列表里的 id —— 表单可以改,不能凭它把人塞进
// 别人私有的分组、或根本不存在的分组里。
$gid = (int)($_POST['group_id'] ?? 0);
$okGids = array_map('intval', array_column(
PersonGroup::orderFor($familyId, (int)Auth::personId()), 'id'));
if ($gid > 0 && !in_array($gid, $okGids, true)) {
$gid = 0;
}
PersonRoster::ensure($familyId, (int)Auth::personId(), $id, $gid > 0 ? $gid : null, 'self');
Person::replaceContacts($id, $contacts);
// 工作经历:整组替换(第 4 条)。空的那组会把这段经历清空 —— 与联系人同一口径。
PersonJob::replace($id, (array)($d['jobs'] ?? []));
clear_old();
if ($avatarWarn !== '') {
flash('ok', '已把「' . $d['name'] . '」加进名册。头像没有保存:' . $avatarWarn);
} else {
flash('ok', '已把「' . $d['name'] . '」加进名册。');
}
redirect('people/show', ['id' => $id]);
break;
/**
* 成员资料表单 —— **只有这一张**(需求:「编辑成员」与「改自己的资料」本来就是一件事)。
*
* 两个入口都指到这里:名册卡片/详情页的「编辑资料」、以及个人设置顶部的
* 「编辑我的资料」。区别只有一个:编辑**自己**时不要求能写(访客也能改自己的名字),
* 而编辑别人必须是能写的人。
*/
case 'people/edit':
$id = (int)($_GET['id'] ?? 0);
$isSelf = $id > 0 && $id === (int)Auth::personId();
if (!$isSelf) {
Auth::requireWrite();
}
$p = Person::find($id);
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
flash('err', '找不到这个人。');
redirect('people');
}
clear_old();
// 补充联系方式:kind=phone 存的是密文,编辑表单里要给本人(能写的人)看明文
$editContacts = Person::contactsOf($id);
foreach ($editContacts['phone'] as &$r) {
$r['value'] = Crypto::decrypt($r['value']);
}
unset($r);
View::render('people/form', [
'title' => $isSelf ? '我的资料' : '编辑成员',
'person' => $p,
'errors' => [],
'canEditAll' => Auth::canWrite(),
'isSelf' => $isSelf,
'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']),
'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']),
'contacts' => $editContacts,
// 工作经历(第 4 条):库里已有的多段
'jobRows' => PersonJob::list($id),
// 我对他的私备注(第 5 条):**只有我自己**那一份
'myNote' => $isSelf ? '' : PersonNote::get($id, (int)Auth::personId()),
], 'people');
break;
case 'people/update':
csrf_check();
$familyId = (int)Auth::familyId();
$id = (int)($_POST['id'] ?? 0);
$p = Person::find($id);
if (!$p || (int)$p['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
$isSelf = $id === (int)Auth::personId();
if (!$isSelf) {
Auth::requireWrite();
}
// 改别人(= 能写的人编名册):字段是完整的一套。
// 改自己(可能是访客):只允许动属于自己的那些 —— 与 Person::updateSelf 一一对应。
$canEditAll = Auth::canWrite();
// 「我的这一句话」只能本人改(用户第 3 条)→ 非本人提交时沿用库里现有值。
$keepSlogan = (string)($p['slogan'] ?? '');
$d = [
'name' => trim((string)($_POST['name'] ?? '')),
'gender' => (string)($_POST['gender'] ?? ''),
'phone' => $canEditAll ? trim((string)($_POST['phone'] ?? '')) : Crypto::decrypt((string)$p['phone_encrypted']),
'id_card' => $canEditAll ? trim((string)($_POST['id_card'] ?? '')) : Crypto::decrypt((string)$p['id_card_encrypted']),
'email' => trim((string)($_POST['email'] ?? '')),
'address' => trim((string)($_POST['address'] ?? '')),
// 用户 2026-10-04 第 4 条:地址是**结构化五级**(国家/省/市/区/详细),
// 不再是一整串。四个下拉提交的是标准名而不是代码(见 Person::addrOrNull)。
'addr_country' => trim((string)($_POST['addr_country'] ?? '')),
'addr_province' => trim((string)($_POST['addr_province'] ?? '')),
'addr_city' => trim((string)($_POST['addr_city'] ?? '')),
'addr_district' => trim((string)($_POST['addr_district'] ?? '')),
'org' => trim((string)($_POST['org'] ?? '')),
'title' => trim((string)($_POST['title'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')),
// ⚠️ 用户 2026-10-03 第 3 条:这一句话**只能本人改** ——
// 表单里这一栏也只在 $isSelf 时出现。别人提交时这里读不到字段,
// 若直接 $_POST 取值会拿到空串 → **把本人的那句话清空**。
// 所以非本人一律沿用库里已有的值($keepSlogan)。
'slogan' => $isSelf ? trim((string)($_POST['slogan'] ?? '')) : $keepSlogan,
// 工作经历(第 4 条):一组行,整组替换;见 PersonJob::replace
'jobs' => read_jobs_input(),
'avatar' => (string)$p['avatar'],
'birth_rank' => $canEditAll ? trim((string)($_POST['birth_rank'] ?? '')) : ($p['birth_rank'] ?? ''),
'is_placeholder' => $canEditAll
? (!empty($_POST['is_placeholder']) ? 1 : 0)
: (int)($p['is_placeholder'] ?? 0),
] + read_birthday_input($p);
$errors = Person::validate($d);
$contacts = read_contacts_input();
$ctErr = validate_contacts($contacts);
if ($ctErr !== '') {
$errors['contacts'] = $ctErr;
}
// 同 people/create:头像失败不挡保存(见 create 分支里的说明)
$up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
(string)($_POST['avatar_crop'] ?? ''));
$avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : '';
if ($errors) {
set_old($_POST);
View::render('people/form', [
'title' => $isSelf ? '我的资料' : '编辑成员',
'person' => $p,
'errors' => $errors,
'canEditAll' => $canEditAll,
'isSelf' => $isSelf,
'phoneValue' => $d['phone'],
'idCardValue' => $d['id_card'],
'contacts' => $contacts,
], 'people');
exit;
}
if ($up['ok']) {
Upload::remove($familyId, (string)$p['avatar']);
$d['avatar'] = $up['file'];
// 换图就换色:新头像的主色马上覆盖旧的(算不出时保留旧值,不清空)
Person::setAvatarColor($id, (string)($up['color'] ?? ''));
}
try {
// 改自己且没有编名册的权限 → 只能动属于自己的字段(Person::updateSelf)。
// 这一条不是"防贼",是为了让访客也能改自己的名字,同时不会顺手把
// 手机号(= 登录凭据)或身份证号改掉。
if ($isSelf && !$canEditAll) {
Person::updateSelf($id, $d);
} else {
Person::update($id, $d);
}
} catch (\PDOException $ex) {
set_old($_POST);
View::render('people/form', [
'title' => $isSelf ? '我的资料' : '编辑成员',
'person' => $p,
'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'],
'canEditAll' => $canEditAll,
'isSelf' => $isSelf,
'phoneValue' => $d['phone'],
'idCardValue' => $d['id_card'],
'contacts' => $contacts,
], 'people');
exit;
}
Person::replaceContacts($id, $contacts);
// 工作经历:整组替换(第 4 条)。空的那组会把这段经历清空 —— 与联系人同一口径。
PersonJob::replace($id, (array)($d['jobs'] ?? []));
// 「我对他的私人备注」(第 5 条):落在**写的人自己**名下(person_notes)。
// ⚠️ 两条守卫:
// ① 编辑自己时表单里没有这个字段(不需要"我对我的备注")→ 别把已有的清掉;
// ② 即使有,也不该对自己写(`$id !== 我`)。
if ((int)$id !== (int)Auth::personId() && array_key_exists('my_note', $_POST)) {
PersonNote::set($id, (int)Auth::personId(), (string)$_POST['my_note']);
}
if ((int)$p['id'] === (int)Auth::personId()) {
Auth::refreshAvatar((string)$d['avatar']);
// 姓名也可能改了 —— 不同步会话的话顶栏还是旧名字
$_SESSION['name'] = (string)$d['name'];
}
clear_old();
if ($avatarWarn !== '') {
flash('ok', '已保存。头像没有更换:' . $avatarWarn);
} else {
flash('ok', '已保存。');
}
redirect('people/show', ['id' => $id]);
break;
/**
* 保存「我对他的私人备注」(用户 2026-10-03 第 5 条)。
*
* 独立成一个路由而不是塞进 people/update:这两件事的**可见性**不同 ——
* people/update 改的是"这个人的资料"(有写权限的人都能改),
* 而这条备注是"**我**和他之间的事",只有写的人自己看得到。
* 混在一起的话,"有写权限"就等于"能改别人对某人的私评",那是另一回事。
*/
case 'people/note':
Auth::requireLogin();
csrf_check();
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0 || $id === $me || !isset(Person::map($familyId)[$id])) {
flash('err', '找不到这个人。');
redirect('people');
}
PersonNote::set($id, $me, (string)($_POST['note'] ?? ''));
flash('ok', '备注已保存(只有你自己看得到)。');
redirect('people/show', ['id' => $id]);
case 'people/show':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$id = (int)($_GET['id'] ?? 0);
$p = Person::findWithAccount($id, $me);
if (!$p || (int)$p['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
// 这个人在「我」眼里是谁,以及我与他之间的直接关系(可编辑的那些)
$tree = Kinship::tree($graph, $id, $people, 2, 2);
View::render('people/show', [
'title' => $p['name'],
'person' => $p,
'meId' => $me,
'people' => $people,
'graph' => $graph,
'labelToMe' => Kinship::label($graph, $me, $id, $people),
'isMe' => $id === $me,
// 以这个人为中心的两代亲属,供详情页展示
'theirTree' => $tree,
'theirRels' => Relations::forPerson($familyId, $id),
'options' => Person::options($familyId, $id, $me),
'kinReady' => Kinship::graph($familyId) !== [],
// 「我叫他什么」—— 只有我自己能改,改了也只影响我看到的
'myNickname' => $id === $me ? '' : Nickname::get($me, $id),
'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']),
// 工作经历(第 4 条):可以有多段,详情页整列出来
'jobs' => PersonJob::list($id),
// 我对他的私人备注(第 5 条):**只有我自己**那一份;看自己时为空
'myNote' => $id === $me ? '' : PersonNote::get($id, $me),
'birthdays' => Birthday::describe($p),
'birthdayNext'=> Birthday::nextOccurrences($p, date('Y-m-d'), 400),
// 补充联系方式(vCard 对齐):kind=phone 的 value 是密文,模板里用 maskPhone 展示
'contacts' => Person::contactsOf($id),
// 名册(每人一份):他是不是在我的名册里、可以把他的名片分享给谁
'inMyRoster' => in_array($id, PersonRoster::visibleIds($familyId, $me), true),
], 'people');
break;
case 'people/destroy':
Auth::requireAdmin();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$p = Person::find($id);
// ---- v24 第 21 批 第 1 条:名册页的删除改成**无刷新**(用户原话:
// 「删除一个联系人后,能否不刷新页面,删除成功后,直接把这个人,
// 从当前页面的 dom 中删除,页面保持不动。」)
//
// ⚠️ 同一个路由要同时服务两种调用者,所以**必须认 X-Requested-With**:
// · 名册页的 JS 带它 → 回 JSON,前端自己摘卡片;
// · 详情页底部那个删除**不带**(原生表单)→ 照旧 302 回名册。
// 判据不能用"有没有 POST 参数"之类的猜测 —— 那两处的参数是一样的。
//
// ⚠️ 每一条**失败**也要回 JSON(而且 msg 要说清为什么):
// 前端唯一的退路是"退回原生提交",而退回意味着刷页 ——
// "不能删自己"这种可预期的拒绝根本不该刷页。
$ajax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest')
|| (strpos((string)($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json') !== false);
$jsonOut = static function (bool $ok, string $msg, array $extra = []) use ($ajax) {
if (!$ajax) {
flash($ok ? 'ok' : 'err', $msg);
return false;
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode(array_merge(['ok' => $ok, 'msg' => $msg], $extra),
JSON_UNESCAPED_UNICODE);
exit;
};
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
// 原本这里是静默 redirect("点了没反应");现在明确回一句
if ($jsonOut(false, '找不到这个人,可能已经被删掉了。')) { }
redirect('people');
}
if ((int)$p['id'] === (int)Auth::personId()) {
// 这条原本是 redirect 到**详情页**(用的人自己);AJAX 时不该跳页
if ($jsonOut(false, '不能把自己从名册里移除。')) { }
flash('err', '不能把自己从名册里移除。');
redirect('people/show', ['id' => $id]);
}
// 不让最后一个管理员消失:系统里没人能管理家庭就锁死了
$acc = Account::findByPerson($id);
if ($acc && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
if ($jsonOut(false, '这是唯一的管理员,先把另一个人设为管理员再移除。')) { }
flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。');
redirect('people/show', ['id' => $id]);
}
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
$gone = (string)$p['name'];
Person::delete($id);
if ($jsonOut(true, '已删除「' . $gone . '」。', ['id' => $id])) { }
flash('ok', '已从名册移除「' . $gone . '」。');
redirect('people');
break;
/**
* 行政区划级联数据(用户 2026-10-04 第 4 条:地址要「省 / 市 / 区」三级联动)。
*
* 只吐一个 JSON 列表给前端填下拉 —— 3429 条一次性塞进页面会让表单页重一百多 KB,
* 而用户一次只会用到其中两个下拉,按级取就够了。
* ⚠️ 必须登录才能取(虽然不是什么秘密,但没必要给匿名访客开一个能枚举全国的接口)。
*/
case 'regions':
Auth::requireLogin();
$parent = (string)($_GET['parent'] ?? '');
header('Content-Type: application/json; charset=utf-8');
echo json_encode(
['ok' => true, 'items' => \App\Region::childrenOf($parent)],
JSON_UNESCAPED_UNICODE
);
exit;
// ==================== 账号 ====================
case 'accounts/create':
Auth::requireAdmin();
csrf_check();
$personId = (int)($_POST['person_id'] ?? 0);
$p = Person::find($personId);
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
flash('err', '找不到这个人。');
redirect('people');
}
if (Account::findByPerson($personId)) {
flash('err', '这个人的账号已经开通过了。');
redirect('people/show', ['id' => $personId]);
}
if (Crypto::decrypt((string)$p['phone_encrypted']) === '') {
flash('err', '要先填手机号,才能开通登录。');
redirect('people/show', ['id' => $personId]);
}
$pass = (string)($_POST['password'] ?? '');
$errs = Account::validatePassword($pass);
if ($errs) {
flash('err', implode(';', $errs));
redirect('people/show', ['id' => $personId]);
}
$r0 = (string)($_POST['role'] ?? '');
Account::create($personId, $pass, in_array($r0, Account::ROLES, true) ? $r0 : 'member');
// v7:多了一个「看家谱的人」——他的名册也要按树播种,一登录三处口径就是对的
PersonRoster::syncKinForFamily((int)Auth::familyId());
flash('ok', '账号已开通,用手机号就能登录。');
redirect('people/show', ['id' => $personId]);
break;
case 'accounts/role':
Auth::requireAdmin();
csrf_check();
$personId = (int)($_POST['person_id'] ?? 0);
$role = in_array(($r1 = (string)($_POST['role'] ?? '')), Account::ROLES, true) ? $r1 : 'member';
$p = Person::find($personId);
$acc = Account::findByPerson($personId);
if ($p && $acc) {
// ⚠️ 判的是 `$role !== 'admin'` 而不是 `$role === 'member'`:
// 早先只拦"降成家庭成员",于是把**最后一位管理员**降成访客能一路通过 ——
// 降完家里一个管理员都不剩,谁也进不了系统设置了。
// 允许改自己的权限(包括给自己降级),只在"会一个管理员都不剩"时拦。
if ($role !== 'admin' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
flash('err', '至少要留一位管理员 —— 把最后一位管理员降级,全家就没人能管理系统了。');
redirect('people/show', ['id' => $personId]);
}
Account::setRole((int)$acc['id'], $role);
flash('ok', '权限已更新。');
}
redirect('people/show', ['id' => $personId]);
break;
case 'accounts/status':
Auth::requireAdmin();
csrf_check();
$personId = (int)($_POST['person_id'] ?? 0);
$status = ($_POST['status'] ?? '') === 'active' ? 'active' : 'disabled';
$acc = Account::findByPerson($personId);
if ($acc) {
if ((int)$acc['person_id'] === (int)Auth::personId()) {
flash('err', '不能停用自己。');
redirect('people/show', ['id' => $personId]);
}
if ($status === 'disabled' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
flash('err', '至少要留一位管理员。');
redirect('people/show', ['id' => $personId]);
}
Account::setStatus((int)$acc['id'], $status);
flash('ok', $status === 'active' ? '账号已启用。' : '账号已停用。');
}
redirect('people/show', ['id' => $personId]);
break;
// ==================== 亲属关系 ====================
case 'kin/relate':
Auth::requireWrite();
csrf_check();
$familyId = (int)Auth::familyId();
$from = (int)($_POST['from_id'] ?? 0); // 我(或当前正在编辑的人)
$to = (int)($_POST['to_id'] ?? 0); // 对方
$kind = (string)($_POST['kind'] ?? '');
$role = (string)($_POST['role'] ?? '');
$ok = Person::find($from) && Person::find($to)
&& (int)Person::find($from)['family_id'] === $familyId
&& (int)Person::find($to)['family_id'] === $familyId;
if (!$ok) {
flash('err', '选的人不对。');
redirect('people');
}
// ⚠️⚠️ 方向极易写反,而且写反了**不会报任何错**:页面照常显示「关系已记录」,
// 只是把人记成了对方的父母 —— 家谱整个倒过来。
// 约定:表单里 from_id 是「当前打开的人」,也就是表单上写的那个「我」;
// kind=parent 表示「对方是我的父母」→ 所以我是 child、对方是 parent。
// Relations::addParent($familyId, $childId, $parentId, $role) 的第二个参数是**孩子**。
//
// ⚠️⚠️ 这里是**白名单**,不是 else 兜底。原先写成 `else { addSpouse(...) }`,
// 于是任何没被识别的 kind(大小写错、拼错、以后新增的种类)都会被**静默记成配偶**
// 且照样提示「关系已记录」——关系是错的,用户却以为成功了。
// 实测踩到:夹具传 kind=sibling(兄弟姐妹),库里落的全是 spouse,
// 于是「夫妻并排」「父母括号」这些断言看着在测,其实测的是一堆错关系。
// 兄弟姐妹本就不需要单独记 —— 有同一对父母就会自动认出来(见 Kinship::siblingIds)。
$kindMap = ['parent', 'child', 'spouse'];
if (!in_array($kind, $kindMap, true)) {
flash('err', '不认识这种关系,没有记录。兄弟姐妹不用单独填 —— 有同一对父母就会自动认出来。');
redirect('people/show', ['id' => $from]);
}
if ($kind === 'parent') {
// 对方是我的父母:我是孩子,对方是父母
$r = Relations::addParent($familyId, $from, $to, $role);
} elseif ($kind === 'child') {
// 对方是我的子女:对方是孩子,我是父母
$r = Relations::addParent($familyId, $to, $from, $role);
} else {
$r = Relations::addSpouse($familyId, $from, $to);
}
flash($r['ok'] ? 'ok' : 'err', $r['ok'] ? '关系已记录。' : $r['error']);
redirect('people/show', ['id' => $from]);
break;
case 'kin/unrelate':
Auth::requireWrite();
csrf_check();
$familyId = (int)Auth::familyId();
$id = (int)($_POST['id'] ?? 0);
$who = (int)($_POST['person_id'] ?? 0);
$kinRemoved = Relations::remove($familyId, $id);
// v7:关系没了,因这条关系自动进两端名册的条目也收回 —— 把这件事说破,
// 不然用户会以为名册里的人凭空消失了
flash('ok', $kinRemoved
? '这条关系已解除,两人名册里因此自动加入的对方也一并移出了。'
: '这条关系已解除。');
redirect('people/show', ['id' => $who]);
break;
// ==================== 访客邀请 ====================
case 'accounts/invite':
Auth::requireAdmin();
clear_old();
// ⚠️ 访客建出来是 kind='contact'(不进家谱),而 Person::all() 默认只取 'member' ——
// 早先这里没传 kind,于是"已有访客"永远列不出人来(页面看着像是从没开通过)。
View::render('people/invite', [
'title' => '邀请访客',
'people' => Person::all((int)Auth::familyId(), '', 'contact', (int)Auth::personId()),
'options' => Person::options((int)Auth::familyId()),
], 'people');
break;
case 'accounts/invite_create':
Auth::requireAdmin();
csrf_check();
$familyId = (int)Auth::familyId();
$name = trim((string)($_POST['name'] ?? ''));
$phone = trim((string)($_POST['phone'] ?? ''));
$pass = (string)($_POST['password'] ?? '');
$note = trim((string)($_POST['note'] ?? ''));
$errors = [];
if ($name === '') {
$errors['name'] = '请填写对方的名字';
}
if (!preg_match('/^\+?[0-9\-]{6,20}$/', $phone)) {
$errors['phone'] = '手机号格式看起来不对';
}
$pw = Account::validatePassword($pass);
if ($pw) {
$errors['password'] = implode(';', $pw);
}
if (!$errors) {
try {
// 访客也进名册(kind=contact,与家庭成员区分开),但不参与家谱
$pid = Person::create($familyId, [
'name' => $name, 'gender' => '', 'avatar' => '', 'phone' => $phone,
'id_card' => '', 'email' => '', 'address' => '', 'note' => $note,
'slogan' => '', 'kind' => 'contact',
]);
Account::create($pid, $pass, 'guest');
// 我邀的访客也进**我的**名册(名册是每人一份的;访客自己那份由
// Account::create 给他补上他自己)
PersonRoster::ensure($familyId, (int)Auth::personId(), $pid, null, 'self');
clear_old();
flash('ok', '访客「' . $name . '」已开通,把手机号和密码告诉他就能登录。');
redirect('accounts/invite');
} catch (\PDOException $ex) {
$errors['phone'] = '这个手机号已经有人用了';
}
}
set_old($_POST);
View::render('people/invite', [
'title' => '邀请访客',
'people' => Person::all($familyId, '', 'contact', (int)Auth::personId()),
'options' => Person::options($familyId),
'errors' => $errors,
], 'people');
break;
// ==================== 设置 ====================
case 'settings':
Auth::requireAdmin();
$dbVersion = '未知';
try {
$dbVersion = Database::get()->query('SELECT VERSION()')->fetchColumn();
} catch (\Throwable $ex) {
// 拿不到版本不影响页面
}
$rawKey = (string)fb_config('app_key', '');
$acc = Account::roleCounts();
// 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET)
$diag = $_SESSION['_oss_diag'] ?? null;
unset($_SESSION['_oss_diag']);
// 公共分组(owner_id = 0)—— 管理员在这里维护全家共用的那一套。
// 人数按"全家名册"统计(这里要看的是"这一组里全家有多少人",不是我个人那份)。
View::render('settings', [
'title' => '设置',
'family' => Family::find((int)Auth::familyId()),
'dbVersion' => $dbVersion,
'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0,
'roleCounts' => $acc,
'relCount' => Relations::count((int)Auth::familyId()),
'pubGroups' => PersonGroup::forOwner((int)Auth::familyId(), PersonGroup::PUBLIC_OWNER),
'pubCounts' => PersonGroup::counts((int)Auth::familyId(), PersonGroup::PUBLIC_OWNER),
'upgradeWarn'=> (string)Setting::get((int)Auth::familyId(), 'schema_upgrade_warning', ''),
'storageDriver' => \App\Storage\Storage::driver((int)Auth::familyId()),
'storageOss' => \App\Storage\Storage::config((int)Auth::familyId()),
'storageOk' => \App\Storage\Storage::configComplete((int)Auth::familyId()),
'storageEffective' => \App\Storage\Storage::effectiveDriver((int)Auth::familyId()),
'ossDiag' => $diag,
], 'settings');
break;
/**
* 阿里云 OSS 连通性测试。
*
* 与 settings/test_storage 的分工:
* · test_storage 测的是**当前生效的驱动**(可能是本地),判"能不能存东西";
* · 这一个测的是**已填的 OSS 配置本身**,即使当前跑在本地也能测 ——
* 这样就能"先把 OSS 测通、再切过去",而不是"先切、发现不行、再切回来"。
* 它会把每一步的结论摊开(配置/凭据/地域/公网/内网/读写),
* 让"连不上"这件事有具体原因,而不是一句"写入失败"。
*/
case 'settings/test_oss':
Auth::requireAdmin();
csrf_check();
$familyId = (int)Auth::familyId();
$disk = \App\Storage\Storage::ossDiskForDiagnosis($familyId);
if ($disk === null) {
$_SESSION['_oss_diag'] = [
'ok' => false,
'steps' => [[
'name' => '配置完整性', 'ok' => false, 'ms' => 0,
'detail' => 'OSS 参数还没填全(至少要 Bucket、Endpoint 和一种凭据)。先在下面填好并保存,再测。',
]],
'at' => time(),
];
redirect('settings');
}
$steps = $disk->diagnose();
$ok = true;
foreach ($steps as $s) {
if (empty($s['ok'])) {
$ok = false;
break;
}
}
$_SESSION['_oss_diag'] = ['ok' => $ok, 'steps' => $steps, 'at' => time()];
redirect('settings');
break;
case 'settings/storage':
Auth::requireAdmin();
csrf_check();
$familyId = (int)Auth::familyId();
$old = \App\Storage\Storage::config($familyId);
$in = (array)($_POST['oss'] ?? []);
// 密钥留空 = 不改(避免「保存时没重填就把已存的密钥清掉」)
foreach (['access_key_secret', 'access_key_id'] as $k) {
if (trim((string)($in[$k] ?? '')) === '') {
$in[$k] = (string)$old[$k];
}
}
$oss = [
'auth_mode' => ($in['auth_mode'] ?? 'ak') === 'ecs_role' ? 'ecs_role' : 'ak',
'access_key_id' => trim((string)($in['access_key_id'] ?? '')),
'access_key_secret' => trim((string)($in['access_key_secret'] ?? '')),
'bucket' => trim((string)($in['bucket'] ?? '')),
'endpoint' => trim((string)($in['endpoint'] ?? '')),
'prefix' => trim((string)($in['prefix'] ?? '')),
'ram_role_name' => trim((string)($in['ram_role_name'] ?? '')),
'custom_domain' => trim((string)($in['custom_domain'] ?? '')),
'internal_endpoint' => !empty($in['internal_endpoint']) ? '1' : '0',
'https' => !empty($in['https']) ? '1' : '0',
];
\App\Storage\Storage::saveConfig($familyId, (string)($_POST['driver'] ?? 'local'), $oss);
flash('ok', '附件存储设置已保存。');
redirect('settings');
break;
case 'settings/test_storage':
Auth::requireAdmin();
csrf_check();
$familyId = (int)Auth::familyId();
$disk = \App\Storage\Storage::disk($familyId);
$err = $disk->validateConfig();
if ($err !== '') {
flash('err', '配置不完整:' . $err);
redirect('settings');
}
// 真写一个探针对象再读回来,最后删掉 —— 比只 ping 一下可靠得多
$probeKey = 'avatars/.probe-' . bin2hex(random_bytes(6)) . '.txt';
$payload = 'familybook-probe-' . time();
$put = \App\Storage\Storage::putContents($familyId, $payload, $probeKey, 'text/plain');
if (empty($put['ok'])) {
flash('err', '写入失败:' . (string)($put['error'] ?? ''));
redirect('settings');
}
$get = \App\Storage\Storage::read($familyId, $probeKey);
$ok = !empty($get['ok']) && trim((string)$get['body']) === $payload;
\App\Storage\Storage::delete($familyId, $probeKey);
flash($ok ? 'ok' : 'err',
$ok ? '存储可用:写入 → 读回 → 删除 三步都通过了(驱动:' . $disk->id() . ')'
: '写入成功但读回来对不上,请检查桶权限或内网/外网 endpoint。');
redirect('settings');
break;
case 'settings/update':
Auth::requireAdmin();
csrf_check();
$fid = (int)Auth::familyId();
// ⚠️ 「家庭名称」已从设置里撤掉(见 settings 模板的说明):
// 首页标题现在是「某某某的家书」,一个家庭级名字既没人看、也容易和它打架。
// 这里沿用已有值,不再接受表单输入 —— 免得"某次提交把名字清空"。
$cur = Family::find($fid);
Family::update(
$fid,
(string)($cur['name'] ?? '家书'),
trim((string)($_POST['motto'] ?? ''))
);
// ⚠️ 天气坐标**不再从系统设置收**(用户 2026-10-03 第 1 条:改到个人设置填城市)。
// 这里也不再 Set 那三个键 —— 但**不删**库里已有的旧值:
// 万一有人还没去个人设置填城市,旧的家庭级坐标留着还能兜底(见下)。
// (Home::weatherLocation 只读 user_settings,旧的家庭值已经不参与;
// 留着只是为了"哪天想回滚"时有据可查,代价是三个字符串。)
flash('ok', '已保存。');
redirect('settings');
break;
// ==================== 观影(v8) ====================
//
// MovieLog 整体迁入后的新家。数据落在统一事件原语上:
// events(骨架)+ movie_details(卫星)+ event_attendees + event_photos,
// 见 App\Movie。可见性与名册同一口径:能登录就能看,能写才能改;
// 迁进来的历史记录 source_ref = 'movielog:movie:<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);
}
}
}
// 现场照片(多选)。
// ⚠️ v24 第 20 批 第 1 条:**编辑时也收**这里传上来的照片了。
// 以前只有新增表单给这个字段(编辑页只在详情页里逐张补),所以这里写着
// `if (!$isEdit)`。现在编辑页也能一次多选加几张,所以要处理"合并"而不是替换:
// 编辑 = 库里已有的(在编辑页逐张删过的会被 photo_del 立刻删掉,库里就是最新的)
// + 这次新选上来的。
// ⚠️ 不能沿用"整组替换":那样一编辑就会把已有的照片全冲掉(用户报的
// "合影没带进来"如果按替换实现,会变成更难发现的"合影丢了")。
$photoFiles = [];
if (!empty($_FILES['photo']) && is_array($_FILES['photo']['name'])) {
foreach ($_FILES['photo']['name'] as $i => $_n) {
$photoFiles[] = [
'name' => $_FILES['photo']['name'][$i],
'type' => $_FILES['photo']['type'][$i],
'tmp_name' => $_FILES['photo']['tmp_name'][$i],
'error' => $_FILES['photo']['error'][$i],
'size' => $_FILES['photo']['size'][$i],
];
}
}
if ($photoFiles) {
$photoKeys = $isEdit
? array_map('strval', array_column(Movie::photos($id), 'key')) // 保住已有的
: [];
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;
} elseif (!$isEdit) {
// 新增且一张都没选 → 明确给空(否则 create 会拿到一个不存在的键)
$d['photos'] = [];
}
/* ---- v24 第 20 批 第 4 条:拖动**即时上传**的海报 / 票根 ----
用户原话:「拖动一个进去,就开始上传…最后统一保存即可。」
拖动会上传到暂存区并回一个 stoken,前端放进隐藏字段 `stage_poster` /
`stage_ticket`(**海报/票根各一个字段名**,因为它们要区分槽位)。
⚠️ 这里直接取 stoken 对应的暂存文件来重跑 Upload::image(按该槽位的比例),
而不是把暂存文件原样搬过去 —— 海报是 2:3、票根是 free,
不按槽位处理就会存成比例不对的图(页面上会变形)。
⚠️ 临时文件上传(`$_FILES[$slot]`)优先:用户**又选又拖**时以"选"的为准
(那是他更明确的动作),免得两个来源打架。
⚠️ 认领失败要记进 $warns(不静默丢),且**不影响保存**(与既有口径一致)。 */
foreach (['poster' => 'stage_poster', 'ticket' => 'stage_ticket'] as $slotKey => $fieldName) {
if (!empty($_FILES[$slotKey]) && (int)($_FILES[$slotKey]['error'] ?? 4) === UPLOAD_ERR_OK) {
continue; // 选了新文件 → 上面的常规路径已经处理过了
}
$stoken = trim((string)($_POST[$fieldName] ?? ''));
if ($stoken === '') {
continue;
}
$sf = \App\StagedUpload::read($familyId, (int)Auth::personId(), $stoken);
if ($sf === null) {
$warns[] = ($slotKey === 'poster' ? '海报' : '票根') . ':暂存文件已失效,请重拖一次。';
continue;
}
$fake = [
'name' => (string)$sf['name'],
'type' => (string)$sf['mime'],
'tmp_name' => (string)$sf['path'],
'error' => UPLOAD_ERR_OK,
'size' => (int)@filesize((string)$sf['path']),
];
$up = Upload::image($familyId, $fake, $slotKey);
if (!empty($up['ok'])) {
$newKey = (string)$up['file'];
$oldKey = $isEdit ? (string)($m[$slotKey . '_key'] ?? '') : '';
$d[$slotKey . '_key'] = $newKey;
if ($oldKey !== '' && $oldKey !== $newKey) {
\App\Storage\Storage::delete($familyId, $oldKey);
}
\App\StagedUpload::forget($stoken); // 暂存那份不再需要
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = ($slotKey === 'poster' ? '海报' : '票根') . '没有保存:' . $up['error'];
}
}
if ($errors) {
set_old($_POST);
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', '照片已删除。');
// ⚠️ v24 第 20 批 第 1 条:编辑页也能删照片了,所以按来路回去 ——
// 从编辑页删的就回编辑页(否则用户一删就被甩回详情页,还得再点一次编辑)。
// 来路用表单里的 `back` 判断(由模板给出,白名单,免得开放重定向)。
$back = (string)($_POST['back'] ?? '');
if ($back === 'edit') {
redirect('movies/edit', ['id' => $id]);
}
redirect('movies/show', ['id' => $id]);
break;
/**
* 删掉海报 / 票根(v24 第 20 批 第 1 条)。
*
* 为什么需要它:编辑页现在把已存的海报/票根**显示出来**了,
* 用户看着一张不想要的图,唯一的动作就是"再选一张新的"——
* 而他可能只是**想去掉它**(海报库里没有更好的,删掉反而干净)。
*
* ⚠️ 顺带把存储对象一起删(只清库不留孤儿文件,与 Movie::deletePhoto 同一口径)。
* ⚠️ 换图时(movies/update)服务端已经会删旧图,所以这里只管"只删不换"。
*/
case 'movies/media_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$slot = (string)($_POST['slot'] ?? '');
$m = Movie::find($familyId, $id, (int)Auth::personId());
if (!$m) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
// 白名单:只认这两个槽位(表单可以被改,不能凭它去删别的字段/别的对象)
$col = ['poster' => 'poster_key', 'ticket' => 'ticket_key'][$slot] ?? '';
if ($col === '') {
flash('err', '不知道要删哪一样。');
redirect('movies/edit', ['id' => $id]);
}
$old = (string)($m[$col] ?? '');
Database::get()->prepare('UPDATE movie_details SET ' . $col . ' = NULL WHERE event_id = ?')
->execute([$id]);
if ($old !== '') {
\App\Storage\Storage::delete($familyId, $old);
}
flash('ok', ($slot === 'poster' ? '海报' : '票根') . '已删除。');
redirect('movies/edit', ['id' => $id]);
break;
case 'movies/destroy':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Movie::find($familyId, $id)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
Movie::destroy($familyId, $id);
flash('ok', '这条观影记录已删除。');
redirect('movies');
break;
// ---------------- 豆瓣自动补充(v10,MovieLog fetch_movie 同源迁移) ----------------
/** 片名联想 / 缩略图代理 / 抓取详情+海报入库。全部回 JSON(thumb 回图片字节)。 */
case 'movies/douban':
$familyId = (int)Auth::familyId();
$act = (string)($_GET['action'] ?? '');
if (!Auth::check()) {
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => '未登录或登录已过期,请重新登录后再试'], JSON_UNESCAPED_UNICODE);
break;
}
if ($act === 'suggest') {
header('Content-Type: application/json; charset=utf-8');
$res = Douban::suggest((string)($_GET['q'] ?? ''));
echo json_encode($res, JSON_UNESCAPED_UNICODE);
break;
}
if ($act === 'thumb') {
// 浏览器不能直连豆瓣图片(防盗链),由服务端代取;只读、不落盘
$img = Douban::thumb((string)($_GET['u'] ?? ''));
if ($img === null) {
http_response_code(404);
break;
}
header('Content-Type: ' . $img['mime']);
header('Content-Length: ' . strlen($img['body']));
header('Cache-Control: public, max-age=86400');
echo $img['body'];
break;
}
if ($act !== '') {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => '不支持的 action'], JSON_UNESCAPED_UNICODE);
break;
}
// 默认:抓取详情 + 海报入库(写存储,访客不行)。JSON 错误而非 302,
// 前端才能在会话过期/无权限时给出明确提示。
header('Content-Type: application/json; charset=utf-8');
if (!Auth::canWrite()) {
http_response_code(403);
echo json_encode(['error' => '访客账号没有写权限'], JSON_UNESCAPED_UNICODE);
break;
}
$idDb = trim((string)($_GET['id'] ?? ''));
$qDb = trim((string)($_GET['q'] ?? ''));
if ($idDb !== '') {
$resDb = Douban::fetchById($idDb);
} elseif ($qDb !== '') {
$resDb = Douban::fetchByTitle($qDb);
} else {
$resDb = ['ok' => false, 'error' => '请输入电影名称'];
}
if (!$resDb['ok']) {
echo json_encode(['error' => $resDb['error']], JSON_UNESCAPED_UNICODE);
break;
}
// 海报:下载字节 → poster 槽位管线(裁 2:3、缩放、转 JPEG、Storage 统一出口)
$posterKey = '';
$posterShow = '';
if (($resDb['poster_url'] ?? '') !== '') {
$bytes = Douban::downloadPoster((string)$resDb['poster_url']);
if ($bytes !== '') {
$tmp = tempnam(sys_get_temp_dir(), 'fbdb');
if ($tmp !== false) {
file_put_contents($tmp, $bytes);
$up = Upload::image($familyId, ['error' => UPLOAD_ERR_OK, 'tmp_name' => $tmp], 'poster');
@unlink($tmp);
if ($up['ok']) {
$posterKey = (string)$up['file'];
$posterShow = \App\Storage\Storage::url($familyId, $posterKey);
}
}
}
}
// 清掉上一次抓取但没被采用的海报(每次「自动获取」都下一张,不清就堆孤儿)
$oldPoster = trim((string)($_GET['old_poster'] ?? ''));
if ($oldPoster !== '' && $oldPoster !== $posterKey
&& preg_match('#^posters/[A-Za-z0-9._-]+$#', $oldPoster)) {
$stRef = Database::get()->prepare('SELECT COUNT(*) FROM movie_details WHERE poster_key = ?');
$stRef->execute([$oldPoster]);
if ((int)$stRef->fetchColumn() === 0) {
\App\Storage\Storage::delete($familyId, $oldPoster);
}
}
echo json_encode([
'ok' => true,
'source' => 'douban',
'title' => (string)($resDb['title'] ?? ''),
'meta' => $resDb['meta'],
// 拼好的简介(含导演/主演/年份/类型/片长/国家/评分 + 剧情原文)。
// 放在服务端拼、由前端填空 —— 见 Douban::summaryText 的注释:
// 这份文本会**落库**(movie_details.info),所以拼法要可测、只有一处。
'summary' => Douban::summaryText(
(array)$resDb['meta'],
(string)($resDb['title'] ?? ''),
true
),
'poster_key' => $posterKey,
'poster' => $posterShow,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
break;
// ---------------- 影院管理(v9,MovieLog 同款) ----------------
/** 影院管理单页:上面加/编表单,下面列表。GET 带参数时进入编辑态。 */
case 'movies/cinemas':
$familyId = (int)Auth::familyId();
$editId = (int)($_GET['edit'] ?? 0);
$editing = $editId > 0 ? Cinema::find($familyId, $editId) : null;
View::render('movies/cinemas', [
'title' => '影院管理',
'cinemas' => Cinema::all($familyId),
'editing' => $editing,
], 'movies');
break;
case 'movies/cinemas_save':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$editing = $id > 0 ? Cinema::find($familyId, $id) : null;
if ($id > 0 && !$editing) {
flash('err', '找不到这家影院。');
redirect('movies/cinemas');
}
$err = Cinema::save(
$familyId,
$id,
trim((string)($_POST['place'] ?? '')),
trim((string)($_POST['name'] ?? '')),
trim((string)($_POST['address'] ?? '')) !== '' ? trim((string)($_POST['address'] ?? '')) : null,
Cinema::normalizeColor((string)($_POST['color'] ?? '#888888'))
);
if ($err !== '') {
flash('err', $err);
redirect('movies/cinemas', $id > 0 ? ['edit' => $id] : []);
}
flash('ok', $id > 0 ? '影院信息已更新。' : '影院已添加。');
// 用户 2026-10-04 第 11 批 #4:「管理影院按钮,改为新建影院,点击后弹框,添加新影院,
// 不要跳转到管理影院的页面,操作被割裂了。」
// → 从**观影表单页**弹框新建时,表单带一个 back=movies/new,
// 存完回到那里(而不是甩到影院管理页),并且带上新影院的 id 让下拉直接选中它。
// ⚠️ 只认白名单里的两个值,绝不把 POST 的字符串直接当跳转地址(开放重定向)。
$back = (string)($_POST['back'] ?? '');
if ($back === 'movies/new') {
// 新建时要拿到刚插入的 id 才能"回来直接选中它"。
// ⚠️ Cinema::save() 返回的是**错误串**(空串=成功),不返回 id ——
// 所以这里按 (place, name) 回查一次;它是唯一键,查得到就是刚那条。
$newId = $id;
if ($newId <= 0) {
$st = Database::get()->prepare(
'SELECT id FROM cinemas WHERE family_id = ? AND place = ? AND name = ? LIMIT 1'
);
$st->execute([$familyId, trim((string)($_POST['place'] ?? '')),
trim((string)($_POST['name'] ?? ''))]);
$newId = (int)($st->fetchColumn() ?: 0);
}
redirect('movies/new', $newId > 0 ? ['cinema' => $newId] : []);
}
redirect('movies/cinemas');
break;
case 'movies/cinemas_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (Cinema::find($familyId, $id)) {
Cinema::destroy($familyId, $id);
flash('ok', '影院已删除,关联观影记录的影院信息已置空。');
} else {
flash('err', '找不到这家影院。');
}
redirect('movies/cinemas');
break;
// ---------------- 统计 / 高级搜索 / 年度报告(v11,从 MovieLog 迁入) ----------------
// 三个页面都挂在观影模块「设置」按钮下(顶栏不再单独放影院/统计入口)。
// 只读,不需要 requireWrite —— 访客也能看统计,与观影列表同一口径。
/** 统计仪表板 */
case 'movies/stats':
$familyId = (int)Auth::familyId();
View::render('movies/stats', [
'title' => '观影统计',
'ov' => MovieStats::overview($familyId),
'byCinema' => MovieStats::byCinema($familyId),
'byPerson' => MovieStats::byPerson($familyId, (int)Auth::personId()),
'byMonth' => MovieStats::byMonth($familyId, 12),
'byYear' => MovieStats::byYear($familyId),
], 'movies');
break;
/** 高级搜索(组合条件;条件全空时不查,页面提示先设置条件) */
case 'movies/search':
$familyId = (int)Auth::familyId();
$p = [
'title' => trim((string)($_GET['title'] ?? '')),
'cinema_id' => trim((string)($_GET['cinema_id'] ?? '')),
'person_id' => trim((string)($_GET['person_id'] ?? '')),
'date_from' => trim((string)($_GET['date_from'] ?? '')),
'date_to' => trim((string)($_GET['date_to'] ?? '')),
];
$hasCond = false;
foreach ($p as $v) {
if ($v !== '' && $v !== '0') {
$hasCond = true;
break;
}
}
View::render('movies/search', [
'title' => '高级搜索',
'cinemas' => Cinema::all($familyId),
'persons' => Person::all($familyId, '', 'member', (int)Auth::personId()),
'p' => $p,
'hasCond' => $hasCond,
'rows' => $hasCond ? MovieStats::search($familyId, $p, (int)Auth::personId()) : [],
], 'movies');
break;
/** 年度报告(以登录者为中心) */
case 'movies/report':
$familyId = (int)Auth::familyId();
$meId = (int)Auth::personId();
$years = MovieStats::years($familyId);
$year = (int)($_GET['year'] ?? 0);
if ($year <= 0 || !in_array($year, $years, true)) {
$year = $years ? (int)$years[0] : (int)date('Y');
}
$people = Person::map($familyId);
View::render('movies/report', [
'title' => $year . ' 年度观影报告',
'year' => $year,
'years' => $years ?: [$year],
'stats' => MovieStats::annual($familyId, $meId, $year),
'meName' => (string)($people[$meId]['name'] ?? '我'),
], 'movies');
break;
// ---------------- 评价与讨论(v9,MovieLog 同款) ----------------
// 评价人是「登录账号背后的人」,与同行观影人无关;访客只读(requireWrite)。
case 'movies/review_save':
$familyId = (int)Auth::familyId();
$meId = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$eventId = (int)($_POST['id'] ?? 0);
$m = Movie::find($familyId, $eventId);
if (!$m) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
$my = MovieReview::mine($eventId, $meId);
$err = MovieReview::save(
$eventId, $familyId, $meId,
(int)($_POST['rating'] ?? 0),
trim((string)($_POST['content'] ?? '')),
array_values((array)($_POST['feelings'] ?? [])),
$my !== null
);
if ($err !== '') {
flash('err', $err);
} else {
flash('ok', $my !== null ? '评价已更新。' : '评价已发布。');
}
redirect('movies/show', ['id' => $eventId], 'reviews');
break;
case 'movies/review_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$eventId = (int)($_POST['id'] ?? 0);
if (!Movie::find($familyId, $eventId)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
if (!MovieReview::deleteMine((int)($_POST['review_id'] ?? 0), (int)Auth::personId())) {
flash('err', '只能删除自己的评价。');
} else {
flash('ok', '评价已删除。');
}
redirect('movies/show', ['id' => $eventId], 'reviews');
break;
case 'movies/comment_add':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$eventId = (int)($_POST['id'] ?? 0);
$reviewId = (int)($_POST['review_id'] ?? 0);
$parentId = (int)($_POST['parent_id'] ?? 0);
if (!Movie::find($familyId, $eventId)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
$res = MovieReview::addComment(
$reviewId, (int)Auth::personId(),
trim((string)($_POST['content'] ?? '')),
array_values((array)($_POST['feelings'] ?? [])),
$parentId
);
if (is_string($res)) {
flash('err', $res);
} else {
flash('ok', $parentId > 0 ? '回复已发表。' : '评论已发表。');
}
redirect('movies/show', ['id' => $eventId], 'reviews');
break;
case 'movies/comment_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$eventId = (int)($_POST['id'] ?? 0);
if (!Movie::find($familyId, $eventId)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
if (!MovieReview::deleteComment((int)($_POST['comment_id'] ?? 0), (int)Auth::personId())) {
flash('err', '只能删除自己的评论。');
} else {
flash('ok', '评论已删除。');
}
redirect('movies/show', ['id' => $eventId], 'reviews');
break;
// ==================== 旅程(v12,TripLog 迁入) ====================
// 与观影共用 events 骨架(kind='trip'):一次出行 = 一个有起止、有人、会上日历的事件。
// 旅程特有的(封面 / 小结)放 trip_details,行程段落放 trip_nodes(计划 / 执行双轨)。
case 'trips':
$familyId = (int)Auth::familyId();
$keyword = trim((string)($_GET['keyword'] ?? ''));
$rows = Trip::all($familyId, $keyword);
Trip::attachAttendees($rows, (int)Auth::personId());
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');
}
$d = [
'title' => trim((string)($_POST['title'] ?? '')),
// ⚠️ 用户第 15 批 第 2 条:活动**不收**起止时间与目的地 ——
// 时间由各段旅程的起止算出来、城市由各段旅程的 place 去重得到。
// 表单里已经没有这些字段;这里不是"忘了读",是**故意不读**。
// ($startsAt / $endsAt 的 read_dt_input 一并删掉了,
// 否则"表单没这两个字段 → 读出 null → 把库里老值冲成 NULL",
// 会静默毁掉迁移前的数据。)
'note' => trim((string)($_POST['note'] ?? '')),
'summary' => trim((string)($_POST['summary'] ?? '')),
// 用户 2026-10-03 第 4 条:旅程是否公开(只影响列表与日历,不影响能否直接打开)
// 用户第 4 条:是否公开。
// ⚠️ 三种情况必须分清(少了任何一种都会错):
// ① 表单**没这个字段**(程序化调用 / 老表单 / 迁移)→ 连键都不放,
// 让 Trip::saveDetails 按"默认公开"处理;
// ② 表单提交了 hidden=0(用户取消勾选)→ 明确的 0;
// ③ 勾选 → '1'。
// 之前写成 `?? ''` 会把 ① 也变成"键存在且为空" → 被当成不公开,
// 于是数据在列表里**静默消失**(实测:新建的旅程从列表与日历上都没了)。
// ⚠️ v24 第 16 批 用户第 10 条:建/编辑旅程的表单里**没有**同行的人了
// ("这些都是从行程里面总结出来的")。所以这里**不再收** attendees。
// ⚠️ 但**不能**就此把活动上那份名单清空:老数据里很多人的参与人
// 只登记在这一层(那时还没有行程级参与人),清掉就"人没了"。
// 所以"键不存在"时保持原样(Trip::saveAttendees 收到空数组会全删,
// 这里刻意不传这个键)。新数据的参与人一律从行程里长出来。
];
if (array_key_exists('is_public', $_POST)) {
$d['is_public'] = (string)$_POST['is_public'];
}
$errors = [];
if ($d['title'] === '') {
$errors['title'] = '旅程名字不能为空';
}
// 「回来不能早于出发」的校验一并删掉:时间不再由这里填,
// 而是从各段旅程算 max/min —— 二者反了也不可能(min ≤ max 恒成立)。
// 封面图(编辑时换新图才动它;失败不挡保存,与观影海报同一个口径)
$oldCover = $isEdit ? (string)($t['cover_key'] ?? '') : '';
$d['cover_key'] = $oldCover;
$warns = [];
// v24 第 14 批 用户第 3 条:题图按 **16:9 裁剪**(走 'cover' 槽位,不再是
// 'photo' 那种"只缩不裁"的自由槽位)。裁剪矩形由前端 Cropper 写成归一化
// "x,y,w,h";没裁(空串)时服务端按 16:9 居中裁。
$up = Upload::image($familyId, $_FILES['cover'] ?? [], 'cover',
(string)($_POST['cover_crop'] ?? ''));
if ($up['ok']) {
$d['cover_key'] = (string)$up['file'];
if ($oldCover !== '' && $oldCover !== $d['cover_key']) {
\App\Storage\Storage::delete($familyId, $oldCover);
}
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = '封面图没有保存:' . $up['error'];
}
if ($errors) {
set_old($_POST);
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,
'nodes' => Trip::nodes((int)$t['id'], $meId),
// 用户第 2 条:题图右侧那五项统计(参与人/城市/天数/段数/花销)
'sm' => Trip::summary((int)$t['id'], $meId),
], 'trips');
break;
// ---------------- 行程:新增 / 编辑(独立页面,v24 第 16 批 第 5/6/7 条)----------------
//
// ⚠️ 用户第 5 条:「行程的编辑功能,点击后会把页面撑破。编辑行程,打开新的页面进行编辑。」
// 以前用的是详情页里的 <details> 内联表单 —— 每个行程一份,
// TripLog 那种十几组字段的表单内联一开就把页面撑到几屏长,
// 而且 N 份表单的元素 id 互相撞、密码管理器逐个扫描还会卡。
//
// 表单字段照 TripLog 的 nodes/form.php(用户第 7 条)。
case 'trips/node_new':
case 'trips/node_edit':
Auth::requireWrite();
$familyId = (int)Auth::familyId();
$isEdit = $r === 'trips/node_edit';
$t = null;
$nd = null;
$stage = 'plan';
if ($isEdit) {
// ⚠️ 必须带上 $viewerId:Trip::node() 现在会一并挂上 members(第 19 批 第 5 条:
// "编辑行程参与人是空的")。成员读取要在**看的人**视角下取昵称。
$nd = Trip::node((int)($_GET['id'] ?? 0), (int)Auth::personId());
if (!$nd) {
flash('err', '找不到这段行程。');
redirect('trips');
}
// ⚠️ 必须校验这段行程确实属于**本家的**某个旅程:
// 否则知道 id 就能改别人家的行程(走的还是 Auth::requireWrite)
$t = Trip::find($familyId, (int)$nd['event_id'], (int)Auth::personId());
if (!$t) {
flash('err', '找不到这段行程。');
redirect('trips');
}
$stage = (string)($nd['stage'] ?? 'plan');
} else {
$t = Trip::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId());
if (!$t) {
flash('err', '找不到这个旅程。');
redirect('trips');
}
$stage = (string)($_GET['stage'] ?? 'plan');
if (!isset(Trip::NODE_STAGES[$stage]) || $stage === 'skipped') {
$stage = 'plan'; // "未成行"是标记出来的,不是新建出来的
}
}
View::render('trips/node_form', [
'title' => ($isEdit ? '编辑行程' : '添加行程') . ' · ' . (string)$t['title'],
'ev' => $t,
'n' => $nd,
'stage' => $stage,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'types' => NodeType::all($familyId),
'vehicles' => Vehicle::pickable($familyId),
'expenses' => $nd ? Expense::ofNode((int)$nd['id']) : [],
'errors' => [],
], 'trips');
break;
// ---------------- 花销(行程级,v24 第 16 批 第 2 条)----------------
case 'trips/expense_add':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$nodeId = (int)($_POST['node_id'] ?? 0);
// 校验这段行程确实属于本家(顺着 nodes → events 查一遍)
$st = Database::get()->prepare(
"SELECT n.id FROM trip_nodes n JOIN events e ON e.id = n.event_id
WHERE n.id = ? AND n.family_id = ? AND e.family_id = ?"
);
$st->execute([$nodeId, $familyId, $familyId]);
if ($st->fetchColumn() === false) {
flash('err', '找不到这段行程。');
redirect('trips');
}
$res = Expense::add($familyId, $nodeId, [
'amount' => (string)($_POST['amount'] ?? ''),
'category' => (string)($_POST['category'] ?? ''),
'title' => (string)($_POST['title'] ?? ''),
'person_id' => (int)($_POST['person_id'] ?? 0),
]);
flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '花销记下了。' : (string)$res['error']);
redirect('trips/node_edit', ['id' => $nodeId]);
break;
case 'trips/expense_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$nodeId = (int)($_POST['node_id'] ?? 0);
Expense::destroy($familyId, (int)($_POST['expense_id'] ?? 0));
flash('ok', '这笔花销删掉了。');
redirect('trips/node_edit', ['id' => $nodeId]);
break;
// ---------------- 旅程设置:车辆 / 出行方式 / 节点类型(用户第 8 条)----------------
case 'trips/vehicles':
$familyId = (int)Auth::familyId();
View::render('trips/vehicles', [
'title' => '车辆管理',
// ⚠️ v24 第 17 批 第 3 条:**不再传** ledger(财务里的车)——
// 用户原话「车辆下拉框,不需要出现什么财务里登记的车」。
// 模板里那一整块也删掉了。fromLedger() 方法仍在模型层保留
// (将来按车统计油费/保养要用它跟 vehicles.ledger_id 对上)。
'mine' => Vehicle::all($familyId),
], 'trips');
break;
case 'trips/vehicles_save':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$res = Vehicle::save($familyId, $_POST, $id > 0 ? $id : null);
flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '车辆已保存。' : (string)$res['error']);
redirect('trips/vehicles');
break;
case 'trips/vehicles_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$res = Vehicle::destroy($familyId, (int)($_POST['id'] ?? 0));
flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '车辆已删除。' : (string)$res['error']);
redirect('trips/vehicles');
break;
case 'trips/modes':
$familyId = (int)Auth::familyId();
// "常用信息"的记忆:每种出行方式下用户常填的值(不是新增交通方式)
View::render('trips/modes', [
'title' => '出行方式',
'modes' => Trip::TRAVEL_MODES,
'memo' => json_decode((string)Setting::get($familyId, 'trip_mode_memo', '{}'), true) ?: [],
], 'trips');
break;
case 'trips/modes_save':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$raw = (array)($_POST['memo'] ?? []);
$out = [];
foreach ($raw as $k => $v) {
// 只认确实存在的出行方式,值限长 —— 表单可以改,不能凭它往设置里塞任意东西
if (!isset(Trip::TRAVEL_MODES[(string)$k])) {
continue;
}
$v = mb_substr(trim((string)$v), 0, 60, 'UTF-8');
if ($v !== '') {
$out[(string)$k] = $v;
}
}
Setting::set($familyId, 'trip_mode_memo', json_encode($out, JSON_UNESCAPED_UNICODE));
flash('ok', '出行方式设置已保存。');
redirect('trips/modes');
break;
case 'trips/types':
$familyId = (int)Auth::familyId();
$types = NodeType::all($familyId);
foreach ($types as &$ty) {
$ty['used'] = NodeType::usedCount($familyId, (string)$ty['name']);
}
unset($ty);
View::render('trips/types', [
'title' => '节点类型',
'types' => $types,
], 'trips');
break;
case 'trips/types_save':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$res = NodeType::save($familyId, $_POST, $id > 0 ? $id : null);
flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '类型已保存。' : (string)$res['error']);
redirect('trips/types');
break;
case 'trips/types_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$res = NodeType::destroy($familyId, (int)($_POST['id'] ?? 0));
flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '类型已删除。' : (string)$res['error']);
redirect('trips/types');
break;
case 'trips/destroy':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) {
flash('err', '找不到这个旅程。');
redirect('trips');
}
Trip::destroy($familyId, $id);
flash('ok', '这个旅程已删除。');
redirect('trips');
break;
case 'trips/photo_add':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) {
flash('err', '找不到这个旅程。');
redirect('trips');
}
$warns = attach_save_files($familyId, 'trip', $id, 'photo', (int)Auth::personId());
if ($warns) {
flash('err', '有照片没有保存:' . implode(';', $warns));
} else {
flash('ok', '照片已加上。');
}
redirect('trips/show', ['id' => $id]);
break;
case 'trips/photo_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) {
flash('err', '找不到这个旅程。');
redirect('trips');
}
\App\Attachment::remove((int)($_POST['photo_id'] ?? 0));
flash('ok', '照片已删除。');
redirect('trips/show', ['id' => $id]);
break;
// ---- 行程节点(计划 / 执行双轨)----
case 'trips/node_add':
case 'trips/node_update':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
$t = Trip::find($familyId, $tripId);
if (!$t) {
flash('err', '找不到这个旅程。');
redirect('trips');
}
$isEdit = $r === 'trips/node_update';
$nid = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
/* ---- v24 第 21 批 第 4 条:**编辑行程绝不能悄悄改阶段** ----
用户原话:「旅程模块,实际行程编辑后,点击保存,竟然又变成计划行程了,
这个不合理,实际行程永远是实际行程,不会再变回计划行程了。」
这条违规的直接原因(`stage[]` 与 `stage` 同名合并 → 解析成 "Array")
已经通过改字段名修掉了(见 `_stage_drop.php` 头部)。
但这里再上一道**语义保险**:阶段只由「新建」和详情页那三个显式动作
(完成 / 标记未成行 / 标记为实际 / 恢复为计划)决定 ——
**编辑表单里根本没有让你改阶段的控件**,所以:
· 新建:认表单里那个隐藏 `stage`(值由「+ 计划行程 / + 实际行程」按钮决定,
服务端在 trips/node_new 里按 `?stage=` 渲染进去);
· 编辑:**一律沿用库里那一段自己的 stage**,不读表单。
这样即便将来又冒出某个字段名冲突、或有人往表单里加了个 `stage`,
也不可能把「实际」悄悄变回「计划」—— 那是最不该发生的静默数据损坏。
⚠️ 想改阶段请走详情页的显式按钮(它们各自有独立路由,语义清楚、可确认)。
⚠️ 新建这里的取值用 `!is_array` 兜一道:**同一个坑的通用防线** ——
`stage` 一旦因为任何原因变成数组(比如将来又有个 `stage[]`),
`(string)` 会得到 "Array" → `in_array` 为假 → 静默变成「计划」。
这里明确只接受标量,别的一律不认(宁可退回默认,也不把脏值写进库)。 */
$stageForSave = 'plan';
if ($isEdit) {
$curNode = Trip::node($nid);
$stageForSave = (string)($curNode['stage'] ?? 'plan');
} else {
$pick = $_POST['stage'] ?? 'plan';
$stageForSave = is_array($pick) ? 'plan' : (string)$pick;
}
// v24 第 16 批:表单照 TripLog 的节点,字段全量(用户第 7 条)。
// ⚠️ 第 17 批 用户第 3 条把「角色标注」去掉了("这个功能去掉,不要了"),
// 所以这里**不再读** roles;座位/登机口也不再从表单收(字段已从界面移除,
// 数据库那两列保留,详见 Trip::saveNode 的处理)。
$d = [
'title' => trim((string)($_POST['title'] ?? '')),
'detail' => trim((string)($_POST['detail'] ?? '')),
'place' => trim((string)($_POST['place'] ?? '')),
'starts_at' => (string)(read_dt_input('starts_at') ?? ''),
'ends_at' => (string)(read_dt_input('ends_at') ?? ''),
'node_type' => (string)($_POST['node_type'] ?? 'other'),
'status' => (string)($_POST['status'] ?? 'planned'),
'category' => (string)($_POST['category'] ?? 'plan'),
// 新建:认表单里选的「计划 / 实际」;**编辑:一律沿用库里的**(见上面那段说明)
'stage' => $stageForSave,
'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId),
// ---- v21:交通(TripLog 同款)----
'travel_mode' => (string)($_POST['travel_mode'] ?? ''),
'vehicle_id' => (int)($_POST['vehicle_id'] ?? 0),
'line_name' => trim((string)($_POST['line_name'] ?? '')),
'carrier' => trim((string)($_POST['carrier'] ?? '')),
'trip_no' => trim((string)($_POST['trip_no'] ?? '')),
// ---- v22:出发地 / 到达地(地点名 + 详细地址 + 坐标 + 城市)----
// 用户第 17 批 第 3 条:「出发地和到达地,都用高德地图的接口,记录详细地址」。
// 城市由前端联想带回(`*_city`),带不回时 Trip::saveNode 里用 App\Geo 解析。
'from_place' => trim((string)($_POST['from_place'] ?? '')),
'to_place' => trim((string)($_POST['to_place'] ?? '')),
'from_addr' => trim((string)($_POST['from_addr'] ?? '')),
'to_addr' => trim((string)($_POST['to_addr'] ?? '')),
'from_lng' => trim((string)($_POST['from_lng'] ?? '')),
'from_lat' => trim((string)($_POST['from_lat'] ?? '')),
'to_lng' => trim((string)($_POST['to_lng'] ?? '')),
'to_lat' => trim((string)($_POST['to_lat'] ?? '')),
'from_city' => trim((string)($_POST['from_city'] ?? '')),
'to_city' => trim((string)($_POST['to_city'] ?? '')),
];
if ($d['title'] === '') {
flash('err', '这段行程得有个名字。');
redirect('trips/show', ['id' => $tripId]);
}
$newId = Trip::saveNode($familyId, $tripId, $d, $isEdit ? $nid : null);
$warns = attach_save_files($familyId, 'trip_node', $newId, 'photo', (int)Auth::personId());
flash('ok', ($isEdit ? '这段行程已保存。' : '行程已加进来。')
. ($warns ? '(' . implode(';', $warns) . ')' : ''));
// ⚠️ 用户第 5 条:编辑是**独立页面**,保存后回详情页(而不是留在表单上)
redirect('trips/show', ['id' => $tripId], 'node-' . $newId);
break;
case 'trips/node_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这个旅程。');
redirect('trips');
}
Trip::deleteNode((int)($_POST['id'] ?? 0));
flash('ok', '这一段已删除。');
redirect('trips/show', ['id' => $tripId]);
break;
// ---- 计划行程的三个动作(v24 第 13 批)----
// 用户原话:「如果是计划行程,则这个行程下面有几个按钮,用来进一步操作,
// 一个是删除,如果是标记为删除,不要真的删除,只是做一个标记…
// 另外一个按钮,用来记录实际的情况,按钮名字就叫修正…
// 还有一个按钮,就是完成…这个行程直接就变成了实际行程。」
//
// ⚠️ 三个动作都**只改状态、不真删** —— 这是用户明确要求的语义。
/** 「修正」:记一次实际执行情况(可无限次;行程仍是计划,要再点「标记为实际」) */
case 'trips/node_revise':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这个旅程。');
redirect('trips');
}
$nid = (int)($_POST['id'] ?? 0);
$seq = Trip::reviseNode($nid, [
'starts_at' => (string)(read_dt_input('rev_starts_at') ?? ''),
'ends_at' => (string)(read_dt_input('rev_ends_at') ?? ''),
'place' => trim((string)($_POST['place'] ?? '')),
'detail' => trim((string)($_POST['detail'] ?? '')),
'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId),
], (int)Auth::personId());
flash('ok', $seq > 0 ? '已记录第 ' . $seq . ' 次修正。它仍是**计划行程**;'
. '确认无误后点「标记为实际」即可转为实际行程。' : '修正没成功。');
redirect('trips/show', ['id' => $tripId]);
break;
/** 「标记为实际」:把(已经修正过的)计划行程正式转为实际行程 */
case 'trips/node_actual':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这个旅程。');
redirect('trips');
}
Trip::markActual((int)($_POST['id'] ?? 0));
flash('ok', '已转为**实际行程** —— 它会出现在日历与首页的「往年今日」。');
redirect('trips/show', ['id' => $tripId]);
break;
/** 「完成」:实际与计划完全相同 → 直接变实际行程(不产生修正历史) */
case 'trips/node_done':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这个旅程。');
redirect('trips');
}
Trip::completeNode((int)($_POST['id'] ?? 0));
flash('ok', '这一段已按计划完成,转为**实际行程**。');
redirect('trips/show', ['id' => $tripId]);
break;
/** 「标记未成行」:不真删,只打标记(不上日程、不进往年今日,但旅程里仍显示) */
case 'trips/node_skip':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这个旅程。');
redirect('trips');
}
Trip::skipNode((int)($_POST['id'] ?? 0));
flash('ok', '已标记为未成行。它不再出现在日程与往年今日,但在这个旅程里仍然看得到。');
redirect('trips/show', ['id' => $tripId]);
break;
/** 撤销「未成行」→ 退回计划行程(点错了要能改回来) */
case 'trips/node_restore':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这个旅程。');
redirect('trips');
}
Trip::restoreNode((int)($_POST['id'] ?? 0));
flash('ok', '已恢复为计划行程,它会重新出现在日程里。');
redirect('trips/show', ['id' => $tripId]);
break;
// ==================== 日程 / 日历(v12) ====================
// 自定义日程落在 calendar_events;**观影与旅行两本是派生的**(从 events 实时汇总,
// 不复制),给这两本"加日程"= 真的建一条观影 / 旅程记录。
/**
* 财务(v24 第 15 条):账本 + 账目 + 共享。
*
* 用户原话要的是「一个可以共享的家庭总账」:先有账本,账本共享给某人,
* 双方都能往里记账、都能看到全部账目,最后算出总数。
* ⚠️ 金额一律以**分**存取(见 Ledger::toCents 的注释),显示时再转元。
*/
case 'ledger':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$books = Ledger::mine($familyId, $me);
// 一本都没有 → 自动建一本(不然新用户进来一片空白,也不知道要先"创建家庭")
if (!$books) {
Ledger::ensureDefault($familyId, $me);
$books = Ledger::mine($familyId, $me);
}
$lid = (int)($_GET['book'] ?? 0);
$cur = null;
foreach ($books as $b) {
if ($lid > 0 && (int)$b['id'] === $lid) {
$cur = $b;
break;
}
}
if (!$cur) {
$cur = $books[0];
}
$entries = Ledger::entries((int)$cur['id']);
// v24 第 23 批 用户第 2 条:上面那张「总金额图」点类别标签后要换成**该类别的曲线**。
// 图是**服务端画 SVG**(零依赖,见 app/chart.php 的头),所以每一类都得
// 预先算好时间线 —— 点标签只是显隐切换,不用再打一次服务器(点了就变,无延迟)。
// ⚠️ 只在真出现该类别时才算:类别最多 10 个,但"只为用到的算"能省下
// 每个类别一次全表遍历(账目多时这个差别是能感觉到的)。
$sumNow = Ledger::summary($entries);
$kindLines = [];
foreach (array_keys((array)$sumNow['by_kind']) as $k) {
$kindLines[(string)$k] = Ledger::timeline((int)$cur['id'], (string)$k);
}
View::render('ledger/index', [
'title' => '财务',
'books' => $books,
'cur' => $cur,
// ⚠️ `me` 必须传进去:模板里「挂在谁名下默认是自己」(用户第 8 条)
// 要用它当默认选中值。漏传的话默认值会静默变成"不选" ——
// 功能"看起来在",只是每次都要手动点一次选人框。
'me' => $me,
'entries' => $entries,
'summary' => $sumNow,
'shares' => Ledger::shares((int)$cur['id']),
// 用户 2026-10-03 第 8 条:上面画"金额随时间怎么变",所以要历史。
'charts' => Ledger::charts((int)$cur['id']),
'timeline'=> Ledger::timeline((int)$cur['id']),
'kindLines' => $kindLines,
'people' => Person::roster($familyId, $me, 'member', ''),
], 'ledger');
break;
/* 🔴 v24 第 26 批 用户第 1 条:**多选类别时,上面那张「总金额」图要按选中集合统计**。
*
* 用户原话:「财务模块,多个标签勾选后,第一个图,总额那里,勾选一个会变化,
* 勾选多个后,变成总数了。」
*
* 上一批(第 25 批)把标签改成多选时,上图只保留了「恰好选一类」才切图的分支:
* 选 1 类 → 显示该类曲线;
* 选 2 类以上 → `sole` 为空 → **回落成"整本账"**(这正是用户看到的"变成总数了")。
*
* 为什么不能在页面里把几条已渲染的曲线**加起来**:图是服务端画的 **SVG**,
* 里面是一堆 `<path>` 与坐标 —— 前端没法把两条折线"相加"成一条。
* (金额能加,但曲线形状加不出来:各自的时间刻度、各自的纵轴比例都不一样。)
*
* 所以做法是**按需现画**:前端把选中集合 POST 过来,这里用
* `Ledger::timeline($lid, $kinds)`(它已支持集合,见其 @param)现算一条曲线,
* 用与页面**同一个** `chart_line()` 画成 SVG 回给前端替换。
*
* ⚠️ 这里是 **GET**(只读、可缓存、可分享),所以不写 csrf ——
* 它不改变任何状态。但仍要 **Auth::requireLogin()**:账本金额是家庭隐私。
* ⚠️ 只认**本账本真的有的类别**(拿 summary 的白名单过滤),
* 否则 `?kinds=xxx` 可以随便塞字符串(虽然只会算不出东西,但没必要留口子)。 */
case 'ledger/total_chart':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$lid = (int)($_GET['book'] ?? 0);
$book = Ledger::find($familyId, $lid, $me);
if (!$book) {
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'msg' => '账本不存在。'], JSON_UNESCAPED_UNICODE);
exit;
}
/* 🔴🔴 v28 用户第 8 条:「财务,多选分类的时候,净资产 · 金额变化这个图表,
里面的数字还是不改变。」
根因:**前后端的参数形状对不上** ——
· 前端(templates/ledger/index.php)发的是 `&kinds=deposit%2Chouse`
(把选中集合拼成**一个逗号串**,因为 URLSearchParams 里一个 key 只能一个值);
· 服务端这里写的是 `(array)($_GET['kinds'] ?? [])` —— 对字符串来说是
`['deposit,house']`(**一个元素**,值是带逗号的整串),
拿去和真实类别白名单求交 → 空 → 下面的 `if (!$kinds)` 分支把它当成"空集合"
→ **回落成"整本账"** → 图与数字当然不变。
⇒ 修法:把逗号串**也切开**(两种形状都收:`kinds=a,b` 与 `kinds[]=a&kinds[]=b`)。
只收一种形状就会在下次换前端写法时再断一次 —— 收两种是一行代码的事。
⚠️ 这与 `Ledger::timeline()` 的入参口径一致:它本来就同时接受字符串与数组。 */
$_rawKinds = $_GET['kinds'] ?? [];
if (!is_array($_rawKinds)) {
$_rawKinds = [$_rawKinds];
}
$want = [];
foreach ($_rawKinds as $_rk) {
// 每个值再按逗号切一次 —— `a,b` 与 `a` 都能吃
foreach (preg_split('/[,\s]+/', (string)$_rk) ?: [] as $_one) {
$_one = trim($_one);
if ($_one !== '') {
$want[$_one] = true;
}
}
}
$want = array_keys($want);
// 白名单:只留下这本账里真实存在的类别
$present = array_map('strval', array_keys((array)Ledger::summary(Ledger::entries($lid))['by_kind']));
$kinds = array_values(array_intersect($want, $present));
if (!$kinds) {
// 空集合 = 整本账(与前端 `picked.length === 0` 的语义一致)
$tl = Ledger::timeline($lid);
$label = '净资产';
$tkind = 'asset';
} else {
$tl = Ledger::timeline($lid, $kinds);
// 全选中的都是负债类 → 用绿线(与单类别那套配色一致)
$allDebt = true;
foreach ($kinds as $k) {
if (!Ledger::isDebt($k)) { $allDebt = false; break; }
}
$label = count($kinds) === 1
? Ledger::kindWord($kinds[0])
: count($kinds) . ' 类合计';
$tkind = $allDebt ? 'debt' : 'asset';
}
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: private, max-age=30');
echo json_encode([
'ok' => true,
'svg' => $tl ? chart_line($tl, $label, $tkind, 900) : '',
'label' => $label,
'n' => count($tl),
], JSON_UNESCAPED_UNICODE);
break;
case 'ledger/book_add':
case 'ledger/book_rename':
case 'ledger/book_del':
case 'ledger/entry_add':
case 'ledger/entry_update':
case 'ledger/entry_del':
case 'ledger/reconcile':
case 'ledger/share':
case 'ledger/unshare':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$lid = (int)($_POST['ledger_id'] ?? 0);
$act = (string)($_GET['r'] ?? '');
$book = $lid > 0 ? Ledger::find($familyId, $lid, $me) : null;
if ($act === 'ledger/book_add') {
$id = Ledger::create($familyId, $me, (string)($_POST['name'] ?? ''));
flash('ok', '账本已创建。');
redirect('ledger', ['book' => $id]);
}
if (!$book) {
flash('err', '找不到这本账。');
redirect('ledger');
}
if (!Ledger::canWrite($book)) {
flash('err', '这本账你只有只读权限。');
redirect('ledger', ['book' => $lid]);
}
if ($act === 'ledger/book_rename') {
Ledger::rename($familyId, $lid, (string)($_POST['name'] ?? ''));
flash('ok', '账本已改名。');
} elseif ($act === 'ledger/book_del') {
Ledger::remove($familyId, $lid);
flash('ok', '账本已删除。');
redirect('ledger');
} elseif ($act === 'ledger/entry_add') {
Ledger::addEntry($lid, [
'kind' => (string)($_POST['kind'] ?? 'other'),
'title' => (string)($_POST['title'] ?? ''),
'amount' => (string)($_POST['amount'] ?? ''),
'note' => (string)($_POST['note'] ?? ''),
'person_id' => (int)($_POST['person_id'] ?? 0),
], $me);
flash('ok', '账目已添加。');
} elseif ($act === 'ledger/entry_update') {
Ledger::updateEntry($lid, (int)($_POST['id'] ?? 0), [
'kind' => (string)($_POST['kind'] ?? 'other'),
'title' => (string)($_POST['title'] ?? ''),
'amount' => (string)($_POST['amount'] ?? ''),
'note' => (string)($_POST['note'] ?? ''),
'person_id' => (int)($_POST['person_id'] ?? 0),
]);
flash('ok', '账目已保存。');
} elseif ($act === 'ledger/entry_del') {
Ledger::removeEntry($lid, (int)($_POST['id'] ?? 0));
flash('ok', '账目已删除。');
} elseif ($act === 'ledger/reconcile') {
// 用户 2026-10-03 第 8 条:盘点 = 记一次新金额,**旧值不被清空**
// (落一行快照,见 Ledger::reconcile 的注释)。
$eid = (int)($_POST['id'] ?? 0);
$ok = Ledger::reconcile(
$lid, $eid,
(string)($_POST['amount'] ?? ''),
(string)($_POST['note'] ?? ''),
$me
);
flash($ok ? 'ok' : 'err', $ok ? '盘点已记下(历史保留)。' : '找不到这笔账目。');
} elseif ($act === 'ledger/share') {
$pid = (int)($_POST['person_id'] ?? 0);
if ($pid > 0 && isset(Person::map($familyId)[$pid])) {
Ledger::setShare($lid, $pid, (string)($_POST['perm'] ?? 'read'));
flash('ok', '已共享。');
} else {
flash('err', '请选择要共享给的人。');
}
} else {
Ledger::unshare($lid, (int)($_POST['person_id'] ?? 0));
flash('ok', '已取消共享。');
}
redirect('ledger', ['book' => $lid]);
break;
/**
* 纪念日(v24 第 13 条):阴历/阳历都行;名册里每个人的生日默认可见
* (生日本身就是纪念日的一种,且**实时派生**、不复制一份)。
* 所有纪念日都会作为**全天日程**出现在日历里(见 Calendar::month 那段)。
*/
case 'days':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// 用户 2026-10-03 第 9 条:手动添加的与模块派生的**分开**,手动的在上。
$sp = Anniversary::split($familyId, $me);
$people = Person::roster($familyId, $me, 'member', '');
// v24 第 25 批 用户第 2 条:「再加一列,关联人。列表中,人的前面,都加头像。」
// 模板按 id 取头像与称呼 → 这里先做成 id => 行 的映射(不必在模板里循环找人)。
$pmap = [];
foreach ($people as $pp) {
$pmap[(int)$pp['id']] = $pp;
}
View::render('days/index', [
'title' => '纪念日',
'custom' => $sp['custom'],
'derived' => $sp['derived'],
'upcoming' => Anniversary::upcoming($familyId, $me, 60),
'kinds' => Anniversary::kinds($familyId),
'kindCounts' => Anniversary::kindCounts($familyId),
'people' => $people,
'peopleMap' => $pmap,
], 'days');
break;
/**
* 纪念日分类设置(v24 用户 2026-10-03 第 9 条)。
*
* 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」
*
* 分类清单是**家庭级**的(全家共用一套,不然「结婚纪念日」在两个人屏幕上
* 是两套叫法,筛选/看的时候对不上)。所以放在 settings 而不是 user_settings。
*/
case 'days/kinds':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
// 两个动作共用一个路由:加一个 / 删一个(表单里带 del 或 add)
$kinds = Anniversary::kinds($familyId);
$del = trim((string)($_POST['del'] ?? ''));
if ($del !== '') {
$kinds = array_values(array_filter($kinds, static fn($k) => (string)$k !== $del));
Anniversary::saveKinds($familyId, $kinds);
flash('ok', '分类已删除(已用它的纪念日不变,只是新建时不再出现在下拉里)。');
redirect('days', [], 'anniv-kinds');
}
$add = trim((string)($_POST['add'] ?? ''));
if ($add !== '') {
if (mb_strlen($add, 'UTF-8') > 20) {
flash('err', '分类名太长了(最多 20 个字)。');
} else {
$kinds[] = $add;
Anniversary::saveKinds($familyId, $kinds);
flash('ok', '分类已添加。');
}
redirect('days', [], 'anniv-kinds');
}
// 整份覆盖(设置面板里直接改一串)
$list = (array)($_POST['kinds'] ?? []);
Anniversary::saveKinds($familyId, $list);
flash('ok', '分类已保存。');
redirect('days', [], 'anniv-kinds');
break;
case 'days/new':
case 'days/edit':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$id = (int)($_GET['id'] ?? 0);
$editing = $id > 0 ? Anniversary::find($familyId, $id) : null;
if ($id > 0 && !$editing) {
flash('err', '找不到这条纪念日。');
redirect('days');
}
// v24 第 25 批 用户第 2 条:编辑页要把**已关联的多个人**预勾选出来。
// 新表为空时回落到老列 person_id(升级过渡期的兜底)。
if ($editing) {
$pids = Anniversary::peopleOf($editing);
$editing['person_ids'] = $pids;
}
View::render('days/form', [
'title' => $id > 0 ? '编辑纪念日' : '新建纪念日',
'editing' => $editing,
'kinds' => Anniversary::kinds($familyId),
'people' => Person::roster($familyId, $me, 'member', ''),
], 'days');
break;
case 'days/save':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
// v24 第 25 批 用户第 2 条:关联人改成**多值** `person_ids[]`。
// ⚠️ 兼容老表单:只传单个 `person_id` 时也认(见 Anniversary::save 的说明)。
$pids = (array)($_POST['person_ids'] ?? []);
if (!$pids && isset($_POST['person_id'])) {
$pids = [(int)$_POST['person_id']];
}
Anniversary::save($familyId, [
'title' => (string)($_POST['title'] ?? ''),
'repeat_type' => (string)($_POST['repeat_type'] ?? 'solar'),
'month' => (int)($_POST['month'] ?? 1),
'day' => (int)($_POST['day'] ?? 1),
'is_leap' => !empty($_POST['is_leap']),
'kind' => (string)($_POST['kind'] ?? ''),
'note' => (string)($_POST['note'] ?? ''),
'person_ids' => array_map('intval', $pids),
], $id);
flash('ok', $id > 0 ? '纪念日已保存。' : '纪念日已添加。');
redirect('days');
break;
case 'days/destroy':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
Anniversary::remove($familyId, (int)($_POST['id'] ?? 0));
flash('ok', '纪念日已删除。');
redirect('days');
break;
case 'calendar':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// v24 第 4 条:月 / 周 / 天三种看法。view 只认这三个,认不出就回落**默认视图**。
// ⚠️ 默认视图是**每人一份**的设置(用户第 5 条:设置里选月/周/天)。
// 但只在**没带 view 参数**时才生效 —— 手点了「周」按钮之后,
// 那个选择必须优先,否则每次翻页都被默认值拽回去。
$viewIn = trim((string)($_GET['view'] ?? ''));
if ($viewIn === '') {
$view = Calendar::defaultView($me);
} else {
$view = in_array($viewIn, ['month', 'week', 'day'], true) ? $viewIn : 'month';
}
$anchor = trim((string)($_GET['d'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $anchor)) {
$anchor = date('Y-m-d');
}
$ym = substr($anchor, 0, 7);
/* 🔴 v24 第 21 批 第 7 条:**打开日历顺手补一本「我的日程」**。
为什么必须在这儿补(而不是只改 DEFAULT_KINDS):
`Calendar::seedDefaults()` 只在**安装**与**结构升级**两条路径上跑过,
线上这套库早就升完级了 → 老用户永远拿不到那一本。
结果:可写的普通本 0 本、"放在哪本"全是派生本、默认还选中排最前的
**观影** → 用户直接点「加上」建出来的是一条**观影记录**(建错东西)。
⚠️ 幂等(owner_id + kind='custom' 有就不再补),一次请求只多一条 SELECT。 */
Calendar::ensurePersonalBook($familyId, $me);
$cals = Calendar::mine($familyId, $me);
// 用户第 4 条:哪些本子显示,改由设置里的复选框决定(以前是左侧那一栏)。
// 藏掉的本子**不参与取数**,而不是"取出来再 display:none" ——
// 前端藏的话,关掉一本之后月视图的"+N 条"仍然按它算,数字对不上。
$hiddenCals = Calendar::hiddenCalendars($me);
$visCals = [];
foreach ($cals as $c) {
if (!isset($hiddenCals[(int)$c['id']])) {
$visCals[] = $c;
}
}
if ($view === 'month') {
$gym = trim((string)($_GET['ym'] ?? ''));
if (preg_match('/^\d{4}-\d{2}$/', $gym)) {
$ym = $gym;
}
[$from, $to] = month_grid_range($ym);
$events = Calendar::month($familyId, $visCals, $me, $from, $to);
$grid = build_month_grid($ym, $events);
} else {
// 周视图 7 列、天视图 1 列,共用同一个时间网格
$cols = day_columns($anchor, $view === 'week' ? 7 : 1);
$from = $cols[0]['date'];
$to = $cols[count($cols) - 1]['date'];
$events = Calendar::month($familyId, $visCals, $me, $from, $to);
$grid = build_time_grid($events, $cols);
}
// 用户第 5 条:在日历里点一下就建日程,**开始时间 = 点的那一格**。
// 点空白处只带日期/时刻过来,标题等还是空的(不能替用户瞎填)。
$newAt = trim((string)($_GET['new_at'] ?? ''));
/* ---- 当前时间红线(v24 第 20 批 用户第 6 条)----
用户原话:「周视图和日视图,应该在视图中间显示一条红线…来表示当前时间
在视图上的刻度和位置。」
⚠️ 位置在**服务端**算好(与 .tg-block 的 top/height 同一套算法:
列高固定 24 小时 → 1 分钟 = 100/1440 %),前端只负责"过一会儿自己挪一下"。
不在 CSS 里写死时间,也不在前端算 —— 算法只有一处,不会两边对不上。 */
$nowMinutes = ((int)date('G')) * 60 + (int)date('i');
$nowTop = round($nowMinutes / 1440 * 100, 4);
View::render('calendar/index', [
'title' => '日程',
'ym' => $ym,
'view' => $view,
'anchor' => $anchor,
'nowTop' => $nowTop,
'cols' => $view === 'month' ? [] : ($cols ?? []),
'weeks' => $view === 'month' ? $grid : [],
'tgrid' => $view === 'month' ? null : $grid,
'cals' => $cals,
'visCals'=> array_map('intval', array_column($visCals, 'id')),
'hiddenCals' => array_keys($hiddenCals),
'newAt' => $newAt,
'people' => Person::roster($familyId, $me, 'member', ''),
], 'calendar');
break;
/**
* 保存「日程显示设置」(v24 用户 2026-10-03 第 4、5 条)—— 每人一份。
*
* 用户原话:「在右侧,也加一个设置按钮,设置中,可以设置哪些日历本显示,
* 调整日历的颜色,导入日历等功能,都放在这个设置下。」
* 以及「在日程的设置中,可以设置默认显示的视图模式」。
*
* 这一条只存两件**个人偏好**:默认视图 + 关掉哪几本。
* 颜色/新建/共享/导入仍在各自的既有路由上(它们改的是本子本身或数据,不是偏好),
* 只是界面上统一收进了那个设置下拉里。
*/
case 'calendar/prefs':
Auth::requireLogin();
csrf_check();
$me = (int)Auth::personId();
Calendar::saveDefaultView($me, (string)($_POST['view'] ?? 'month'));
// ⚠️ 复选框的语义是"**显示**",但库里存的是"关掉的"(见 hiddenCalendars 的注释)。
// 所以这里要把「没勾的」收集起来存。
//
// ⚠️ 但**只有表单真的带了这部分字段时才动它**:
// HTML 里"一个都没勾"会让浏览器整个省掉 `show` 这个键 ——
// 与"这个请求压根没想改显隐"(比如将来某个页面只想改默认视图)
// 在服务端看起来**一模一样**。
// 靠 `show_present` 这个隐藏标记把两者分开:
// 没有它 = 不改显隐;有它 + 一个都没勾 = 用户确实想把本子全关掉。
// 不这么做的话,任何一次"只改默认视图"的提交都会静默把所有日历本藏掉。
if (!empty($_POST['show_present'])) {
$onIds = array_map('intval', (array)($_POST['show'] ?? []));
$allIds = [];
foreach (Calendar::mine((int)Auth::familyId(), $me) as $c) {
$allIds[] = (int)$c['id'];
}
$off = [];
foreach ($allIds as $cid) {
if (!in_array($cid, $onIds, true)) {
$off[] = $cid;
}
}
Calendar::saveHiddenCalendars($me, $off);
}
flash('ok', '日程显示设置已保存。');
redirect('calendar', ['view' => (string)($_POST['back_view'] ?? 'month')]);
break;
/**
* 旧地址 → 新地址(v24 第 22 批)。
*
* 口令从「CalDAV 专用」升成「DAV 通用」(日历 + 通讯录共用一把,见
* Schema 版本 23 与 Auth::davIssue),管理入口也一并搬到**个人设置**页
* —— 它本来就是"我的凭据",跟着日历本页走会让只想要通讯录的人找不着。
*
* ⚠️ 这两条**必须留着**:老书签、以及此前在页面里渲染过的表单地址都在这里,
* 直接删掉的话点旧按钮会 404,用户看到的现象是"改了之后按钮没反应了"。
* 用 302 跳过去,两边都活着。
*/
case 'calendar/caldav_token':
case 'calendar/caldav_revoke':
// ⚠️ 用第三个参数(anchor)而不是塞进 query:`redirect` 的签名是
// (route, query, anchor),把 '#dav' 当 query 传会变成 `?%23dav=`。
redirect('settings/me', [], 'dav');
break;
/**
* DAV 通用口令(v24 第 22 批):生成 / 重置 / 撤销。
*
* 用户原话:「caldav和cardav,都增加一个创建口令的功能,这样,就使用用户名和口令
* 来同步,避免在第三方客户端进行同步时自己的密码泄露。」
*
* ⚠️ 明文口令只在这**一次**跳转里带回页面显示(存 $_SESSION 一次性取用),
* 库里只有 sha256。不这么做的话,要么明文落库(一旦泄露全暴露),
* 要么用户永远看不到自己刚设的花口令。
* ⚠️ 生成与撤销共用 `settings/dav_plain` 这个槽位:撤销时**必须把它清掉**,
* 否则"刚撤销完还显示着上一串口令",用户会以为撤销没生效。
*/
case 'settings/dav_token':
case 'settings/dav_revoke':
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
if ($r === 'settings/dav_token') {
$_SESSION['dav_plain'] = Auth::davIssue($me);
flash('ok', 'DAV 口令已生成(日历与通讯录共用这一把;旧口令立即失效,请在客户端里更新)。');
} else {
Auth::davRevoke($me);
unset($_SESSION['dav_plain']);
flash('ok', 'DAV 口令已撤销,所有已同步的客户端(日历与通讯录)都会失去访问。');
}
redirect('settings/me');
break;
case 'calendar/calendars':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$all = Calendar::mine($familyId, $me);
$mine = [];
$shared = [];
foreach ($all as $c) {
if ((int)$c['shared'] === 1) {
$shared[] = $c;
} else {
$mine[] = $c;
}
}
$shares = [];
foreach ($mine as $c) {
$shares[(int)$c['id']] = Calendar::shares((int)$c['id']);
}
// v24 第 22 批:口令改成**通用 DAV 口令**(日历 + 通讯录共用),
// 管理入口也搬到了「个人设置 → DAV 同步」。所以这一页**不再处理口令表单**
// —— 只显示它的状态与入口(点过去配置)。口令的明文仍走一次性会话槽位,
// 但那一页在 settings/me,这一页读不到(也没必要读:这里只提示"去那边配")。
$cdHas = Auth::hasDav($me);
$cdPlain = '';
$editing = null;
$editId = (int)($_GET['edit'] ?? 0);
if ($editId > 0) {
foreach ($mine as $c) {
if ((int)$c['id'] === $editId && (string)$c['kind'] === 'custom') {
$editing = $c;
}
}
}
View::render('calendar/calendars', [
'title' => '日历本',
'cals' => $mine,
'shared' => $shared,
'shares' => $shares,
'editing' => $editing,
'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();
/* 🔴 v24 第 21 批 第 7 条:日历新建日程改成**无刷新**(用户原话:
「添加完成后,动态修改当前视图,不要刷新。」)。
同一个路由要同时服务两种调用者 → 必须认 X-Requested-With:
· 页面里的 JS 带它 → 回 JSON(含新日程的颜色/时间/链接,前端自己插进日历);
· 无 JS 的原生表单 → 照旧 302 回日历(渐进增强的兜底不变)。
⚠️ 判据不能用"有没有某个参数"之类的猜测 —— 两条路的参数完全一样。 */
$ajax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest')
|| (strpos((string)($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json') !== false);
$jsonFail = static function (string $msg) use ($ajax, &$ym) {
if ($ajax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'msg' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', $msg);
redirect('calendar', ['ym' => $ym]);
};
csrf_check();
$ym = trim((string)($_POST['ym'] ?? ''));
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
$calId = (int)($_POST['calendar_id'] ?? 0);
// ⚠️ 走 mineOne 而不是 Calendar::find():要带 perm,共享本的可读写才判得对
$cal = Calendar::mineOne($familyId, $me, $calId);
// v24 第 10 批 #4:农历 / 节假日(还有观影 / 旅行)是**派生只读本**,
// 内容由别处算出来,不能往里写日程 —— 写了也不会显示(Calendar::month 只从
// kind='custom' 取 calendar_events),却会在库里留下一堆"看不见的日程"。
// 用户的要求是"不可删除",这里顺带把"不可写入"也钉死。
if ($cal && Calendar::isReadonlyKind((string)$cal['kind'])) {
$jsonFail('「' . $cal['name'] . '」是系统日历,内容自动生成,不能往里加日程。');
}
if (!$cal || !Calendar::canWrite($cal, $me)) {
$jsonFail('这本日历你没有写权限。');
}
// v24 第 5、6 条:起止各是一个「日期+时分秒」的完整时刻(_datetime.php 控件),
// 不再拆成 date / end_date / start_time / end_time 四格。
$startsAt = read_dt_input('starts_at');
if ($startsAt === null) {
$jsonFail('开始时间不对。');
}
$endsAt = read_dt_input('ends_at');
$title = trim((string)($_POST['title'] ?? ''));
if ($title === '') {
$jsonFail('日程得有个标题。');
}
$allDay = !empty($_POST['all_day']);
if ($allDay) {
// 全天日程:时刻部分一律归零(跨天的全天日程截止时刻含到当天末尾)
$startsAt = substr($startsAt, 0, 10) . ' 00:00:00';
$endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null;
}
$note = trim((string)($_POST['note'] ?? ''));
// ⚠️ 观影 / 旅行两本是派生的:往这两本里加日程 = 建一条真正的观影 / 旅程记录,
// 而不是往 calendar_events 里塞一行(那样日历里有、观影列表里没有)。
$kind = (string)$cal['kind'];
/* 🔴 v24 第 21 批 第 7 条:观影 / 旅行两本是**可写**的 —— 往它们里面"加一条"
等于建一条真正的观影 / 旅程记录(只往 calendar_events 里塞一行的话,
日历里有、观影列表里没有)。
⚠️ 这两段原来只 `redirect()`,AJAX 时会把 fetch 带着跳走:
客户端拿到的是一整页 HTML、`r.json()` 解析失败 → 退回原生提交 →
又整页刷新一次。所以这里也要**回 JSON**,让前端照旧"插进当前视图"。
⚠️ AJAX 时**不再自动跳到观影/旅程详情页** —— 用户的原话是
"添加完成后,动态修改当前视图,不要刷新",跳走就等于离开了日历。
改在提示行里给一个可点的链接(想看详情随手点,不点也不打扰)。 */
if ($kind === 'movie' || $kind === 'trip') {
if ($kind === 'movie') {
$newId = Movie::create($familyId, $me, [
'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
'attendees' => [$me],
]);
$okMsg = '已加进观影,日历里也能看到了。';
$okHref = url('movies/show', ['id' => $newId]);
} else {
$newId = Trip::create($familyId, $me, [
'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
'attendees' => [$me],
]);
$okMsg = '已加进旅程,日历里也能看到了。';
$okHref = url('trips/show', ['id' => $newId]);
}
if ($ajax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'msg' => $okMsg,
'goto' => $okHref, /* 前端只用来在提示行里放一个链接,不自动跳 */
'event' => [
'id' => (int)$newId,
'title' => $title,
'starts_at' => (string)$startsAt,
'ends_at' => (string)($endsAt ?? ''),
'all_day' => $allDay ? 1 : 0,
'cal' => (int)$cal['id'],
'color' => (string)($cal['color'] ?? ''),
'time' => ($allDay || $startsAt === '') ? '' : substr((string)$startsAt, 11, 5),
'href' => $okHref,
],
], JSON_UNESCAPED_UNICODE);
exit;
}
flash('ok', $okMsg);
redirect($kind === 'movie' ? 'movies/show' : 'trips/show', ['id' => $newId]);
}
$evId = Calendar::addEvent((int)$cal['id'], [
'title' => $title,
'location' => trim((string)($_POST['location'] ?? '')),
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'all_day' => $allDay,
// v24 第 20 批 第 8 条:提醒**多选** → 表单来的是数组 `reminder_minutes[]`。
// ⚠️ 不要再 `(string)` 强转 —— 那会得到 "Array" 字样(PHP 的经典坑),
// Calendar::reminderMinutes() 收到它就一份都认不出来,提醒静默丢光。
// 直接原样把数组交下去,由 Calendar 那边做白名单 + 规范化。
'reminder_minutes' => ($_POST['reminder_minutes'] ?? ''),
'status' => (string)($_POST['status'] ?? 'confirmed'),
'transp' => !empty($_POST['transp']),
// 用户 2026-10-03 第 5、6 条:重复规则(RFC 5545 RRULE 原文)
'rrule' => Repeat::build($_POST),
'note' => $note,
'created_by' => $me,
]);
$warns = attach_save_files($familyId, Calendar::ATTACH, $evId, 'attach', $me);
/* v24 第 21 批 第 7 条:AJAX 时回**够前端把它插进当前视图**的那几个字段。
⚠️ 字段必须与模板渲染那一行**完全一致**,否则"插进去的样子"与
"刷新一次之后的样子"会不同:
· time —— 月视图小条前面的 `<b>HH:MM</b>`(全天不显示);
· color —— 小条/时间块的底色(来自日历本,这正是用户要的"带上颜色");
· href —— 点它打开详情(与 Calendar::month 给的是同一个路由);
· cal —— data-cal,前端/样式按它分组。 */
if ($ajax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'msg' => '日程已加上。',
'warns' => $warns,
'event' => [
'id' => (int)$evId,
'title' => $title,
'starts_at' => (string)$startsAt,
'ends_at' => (string)($endsAt ?? ''),
'all_day' => $allDay ? 1 : 0,
'cal' => (int)$cal['id'],
'color' => (string)($cal['color'] ?? ''),
'time' => ($allDay || $startsAt === '') ? '' : substr((string)$startsAt, 11, 5),
'href' => url('calendar/event', ['id' => (int)$evId, 'd' => substr((string)$startsAt, 0, 10)]),
],
], JSON_UNESCAPED_UNICODE);
exit;
}
if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns));
}
flash('ok', '日程已加上。');
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
break;
case 'calendar/event':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$id = (int)($_GET['id'] ?? 0);
$st = Database::get()->prepare(
'SELECT e.*, c.owner_id, c.name AS cal_name, c.color AS cal_color, c.kind AS cal_kind
FROM calendar_events e JOIN calendars c ON c.id = e.calendar_id
WHERE e.id = ? AND c.family_id = ?'
);
$st->execute([$id, $familyId]);
$ev = $st->fetch();
if (!$ev) {
flash('err', '找不到这条日程。');
redirect('calendar');
}
$ev['_can_write'] = Calendar::canWriteEvent($ev, $me);
// 用户第 5 条:从日历点进来时会带 ?d=(那一次是哪天),
// 编辑重复日程时用它定位"仅此一次 / 这一次及以后"。
$occ = trim((string)($_GET['d'] ?? ''));
$occ = preg_match('/^\d{4}-\d{2}-\d{2}$/', $occ) ? $occ : '';
// 编辑页要回填"哪些天被跳过了"(跳过的那次不再出现在日历里)
$ev['skips'] = Calendar::skipsOf((int)$ev['id']);
View::render('calendar/event', [
'title' => (string)$ev['title'],
'ev' => $ev,
'cal' => $cal,
'occ' => $occ,
// ⚠️ 不要再传 'ym':模板自己从 $ev['starts_at'] 算(模板里那行更权威,
// 传进来反而会在"改过日期后"与模板算出的值不一致)。
], 'calendar');
break;
case 'calendar/event_update':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$ym = trim((string)($_POST['ym'] ?? ''));
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
// ⚠️ 这里要把**整行**取回来(含 rrule / starts_at / 各字段):
// 改重复日程时,"仅此一次 / 这一次及以后"都要拿旧内容来裁剪或复制,
// 只取 id 的话还得再查一遍(而且容易漏字段)。
$st = Database::get()->prepare(
'SELECT e.*, c.owner_id FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
WHERE e.id = ? AND c.family_id = ?'
);
$st->execute([$id, $familyId]);
$ev = $st->fetch();
$ev2 = $ev; // 旧内容(scope 分支要用,别被后面的 $d 覆盖)
if (!$ev) {
flash('err', '找不到这条日程。');
redirect('calendar', ['ym' => $ym]);
}
$cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: [];
if (!Calendar::canWrite($cal, $me)) {
flash('err', '这本日历你没有写权限。');
redirect('calendar', ['ym' => $ym]);
}
// v24 第 5、6 条:起止各是一个完整时刻(精确到秒),见上面 event_add 的说明。
$startsAt = read_dt_input('starts_at');
if ($startsAt === null) {
flash('err', '开始时间不对。');
redirect('calendar', ['ym' => $ym]);
}
$endsAt = read_dt_input('ends_at');
$allDay = !empty($_POST['all_day']);
if ($allDay) {
$startsAt = substr($startsAt, 0, 10) . ' 00:00:00';
$endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null;
}
$d = [
'title' => trim((string)($_POST['title'] ?? '')),
'location' => trim((string)($_POST['location'] ?? '')),
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'all_day' => $allDay,
// v24 第 20 批 第 8 条:提醒**多选** → 表单来的是数组 `reminder_minutes[]`。
// ⚠️ 不要再 `(string)` 强转 —— 那会得到 "Array" 字样(PHP 的经典坑),
// Calendar::reminderMinutes() 收到它就一份都认不出来,提醒静默丢光。
// 直接原样把数组交下去,由 Calendar 那边做白名单 + 规范化。
'reminder_minutes' => ($_POST['reminder_minutes'] ?? ''),
'status' => (string)($_POST['status'] ?? 'confirmed'),
'transp' => !empty($_POST['transp']),
'rrule' => Repeat::build($_POST),
'note' => trim((string)($_POST['note'] ?? '')),
];
/* ---- 用户 2026-10-03 第 5 条:改一条**重复**日程时,先问"改哪些" ----
scope 三档:
all = 整条系列都按新内容改(直接 update 本行,rrule 也跟着改)
future = 这一次**以及以后**的:把原系列截止到"这一次"的前一天,
再新建一条从这一天开始的系列 —— 这样过去的历史原样保留,
以后的全部采用新内容(与 iOS/群晖日历的"此事件及后续"一致)
once = 仅这一次:原系列里跳掉这一天,另建一条只发生一次(detachOnce)
⚠️ 不重复的日程直接按 all 处理(没有"系列"可言)。 */
$scope = (string)($_POST['scope'] ?? 'all');
$occDay = substr(trim((string)($_POST['occ'] ?? '')), 0, 10);
$rruleOld = (string)($ev2['rrule'] ?? '');
if (in_array($scope, ['once', 'future'], true)
&& $rruleOld !== ''
&& preg_match('/^\d{4}-\d{2}-\d{2}$/', $occDay)) {
if ($scope === 'once') {
// 仅这一次:原系列跳掉那天 + 落一条独立单次
Calendar::detachOnce(
['id' => $id, 'calendar_id' => (int)$ev['calendar_id']],
$occDay, $d, $me
);
flash('ok', '只改了这一次。');
} else {
// 这一次及以后:老系列截到前一天,新系列从这天起
$rp = Repeat::parse($rruleOld);
$cut = date('Y-m-d', strtotime($occDay . ' -1 day'));
$rp['until'] = $cut;
$rp['count'] = 0;
// 老系列:只保留到 $cut,并**移除**原来的 days 定义之外的东西
Calendar::updateEvent($id, [
'title' => (string)($ev2['title'] ?? ''),
'location' => (string)($ev2['location'] ?? ''),
'starts_at' => (string)$ev2['starts_at'],
'ends_at' => $ev2['ends_at'] ?: null,
'all_day' => (int)($ev2['all_day'] ?? 0) === 1,
'reminder_minutes' => $ev2['reminder_minutes'] ?? '',
'status' => (string)($ev2['status'] ?? 'confirmed'),
'transp' => (int)($ev2['transp'] ?? 0) === 1,
'rrule' => 'FREQ=' . strtoupper((string)$rp['freq'])
. ($rp['freq'] === 'weekday' ? ';BYDAY=MO,TU,WE,TH,FR' : '')
. ';UNTIL=' . str_replace('-', '', $cut) . 'T235959Z',
'note' => (string)($ev2['note'] ?? ''),
]);
// 新系列:从 $occDay 起,用用户改后的内容
$d2 = $d;
$offsetDays = (int)floor((strtotime(substr((string)$d['starts_at'], 0, 10))
- strtotime(substr((string)$ev2['starts_at'], 0, 10))) / 86400);
$d2['starts_at'] = $occDay . ' ' . substr((string)$d['starts_at'], 11, 8);
$d2['ends_at'] = $d['ends_at'] !== null ? $occDay . ' ' . substr((string)$d['ends_at'], 11, 8) : null;
unset($offsetDays);
$d2['created_by'] = $me;
Calendar::addEvent((int)$ev['calendar_id'], $d2);
flash('ok', '这一次及以后的都改好了(过去的保持不变)。');
}
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
}
// all(或本来就不重复):整条按新内容改
Calendar::updateEvent($id, $d);
$warns = attach_save_files($familyId, Calendar::ATTACH, $id, 'attach', $me);
if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns));
}
flash('ok', '日程已保存。');
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
break;
case 'calendar/event_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$ym = trim((string)($_POST['ym'] ?? ''));
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
$st = Database::get()->prepare(
'SELECT e.id, e.calendar_id, e.rrule, e.starts_at FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
WHERE e.id = ? AND c.family_id = ?'
);
$st->execute([$id, $familyId]);
$ev = $st->fetch();
if (!$ev) {
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 第 20 批 用户第 7 条)====================
用户原话:「对于重复的日程,当点击删除的时候,应该提示是删除这一次,
还是全部都删除,还是删除这一次及以后的。」
⚠️ 三档与**编辑**那三档(第 13 批做的 scope)刻意对齐,用户学一次就够:
once = 只删这一次 → 在 calendar_event_skips 里记一天(**不是**删整条系列!
真删了就"全部都没了",正是用户要避免的);
future = 删除这一次及以后 → 老系列截止到「这一次的前一天」;
这一步**必须**算出截止日并写进 RRULE,否则整条系列都会留着一个
"还没结束的重复",照样冒出来;
all = 全部(整条删除,与以前一样)。
⚠️ 范围信息从哪来:`scope`(用户在弹框里选的)+ `occ`(点开的是哪一天)。
两者缺一不可 —— 少了 occ 就定位不到"这一次"(重复日程每一天都是同一条记录 id)。
⚠️ 不重复的日程直接按 all(没有"系列"可言),表单也不会给它选范围。
*/
$scope = (string)($_POST['scope'] ?? 'all');
$occDay = substr(trim((string)($_POST['occ'] ?? '')), 0, 10);
$rrule = (string)($ev['rrule'] ?? '');
$hasOcc = (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $occDay);
if ($rrule !== '' && $hasOcc && $scope === 'once') {
// 只删这一次:记一个"跳过这一天的例外",系列原样保留
Calendar::skipOnce($id, $occDay);
flash('ok', '只删了 ' . $occDay . ' 这一次;后面的照旧。');
redirect('calendar', ['ym' => $ym]);
}
if ($rrule !== '' && $hasOcc && $scope === 'future') {
// 这一次及以后:老系列截至「这一次的前一天」。
$rp = Repeat::parse($rrule);
$cut = date('Y-m-d', strtotime($occDay . ' -1 day'));
// ⚠️ 截断结果要重新拼 RRULE:只改 UNTIL、并清掉 COUNT
// (COUNT 与 UNTIL 同时存在时以 COUNT 为准,不干掉它这次截断就失效)。
$rp['count'] = 0;
$rp['until'] = $cut;
// ⚠️ 边界:如果"这一天"就是系列的第一次(cut 落在首次之前),
// 那"这一次及以后"等于**全部** —— 截断会产出一条永不发生的系列,
// 留着纯属垃圾,所以那种情况直接整条删掉。
$firstDay = substr((string)$ev['starts_at'], 0, 10);
if ($cut < $firstDay) {
Calendar::deleteEvent($id);
flash('ok', '这一天就是第一次,「这一次及以后」等于整条删除。');
redirect('calendar', ['ym' => $ym]);
}
$newRule = 'FREQ=' . strtoupper((string)$rp['freq'])
. (!empty($rp['interval']) && (int)$rp['interval'] > 1 ? ';INTERVAL=' . (int)$rp['interval'] : '')
. ($rp['freq'] === 'weekday' ? ';BYDAY=MO,TU,WE,TH,FR' : '')
. ';UNTIL=' . str_replace('-', '', $cut) . 'T235959Z';
Database::get()->prepare('UPDATE calendar_events SET rrule = ?, sequence = sequence + 1 WHERE id = ?')
->execute([$newRule, $id]);
flash('ok', $occDay . ' 及以后的那些都删了(之前的保持不变)。');
redirect('calendar', ['ym' => $ym]);
}
// all(或本来就不重复):整条删除
Calendar::deleteEvent($id);
flash('ok', '日程已删除。');
redirect('calendar', ['ym' => $ym]);
break;
// ==================== ics 导出 / 导入(v24 第 7 条)====================
case 'calendar/export':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$calId = (int)($_GET['id'] ?? 0);
$cal = Calendar::mineOne($familyId, $me, $calId);
if (!$cal) {
flash('err', '找不到这本日历。');
redirect('calendar');
}
$rows = Calendar::exportRows($cal, $familyId, $me);
$ics = Ics::export($rows, (string)$cal['name']);
// ⚠️ 文件名要用**本子名 + 日期**,别用中文之外的怪字符:
// Content-Disposition 的非 ASCII 文件名各家浏览器编码方式不一样,
// 最稳的做法是给一个 ASCII 兜底名(filename=)再给一个 UTF-8 的(filename*=)。
$ascii = 'calendar-' . (int)$cal['id'] . '-' . date('Ymd') . '.ics';
$utf8 = rawurlencode((string)$cal['name'] . '-' . date('Y-m-d') . '.ics');
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $ascii . '"; filename*=UTF-8\'\'' . $utf8);
header('Content-Length: ' . strlen($ics));
echo $ics;
exit;
case 'calendar/import':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$calId = (int)($_POST['calendar_id'] ?? 0);
$cal = Calendar::mineOne($familyId, $me, $calId);
if (!$cal || !Calendar::canWrite($cal, $me)) {
flash('err', '这本日历你没有写权限。');
redirect('calendar/calendars');
}
// ⚠️ 只能导进**自定义本**:观影 / 旅行两本是派生的(内容是 events / trip_nodes),
// 往里塞 calendar_events 会出现"日历里有、观影列表里没有"的孤儿。
if ((string)$cal['kind'] !== 'custom') {
flash('err', '只能往自定义日历本里导入。观影、旅行两本是从记录里自动汇总的。');
redirect('calendar/calendars');
}
if (empty($_FILES['ics']) || (int)($_FILES['ics']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
flash('err', '没收到 .ics 文件。');
redirect('calendar/calendars');
}
$text = (string)file_get_contents((string)$_FILES['ics']['tmp_name']);
$list = Ics::parse($text);
if (!$list) {
flash('err', '这个文件里没有找到任何日程(VEVENT)。');
redirect('calendar/calendars');
}
$added = 0;
$updated = 0;
foreach ($list as $v) {
if (($v['starts_at'] ?? null) === null) {
continue; // 没有 DTSTART 的 VEVENT 是坏数据,跳过而不是写成 1970 年
}
if (trim((string)$v['title']) === '') {
$v['title'] = '(无标题日程)';
}
if (Calendar::importEvent($calId, $v, $me) === 'added') {
$added++;
} else {
$updated++;
}
}
flash('ok', '导入完成:新增 ' . $added . ' 条,更新 ' . $updated . ' 条。');
redirect('calendar/calendars');
break;
case 'calendar/attach_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$aid = (int)($_POST['attach_id'] ?? 0);
$ev = Calendar::event($id);
if ($ev && Calendar::canWriteEvent($ev, $me)) {
// ⚠️ 附件 id 与宿主 id 一起判:只按 attach_id 删,就能用别的日程的
// 附件 id 把人家文件删掉(宿主对不上就静默放行是很典型的越权)。
$st = Database::get()->prepare(
'SELECT id FROM attachments WHERE id = ? AND target_type = ? AND target_id = ?'
);
$st->execute([$aid, Calendar::ATTACH, $id]);
if ($st->fetchColumn() !== false) {
Attachment::remove($aid);
flash('ok', '附件已删除。');
}
}
redirect('calendar/event', ['id' => $id]);
break;
// ==================== 资源(v12) ====================
// 挂在**某个人名下**的东西(证件 / 照片 / 账号),可共享给别人(只读或读写)。
case 'resources':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$keyword = trim((string)($_GET['keyword'] ?? ''));
$ownerId = (int)($_GET['owner'] ?? 0);
// 用户 2026-10-03 第 10 条:按"最新新增 / 最近修改"排序,默认只显示最新 10 条。
// 原话:「应该按照最新的或者近期修改使用,进行排序,比如,只显示最新新增或者
// 修改或者使用过的 10 个,要不然太多了放不下。上面反正还有搜索呢。」
$sort = ($_GET['sort'] ?? '') === 'new' ? 'new' : 'used';
$limit = \App\Resource::LIST_LIMIT;
// 搜索时不受"只显示 10 条"限制:用户是带着明确目标来找的,
// 这时候还只给 10 条会让他以为"没搜到"。看全部也用 all=1 放开。
$showAll = !empty($_GET['all']) || $keyword !== '';
$rows = Resource::all($familyId, $me, $ownerId, $keyword, $sort, $showAll ? 0 : $limit);
$totalAll = Resource::countVisible($familyId, $me, $ownerId, $keyword);
// 附件与共享一次取齐(列表里每条都要显示"几个附件""共享给谁")
$attachs = [];
$shares = [];
foreach ($rows as $r) {
$attachs[(int)$r['id']] = \App\Attachment::list(Resource::ATTACH, (int)$r['id']);
$shares[(int)$r['id']] = Resource::shares((int)$r['id']);
}
$editing = null;
$editId = (int)($_GET['edit'] ?? 0);
if ($editId > 0) {
$editing = Resource::find($familyId, $editId, $me);
if ($editing !== null && !Resource::canWrite($editing, $me)) {
$editing = null; // 只读的不给编辑(表单照旧收起)
}
}
View::render('resources/index', [
'title' => '资源',
'rows' => $rows,
'attachs' => $attachs,
'shares' => $shares,
'editing' => $editing,
'keyword' => $keyword,
'ownerId' => $ownerId,
'sort' => $sort,
'limit' => $limit,
'totalAll'=> $totalAll,
'showAll' => $showAll,
'people' => Person::roster($familyId, $me, 'member', ''),
], 'resources');
break;
case 'resources/create':
case 'resources/update':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$isEdit = $r === 'resources/update';
$id = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
$d = [
'title' => trim((string)($_POST['title'] ?? '')),
'content' => trim((string)($_POST['content'] ?? '')),
];
if ($d['title'] === '') {
flash('err', '资源得有个标题。');
redirect('resources');
}
$ownerId = (int)($_POST['owner_id'] ?? 0);
$map = Person::map($familyId);
if (!isset($map[$ownerId])) {
$ownerId = $me; // 表单可以改,但不能指到不存在的人身上
}
if ($isEdit) {
$old = Resource::find($familyId, $id, $me);
if (!$old || !Resource::canWrite($old, $me)) {
flash('err', '这条资源不能改。');
redirect('resources');
}
Resource::update($familyId, $id, $d);
// 允许改归属人(记错了挂在谁名下)
if ((int)$old['owner_id'] !== $ownerId) {
Database::get()->prepare('UPDATE resources SET owner_id = ? WHERE id = ? AND family_id = ?')
->execute([$ownerId, $id, $familyId]);
}
$rid = $id;
flash('ok', '资源已保存。');
} else {
$rid = Resource::create($familyId, $ownerId, $me, $d);
flash('ok', '资源已新建。');
}
$warns = attach_save_files($familyId, Resource::ATTACH, $rid, 'attach', $me);
if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns));
}
redirect('resources');
break;
case 'resources/attach_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$r0 = Resource::find($familyId, $id, $me);
if (!$r0 || !Resource::canWrite($r0, $me)) {
flash('err', '这条资源不能改。');
redirect('resources');
}
$warns = attach_save_files($familyId, Resource::ATTACH, $id, 'attach', $me);
flash($warns ? 'err' : 'ok', $warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。');
redirect('resources');
break;
case 'resources/attach_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$aid = (int)($_POST['id'] ?? 0);
// ⚠️ 要按"这条附件属于哪条资源"找到宿主再判权限:只传附件 id 的话,
// 谁都能构造一个请求删掉别人家的附件。
$st = Database::get()->prepare('SELECT target_id FROM attachments WHERE id = ? AND target_type = ?');
$st->execute([$aid, Resource::ATTACH]);
$tid = (int)$st->fetchColumn();
$r0 = $tid > 0 ? Resource::find($familyId, $tid, $me) : null;
if (!$r0 || !Resource::canWrite($r0, $me)) {
flash('err', '这个附件不能删。');
redirect('resources');
}
\App\Attachment::remove($aid);
flash('ok', '附件已删除。');
redirect('resources');
break;
case 'resources/share':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$r0 = Resource::find($familyId, $id, $me);
if (!$r0 || !Resource::canWrite($r0, $me)) {
flash('err', '这条资源不能共享。');
redirect('resources');
}
$target = (int)($_POST['person_id'] ?? 0);
if ($target > 0 && isset(Person::map($familyId)[$target])) {
Resource::setShare($id, (int)$r0['owner_id'], $target, (string)($_POST['perm'] ?? 'read'));
flash('ok', '已共享。');
}
redirect('resources');
break;
case 'resources/unshare':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$r0 = Resource::find($familyId, $id, $me);
if ($r0 && Resource::canWrite($r0, $me)) {
Resource::dropShare($id, (int)($_POST['person_id'] ?? 0));
flash('ok', '已取消共享。');
}
redirect('resources');
break;
case 'resources/destroy':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$r0 = Resource::find($familyId, $id, $me);
if (!$r0 || !Resource::canWrite($r0, $me)) {
flash('err', '这条资源不能删。');
redirect('resources');
}
Resource::destroy($familyId, $id);
flash('ok', '资源已删除。');
redirect('resources');
break;
// ==================== 任务(v12) ====================
case 'tasks':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// v24(用户 2026-10-03 第 7 条):**列 = 用户自定义的看板本身**。
// 原话:「每个人自定义的看板,不是显示在现在的看板上面,而是直接代替现在的
// 待办和进行中,是看板,而不是看板的分类。」
// 所以不再有「待办 / 进行中」两列、也不再有顶上那个"切换看板"的横条 ——
// 一列就是一个看板,都在同一屏上,一眼看全。
$bc = Task::boardColumns($familyId, $me);
$cols = $bc['cols'];
$total = 0;
foreach ($cols as $list) {
$total += count($list);
}
View::render('tasks/index', [
'title' => '任务',
'cols' => $cols,
'boards' => $bc['boards'],
'categories' => Task::categories($familyId),
'total' => $total,
'people' => Person::roster($familyId, $me, 'member', ''),
], 'tasks');
break;
/**
* 任务设置(v24 第 9、10 条):分类 / 看板 / 已完成的归档。
* 入口在「新建任务」旁边那个 ☰ 下拉里,与观影的 ☰ 是同一套做法。
*/
case 'tasks/settings':
$familyId = (int)Auth::familyId();
View::render('tasks/settings', [
'title' => '任务设置',
'boards' => Task::boards($familyId),
'categories' => Task::categories($familyId),
], 'tasks');
break;
/** 已完成的任务(按分类查看 / 按关键词搜) */
case 'tasks/done':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$cid = (int)($_GET['cat'] ?? 0);
$kw = trim((string)($_GET['kw'] ?? ''));
View::render('tasks/done', [
'title' => '已完成',
'rows' => Task::doneList($familyId, $me, $cid, $kw),
'categories' => Task::categories($familyId),
'cat' => $cid,
'kw' => $kw,
], 'tasks');
break;
case 'tasks/board_add':
case 'tasks/board_update':
case 'tasks/board_del':
case 'tasks/cat_add':
case 'tasks/cat_update':
case 'tasks/cat_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$nm = trim((string)($_POST['name'] ?? ''));
$act = (string)($_GET['r'] ?? '');
if ($act === 'tasks/board_add') {
if ($nm === '') {
flash('err', '看板名不能为空。');
} else {
Task::createBoard($familyId, $nm);
flash('ok', '看板已添加。');
}
} elseif ($act === 'tasks/board_update') {
if ($nm === '') {
flash('err', '看板名不能为空。');
} else {
// 🔴 v24 第 26 批 用户第 3 条:这一行表单现在同时提交**名字 + 颜色**。
// 颜色合法性在 Task::updateBoard 里收口(空 = 清除自定义、回到按名派生)。
// 这里不自己判断颜色格式 —— 两处各判一次必然走样。
Task::updateBoard($familyId, $id, $nm, (string)($_POST['color'] ?? ''));
flash('ok', '看板已保存(名字与颜色)。');
}
} elseif ($act === 'tasks/board_del') {
Task::deleteBoard($familyId, $id);
flash('ok', '看板已删除,里头的任务挪到了默认看板。');
} elseif ($act === 'tasks/cat_add') {
if ($nm === '') {
flash('err', '分类名不能为空。');
} else {
Task::createCategory($familyId, $nm);
flash('ok', '分类已添加。');
}
} elseif ($act === 'tasks/cat_update') {
if ($nm === '') {
flash('err', '分类名不能为空。');
} else {
Task::renameCategory($familyId, $id, $nm);
flash('ok', '分类已改名。');
}
} else {
Task::deleteCategory($familyId, $id);
flash('ok', '分类已删除,用它标过的任务变成「未分类」。');
}
redirect('tasks/settings');
break;
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),
// v24 第 14 批 用户第 2 条:新建时可以**直接定状态**(表单里有这一项)。
// ⚠️ 编辑时**不**在这里改状态:状态由专门的 tasks/status 路由管
// (那个路由还要处理循环任务的"下一次也排上"),这里保持原值 ——
// 否则"只改个标题"会把进行中的任务悄悄打回待办。
'status' => $isEdit ? (string)$old['status'] : (string)($_POST['status'] ?? 'todo'),
];
if ($d['assignee_id'] > 0 && !isset(Person::map($familyId)[$d['assignee_id']])) {
$d['assignee_id'] = 0;
}
if ($isEdit) {
Task::update($familyId, $id, $d);
$tid = $id;
flash('ok', '任务已保存。');
} else {
$tid = Task::create($familyId, $me, $d);
foreach (filter_family_person_ids((array)($_POST['shares'] ?? []), $familyId) as $pid) {
Task::setShare($tid, $pid, 'write');
}
$warns = attach_save_files($familyId, Task::ATTACH, $tid, 'attach', $me);
if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns));
}
flash('ok', '任务已新建。');
}
redirect('tasks/show', ['id' => $tid]);
break;
case 'tasks/move':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$status = (string)($_POST['status'] ?? '');
$t = Task::find($familyId, $id, $me);
if (!$t || !Task::canWrite($t, $me)) {
flash('err', '这项任务不能改。');
redirect('tasks');
}
$before = (int)($_POST['before'] ?? 0);
// ⚠️ v24(用户 2026-10-03 第 7 条):看板的列现在是**用户自定义的看板**,
// 所以"拖到另一列" = **换看板**(改 board_id),不是改 status。
// 两种搬法用一个路由:带 board 的走看板,带 status 的走状态
// (状态那条保留给任务详情页的"标记完成"和子任务用)。
if (array_key_exists('board', $_POST)) {
Task::moveToBoard($familyId, $id, (int)$_POST['board'], $before, $me);
redirect('tasks');
break;
}
// 子任务只在 todo / done 之间切(看板里没有子任务的列,给它做定位没意义)
if (!empty($t['parent_id'])) {
$status = $status === 'done' ? 'done' : 'todo';
Task::setStatus($familyId, $id, $status);
} else {
Task::moveTo($familyId, $id, $status, $before);
}
if ($status === 'done' && !empty($t['repeat_rule']) && ($t['due_at'] ?? '') !== '') {
flash('ok', '已完成,下一次的也排上了。');
} elseif ($status === 'done') {
flash('ok', '已完成。');
}
redirect('tasks/show', ['id' => $id]);
break;
/**
* 改一条任务的**状态**(v24 第 14 批 用户第 2 条)。
*
* 用户原话:「任务模块,每个任务都应该有不同的状态,例如正在进行、已完成等。」
* → 看板卡片上的状态徽标点一下就走这里:待办 → 进行中 → 已完成 → 待办。
*
* ⚠️ 与 tasks/move 的分工:move 管"搬到哪个看板/哪一列"(改 board_id 或带定位的
* status),这里只管"这件事做到哪一步"(改 status),不带位置信息 ——
* 在看板上改状态不该顺手把卡片挪到列尾。
*/
case 'tasks/status':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$status = (string)($_POST['status'] ?? '');
$t = Task::find($familyId, $id, $me);
if (!$t || !Task::canWrite($t, $me)) {
// v24 第 25 批 用户第 6 条:复选框在**任何地方**都能点(首页/看板/详情),
// 所以这三处都可能发来 AJAX。失败也回 JSON,别让它落进"整页跳转"。
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
header('Content-Type: application/json; charset=utf-8', true, 403);
echo json_encode(['ok' => false, 'msg' => '这项任务不能改。'], JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', '这项任务不能改。');
redirect('tasks');
}
Task::setStatus($familyId, $id, $status);
$word = Task::statusWord(Task::normalizeStatus($status));
$msg = ($status === 'done' && !empty($t['repeat_rule']) && ($t['due_at'] ?? '') !== '')
? '已标成「' . $word . '」,下一次的也排上了。'
: '已标成「' . $word . '」。';
// v24 第 25 批:勾复选框 / 点状态徽标都走这条 ——
// 带 X-Requested-With 时回 JSON(前端就地改那张卡,不刷页);
// 普通表单提交照旧 302(没有 JS 时的兜底)。
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'id' => $id,
'status' => Task::normalizeStatus($status),
'word' => $word,
'msg' => $msg,
], JSON_UNESCAPED_UNICODE);
exit;
}
flash('ok', $msg);
// 无 JS 时的兜底回跳。三处复选框各自带自己的来源:
// show=任务详情 / home=首页 / tasks(默认,看板)。
// ⚠️ v24 第 26 批 用户第 3 条顺带修:原来只认 'show',于是**首页**那颗
// 在没 JS 的浏览器上提交完会跳到任务看板(人在首页,却被扔去了另一个模块)。
// 首页现在传 back=home,这里认它。
$back = (string)($_POST['back'] ?? '');
if ($back === 'show') {
redirect('tasks/show', ['id' => $id]);
} elseif ($back === 'home') {
redirect('home');
}
redirect('tasks');
break;
case 'tasks/comment_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$t = Task::find($familyId, $id, $me);
if (!$t) {
flash('err', '找不到这项任务。');
redirect('tasks');
}
$cid = Task::addComment($id, $me, (string)($_POST['content'] ?? ''));
if ($cid > 0) {
attach_save_files($familyId, 'task_comment', $cid, 'attach', $me);
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;
// ==================== 读书(v24 第 14 批 用户第 5 条) ====================
// ⚠️ 与观影 / 旅程不同,读书**不走 events 骨架**:一本书是"一段持续几个月的状态",
// 不是"某一天发生的事"(详见 app/Book.php 的头注释)。真要上日历的那天是"读完那天"。
//
// 入口就两个:书架(books)与一本书(books/show);阅读器(books/read)从书进去。
// 统计与笔记导入挂在读书页右上角的「☰」里,与观影同一套规矩。
case 'books':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// ---- v24 第 25 批 用户第 8 条:「需要能够做到定期完成,自动同步」----
//
// ⚠️ 触发点放在**读书列表页**(而不只是"微信读书导入"那一页):
// 用户日常打开的是「读书」这个模块入口,导入页他一个月也去不了一次。
// 挂在导入页 = 自动同步基本永远不会被触发(等于没做)。
// ⚠️ 代价可忽略:maybeSync 内部先看间隔(6 小时),绝大多数请求只是
// 读一个 user_settings 时间戳,不联网。
// ⚠️ 失败静默(只记时间戳,下次再试)—— 外部依赖不能把书架页也拖慢/打挂。
if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) {
WeRead::maybeSync($familyId, $me, false);
}
$f = [
'status' => (string)($_GET['status'] ?? ''),
'category' => (string)($_GET['cat'] ?? ''),
'q' => (string)($_GET['q'] ?? ''),
'owner' => (int)($_GET['owner'] ?? 0),
];
View::render('books/index', [
'title' => '读书',
'rows' => Book::all($familyId, $f),
// ⚠️ 计数**不带筛选条件**(见 Book::statusCounts 的注释):
// 否则点进某个状态后其它状态的数字全变 0,筛选条成了死路。
'counts' => Book::statusCounts($familyId),
'cats' => Book::categories($familyId),
'status' => $f['status'],
'cat' => $f['category'],
'q' => $f['q'],
'owner' => $f['owner'],
'people' => Person::roster($familyId, $me, 'member', ''),
], 'books');
break;
case 'books/new':
Auth::requireWrite();
View::render('books/form', [
'title' => '添加图书',
'b' => null,
'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''),
'errors' => [],
], 'books');
break;
case 'books/edit':
Auth::requireWrite();
$familyId = (int)Auth::familyId();
$b = Book::find($familyId, (int)($_GET['id'] ?? 0));
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
View::render('books/form', [
'title' => '编辑图书',
'b' => $b,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => [],
], 'books');
break;
case 'books/create':
case 'books/update':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$isEdit = $r === 'books/update';
$id = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
$b = $isEdit ? Book::find($familyId, $id) : null;
if ($isEdit && !$b) {
flash('err', '找不到这本书。');
redirect('books');
}
$errors = [];
$warns = [];
// ---- ① 图书文件:先存原文件,再解析出元数据与正文 ----
// ⚠️ 顺序不能反:文件是"本来就想要的"(下载 / 浏览器内嵌看 PDF 都靠它),
// 解析只是锦上添花。解析失败也必须有一句人能看懂的话,不能静默。
$fileKey = $isEdit ? (string)($b['file_key'] ?? '') : '';
$fileName = $isEdit ? (string)($b['file_name'] ?? '') : '';
$fileFormat = $isEdit ? (string)($b['file_format'] ?? '') : '';
$fileSize = $isEdit ? (int)($b['file_size'] ?? 0) : 0;
$parsed = null;
$upFile = $_FILES['bookfile'] ?? [];
if (isset($upFile['error']) && !is_array($upFile['error'])
&& (int)$upFile['error'] === UPLOAD_ERR_OK) {
$origName = (string)($upFile['name'] ?? '');
$tmp = (string)($upFile['tmp_name'] ?? '');
$ext = strtolower((string)pathinfo($origName, PATHINFO_EXTENSION));
if (!in_array($ext, Book::FILE_EXT, true)) {
$warns[] = '这个文件的格式不在允许范围内(' . implode(' / ', Book::FILE_EXT)
. '),没有保存。';
} elseif ($tmp === '' || !is_file($tmp)) {
$warns[] = '文件没有传上来(可能是太大了),其他信息已保存。';
} else {
$safeExt = preg_replace('/[^a-z0-9]/', '', $ext) ?? '';
$newKey = 'books/' . bin2hex(random_bytes(12)) . ($safeExt !== '' ? '.' . $safeExt : '');
$fino = new \finfo(FILEINFO_MIME_TYPE);
$mime = (string)$fino->file($tmp);
$res = \App\Storage\Storage::putFile(
$familyId, $tmp, $newKey,
$mime !== '' ? $mime : 'application/octet-stream'
);
if (!empty($res['ok'])) {
if ($fileKey !== '' && $fileKey !== $newKey) {
\App\Storage\Storage::delete($familyId, $fileKey);
}
$fileKey = $newKey;
$fileName = $origName;
$fileFormat = $ext;
$fileSize = (int)@filesize($tmp);
$p = BookParser::parse($tmp, $ext, $origName);
if (empty($p['ok'])) {
// 文件已经存好了 —— 解析失败不等于白传,如实说清楚
$warns[] = '文件存好了,但读不出内容:' . (string)($p['error'] ?? '未知原因');
} else {
$parsed = $p;
if (!empty($p['note'])) {
$warns[] = (string)$p['note'];
}
}
} else {
$warns[] = '文件没有保存:' . (string)($res['error'] ?? '未知错误');
}
}
}
// ---- ② 字段:手填优先,空着的才用解析出来的值补 ----
// ("解析出来的覆盖手填的"是最招人烦的一种自作聪明 —— 用户改了书名,
// 再传一次文件又变回去,他会以为保存坏了。)
$meta = is_array($parsed['meta'] ?? null) ? $parsed['meta'] : [];
$mv = static function (string $k) use ($meta): string {
return trim((string)($meta[$k] ?? ''));
};
$titleIn = trim((string)($_POST['title'] ?? ''));
if ($titleIn === '') {
$titleIn = $mv('title');
}
if ($titleIn === '') {
$errors['title'] = '书名不能为空(上传 EPUB / TXT 会自动读出来)';
}
$d = [
'title' => $titleIn,
'subtitle' => trim((string)($_POST['subtitle'] ?? '')) ?: $mv('subtitle'),
'author' => trim((string)($_POST['author'] ?? '')) ?: $mv('author'),
'publisher' => trim((string)($_POST['publisher'] ?? '')) ?: $mv('publisher'),
'published' => trim((string)($_POST['published'] ?? '')) ?: $mv('published'),
'isbn' => trim((string)($_POST['isbn'] ?? '')) ?: $mv('isbn'),
'language' => trim((string)($_POST['language'] ?? '')) ?: $mv('language'),
'summary' => trim((string)($_POST['summary'] ?? '')) ?: $mv('summary'),
'translator' => trim((string)($_POST['translator'] ?? '')),
'category' => trim((string)($_POST['category'] ?? '')),
'tags' => trim((string)($_POST['tags'] ?? '')),
'pages' => (int)($_POST['pages'] ?? 0),
'status' => (string)($_POST['status'] ?? 'wish'),
'rating' => (string)($_POST['rating'] ?? ''),
'owner_id' => (int)($_POST['owner_id'] ?? 0),
'file_key' => $fileKey,
'file_name' => $fileName,
'file_format' => $fileFormat,
'file_size' => $fileSize,
];
// ---- ③ 封面:用户选的优先,其次用 EPUB 自带的 ----
$oldCover = $isEdit ? (string)($b['cover_key'] ?? '') : '';
$d['cover_key'] = $oldCover;
$up = Upload::image($familyId, $_FILES['cover'] ?? [], 'bookcover',
(string)($_POST['cover_crop'] ?? ''));
if ($up['ok']) {
$d['cover_key'] = (string)$up['file'];
if ($oldCover !== '' && $oldCover !== $d['cover_key']) {
\App\Storage\Storage::delete($familyId, $oldCover);
}
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = '封面没有保存:' . $up['error'];
} elseif ($parsed !== null && !empty($parsed['cover']['tmp'])) {
// 用户没另外选图 → 用书里自带的封面(Upload::image 只认 $_FILES 结构,
// 这里按同样的形状喂一个进去,就能复用同一条"缩放 + 转 JPG"的链路)。
$ctmp = (string)$parsed['cover']['tmp'];
$fake = [
'name' => 'cover',
'type' => (string)($parsed['cover']['mime'] ?? 'image/jpeg'),
'tmp_name' => $ctmp,
'error' => UPLOAD_ERR_OK,
'size' => (int)@filesize($ctmp),
];
$up2 = Upload::image($familyId, $fake, 'bookcover', '');
if ($up2['ok']) {
$d['cover_key'] = (string)$up2['file'];
if ($oldCover !== '' && $oldCover !== $d['cover_key']) {
\App\Storage\Storage::delete($familyId, $oldCover);
}
}
}
// 解析产生的临时封面文件用完即删(它是 BookParser 在 temp 下生成的)
if ($parsed !== null && !empty($parsed['cover']['tmp'])) {
@unlink((string)$parsed['cover']['tmp']);
}
if ($errors) {
set_old($_POST);
View::render('books/form', [
'title' => $isEdit ? '编辑图书' : '添加图书',
'b' => $isEdit ? array_merge($b, $d) : null,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => $errors,
], 'books');
exit;
}
if ($isEdit) {
Book::update($familyId, $id, $d);
// 重新上传了文件 → 章节按新文件整组替换(旧章节留着就是"两本书混在一起")
if ($parsed !== null && is_array($parsed['chapters'] ?? null)) {
Book::replaceChapters($id, $parsed['chapters']);
}
clear_old();
flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('books/show', ['id' => $id]);
}
$newId = Book::create($familyId, (int)Auth::personId(), $d);
if ($parsed !== null && is_array($parsed['chapters'] ?? null)) {
Book::replaceChapters($newId, $parsed['chapters']);
}
clear_old();
flash('ok', '这本书进书架了。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('books/show', ['id' => $newId]);
break;
case 'books/show':
$familyId = (int)Auth::familyId();
$id = (int)($_GET['id'] ?? 0);
$b = Book::find($familyId, $id);
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
View::render('books/show', [
'title' => (string)$b['title'],
'b' => $b,
'chapters' => Book::chapters($id),
'notes' => Book::notes($id, (int)Auth::personId()),
], 'books');
break;
case 'books/read':
$familyId = (int)Auth::familyId();
$id = (int)($_GET['id'] ?? 0);
$b = Book::find($familyId, $id);
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
if (empty($b['file_key'])) {
flash('err', '这本书还没有上传文件,先到「编辑」里传一个。');
redirect('books/show', ['id' => $id]);
}
$chapters = Book::chapters($id);
$total = count($chapters);
$seq = (int)($_GET['seq'] ?? 0);
if ($seq <= 0) {
// 没指定 → 接着上次读到的地方(这就是"继续读"按钮能直接回原位的原因)
$seq = max(1, (int)($b['progress'] ?? 0));
}
$seq = max(1, min($seq, max(1, $total)));
// ⚠️ 翻章就顺手记进度:让人手动记"我读到第几章"的阅读器没人会用。
if (Auth::canWrite() && $total > 0 && (int)($b['progress'] ?? 0) !== $seq) {
Book::setProgress($familyId, $id, $seq);
$b['progress'] = $seq;
}
View::render('books/read', [
'title' => (string)$b['title'],
'b' => $b,
'chapters' => $chapters,
'chapter' => Book::chapter($id, $seq),
'seq' => $seq,
], 'books');
break;
case 'books/status':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$b = Book::find($familyId, $id);
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
$st = Book::normalizeStatus((string)($_POST['status'] ?? ''));
Book::setStatus($familyId, $id, $st);
flash('ok', '《' . (string)$b['title'] . '》标成「' . Book::statusWord($st) . '」了。');
// 列表上的一键 → 回列表;详情页 / 阅读页 → 回这本书
redirect((string)($_POST['back'] ?? '') === 'list' ? 'books' : 'books/show', ['id' => $id]);
break;
case 'books/destroy':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$b = Book::find($familyId, $id);
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
Book::destroy($familyId, $id);
flash('ok', '《' . (string)$b['title'] . '》已从书架移走。');
redirect('books');
break;
case 'books/note_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$b = Book::find($familyId, $id);
if (!$b) {
flash('err', '找不到这本书。');
redirect('books');
}
$nid = Book::addNote($familyId, $id, $me, [
'kind' => (string)($_POST['kind'] ?? 'note'),
'chapter' => (string)($_POST['chapter'] ?? ''),
'content' => (string)($_POST['content'] ?? ''),
'source' => 'manual',
]);
flash($nid > 0 ? 'ok' : 'err', $nid > 0 ? '记下了。' : '笔记是空的,没记。');
redirect('books/show', ['id' => $id], 'notes');
break;
case 'books/note_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$noteId = (int)($_POST['note_id'] ?? 0);
// 只能删自己的或无主的(导入进来的笔记没有归属,谁都能删)
$st = Database::get()->prepare(
'SELECT n.book_id FROM book_notes n JOIN books b ON b.id = n.book_id
WHERE n.id = ? AND b.family_id = ?'
);
$st->execute([$noteId, $familyId]);
$bid = (int)$st->fetchColumn();
Book::deleteNote($familyId, $noteId, $me);
flash('ok', '这条笔记删掉了。');
if ($bid > 0) {
redirect('books/show', ['id' => $bid], 'notes');
}
redirect('books');
break;
case 'books/stats':
$familyId = (int)Auth::familyId();
View::render('books/stats', [
'title' => '读书统计',
's' => Book::stats($familyId),
], 'books');
break;
// ---- 微信读书导入(v24 第 24 批 用户第 5 条)----
//
// 用户原话:「能不能导入微信的读书笔记,就是每一个人设置一下…授权一下」
// → 后续明确「用微信读书 api key」(官方 2026 已开放,见 App\WeRead 头部)
//
// ⚠️ Key 是**账号凭据**:加密落库、界面只回显掩码(同 DAV 口令那套思路)。
case 'books/weread':
$me = (int)Auth::personId();
Auth::requireWrite();
$imported = null;
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
csrf_check();
$act = (string)($_POST['act'] ?? '');
if ($act === 'key_save') {
$r = WeRead::saveKey($me, (string)($_POST['api_key'] ?? ''));
flash($r['ok'] ? 'ok' : 'err', $r['ok']
? 'API Key 已保存(加密存放,页面只显示掩码)。'
: (string)$r['error']);
redirect('books/weread');
}
// v24 第 25 批 用户第 8 条:自动同步的总开关(默认开)
if ($act === 'auto_toggle') {
WeRead::setAutoSync($me, !WeRead::autoSyncOn($me));
flash('ok', WeRead::autoSyncOn($me)
? '已打开自动同步(打开读书页时顺带同步,约每 6 小时一次)。'
: '已关闭自动同步,需要手动点「立即同步」。');
redirect('books/weread');
}
// 用户第 8 条:手动「立即同步」(忽略间隔,立刻打一次接口)
if ($act === 'sync_now') {
$r = WeRead::maybeSync((int)Auth::familyId(), $me, true);
if (!$r['ok']) {
flash('err', '同步失败:' . (string)$r['error']);
} else {
flash('ok', '同步完成:新增 ' . (int)$r['books'] . ' 本书、'
. (int)$r['covers'] . ' 张封面、' . (int)$r['notes'] . ' 条笔记。');
}
redirect('books/weread');
}
if ($act === 'fetch') {
// 「拉取预览」:只取数据、**不落库**,让用户先看一眼再决定
$nb = WeRead::notebooks($me);
if (!$nb['ok']) {
flash('err', (string)$nb['error']);
redirect('books/weread');
}
$preview = [];
foreach (array_slice($nb['books'], 0, 40) as $b) {
$nt = WeRead::notes($me, (string)$b['bookId']);
// v24 第 25 批 用户第 8 条:预览里也把**封面**带出来,
// 让用户选书时看得见是哪一本(原来只有一行文字)。
$info = WeRead::bookInfo($me, (string)$b['bookId']);
$preview[] = [
'title' => (string)$b['title'],
'author' => (string)$b['author'],
'bookId' => (string)$b['bookId'],
'cover' => $info['ok'] ? (string)($info['book']['cover'] ?? '') : '',
'count' => count((array)($nt['items'] ?? [])),
'items' => array_slice((array)($nt['items'] ?? []), 0, 3),
'local' => WeRead::matchLocalBook((int)Auth::familyId(), (string)$b['title']),
];
}
View::render('books/weread', [
'title' => '微信读书导入',
'preview' => $preview,
'nbCount' => count($nb['books']),
'stats' => WeRead::readStats($me, 'overall'),
], 'books');
break;
}
if ($act === 'import') {
// 「真的导入」:只导**勾选的那些书**(用户在预览页里挑)
//
// ⚠️ v24 第 25 批 用户第 8 条:**不再自己写一遍导入逻辑** ——
// 原来这里只建 title + author + status(于是封面永远是空的),
// 现在整份交给 `WeRead::syncNow()`:它会把封面、简介、ISBN、
// 出版社、字数、进度**一次拿齐**,而且「只补空字段」。
// 两处各写一遍 = 迟早只修一处(这正是"封面看不到"的来路)。
$want = array_map('strval', (array)($_POST['pick'] ?? []));
if (!$want) {
flash('err', '没有勾选任何书。');
redirect('books/weread');
}
UserSetting::set($me, WeRead::SYNC_AT_KEY, '0'); // 强制真同步
$nb = WeRead::notebooks($me);
if (!$nb['ok']) {
flash('err', (string)$nb['error']);
redirect('books/weread');
}
$familyId = (int)Auth::familyId();
$sumBooks = $sumNotes = $sumCovers = 0;
foreach ($nb['books'] as $b) {
if (!in_array((string)$b['bookId'], $want, true)) {
continue;
}
$title = trim((string)$b['title']);
if ($title === '') {
continue;
}
// 逐本走 syncNow 的那套「建书/补空」逻辑:把它当"只同步这一本"。
// ⚠️ syncNow 是按"有笔记的书"遍历的,这里已经有确定的 bookId,
// 所以借它内部那两个入口(bookInfo + fetchCover)手动走一遍,
// 保持与自动同步**完全同一条路径**(封面/元数据不会两样)。
$bid = WeRead::matchLocalBook($familyId, $title);
$info = WeRead::bookInfo($me, (string)$b['bookId']);
$meta = $info['ok'] ? $info['book'] : [];
$coverKey = '';
if (!empty($meta['cover'])) {
$coverKey = WeRead::fetchCover($familyId, (string)$meta['cover'], $title);
if ($coverKey !== '') { $sumCovers++; }
}
if ($bid <= 0) {
$bid = Book::create($familyId, $me, [
'title' => $title,
'author' => (string)($meta['author'] ?? $b['author']),
'translator' => (string)($meta['translator'] ?? ''),
'publisher' => (string)($meta['publisher'] ?? ''),
'published' => (string)($meta['published'] ?? ''),
'isbn' => (string)($meta['isbn'] ?? ''),
'category' => (string)($meta['category'] ?? ''),
'summary' => (string)($meta['intro'] ?? ''),
'words' => (int)($meta['wordCount'] ?? 0),
'rating' => (int)($meta['rating'] ?? 0) ?: null,
'cover_key' => $coverKey,
'status' => 'reading',
]);
if ($bid <= 0) {
continue;
}
$sumBooks++;
} elseif ($coverKey !== '') {
// 已在书架且没封面 → 补上(用户第 8 条「封面图看不到」)
$row = Book::find($familyId, $bid);
if ($row && trim((string)($row['cover_key'] ?? '')) === '') {
WeRead::patchBook($familyId, $bid, ['cover_key' => $coverKey]);
}
}
$nt = WeRead::notes($me, (string)$b['bookId']);
if ($nt['ok']) {
$res = WeRead::store($bid, $me, (array)$nt['items']);
$sumNotes += (int)$res['added'];
}
}
flash('ok', '导入完成:新建 ' . $sumBooks . ' 本书、'
. $sumCovers . ' 张封面、'
. $sumNotes . ' 条笔记(重复的已自动跳过)。');
redirect('books');
}
}
// ---- GET:先"顺手同步一次"(用户第 8 条:定期自动同步)----
//
// ⚠️ 位置很要紧:放在**渲染之前**,这样这一页看到的书架就是最新的;
// 而 maybeSync 内部按间隔判定,绝大多数请求只是读一个时间戳(不联网)。
$syncRes = ['ran' => false, 'books' => 0, 'notes' => 0, 'covers' => 0, 'error' => ''];
if (WeRead::autoSyncOn($me) && WeRead::hasKey($me)) {
$syncRes = WeRead::maybeSync((int)Auth::familyId(), $me, false);
}
View::render('books/weread', [
'title' => '微信读书导入',
'preview' => $imported,
'nbCount' => 0,
'sync' => $syncRes,
'stats' => WeRead::readStats($me, 'overall'),
], 'books');
break;
case 'books/notes_import':
Auth::requireWrite();
// 同一条路由干两件事:GET 给粘贴框,POST 先解析出**预览**。
// ⚠️ 必须有预览这一步:解析是宽容的(宁可归类错也不丢字),
// 直接落库的话,一段没书名的文字会变成一本"未命名的一本"——
// 看不见结果就写进去,是最容易让人以后再也不敢用这个功能的做法。
$raw = '';
$parsed = [];
$source = 'weread';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
csrf_check();
$raw = (string)($_POST['raw'] ?? '');
$source = BookNoteImport::sourceOf((string)($_POST['source'] ?? ''));
$parsed = BookNoteImport::parse($raw);
}
View::render('books/notes_import', [
'title' => '导入读书笔记',
'raw' => $raw,
'parsed' => $parsed,
'source' => $source,
], 'books');
break;
case 'books/notes_import_save':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$raw = (string)($_POST['raw'] ?? '');
$source = BookNoteImport::sourceOf((string)($_POST['source'] ?? ''));
// ⚠️ **重新解析一遍**,不接前端传回来的"解析结果"——
// 表单可以改,信它就是让人往库里塞任意结构。
$parsed = BookNoteImport::parse($raw);
$res = BookNoteImport::import($familyId, $me, $parsed, $source);
if ((int)$res['added'] === 0 && (int)$res['created'] === 0) {
flash('err', '这段里没认出能导入的笔记。检查一下是不是把书名行也贴上了。');
redirect('books/notes_import');
}
flash('ok', '导入完成:新增 ' . (int)$res['created'] . ' 本书、'
. (int)$res['added'] . ' 条笔记。');
redirect('books');
break;
// ==================== 个人导航(v24 第 24 批 用户第 4 条,从 SimpleNav 搬进来)====================
//
// ⚠️ 这一块与其它模块最大的不同:**导航展示页不需要登录**(用户明确要求),
// 它的入口在 index.php 之外(`nav_show.php`),因为它必须在 bootstrap
// 的"要求登录"之前就放行。这里的两条路由都是**管理端**(要登录)。
case 'nav':
$me = (int)Auth::personId();
Nav::ensureSeed($me); // 第一次进来给一份默认导航(只在他一条板块都没有时)
$gridGroups = Nav::groups($me, 'grid', true);
$tabGroups = Nav::groups($me, 'tabs', true);
$allGroups = Nav::groups($me);
$sitesBy = Nav::sitesByGroup($me);
$engines = Nav::engines($me);
$settings = Nav::settings($me);
$phrase = Nav::phrase($me);
View::render('nav/index', [
'title' => '导航',
'meId' => $me,
'gridGroups' => $gridGroups,
'tabGroups' => $tabGroups,
'allGroups' => $allGroups,
'sitesBy' => $sitesBy,
'engines' => $engines,
'navSet' => $settings,
'phrase' => $phrase,
// 展示页地址(短语为空时模板里会提示去设置)
// ⚠️ 用 `Nav::abs()` 而不是自己拼 `base_url`(v24 第 26 批顺带统一):
// `abs()` 以**当前请求的 host** 为准,所以从 IP / 测试域名进来时
// 给出的地址也是那个入口的(写死 base_url 会把人指到另一个域上)。
'showUrl' => $phrase !== ''
? Nav::abs('/nav/') . '?q=' . rawurlencode($phrase)
: '',
// 我自己的头像 —— 展示页右上角要用(用户原话「头像就变成用户自己的头像」)
'meAvatar' => avatar_url(Person::find($me)['avatar'] ?? null),
], 'nav');
break;
case 'nav/save':
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
// 一个路由干多件事(与 SimpleNav 的 admin/actions.php 同一个思路:
// 那个文件就是按 ?action= 分派的)。这里用 act 区分,避免开十几条路由。
$act = (string)($_POST['act'] ?? '');
$ok = static function (string $msg): void {
// AJAX(导航管理页的拖拽排序/开关都是 fetch)→ 回 JSON;否则回 302
if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => true, 'msg' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
flash('ok', $msg);
redirect('nav');
};
$bad = static function (string $msg): void {
if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', $msg);
redirect('nav');
};
$gid = (int)($_POST['id'] ?? 0);
switch ($act) {
case 'group_save':
$id = Nav::saveGroup($me, [
'name' => (string)($_POST['name'] ?? ''),
'display' => (string)($_POST['display'] ?? 'grid'),
'is_visible' => !empty($_POST['is_visible']),
], $gid);
$id > 0 ? $ok('板块已保存。') : $bad('板块得有个名字。');
break;
case 'group_delete':
Nav::removeGroup($me, $gid);
$ok('板块已删除(里面的站点一起删了)。');
break;
case 'group_toggle':
Nav::toggleGroup($me, $gid);
$ok('已切换显示。');
break;
case 'group_reorder':
// 拖拽后整组顺序一次提交(SimpleNav 的做法:整组归一化,间隙恢复整齐)
// ⚠️ 同 site_reorder:不是数组就拒绝,别静默只改一项(见那边的说明)。
if (!is_array($_POST['ids'] ?? null)) {
$bad('排序数据格式不对(应是一组 id)。');
}
$ids = array_map('intval', (array)$_POST['ids']);
$n = Nav::applySort('nav_groups', $me, $ids);
$ok('顺序已保存(' . $n . ' 项)。');
break;
case 'site_save':
$url = Nav::normUrl((string)($_POST['url'] ?? ''));
$id = Nav::saveSite($me, [
'group_id' => (int)($_POST['group_id'] ?? 0),
'url' => $url,
'name' => (string)($_POST['name'] ?? ''),
'short_name' => (string)($_POST['short_name'] ?? ''),
'is_visible' => !empty($_POST['is_visible']),
], $gid);
if ($id <= 0) {
$bad('站点地址和所属板块都不能空。');
}
/* 🔴 v24 第 26 批 用户第 4 条:**复用别人加过的站点**。
*
* 用户原话:「输入 url 之后,如果其他用户添加了,他可以直接复用,
* 复用的时候,自定义图标和简称,他可以自己再设置,
* 设置好以后就固定了。」
*
* 做法:表单里带一个 `reuse_key`(= 显示名|图标文件名)。
* ⚠️ 这里**重新查一遍**别人那条(不信任前端回传的内容)——
* 前端只被允许"指认是哪一条",绝不被允许"提供那条的内容"。
* 否则任何人都能凭一个伪造的 key 把任意的 name/icon 写进自己库里
* (比如塞一个 name 做 XSS 载荷 —— 展示页那份是 `e()` 转义过的,
* 但"不要相信前端给的展示数据"这条规则不该有例外)。
* ⚠️ 复用只发生在**新建**(`$editSite` 为空)时:编辑一条已有站点时
* 又去抄别人,语义会很乱(是覆盖我刚改的简称吗?)。
* 编辑时 `reuse_key` 一律忽略。 */
$reuseKey = trim((string)($_POST['reuse_key'] ?? ''));
$didReuse = false;
if ($reuseKey !== '' && !empty($_POST['is_new'])) {
foreach (Nav::sharedByUrl($me, $url, 20) as $cand) {
$k = Nav::siteLabel($cand) . '|' . (string)($cand['icon'] ?? '');
if ($k !== $reuseKey) {
continue;
}
// 用户在前端填的 简称/显示名 优先("他可以自己再设置");
// 没填的字段由 adoptSite 用别人的补上。
$didReuse = Nav::adoptSite($me, $id, $cand,
(string)($_POST['name'] ?? ''),
(string)($_POST['short_name'] ?? ''));
break;
}
}
// 抓元信息(名称 + 图标):用户勾了"顺便抓取"才做(联网、慢)
// ⚠️ 复用过的那条**跳过抓取**:用户刚说"就用这个图标、以后固定"
// (adoptSite 已把它标成 icon_custom=1),紧接着又按本站规则重抓一遍,
// 等于当场把他的话推翻。(这也是"设置好以后就固定了"的一部分。)
if (!$didReuse && !empty($_POST['do_fetch'])) {
$meta = NavFetch::meta($url);
$db = Database::get();
$newName = trim((string)($meta['name'] ?? ''));
$file = NavFetch::fetchIcon($meta, $url);
if ($file !== '') {
// ⚠️ 抓来的图标**不算**自定义(icon_custom=0),
// 下次「全部更新」可以覆盖它 —— 这正是 SimpleNav 的语义。
$db->prepare('UPDATE nav_sites SET icon = ?, icon_custom = 0,
name = CASE WHEN name = \'\' OR name IS NULL THEN ? ELSE name END
WHERE id = ? AND person_id = ?')
->execute([$file, $newName !== '' ? $newName : Nav::domainOf($url), $id, $me]);
}
}
$ok('站点已保存。');
break;
case 'site_delete':
Nav::removeSite($me, $gid);
$ok('站点已删除。');
break;
case 'site_toggle':
Nav::toggleSite($me, $gid);
$ok('已切换显示。');
break;
case 'site_reorder':
// ⚠️ 此处**必须**把「不是数组」当成拒绝,不能静默将就:
// 如果前端漏了 `ids[]` 的方括号(或有人手搓请求),
// `$_POST['ids']` 会是**一个字符串**而不是数组。
// 直接 `(array)` 强转的话它变成 `['8,9,10']` → applySort 里
// `(int)'8,9,10'` = 8 → **只改了第一项却回「成功」**。
// (本轮实测:探针正好踩到这个组合,报了"排序成功但没落库"。)
// 宁可回一句明确的话,也不要"看起来成功"。
$rawIds = $_POST['ids'] ?? null;
if (!is_array($rawIds)) {
$bad('排序数据格式不对(应是一组 id)。');
}
$ids = array_map('intval', $rawIds);
$to = (int)($_POST['group_id'] ?? 0);
$n = Nav::applySort('nav_sites', $me, $ids, 'group_id', $to);
$ok('顺序已保存(' . $n . ' 项)。');
break;
case 'site_move':
Nav::moveSite($me, $gid, (int)($_POST['group_id'] ?? 0))
? $ok('已移动到别的板块。') : $bad('目标板块不存在。');
break;
case 'engine_save':
$id = Nav::saveEngine($me, $_POST, $gid);
$id > 0 ? $ok('搜索引擎已保存。') : $bad('名称和搜索地址都不能空。');
break;
case 'engine_delete':
Nav::removeEngine($me, $gid);
$ok('搜索引擎已删除。');
break;
case 'engine_toggle':
Nav::toggleEngine($me, $gid);
$ok('已切换启用状态。');
break;
/* 🔴 v24 第 26 批 用户第 4 条:「可以内置几个,让用户选择」。
老用户库里只有当年那几条引擎(`ensureSeed` 只在"一条都没有"时才灌),
所以给他们一个**一键补齐内置引擎**的入口。
⚠️ 只补缺的,已有的(含用户改过的)一律不动 —— 见 Nav::addMissingEngines 的说明。 */
case 'engine_seed_add':
$n = Nav::addMissingEngines($me);
$ok($n > 0
? ('已补上 ' . $n . ' 个内置搜索引擎。')
: '内置搜索引擎都已经在列表里了,没有可补的。');
break;
/* 🔴 v24 第 26 批 用户第 4 条(后半):新增站点时**按 URL 查可复用**。
⚠️ 这是 AJAX 接口(前端在 URL 输入框失焦时问一次)。
做成 POST + csrf 而不是 GET:它带的是"我现在想加哪个 URL",
虽然是只读,但走同一个表单动作链更省事,且防了被当探测接口刷。
⚠️ 回的是**展示信息**(名字/简称/图标 URL),**不含是谁加的**。
隐私边界见 Nav::sharedByUrl() 的注释。 */
case 'site_lookup':
$u = (string)($_POST['url'] ?? '');
$rows = Nav::sharedByUrl($me, $u);
$out = [];
foreach ($rows as $r) {
$out[] = [
'name' => (string)($r['name'] ?? ''),
'short_name' => (string)($r['short_name'] ?? ''),
'label' => Nav::siteLabel($r),
'icon' => Nav::iconUrl((string)($r['icon'] ?? '')),
// 复用时给前端一个"可点选"的标识(用显示名+图标即可,
// ⚠️ 不能回 id —— 那是别人的行 id,暴露它没有必要)
'key' => Nav::siteLabel($r) . '|' . (string)($r['icon'] ?? ''),
];
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => true, 'items' => $out], JSON_UNESCAPED_UNICODE);
exit;
case 'engine_reorder':
if (!is_array($_POST['ids'] ?? null)) {
$bad('排序数据格式不对(应是一组 id)。');
}
$ids = array_map('intval', (array)$_POST['ids']);
$n = Nav::applySort('nav_engines', $me, $ids);
$ok('顺序已保存(' . $n . ' 项)。');
break;
case 'settings_save':
Nav::setSetting($me, 'site_name', trim((string)($_POST['site_name'] ?? '我的导航')));
// ⚠️ 副标题**允许为空**,而且不能把空串当"未设置"回退成默认值 ——
// SimpleNav 原文把这一条单列为 bug 现场("删了照样显示")。
Nav::setSetting($me, 'subtitle', trim((string)($_POST['subtitle'] ?? '')));
Nav::setSetting($me, 'primary_color',
Nav::safeColor((string)($_POST['primary_color'] ?? ''), '#2F6BF7'));
Nav::setSetting($me, 'default_engine', trim((string)($_POST['default_engine'] ?? 'bing')));
Nav::setSetting($me, 'open_blank', !empty($_POST['open_blank']) ? '1' : '0');
$sk = (string)($_POST['suggest_keys'] ?? 'both');
Nav::setSetting($me, 'suggest_keys',
in_array($sk, ['arrows', 'punct', 'both'], true) ? $sk : 'both');
Nav::setSetting($me, 'collections_layout',
((string)($_POST['collections_layout'] ?? 'tabs')) === 'flat' ? 'flat' : 'tabs');
$ok('设置已保存。');
break;
case 'phrase_save':
$r = Nav::setPhrase($me, (string)($_POST['phrase'] ?? ''));
$r['ok'] ? $ok('短语已保存,展示页地址已更新。') : $bad((string)$r['error']);
break;
case 'phrase_random':
$r = Nav::setPhrase($me, Nav::randomPhrase());
$r['ok'] ? $ok('已换成随机短语。') : $bad((string)$r['error']);
break;
case 'fetch_all':
// 「全部更新」:把每条站点的名称与图标重新抓一遍。
// ⚠️ 跳过 icon_custom=1 的(用户自己传的图标不许被覆盖)—— SimpleNav 的明确语义。
$n = 0;
$db = Database::get();
foreach (Nav::sitesByGroup($me) as $sites) {
foreach ($sites as $s) {
$meta = NavFetch::meta((string)$s['url']);
$name = trim((string)($meta['name'] ?? ''));
$sets = [];
$args = [];
// 只补**空**的名字,不覆盖用户已经起好的(含自定义简称)
if ($name !== '' && trim((string)$s['name']) === '') {
$sets[] = 'name = ?';
$args[] = $name;
}
if (empty($s['icon_custom'])) {
$file = NavFetch::fetchIcon($meta, (string)$s['url']);
if ($file !== '') {
$sets[] = 'icon = ?';
$args[] = $file;
}
}
if ($sets) {
$args[] = date('Y-m-d H:i:s');
$args[] = (int)$s['id'];
$args[] = $me;
$db->prepare('UPDATE nav_sites SET ' . implode(', ', $sets)
. ', updated_at = ? WHERE id = ? AND person_id = ?')
->execute($args);
}
$n++;
}
}
$ok('已更新 ' . $n . ' 条站点的名称与图标。');
break;
default:
$bad('未知操作。');
}
break;
/* =====================================================================
* 个人笔记(v27 用户 2026-10-07)
*
* 「参考思源和 obsidian,采用分块方式,支持图文混排,注意,要轻量级,别太复杂,
* 兼顾笔记和标签功能,哦,再参考一下 flomo」
*
* 路由就这几条(刻意少):列表(含搜索/标签筛选) / 表单(新建+编辑同页) /
* 保存(create+update 合一) / 删除 / 图片上传。
* ⚠️ 新建与编辑走**同一个表单页**(`notes/form`):两者字段完全一样,
* 分成两个模板只会让改一处忘一处(本仓在 resources/books 上已踩过)。
* ===================================================================== */
case 'notes':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$keyword = trim((string)($_GET['keyword'] ?? ''));
$tag = trim((string)($_GET['tag'] ?? ''));
$showAll = !empty($_GET['all']) || $keyword !== '' || $tag !== '';
$rows = Note::all($familyId, $me, $keyword, $tag,
$showAll ? 0 : Note::LIST_LIMIT);
View::render('notes/index', [
'title' => '笔记',
'rows' => $rows,
'keyword' => $keyword,
'tag' => $tag,
'tagCloud' => Note::tagCloud($familyId, $me),
'totalAll' => Note::countVisible($familyId, $me, $keyword, $tag),
'showAll' => $showAll,
'limit' => Note::LIST_LIMIT,
], 'notes');
break;
/* 新建 / 编辑:同一套表单(`id>0` 就是编辑)。
⚠️ 编辑时**只能改自己的**:别人的笔记即使共享给全家也只是"看得到",
点进编辑页会被挡回列表(不是静默变成新建,那样用户会以为改成功了)。 */
case 'notes/form':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
$id = (int)($_GET['id'] ?? 0);
$editing = null;
if ($id > 0) {
$editing = Note::find($familyId, $id, $me);
if ($editing === null || empty($editing['is_mine'])) {
flash('err', '只能编辑自己的笔记。');
redirect('notes');
}
}
View::render('notes/form', [
'title' => $editing ? '改笔记' : '写笔记',
'editing' => $editing,
'tagSuggest' => Note::tagSuggest($familyId, $me),
], 'notes');
break;
case 'notes/save':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
// 表单把块拼成 JSON 提交(`blocks` 字段)—— 块的增删排序本来就是个列表,
// 拆成一堆并行数组(kind[]/content[])在服务端还得按索引对齐,容易错位。
$blocks = [];
$rawBlocks = (string)($_POST['blocks'] ?? '');
if ($rawBlocks !== '') {
$dec = json_decode($rawBlocks, true);
if (is_array($dec)) {
$blocks = $dec;
}
}
// 兜底:没有 JS(或 JS 没跑到)时,退化成"一个正文textarea + 标题"
// —— 这样纯服务端也能写出一条文字笔记,不会因为缺 JS 就完全不能用。
if (!$blocks) {
$plain = (string)($_POST['body_plain'] ?? '');
if (trim($plain) !== '') {
$blocks = [['kind' => 'text', 'content' => $plain]];
}
}
$payload = [
'title' => (string)($_POST['title'] ?? ''),
'blocks' => $blocks,
'tags' => (string)($_POST['tags'] ?? ''),
'is_shared' => !empty($_POST['is_shared']),
];
$id = (int)($_POST['id'] ?? 0);
if ($id > 0) {
$done = Note::update($familyId, $id, $me, $payload);
flash($done ? 'ok' : 'err', $done ? '改好了。' : '只能改自己的笔记。');
redirect('notes');
}
$nid = Note::create($familyId, $me, $payload);
flash($nid > 0 ? 'ok' : 'err', $nid > 0 ? '记下了。' : '什么都没写,没记。');
redirect('notes');
break;
case 'notes/destroy':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$n = Note::find($familyId, $id, $me);
if (!$n || empty($n['is_mine'])) {
flash('err', '只能删自己的笔记。');
redirect('notes');
}
Note::destroy($familyId, $id, $me);
flash('ok', '删掉了。');
redirect('notes');
break;
/* 笔记底部附件(v28 用户第 12 条:「再加上附件上传功能,附件,都放在笔记最下面」)。
⚠️ 只允许给**我自己的**笔记加附件:附件是挂在笔记上的东西,
共享出去的笔记别人只能看,不该能往里塞文件。
⚠️ 复用全站统一的 `attach_save_files`(它处理多文件、类型判定、失败清单),
不自己遍历 $_FILES —— 各模块各写一遍必然有人漏掉"哪些字段是数组"。 */
case 'notes/attach_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$n0 = Note::find($familyId, $id, $me);
if (!$n0 || empty($n0['is_mine'])) {
flash('err', '这条笔记不能改。');
redirect('notes');
}
$warns = attach_save_files($familyId, Note::ATTACH, $id, 'file', $me);
flash($warns ? 'err' : 'ok',
$warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。');
redirect('notes/form', ['id' => $id]);
break;
case 'notes/attach_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$aid = (int)($_POST['id'] ?? 0);
// ⚠️ 先按附件 id 找到它挂在**哪条笔记**上,再判那条笔记是不是我的 ——
// 只信传进来的附件 id,等于谁都能构造请求删别人家的附件(资源页踩过)。
$st = Database::get()->prepare(
'SELECT target_id FROM attachments WHERE id = ? AND target_type = ?'
);
$st->execute([$aid, Note::ATTACH]);
$nid = (int)$st->fetchColumn();
$n0 = $nid > 0 ? Note::find($familyId, $nid, $me) : null;
if (!$n0 || empty($n0['is_mine'])) {
flash('err', '这个附件不能删。');
redirect('notes');
}
\App\Attachment::remove($aid);
flash('ok', '附件已删除。');
redirect('notes/form', ['id' => $nid]);
break;
/* 段落 Markdown 的**实时预览**(v28 用户第 12 条:"允许所见即所得编辑")。
⚠️ 为什么预览走**服务端**而不是在前端再写一份渲染器:
两套渲染器必然漂移(前端显示成粗体、保存后却不是),而用户看到的
就是"预览",一旦不一致,他会以为保存坏了。
服务端只有一份 `App\Markdown`,预览与最终渲染保证同源。
⚠️ 所以这段必须够轻:只做一段文本的行内+块级渲染,不碰数据库、不查权限
(不涉及任何数据,纯函数),前端再配合防抖调用。
⚠️ 它仍然要求登录 + 写权限:不是安全所需(没有数据泄露),而是不给
"未登录也能拿这个当 Markdown 渲染器用"的口子。 */
case 'notes/preview':
Auth::requireWrite();
csrf_check();
$src = (string)($_POST['md'] ?? '');
// 限长:预览是交互用的,不该被拿来转整本书
$src = mb_substr($src, 0, Note::MAX_TEXT, 'UTF-8');
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => true, 'html' => Markdown::render($src)],
JSON_UNESCAPED_UNICODE);
break;
/* 图片块上传:走统一附件表(target_type='note')。
⚠️ 为什么复用 attachments 而不在 notes 里另起一套:
图片的"存哪、怎么压缩、怎么通过 media.php 出去"这套逻辑全站只有一份,
每个模块各写一遍迟早出现"某个模块的图不压缩"或"路径拼错 404"。
⚠️ 上传是**即时**的(写笔记时点加图就传),此刻笔记可能还不存在 ——
所以附件行先挂在 `target_type='note'` + `target_id=0` 上(草稿态),
前端拿到 `key` 后把它当一个 image 块随表单提交;笔记保存时
`Note::claimUploads()` 把这些草稿附件认领到真实 note_id 上。
⚠️ 认领是**必须的**:不做的话草稿附件会永远挂在 target_id=0 上,
既不会被"删笔记时连图一起删"覆盖到,也会在附件表里越积越多。 */
case 'notes/upload':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
header('Content-Type: application/json; charset=utf-8');
if (empty($_FILES['img'])) {
echo json_encode(['ok' => false, 'error' => '没有收到文件'], JSON_UNESCAPED_UNICODE);
break;
}
$r = Attachment::save($familyId, Note::ATTACH, 0, $_FILES['img'],
(int)Auth::personId());
if (empty($r['ok'])) {
echo json_encode(['ok' => false, 'error' => (string)($r['error'] ?? '上传失败')],
JSON_UNESCAPED_UNICODE);
break;
}
// 取回刚存的那一行拿 key(Attachment 没有 find(),用 list 按 id 定位)
$key = '';
foreach (Attachment::list(Note::ATTACH, 0) as $a) {
if ((int)$a['id'] === (int)$r['id']) {
$key = (string)$a['key'];
break;
}
}
echo json_encode([
'ok' => $key !== '',
'key' => $key,
'id' => (int)$r['id'],
'url' => $key !== '' ? media_url($key) : '',
], JSON_UNESCAPED_UNICODE);
break;
default:
http_response_code(404);
View::render('error', [
'title' => '页面不存在',
'message' => '找不到这一页。',
], 'home');
break;
}