2026-09-29 23:48:45 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
2026-09-30 00:49:53 +08:00
|
|
|
|
* 家书 FamilyBook · 前端控制器
|
2026-09-29 23:48:45 +08:00
|
|
|
|
*
|
2026-09-30 00:49:53 +08:00
|
|
|
|
* 路由走 query-string(index.php?r=模块/动作),不依赖 Apache 重写,
|
|
|
|
|
|
* 换服务器、换 Nginx 都不用改配置。
|
2026-09-29 23:48:45 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
require __DIR__ . '/app/bootstrap.php';
|
2026-09-29 23:48:45 +08:00
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
use App\Account;
|
2026-10-03 15:01:38 +08:00
|
|
|
|
use App\Attachment;
|
2026-09-30 00:49:53 +08:00
|
|
|
|
use App\Auth;
|
2026-09-30 09:44:55 +08:00
|
|
|
|
use App\Birthday;
|
2026-10-05 10:58:17 +08:00
|
|
|
|
use App\Book;
|
|
|
|
|
|
use App\BookNoteImport;
|
|
|
|
|
|
use App\BookParser;
|
2026-10-02 09:28:07 +08:00
|
|
|
|
use App\Calendar;
|
2026-10-01 00:12:04 +08:00
|
|
|
|
use App\Cinema;
|
2026-10-03 19:27:14 +08:00
|
|
|
|
use App\ContactImport;
|
|
|
|
|
|
use App\ContactImporter;
|
2026-09-30 00:49:53 +08:00
|
|
|
|
use App\Crypto;
|
|
|
|
|
|
use App\Database;
|
2026-10-01 10:21:45 +08:00
|
|
|
|
use App\Douban;
|
2026-10-05 13:26:49 +08:00
|
|
|
|
use App\Expense;
|
2026-09-30 00:49:53 +08:00
|
|
|
|
use App\Family;
|
2026-10-03 15:01:38 +08:00
|
|
|
|
use App\Geo;
|
2026-10-03 11:30:48 +08:00
|
|
|
|
use App\Home;
|
|
|
|
|
|
use App\HomeMods;
|
2026-10-03 08:18:05 +08:00
|
|
|
|
use App\Anniversary;
|
|
|
|
|
|
use App\Ics;
|
2026-09-30 08:13:07 +08:00
|
|
|
|
use App\Kinship;
|
2026-10-03 08:18:05 +08:00
|
|
|
|
use App\Ledger;
|
2026-09-30 20:22:46 +08:00
|
|
|
|
use App\Movie;
|
2026-10-01 00:12:04 +08:00
|
|
|
|
use App\MovieReview;
|
2026-10-02 09:28:07 +08:00
|
|
|
|
use App\Module;
|
2026-10-01 12:11:43 +08:00
|
|
|
|
use App\MovieStats;
|
2026-10-05 13:26:49 +08:00
|
|
|
|
use App\NodeType;
|
2026-09-30 09:44:55 +08:00
|
|
|
|
use App\Nickname;
|
2026-09-30 00:49:53 +08:00
|
|
|
|
use App\Person;
|
2026-09-30 11:06:22 +08:00
|
|
|
|
use App\PersonGroup;
|
2026-10-03 19:27:14 +08:00
|
|
|
|
use App\PersonJob;
|
|
|
|
|
|
use App\PersonNote;
|
2026-10-03 15:01:38 +08:00
|
|
|
|
use App\PersonQuery;
|
2026-09-30 13:24:26 +08:00
|
|
|
|
use App\PersonRoster;
|
2026-10-03 15:01:38 +08:00
|
|
|
|
use App\Pinyin;
|
2026-09-30 08:13:07 +08:00
|
|
|
|
use App\Relations;
|
2026-10-03 11:30:48 +08:00
|
|
|
|
use App\Repeat;
|
2026-10-02 09:28:07 +08:00
|
|
|
|
use App\Resource;
|
2026-09-30 08:13:07 +08:00
|
|
|
|
use App\Schema;
|
2026-09-30 00:49:53 +08:00
|
|
|
|
use App\Setting;
|
2026-10-02 09:28:07 +08:00
|
|
|
|
use App\Task;
|
|
|
|
|
|
use App\Trip;
|
2026-09-30 00:49:53 +08:00
|
|
|
|
use App\Upload;
|
2026-10-03 15:01:38 +08:00
|
|
|
|
use App\UserSetting;
|
2026-10-05 13:26:49 +08:00
|
|
|
|
use App\Vehicle;
|
2026-09-30 00:49:53 +08:00
|
|
|
|
use App\View;
|
2026-09-29 23:48:45 +08:00
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
if (!Database::hasTables()) {
|
|
|
|
|
|
header('Location: install.php');
|
|
|
|
|
|
exit;
|
2026-09-29 23:48:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 08:13:07 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 一次性结构升级。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 为什么要有这段:上线后的库不认识新加的列(birth_rank / is_placeholder)和
|
|
|
|
|
|
* 新的 guest 角色。靠人去手动 ALTER 迟早会漏,所以在每次请求的开头比一下版本号,
|
|
|
|
|
|
* 落后了就补一次 —— 平时只是一次字符串比较,几乎零成本。
|
|
|
|
|
|
*/
|
2026-10-03 12:46:44 +08:00
|
|
|
|
// 仅用于**显示**(设置页/日志里"库结构是哪一版")。判断要不要升级请看
|
|
|
|
|
|
// Schema::needsUpgrade() —— 它比的是源码指纹。
|
2026-09-30 09:44:55 +08:00
|
|
|
|
const FB_SCHEMA_VERSION = Schema::VERSION;
|
2026-09-30 08:13:07 +08:00
|
|
|
|
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) {
|
2026-10-03 12:46:44 +08:00
|
|
|
|
// ⚠️ 判据是 **Schema 源码指纹**,不是版本号 —— 见 Schema::fingerprint() 的说明。
|
|
|
|
|
|
// 版本号靠人记得改,本项目已因此翻车一次(2026-10-03:两批结构变更都没改
|
|
|
|
|
|
// VERSION → 线上判定"已是最新" → 整段升级被跳过 → Calendar::month() 500)。
|
|
|
|
|
|
// 指纹改成"改了 Schema 就跑",不再依赖任何人的记性。
|
|
|
|
|
|
if (Schema::needsUpgrade($familyIdForSchema)) {
|
2026-09-30 08:13:07 +08:00
|
|
|
|
try {
|
|
|
|
|
|
Schema::ensure();
|
2026-10-03 12:46:44 +08:00
|
|
|
|
Schema::markUpgraded($familyIdForSchema);
|
2026-09-30 08:13:07 +08:00
|
|
|
|
} catch (\Throwable $ex) {
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// 升级失败不能把整站挡住;下次请求会再试。
|
|
|
|
|
|
// ⚠️ 但**必须留下痕迹**:这里曾经是空的 catch,结果线上结构升级
|
|
|
|
|
|
// 从 2026-09-30 起一次都没跑完(报错被吞、版本号永远停在 2),
|
|
|
|
|
|
// 而首页/名册都 200,看着完全正常 —— 直到「个人设置 500」才暴露。
|
|
|
|
|
|
// 一行 error_log 就能省掉几小时排查,别再删掉它。
|
|
|
|
|
|
error_log('[familybook] Schema::ensure() 失败(已跳过,下次请求重试):'
|
|
|
|
|
|
. $ex->getMessage() . ' @ ' . $ex->getFile() . ':' . $ex->getLine());
|
2026-09-30 08:13:07 +08:00
|
|
|
|
if (!empty($GLOBALS['config']['debug'])) {
|
|
|
|
|
|
throw $ex;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
$r = isset($_GET['r']) ? trim((string)$_GET['r'], '/') : 'home';
|
|
|
|
|
|
$r = $r === '' ? 'home' : $r;
|
|
|
|
|
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
2026-09-29 23:48:45 +08:00
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
$publicRoutes = ['auth/login'];
|
|
|
|
|
|
if (!Auth::check() && !in_array($r, $publicRoutes, true)) {
|
|
|
|
|
|
redirect('auth/login');
|
2026-09-29 23:48:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 09:44:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 从表单里读「三个生日」。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么要有 `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'] ?? ''),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 15:02:27 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 从表单解析「补充联系方式」(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 19:27:14 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 从表单读「工作经历」那组行(用户 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 15:02:27 +08:00
|
|
|
|
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 '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 11:06:22 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 渲染「个人设置」页(settings/me)。
|
|
|
|
|
|
*
|
2026-09-30 13:24:26 +08:00
|
|
|
|
* 从 v5 起这一页只剩两件事:**我怎么称呼家人**、**我的分组**。
|
|
|
|
|
|
* 「我的资料」表单不在这里 —— 它和「成员资料」是同一张表单
|
|
|
|
|
|
* (`templates/people/form.php`,两条入口都指过去),页面顶部给一个按钮过去。
|
|
|
|
|
|
* 这是用户明确要求的:「编辑成员」和「修改自己的资料」本来就是一件事,
|
|
|
|
|
|
* 两套表单必然填出两种口径。
|
2026-09-30 11:06:22 +08:00
|
|
|
|
*
|
|
|
|
|
|
* @param array $person 当前登录者自己的名册记录
|
2026-09-30 13:24:26 +08:00
|
|
|
|
* @param array $errors 字段名 => 错误文案(保留给"校验失败就地重画"用)
|
2026-09-30 11:06:22 +08:00
|
|
|
|
*/
|
|
|
|
|
|
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),
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// 我的分组 = 公共的 + 我自己加的,顺序是我的(user_settings.group_order)
|
2026-09-30 11:06:22 +08:00
|
|
|
|
'groups' => PersonGroup::orderFor($familyId, $me),
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'groupCounts' => PersonGroup::counts($familyId, $me),
|
|
|
|
|
|
'rosterCount' => PersonRoster::countFor($familyId, $me),
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// 用户 2026-10-03 第 3 条:首页上显示哪些模块、什么顺序(每人一份)
|
|
|
|
|
|
'homeOrder' => HomeMods::orderedForPicker($me),
|
|
|
|
|
|
'homeOn' => HomeMods::forPerson($me),
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 用户 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),
|
2026-10-06 17:33:48 +08:00
|
|
|
|
// 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),
|
2026-09-30 11:06:22 +08:00
|
|
|
|
], 'settings');
|
2026-10-06 17:33:48 +08:00
|
|
|
|
// ⚠️ 取完即清:明文口令只该被看到一次(刷新页面就没了)。
|
|
|
|
|
|
// 放在 View::render **之后** —— 早了就拿不到值了。
|
|
|
|
|
|
unset($_SESSION['dav_plain']);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 上传并挂一组附件到某个宿主(旅程照片 / 资源附件 / 任务附件…全走这一处)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 同时吃「单文件」与「多文件」两种表单字段:`<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
|
|
|
|
|
|
{
|
2026-10-06 03:14:18 +08:00
|
|
|
|
// v24 第 20 批 第 4 条:先把**拖动即时上传**的暂存文件认领到这个条目上。
|
|
|
|
|
|
// 用户原话:「拖动一个进去,就开始上传…最后统一保存即可。」
|
|
|
|
|
|
// 那些文件在拖动时就已经传到服务器了(upload_stage.php),但**没有挂到任何条目**;
|
|
|
|
|
|
// 保存时(这里)才真正认领 —— 于是"立刻上传"与"最后统一保存"两件事都成立。
|
|
|
|
|
|
// ⚠️ 认领放在临时文件之前:两者最终都进同一张附件表、按 position 顺序排,
|
|
|
|
|
|
// 先认领的在前(也就是"拖动加进来的先出现"),符合操作顺序。
|
2026-10-06 12:08:40 +08:00
|
|
|
|
//
|
|
|
|
|
|
// 🔴 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'] ?? []);
|
2026-10-06 03:14:18 +08:00
|
|
|
|
$stageWarns = [];
|
|
|
|
|
|
if ($staged) {
|
|
|
|
|
|
$res = \App\StagedUpload::claim($familyId, $uploaderId, $staged, $type, $targetId);
|
|
|
|
|
|
$stageWarns = (array)($res['warns'] ?? []);
|
|
|
|
|
|
}
|
|
|
|
|
|
// 顺手清掉过期没认领的暂存(不必挂定时任务)
|
|
|
|
|
|
\App\StagedUpload::sweep();
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-06 03:14:18 +08:00
|
|
|
|
$warns = $stageWarns ?? [];
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-10-04 17:42:21 +08:00
|
|
|
|
// ⚠️ v24 第 11 批 #6(用户原话「月视图,从周一开始。而不是周日。」):
|
|
|
|
|
|
// 这里原来是"周日开头"——$w 直接当回退天数(周日=0 → 不回退)。
|
|
|
|
|
|
// 现在改成**周一开头**:把周日(0)折算成 6、其余减 1,回退到本周一。
|
|
|
|
|
|
// ⚠️ 月网格与周视图必须用**同一套**起算规则,否则同一天在两张视图里
|
|
|
|
|
|
// 落在不同的列上,用户会以为日程"跳"了。
|
|
|
|
|
|
$wSun = (int)date('w', $firstTs); // 0=周日 … 6=周六
|
|
|
|
|
|
$w = ($wSun + 6) % 7; // 0=周一 … 6=周日
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$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-03 08:18:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 周视图 / 天视图的日期列(用户 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();
|
|
|
|
|
|
}
|
2026-10-04 10:46:50 +08:00
|
|
|
|
// ⚠️ 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); // 周一开头(仅周视图)
|
|
|
|
|
|
}
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$out = [];
|
|
|
|
|
|
for ($i = 0; $i < max(1, $span); $i++) {
|
2026-10-04 10:46:50 +08:00
|
|
|
|
$d = date('Y-m-d', strtotime('+' . $i . ' days', $start));
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$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 15:01:38 +08:00
|
|
|
|
* 用户 2026-10-03 第 5 条:「日程色块的高度不对,应该是从开始时间到结束时间,
|
|
|
|
|
|
* 高度随着日程的时间而不同才对。」
|
|
|
|
|
|
* 旧实现把**所有**日程都写成「30 分钟高」(`$endMin = $startMin + 30`),
|
|
|
|
|
|
* 于是三小时的会议和十五分钟的站立会同高 —— 一眼看不出"哪段时间被占住了",
|
|
|
|
|
|
* 而"时间网格"的全部意义就在于把时长画出来。
|
|
|
|
|
|
* 现在:有 ends_at 就按真实时长;没填结束才算"最短可见块"(30 分钟)。
|
|
|
|
|
|
*
|
2026-10-03 08:18:05 +08:00
|
|
|
|
* @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];
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 结束时刻:有就按真实时长;没有就画一条 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);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$timed[$ci][] = $e + [
|
|
|
|
|
|
'top' => round($startMin / 1440 * 100, 4),
|
2026-10-03 15:01:38 +08:00
|
|
|
|
'height' => round($spanMin / 1440 * 100, 4),
|
2026-10-03 08:18:05 +08:00
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
return ['all_day' => $allDay, 'timed' => $timed];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 读任务的「截止日期(+ 可选时刻)」。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 只填日期、不填时刻时存 00:00:00,而 00:00 在展示层与日历里都当作**全天**
|
|
|
|
|
|
* (与日程同一个口径):"某天之前做完"本来就没有具体几点。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function read_due_input(): ?string
|
|
|
|
|
|
{
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// v24 第 5 条:截止时刻与全局其它地方一样走 `_datetime.php`(日期 + 时/分/秒一起选,
|
|
|
|
|
|
// 精确到秒)。下面那段 due_date / due_time 只是老表单的兜底,别删 ——
|
|
|
|
|
|
// 旧的链接、书签、脚本还在按老名字提交,删了就静默变成"没有截止日期"。
|
|
|
|
|
|
$full = read_dt_input('due_at');
|
|
|
|
|
|
if ($full !== null) {
|
|
|
|
|
|
return $full;
|
|
|
|
|
|
}
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。
|
|
|
|
|
|
*
|
2026-10-03 08:18:05 +08:00
|
|
|
|
* ⚠️ v24(用户 2026-10-02 第 19 条):原来同一天没有命中时会**退回同一个月**,
|
|
|
|
|
|
* 标题随之变成"往年这个月" —— 但用户要的就是**往年的今天**:
|
|
|
|
|
|
* "10 月 2 日"给我看"去年 10 月 8 日去看了场电影",这根本不是同一天的事,
|
|
|
|
|
|
* 凑出来的条目看着像回忆,其实只是同月的随机噪声,反而把"往年今日"的意味毁了。
|
|
|
|
|
|
* 现在只认同一天;没命中就明说"往年的这一天还没有记录"。
|
2026-10-02 09:28:07 +08:00
|
|
|
|
*
|
2026-10-03 08:18:05 +08:00
|
|
|
|
* @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容)
|
2026-10-02 09:28:07 +08:00
|
|
|
|
*/
|
|
|
|
|
|
function home_memories(int $familyId, int $limit = 6): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$today = date('Y-m-d');
|
|
|
|
|
|
$year = (int)date('Y');
|
2026-10-04 20:57:40 +08:00
|
|
|
|
$md = substr($today, 5, 5);
|
|
|
|
|
|
$rows = [];
|
|
|
|
|
|
|
|
|
|
|
|
// ① 观影:照旧(events kind=movie)
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$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
|
2026-10-04 20:57:40 +08:00
|
|
|
|
WHERE e.family_id = ? AND e.kind = 'movie' AND e.starts_at IS NOT NULL
|
2026-10-02 09:28:07 +08:00
|
|
|
|
AND YEAR(e.starts_at) < ?
|
2026-10-03 08:18:05 +08:00
|
|
|
|
AND DATE_FORMAT(e.starts_at, '%m-%d') = ?
|
|
|
|
|
|
ORDER BY e.starts_at DESC LIMIT " . (int)$limit;
|
|
|
|
|
|
$st = Database::get()->prepare($sql);
|
2026-10-04 20:57:40 +08:00
|
|
|
|
$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);
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
foreach ($rows as &$r) {
|
|
|
|
|
|
$r['years_ago'] = $year - (int)substr((string)$r['starts_at'], 0, 4);
|
2026-10-04 20:57:40 +08:00
|
|
|
|
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']]);
|
|
|
|
|
|
}
|
2026-10-02 09:28:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
unset($r);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
return ['rows' => $rows, 'exact' => true];
|
2026-10-02 09:28:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 只认本家庭、确实存在的 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 11:06:22 +08:00
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
switch ($r) {
|
2026-09-29 23:48:45 +08:00
|
|
|
|
|
2026-10-06 03:14:18 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 暂存上传(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;
|
|
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
// ==================== 登录 / 退出 ====================
|
|
|
|
|
|
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', '家书'),
|
2026-09-29 23:48:45 +08:00
|
|
|
|
]);
|
2026-09-30 00:49:53 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-09-30 08:13:07 +08:00
|
|
|
|
// ==================== 首页(以我为中心) ====================
|
2026-09-30 00:49:53 +08:00
|
|
|
|
case 'home':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
2026-09-30 08:13:07 +08:00
|
|
|
|
$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) : '';
|
2026-09-30 09:44:55 +08:00
|
|
|
|
// 名字优先用"我叫他什么" —— 同一个孩子,爸爸看到"儿子"、奶奶看到"小宝"
|
|
|
|
|
|
$b['shown'] = Nickname::display($me, $people[(int)$b['id']] ?? null) ?: $b['name'];
|
2026-09-30 08:13:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
unset($b);
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// ---- 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 17:42:21 +08:00
|
|
|
|
// 用户 2026-10-04 第 11 批 #1:「首页,未来七天,不显示农历」。
|
|
|
|
|
|
// ⚠️ 农历是**每天一条**的派生条目(节假日也一样),七天就是七八行"廿一 / 廿二…",
|
|
|
|
|
|
// 把真正要看的日程全挤下去了 —— 它在月视图里当"日历刻度"很有用,
|
|
|
|
|
|
// 在首页这种"接下来要做什么"的清单里纯属噪音。
|
|
|
|
|
|
// 节假日保留("这周六放假"是有信息量的),农历整类去掉。
|
|
|
|
|
|
$soon = array_values(array_filter($soon, static function (array $e): bool {
|
|
|
|
|
|
return (string)($e['kind'] ?? '') !== 'lunar';
|
|
|
|
|
|
}));
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// 进行中的任务 + 已经过期的待办(**只有这些才值得占首页**;
|
|
|
|
|
|
// 没截止日期的待办放任务看板里看,堆到首页会变成一张长长的清单)
|
2026-10-05 10:58:17 +08:00
|
|
|
|
//
|
|
|
|
|
|
// ⚠️ v24 第 14 批 用户第 2 条:**按状态取,不看板取**。
|
|
|
|
|
|
// 原话:「首页上显示的任务,是和任务的状态有关,而不是和它在那个看板有关」。
|
|
|
|
|
|
// 原来调的是 Task::board() —— 它只取**默认看板**,于是建在别的看板上的
|
|
|
|
|
|
// 「进行中」永远上不了首页(用户看到的就是"我明明在做,首页却没有")。
|
|
|
|
|
|
$tasks = Task::byStatus($familyId, $me, 'doing');
|
|
|
|
|
|
foreach (Task::byStatus($familyId, $me, 'todo') as $t) {
|
2026-10-02 09:28:07 +08:00
|
|
|
|
if (Task::isOverdue($t['due_at'] ?? null)) {
|
|
|
|
|
|
$tasks[] = $t;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
$memories = home_memories($familyId);
|
|
|
|
|
|
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// ---- 用户 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();
|
2026-10-05 10:58:17 +08:00
|
|
|
|
// ⚠️ 同上(用户第 2 条):这两个数字也是**按状态、跨看板**算的。
|
|
|
|
|
|
// $board 在首页已经不再使用(它只取默认看板)—— 留着它只会让统计与
|
|
|
|
|
|
// 上面的清单对不上("清单上有 3 件,卡片却说 1 件在做")。
|
|
|
|
|
|
$todoN = count(Task::byStatus($familyId, $me, 'todo'));
|
2026-10-03 11:30:48 +08:00
|
|
|
|
$lateN = 0;
|
2026-10-05 10:58:17 +08:00
|
|
|
|
foreach ($tasks as $t) {
|
|
|
|
|
|
if (Task::isOverdue($t['due_at'] ?? null)) {
|
|
|
|
|
|
$lateN++;
|
2026-10-03 11:30:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
$meName = Nickname::display($me, $people[$me] ?? null) ?: (string)($people[$me]['name'] ?? '');
|
|
|
|
|
|
$greeting = Home::greeting($meName, $today, count($soon), $lateN);
|
|
|
|
|
|
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 天气:地点在**个人设置**里(v24 用户 2026-10-03 第 1 条),每人一份。
|
|
|
|
|
|
// 三级回落都在这一个调用里:自定义经纬度 → 内置城市表 → 不显示。
|
|
|
|
|
|
$weatherLoc = Home::weatherLocation($me, $familyId);
|
|
|
|
|
|
$weather = $weatherLoc === null ? null
|
|
|
|
|
|
: Home::weather($familyId, $weatherLoc['lat'], $weatherLoc['lon'], $weatherLoc['place']);
|
2026-10-03 11:30:48 +08:00
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
View::render('home', [
|
2026-09-30 08:13:07 +08:00
|
|
|
|
'title' => '首页',
|
|
|
|
|
|
'family' => Family::find($familyId),
|
|
|
|
|
|
'me' => $people[$me] ?? null,
|
|
|
|
|
|
'meId' => $me,
|
|
|
|
|
|
'kin' => $kin,
|
|
|
|
|
|
'allCount' => count($people),
|
|
|
|
|
|
'relCount' => Relations::count($familyId),
|
|
|
|
|
|
'birthdays' => $birthdays,
|
2026-10-02 09:28:07 +08:00
|
|
|
|
'soon' => $soon,
|
|
|
|
|
|
'tasks' => $tasks,
|
|
|
|
|
|
'memories' => $memories,
|
2026-09-30 09:44:55 +08:00
|
|
|
|
// 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页)
|
|
|
|
|
|
'mySlogan' => trim((string)($people[$me]['slogan'] ?? '')),
|
|
|
|
|
|
'myNicknames'=> Nickname::mapFor($me),
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// 用户第 3 条新增
|
|
|
|
|
|
'greeting' => $greeting,
|
|
|
|
|
|
'weekday' => Home::weekday($today),
|
|
|
|
|
|
'weather' => $weather,
|
|
|
|
|
|
'stat7' => ['events' => $ev7, 'todos' => $todoN, 'overdue' => $lateN],
|
|
|
|
|
|
'homeModules'=> $homeMods,
|
|
|
|
|
|
'moduleCards'=> $moduleCards,
|
2026-09-30 00:49:53 +08:00
|
|
|
|
], 'home');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-09-30 08:13:07 +08:00
|
|
|
|
// ==================== 家谱(以我为中心) ====================
|
|
|
|
|
|
case 'kin':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
$me = (int)Auth::personId();
|
|
|
|
|
|
$people = Person::map($familyId);
|
|
|
|
|
|
$graph = Kinship::graph($familyId);
|
2026-10-02 10:26:14 +08:00
|
|
|
|
// ⚠️ v15:家谱模板不再吃 Kinship::tree() 的产物(那套 units/generations 是给
|
|
|
|
|
|
// 旧画法用的),改由模板里调 Kinship::familyMap() 现算。这里就不再白算一遍。
|
2026-09-30 08:13:07 +08:00
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// v24 第 12 条:可以按某个人为中心重画(姻亲不在主图里时点卡片上的入口进来)。
|
|
|
|
|
|
// ⚠️ 「看的是谁的家谱」与「我登录着」是两件事:$meId 决定称呼视角,$focusId 决定画谁的树。
|
|
|
|
|
|
$focusId = (int)($_GET['focus'] ?? 0);
|
|
|
|
|
|
if ($focusId <= 0 || !isset($people[$focusId])) {
|
|
|
|
|
|
$focusId = $me;
|
|
|
|
|
|
}
|
2026-09-30 08:13:07 +08:00
|
|
|
|
View::render('kin/tree', [
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'title' => $focusId === $me ? '我的家谱' : ((string)($people[$focusId]['name'] ?? '') . ' 的家谱'),
|
|
|
|
|
|
'meId' => $me,
|
|
|
|
|
|
'focusId' => $focusId,
|
|
|
|
|
|
'people' => $people,
|
|
|
|
|
|
'graph' => $graph,
|
2026-09-30 08:13:07 +08:00
|
|
|
|
], 'kin');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
// ==================== 名册 ====================
|
|
|
|
|
|
case 'people':
|
|
|
|
|
|
$q = trim((string)($_GET['q'] ?? ''));
|
2026-09-30 08:13:07 +08:00
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
$me = (int)Auth::personId();
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// ⚠️ 用 roster() 而不是 all():名册是「每人一份」——
|
|
|
|
|
|
// 只列我加的人 + 别人分享给我的(v5 起,见 App\PersonRoster)。
|
|
|
|
|
|
$rows = Person::roster($familyId, $me, 'member', $q);
|
2026-09-30 08:13:07 +08:00
|
|
|
|
$people = Person::map($familyId);
|
|
|
|
|
|
$graph = Kinship::graph($familyId);
|
|
|
|
|
|
// 名册里也标出「我该怎么称呼他」
|
|
|
|
|
|
foreach ($rows as &$row) {
|
|
|
|
|
|
$row['label'] = Kinship::label($graph, $me, (int)$row['id'], $people);
|
2026-09-30 09:44:55 +08:00
|
|
|
|
$row['shown'] = Nickname::display($me, $row) ?: (string)$row['name'];
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 用户 2026-10-03 第 3 条:列表要显示手机号(密文解密)与生日。
|
|
|
|
|
|
// 解密与生日格式化统一走 PersonQuery(见该类的类注释:
|
|
|
|
|
|
// 名册 / 挑人 / 高级搜索必须**同一套判据**,否则同一句搜索在不同页面结果不同)。
|
|
|
|
|
|
$row['phone_plain'] = PersonQuery::phonePlain($row);
|
2026-10-04 08:56:40 +08:00
|
|
|
|
// 用户 2026-10-04 第 3 条:阴历和阳历**都**列出来,且只显示月和日
|
|
|
|
|
|
$row['birthdays'] = PersonQuery::birthdaysOf($row);
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 首字母供前端"输入即筛选"用(filter.js 对 data-initials 做子序列匹配)
|
|
|
|
|
|
$row['initials'] = Pinyin::initials((string)$row['name'] . ' ' . (string)($row['my_nickname'] ?? ''));
|
2026-09-30 08:13:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
unset($row);
|
|
|
|
|
|
|
2026-09-30 11:06:22 +08:00
|
|
|
|
// 按关系分组分栏。顺序用**当前用户自己的**(没自定义过就是默认顺序);
|
|
|
|
|
|
// 归到键 0 的那一堆是还没分类的人,模板单独放在最后一栏。
|
|
|
|
|
|
$grouped = [];
|
|
|
|
|
|
foreach ($rows as $row) {
|
|
|
|
|
|
$grouped[(int)($row['group_id'] ?? 0)][] = $row;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 08:56:40 +08:00
|
|
|
|
// 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');
|
|
|
|
|
|
}
|
2026-09-30 00:49:53 +08:00
|
|
|
|
View::render('people/index', [
|
|
|
|
|
|
'title' => '家庭名册',
|
2026-09-30 08:13:07 +08:00
|
|
|
|
'people' => $rows,
|
2026-09-30 11:06:22 +08:00
|
|
|
|
'grouped'=> $grouped,
|
|
|
|
|
|
'groups' => PersonGroup::orderFor($familyId, $me),
|
2026-09-30 00:49:53 +08:00
|
|
|
|
'q' => $q,
|
2026-09-30 08:13:07 +08:00
|
|
|
|
'meId' => $me,
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// 家里已经有人、但还不在我名册里 → 给一个「直接挑」的入口,
|
|
|
|
|
|
// 免得为了同一个人又录一遍(录两遍会变成两条记录,账号与亲属关系各存一份)
|
|
|
|
|
|
'pickable' => $q === '' ? count(PersonRoster::pickable($familyId, $me)) : 0,
|
2026-10-06 17:33:48 +08:00
|
|
|
|
// v24 第 22 批:CardDAV 那张卡要知道"口令配了没有"(决定显示"去生成"还是"去管理")。
|
|
|
|
|
|
// ⚠️ 这里**不传明文** —— 明文只走一次性会话槽位、只在「个人设置」那页显示一次;
|
|
|
|
|
|
// 名册页能反复看到明文就等于口令多了一个泄露面。
|
|
|
|
|
|
'davHas' => \App\Auth::hasDav($me),
|
2026-09-30 00:49:53 +08:00
|
|
|
|
], 'people');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-09-30 11:06:22 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 名册卡片上的「调整分组」。
|
|
|
|
|
|
*
|
2026-09-30 13:24:26 +08:00
|
|
|
|
* 从 v5 起改的是**我自己那份名册**里的归类(person_roster.group_id),
|
|
|
|
|
|
* 不再动 persons —— 所以「我把张伟归到同事」不会让妈妈那边也变成同事。
|
|
|
|
|
|
* 单开一个路由而不是让人去「编辑成员」里改,是因为那张表很长
|
2026-09-30 11:06:22 +08:00
|
|
|
|
* (三个生日、身份证号、住址…),为了挪一个分组去翻半页是折磨。
|
2026-09-30 13:24:26 +08:00
|
|
|
|
* 而且分组**本来就只能在名册里改**(它是"我和他的关系")。
|
2026-09-30 11:06:22 +08:00
|
|
|
|
*/
|
|
|
|
|
|
case 'people/group':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$me = (int)Auth::personId();
|
2026-09-30 11:06:22 +08:00
|
|
|
|
if (!Auth::canWrite()) {
|
|
|
|
|
|
flash('err', '你的身份是访客,不能改名册里的分组。');
|
|
|
|
|
|
redirect('people');
|
|
|
|
|
|
}
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
$pid = (int)($_POST['person_id'] ?? 0);
|
|
|
|
|
|
$gid = (int)($_POST['group_id'] ?? 0);
|
|
|
|
|
|
$p = Person::find($pid);
|
|
|
|
|
|
if (!$p || (int)$p['family_id'] !== $familyId) {
|
|
|
|
|
|
flash('err', '找不到这个人。');
|
|
|
|
|
|
redirect('people');
|
|
|
|
|
|
}
|
2026-09-30 13:24:26 +08:00
|
|
|
|
if (!PersonRoster::assignGroup($familyId, $me, $pid, $gid > 0 ? $gid : null)) {
|
|
|
|
|
|
flash('err', '这个人不在你的名册里,或者那个分组你用不了。');
|
2026-09-30 11:06:22 +08:00
|
|
|
|
redirect('people');
|
|
|
|
|
|
}
|
|
|
|
|
|
flash('ok', $gid > 0
|
|
|
|
|
|
? '已把「' . $p['name'] . '」归到「' . PersonGroup::name($familyId, $gid) . '」。'
|
|
|
|
|
|
: '已把「' . $p['name'] . '」改成未分类。');
|
|
|
|
|
|
redirect('people');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 名册拖动排序 / 拖动换栏 —— 整版提交。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 前端把每一栏(含「未分类」)的有序 id 列表发过来,服务端一次写完。
|
|
|
|
|
|
* 为什么不"移动一条算一次":拖拽的结果是一组**相对顺序**,
|
|
|
|
|
|
* 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。
|
|
|
|
|
|
* 没有 JS 也能用:卡片上的「调整分组」表单还在(只是没有拖拽那么顺手)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
case 'people/reorder':
|
|
|
|
|
|
Auth::requireLogin();
|
|
|
|
|
|
// 拖动是 AJAX 发的(见 app.js)→ 出错也回 JSON 而不是 302:
|
|
|
|
|
|
// 不然那句 flash 会攒到"下一次刷新"才冒出来,看着像迟到的鬼影。
|
|
|
|
|
|
$isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest');
|
|
|
|
|
|
if (!Auth::canWrite()) {
|
|
|
|
|
|
if ($isAjax) {
|
|
|
|
|
|
header('Content-Type: application/json; charset=utf-8', true, 403);
|
|
|
|
|
|
echo json_encode(['ok' => false, 'error' => 'readonly']);
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
flash('err', '你的身份是访客,不能调整名册顺序。');
|
|
|
|
|
|
redirect('people');
|
|
|
|
|
|
}
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
$raw = (string)($_POST['layout'] ?? '');
|
|
|
|
|
|
$layout = json_decode($raw, true);
|
|
|
|
|
|
if (!is_array($layout)) {
|
|
|
|
|
|
if ($isAjax) {
|
|
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
|
|
|
|
echo json_encode(['ok' => false, 'error' => 'invalid layout']);
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
flash('err', '没收到有效的顺序,请刷新页面再试一次。');
|
|
|
|
|
|
redirect('people');
|
|
|
|
|
|
}
|
|
|
|
|
|
$n = PersonRoster::applyLayout((int)Auth::familyId(), (int)Auth::personId(), $layout);
|
|
|
|
|
|
if ($isAjax) {
|
|
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
|
|
|
|
echo json_encode(['ok' => true, 'n' => $n], JSON_UNESCAPED_UNICODE);
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
flash('ok', $n > 0 ? '名册顺序已更新。' : '没有需要改动的。');
|
|
|
|
|
|
redirect('people');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-03 19:27:14 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 名册导入(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;
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 「从家里已有的人里挑」—— 添加成员的第二个入口。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 存在的理由:同一个人不该被录两遍。录两遍会变成两条 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()) {
|
2026-09-30 16:12:28 +08:00
|
|
|
|
// 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'] . '」从你的名册里移走(他还在家里)。');
|
|
|
|
|
|
}
|
2026-09-30 13:24:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
redirect('people');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-09-30 11:06:22 +08:00
|
|
|
|
// ==================== 我自己的资料 / 个人设置 ====================
|
|
|
|
|
|
// 顶栏头像点开 →「个人设置」。访客也能改自己的资料 ——
|
2026-09-30 09:44:55 +08:00
|
|
|
|
// 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。
|
|
|
|
|
|
case 'people/me':
|
2026-09-30 11:06:22 +08:00
|
|
|
|
// 「我的资料」已并进「个人设置」(设置分两个维度:系统设置 / 个人设置)。
|
|
|
|
|
|
// 旧链接、老书签、以及别处写死的 people/me 一律跳过去,不再留第二个入口。
|
|
|
|
|
|
redirect('settings/me');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'settings/me':
|
2026-09-30 09:44:55 +08:00
|
|
|
|
Auth::requireLogin();
|
2026-09-30 11:06:22 +08:00
|
|
|
|
$p = Person::find((int)Auth::personId());
|
2026-09-30 09:44:55 +08:00
|
|
|
|
if (!$p) {
|
|
|
|
|
|
flash('err', '找不到你自己的名册记录。');
|
|
|
|
|
|
redirect('home');
|
|
|
|
|
|
}
|
|
|
|
|
|
clear_old();
|
2026-09-30 11:06:22 +08:00
|
|
|
|
render_me_settings($p);
|
2026-09-30 09:44:55 +08:00
|
|
|
|
break;
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 旧的「保存我的资料」入口。
|
|
|
|
|
|
*
|
|
|
|
|
|
* v5 起资料表单只剩一张(`templates/people/form.php`,提交到 people/update),
|
|
|
|
|
|
* 所以这两个地址不再处理表单 —— 只把还开着旧标签页的人送回个人设置,
|
|
|
|
|
|
* 免得一次提交撞上 404 让人以为"保存坏了"。
|
|
|
|
|
|
*/
|
|
|
|
|
|
case 'settings/me_save':
|
|
|
|
|
|
case 'people/me_save':
|
2026-09-30 11:06:22 +08:00
|
|
|
|
redirect('settings/me');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 保存「导航条上显示哪些模块、什么顺序」(用户第 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 11:30:48 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 保存「首页上显示哪些模块、什么顺序」(用户 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;
|
|
|
|
|
|
|
2026-10-03 15:01:38 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 保存「我所在的城市」(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;
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 保存模块主题色(用户第 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;
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// ==================== 分组的顺序 ====================
|
|
|
|
|
|
// 两套:「我自己的顺序」(只影响我看到的)与「公共分组的默认顺序」(全家兜底)。
|
|
|
|
|
|
// 靠表单里的 scope 字段区分 —— 同一个动作,落到两个地方。
|
2026-09-30 11:06:22 +08:00
|
|
|
|
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';
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
|
|
|
|
|
|
if ($scope === 'public') {
|
|
|
|
|
|
Auth::requireAdmin();
|
|
|
|
|
|
}
|
2026-09-30 11:06:22 +08:00
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$list = $scope === 'public'
|
|
|
|
|
|
? PersonGroup::forOwner($familyId, PersonGroup::PUBLIC_OWNER)
|
|
|
|
|
|
: PersonGroup::orderFor($familyId, $me);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
$ids = array_map(fn($g) => (int)$g['id'], $list);
|
|
|
|
|
|
$at = array_search($id, $ids, true);
|
|
|
|
|
|
|
|
|
|
|
|
if ($at === false) {
|
|
|
|
|
|
flash('err', '找不到这个分组。');
|
2026-09-30 13:24:26 +08:00
|
|
|
|
redirect($scope === 'public' ? 'settings' : 'settings/me');
|
2026-09-30 11:06:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 挪到边界就什么也不做(按钮在两端本来就是 disabled 的,
|
|
|
|
|
|
// 但地址栏里手敲一次也能到这儿,别让它把顺序搞乱)
|
|
|
|
|
|
$to = $dir === 'up' ? $at - 1 : $at + 1;
|
|
|
|
|
|
if ($to >= 0 && $to < count($ids)) {
|
|
|
|
|
|
[$ids[$at], $ids[$to]] = [$ids[$to], $ids[$at]];
|
2026-09-30 13:24:26 +08:00
|
|
|
|
if ($scope === 'public') {
|
|
|
|
|
|
PersonGroup::savePositions($familyId, PersonGroup::PUBLIC_OWNER, $ids);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
PersonGroup::saveOrder($familyId, $me, $ids);
|
|
|
|
|
|
}
|
2026-09-30 11:06:22 +08:00
|
|
|
|
}
|
2026-09-30 13:24:26 +08:00
|
|
|
|
redirect($scope === 'public' ? 'settings' : 'settings/me');
|
2026-09-30 11:06:22 +08:00
|
|
|
|
break;
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// ==================== 分组的增删改名 ====================
|
|
|
|
|
|
// 两套:公共的(管理员在系统设置里维护,全家共用)与我自己的(个人设置里,
|
|
|
|
|
|
// 只有自己看得见)。访客只读,给个明确的说法再回去,
|
2026-09-30 11:06:22 +08:00
|
|
|
|
// 而不是甩一张 403 页面(他明明是点了自己设置里的按钮)。
|
|
|
|
|
|
case 'settings/groups/add':
|
|
|
|
|
|
Auth::requireLogin();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
if (!Auth::canWrite()) {
|
2026-09-30 13:24:26 +08:00
|
|
|
|
flash('err', '你的身份是访客,不能加分组。');
|
2026-09-30 11:06:22 +08:00
|
|
|
|
redirect('settings/me');
|
|
|
|
|
|
}
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$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);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
flash($res['ok'] ? 'ok' : 'err', $res['ok']
|
2026-09-30 13:24:26 +08:00
|
|
|
|
? '已加上分组「' . trim((string)($_POST['name'] ?? '')) . '」。'
|
2026-09-30 11:06:22 +08:00
|
|
|
|
: (string)($res['error'] ?? '加不上。'));
|
2026-09-30 13:24:26 +08:00
|
|
|
|
redirect($scope === 'public' ? 'settings' : 'settings/me');
|
2026-09-30 11:06:22 +08:00
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'settings/groups/rename':
|
|
|
|
|
|
Auth::requireLogin();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
if (!Auth::canWrite()) {
|
2026-09-30 13:24:26 +08:00
|
|
|
|
flash('err', '你的身份是访客,不能改分组。');
|
2026-09-30 11:06:22 +08:00
|
|
|
|
redirect('settings/me');
|
|
|
|
|
|
}
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
|
|
|
|
|
|
$owner = PersonGroup::PUBLIC_OWNER;
|
|
|
|
|
|
if ($scope === 'public') {
|
|
|
|
|
|
Auth::requireAdmin();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$owner = (int)Auth::personId();
|
|
|
|
|
|
}
|
2026-09-30 11:06:22 +08:00
|
|
|
|
$res = PersonGroup::rename((int)Auth::familyId(), (int)($_POST['id'] ?? 0),
|
2026-09-30 13:24:26 +08:00
|
|
|
|
(string)($_POST['name'] ?? ''), $owner);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
flash($res['ok'] ? 'ok' : 'err', $res['ok']
|
2026-09-30 13:24:26 +08:00
|
|
|
|
? '分组名已改。'
|
2026-09-30 11:06:22 +08:00
|
|
|
|
: (string)($res['error'] ?? '改不了。'));
|
2026-09-30 13:24:26 +08:00
|
|
|
|
redirect($scope === 'public' ? 'settings' : 'settings/me');
|
2026-09-30 11:06:22 +08:00
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'settings/groups/delete':
|
|
|
|
|
|
Auth::requireLogin();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
if (!Auth::canWrite()) {
|
2026-09-30 13:24:26 +08:00
|
|
|
|
flash('err', '你的身份是访客,不能删分组。');
|
2026-09-30 11:06:22 +08:00
|
|
|
|
redirect('settings/me');
|
|
|
|
|
|
}
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$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);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
if ($res['ok']) {
|
|
|
|
|
|
$n = (int)($res['moved'] ?? 0);
|
|
|
|
|
|
flash('ok', $n > 0
|
2026-09-30 13:24:26 +08:00
|
|
|
|
? '分组已删掉,那 ' . $n . ' 个人现在显示在「未分类」里,去名册重新归类即可。'
|
|
|
|
|
|
: '分组已删掉(那一组本来也没人)。');
|
2026-09-30 11:06:22 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
flash('err', (string)($res['error'] ?? '删不了。'));
|
|
|
|
|
|
}
|
2026-09-30 13:24:26 +08:00
|
|
|
|
redirect($scope === 'public' ? 'settings' : 'settings/me');
|
2026-09-30 09:44:55 +08:00
|
|
|
|
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) . '」。这只影响你自己看到的称呼。');
|
2026-09-30 11:06:22 +08:00
|
|
|
|
if ($back === 'settings/me' || $back === 'people/me') {
|
|
|
|
|
|
// 两个写法都认:老表单发的是 people/me(那一页现在是跳转)
|
|
|
|
|
|
redirect('settings/me');
|
2026-09-30 09:44:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
redirect('people/show', ['id' => $target]);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
case 'people/new':
|
2026-09-30 08:13:07 +08:00
|
|
|
|
Auth::requireWrite();
|
2026-09-30 00:49:53 +08:00
|
|
|
|
clear_old();
|
|
|
|
|
|
View::render('people/form', [
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'title' => '添加成员',
|
|
|
|
|
|
'person' => null,
|
|
|
|
|
|
'errors' => [],
|
|
|
|
|
|
'canEditAll' => true,
|
|
|
|
|
|
'isSelf' => false,
|
2026-10-03 19:27:14 +08:00
|
|
|
|
// 工作经历先给一行空白(第 4 条),省得还要点「再加一段」
|
|
|
|
|
|
'jobRows' => [[]],
|
|
|
|
|
|
'myNote' => '',
|
2026-09-30 00:49:53 +08:00
|
|
|
|
], 'people');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'people/create':
|
2026-09-30 08:13:07 +08:00
|
|
|
|
Auth::requireWrite();
|
2026-09-30 00:49:53 +08:00
|
|
|
|
csrf_check();
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$familyId = (int)Auth::familyId();
|
2026-09-30 00:49:53 +08:00
|
|
|
|
$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 08:56:40 +08:00
|
|
|
|
// 用户 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'] ?? '')),
|
2026-09-30 15:02:27 +08:00
|
|
|
|
'org' => trim((string)($_POST['org'] ?? '')),
|
|
|
|
|
|
'title' => trim((string)($_POST['title'] ?? '')),
|
2026-09-30 00:49:53 +08:00
|
|
|
|
'note' => trim((string)($_POST['note'] ?? '')),
|
2026-10-03 19:27:14 +08:00
|
|
|
|
// 新增的人还没有"我的这一句话"(那是他自己的东西,由他之后自己填)
|
|
|
|
|
|
'slogan' => '',
|
|
|
|
|
|
// 工作经历(第 4 条):一组行,整组替换;见 PersonJob::replace
|
|
|
|
|
|
'jobs' => read_jobs_input(),
|
2026-09-30 00:49:53 +08:00
|
|
|
|
'avatar' => '',
|
|
|
|
|
|
'kind' => 'member',
|
2026-09-30 08:13:07 +08:00
|
|
|
|
// ⚠️ 这两项必须显式从表单取回:表单里有,但漏了这一步就会被静默丢掉,
|
|
|
|
|
|
// 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。
|
|
|
|
|
|
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
|
|
|
|
|
|
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
|
2026-09-30 09:44:55 +08:00
|
|
|
|
] + read_birthday_input();
|
2026-09-30 00:49:53 +08:00
|
|
|
|
$errors = Person::validate($d);
|
2026-09-30 15:02:27 +08:00
|
|
|
|
$contacts = read_contacts_input();
|
|
|
|
|
|
$ctErr = validate_contacts($contacts);
|
|
|
|
|
|
if ($ctErr !== '') {
|
|
|
|
|
|
$errors['contacts'] = $ctErr;
|
|
|
|
|
|
}
|
2026-09-30 00:49:53 +08:00
|
|
|
|
|
2026-09-30 15:02:27 +08:00
|
|
|
|
// ⚠️ 头像上传失败**不再挡住整条新增**(用户原话:「添加人员功能不能用了」——
|
|
|
|
|
|
// 早先一张 5MB 的手机原图就把整个人挡在名册外面,看起来像功能坏了)。
|
|
|
|
|
|
// 现在是:人先存进去,头像的事单独提示。
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
|
2026-09-30 11:06:22 +08:00
|
|
|
|
(string)($_POST['avatar_crop'] ?? ''));
|
2026-09-30 15:02:27 +08:00
|
|
|
|
$avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : '';
|
2026-09-30 00:49:53 +08:00
|
|
|
|
if ($errors) {
|
|
|
|
|
|
set_old($_POST);
|
|
|
|
|
|
View::render('people/form', [
|
|
|
|
|
|
'title' => '添加成员',
|
|
|
|
|
|
'person' => null,
|
|
|
|
|
|
'errors' => $errors,
|
|
|
|
|
|
'phoneValue' => $d['phone'],
|
|
|
|
|
|
'idCardValue'=> $d['id_card'],
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'canEditAll' => true,
|
|
|
|
|
|
'isSelf' => false,
|
2026-09-30 15:02:27 +08:00
|
|
|
|
'contacts' => $contacts,
|
2026-10-03 19:27:14 +08:00
|
|
|
|
// 校验失败要把用户刚填的工作经历**原样回显**(第 4 条),
|
|
|
|
|
|
// 私备注同理(第 5 条)—— 别让他重填一遍。
|
|
|
|
|
|
'jobRows' => (array)($d['jobs'] ?? []),
|
|
|
|
|
|
'myNote' => trim((string)($_POST['my_note'] ?? '')),
|
2026-09-30 00:49:53 +08:00
|
|
|
|
], 'people');
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($up['ok']) {
|
|
|
|
|
|
$d['avatar'] = $up['file'];
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$id = Person::create($familyId, $d);
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// 头像主色:上传时已算好,这里顺手存下(算不出就不写,走姓名兜底)
|
|
|
|
|
|
if ($up['ok']) {
|
|
|
|
|
|
Person::setAvatarColor($id, (string)($up['color'] ?? ''));
|
|
|
|
|
|
}
|
2026-09-30 00:49:53 +08:00
|
|
|
|
} catch (\PDOException $ex) {
|
|
|
|
|
|
set_old($_POST);
|
|
|
|
|
|
View::render('people/form', [
|
|
|
|
|
|
'title' => '添加成员',
|
|
|
|
|
|
'person' => null,
|
|
|
|
|
|
'errors' => ['phone' => '这个手机号已经在名册里了'],
|
|
|
|
|
|
'phoneValue' => $d['phone'],
|
|
|
|
|
|
'idCardValue'=> $d['id_card'],
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'canEditAll' => true,
|
|
|
|
|
|
'isSelf' => false,
|
2026-09-30 15:02:27 +08:00
|
|
|
|
'contacts' => $contacts,
|
2026-10-03 19:27:14 +08:00
|
|
|
|
// 校验失败要把用户刚填的工作经历**原样回显**(第 4 条),
|
|
|
|
|
|
// 私备注同理(第 5 条)—— 别让他重填一遍。
|
|
|
|
|
|
'jobRows' => (array)($d['jobs'] ?? []),
|
|
|
|
|
|
'myNote' => trim((string)($_POST['my_note'] ?? '')),
|
2026-09-30 00:49:53 +08:00
|
|
|
|
], 'people');
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// 分组:表单里选了就用它(用户第 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');
|
2026-09-30 15:02:27 +08:00
|
|
|
|
Person::replaceContacts($id, $contacts);
|
2026-10-03 19:27:14 +08:00
|
|
|
|
// 工作经历:整组替换(第 4 条)。空的那组会把这段经历清空 —— 与联系人同一口径。
|
|
|
|
|
|
PersonJob::replace($id, (array)($d['jobs'] ?? []));
|
2026-09-30 00:49:53 +08:00
|
|
|
|
clear_old();
|
2026-09-30 15:02:27 +08:00
|
|
|
|
if ($avatarWarn !== '') {
|
|
|
|
|
|
flash('ok', '已把「' . $d['name'] . '」加进名册。头像没有保存:' . $avatarWarn);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
flash('ok', '已把「' . $d['name'] . '」加进名册。');
|
|
|
|
|
|
}
|
2026-09-30 00:49:53 +08:00
|
|
|
|
redirect('people/show', ['id' => $id]);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 成员资料表单 —— **只有这一张**(需求:「编辑成员」与「改自己的资料」本来就是一件事)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 两个入口都指到这里:名册卡片/详情页的「编辑资料」、以及个人设置顶部的
|
|
|
|
|
|
* 「编辑我的资料」。区别只有一个:编辑**自己**时不要求能写(访客也能改自己的名字),
|
|
|
|
|
|
* 而编辑别人必须是能写的人。
|
|
|
|
|
|
*/
|
2026-09-30 00:49:53 +08:00
|
|
|
|
case 'people/edit':
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$id = (int)($_GET['id'] ?? 0);
|
|
|
|
|
|
$isSelf = $id > 0 && $id === (int)Auth::personId();
|
|
|
|
|
|
if (!$isSelf) {
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
}
|
|
|
|
|
|
$p = Person::find($id);
|
2026-09-30 00:49:53 +08:00
|
|
|
|
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
|
|
|
|
|
flash('err', '找不到这个人。');
|
|
|
|
|
|
redirect('people');
|
|
|
|
|
|
}
|
|
|
|
|
|
clear_old();
|
2026-09-30 15:02:27 +08:00
|
|
|
|
// 补充联系方式:kind=phone 存的是密文,编辑表单里要给本人(能写的人)看明文
|
|
|
|
|
|
$editContacts = Person::contactsOf($id);
|
|
|
|
|
|
foreach ($editContacts['phone'] as &$r) {
|
|
|
|
|
|
$r['value'] = Crypto::decrypt($r['value']);
|
|
|
|
|
|
}
|
|
|
|
|
|
unset($r);
|
2026-09-30 00:49:53 +08:00
|
|
|
|
View::render('people/form', [
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'title' => $isSelf ? '我的资料' : '编辑成员',
|
2026-09-30 00:49:53 +08:00
|
|
|
|
'person' => $p,
|
|
|
|
|
|
'errors' => [],
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'canEditAll' => Auth::canWrite(),
|
|
|
|
|
|
'isSelf' => $isSelf,
|
2026-09-30 00:49:53 +08:00
|
|
|
|
'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']),
|
|
|
|
|
|
'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']),
|
2026-09-30 15:02:27 +08:00
|
|
|
|
'contacts' => $editContacts,
|
2026-10-03 19:27:14 +08:00
|
|
|
|
// 工作经历(第 4 条):库里已有的多段
|
|
|
|
|
|
'jobRows' => PersonJob::list($id),
|
|
|
|
|
|
// 我对他的私备注(第 5 条):**只有我自己**那一份
|
|
|
|
|
|
'myNote' => $isSelf ? '' : PersonNote::get($id, (int)Auth::personId()),
|
2026-09-30 00:49:53 +08:00
|
|
|
|
], 'people');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'people/update':
|
|
|
|
|
|
csrf_check();
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$familyId = (int)Auth::familyId();
|
2026-09-30 00:49:53 +08:00
|
|
|
|
$id = (int)($_POST['id'] ?? 0);
|
|
|
|
|
|
$p = Person::find($id);
|
2026-09-30 13:24:26 +08:00
|
|
|
|
if (!$p || (int)$p['family_id'] !== $familyId) {
|
2026-09-30 00:49:53 +08:00
|
|
|
|
flash('err', '找不到这个人。');
|
|
|
|
|
|
redirect('people');
|
|
|
|
|
|
}
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$isSelf = $id === (int)Auth::personId();
|
|
|
|
|
|
if (!$isSelf) {
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
}
|
|
|
|
|
|
// 改别人(= 能写的人编名册):字段是完整的一套。
|
|
|
|
|
|
// 改自己(可能是访客):只允许动属于自己的那些 —— 与 Person::updateSelf 一一对应。
|
|
|
|
|
|
$canEditAll = Auth::canWrite();
|
2026-10-03 19:27:14 +08:00
|
|
|
|
// 「我的这一句话」只能本人改(用户第 3 条)→ 非本人提交时沿用库里现有值。
|
|
|
|
|
|
$keepSlogan = (string)($p['slogan'] ?? '');
|
2026-09-30 00:49:53 +08:00
|
|
|
|
$d = [
|
|
|
|
|
|
'name' => trim((string)($_POST['name'] ?? '')),
|
|
|
|
|
|
'gender' => (string)($_POST['gender'] ?? ''),
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'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']),
|
2026-09-30 00:49:53 +08:00
|
|
|
|
'email' => trim((string)($_POST['email'] ?? '')),
|
|
|
|
|
|
'address' => trim((string)($_POST['address'] ?? '')),
|
2026-10-04 08:56:40 +08:00
|
|
|
|
// 用户 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'] ?? '')),
|
2026-09-30 15:02:27 +08:00
|
|
|
|
'org' => trim((string)($_POST['org'] ?? '')),
|
|
|
|
|
|
'title' => trim((string)($_POST['title'] ?? '')),
|
2026-09-30 00:49:53 +08:00
|
|
|
|
'note' => trim((string)($_POST['note'] ?? '')),
|
2026-10-03 19:27:14 +08:00
|
|
|
|
// ⚠️ 用户 2026-10-03 第 3 条:这一句话**只能本人改** ——
|
|
|
|
|
|
// 表单里这一栏也只在 $isSelf 时出现。别人提交时这里读不到字段,
|
|
|
|
|
|
// 若直接 $_POST 取值会拿到空串 → **把本人的那句话清空**。
|
|
|
|
|
|
// 所以非本人一律沿用库里已有的值($keepSlogan)。
|
|
|
|
|
|
'slogan' => $isSelf ? trim((string)($_POST['slogan'] ?? '')) : $keepSlogan,
|
|
|
|
|
|
// 工作经历(第 4 条):一组行,整组替换;见 PersonJob::replace
|
|
|
|
|
|
'jobs' => read_jobs_input(),
|
2026-09-30 00:49:53 +08:00
|
|
|
|
'avatar' => (string)$p['avatar'],
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'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),
|
2026-09-30 09:44:55 +08:00
|
|
|
|
] + read_birthday_input($p);
|
2026-09-30 00:49:53 +08:00
|
|
|
|
$errors = Person::validate($d);
|
2026-09-30 15:02:27 +08:00
|
|
|
|
$contacts = read_contacts_input();
|
|
|
|
|
|
$ctErr = validate_contacts($contacts);
|
|
|
|
|
|
if ($ctErr !== '') {
|
|
|
|
|
|
$errors['contacts'] = $ctErr;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 同 people/create:头像失败不挡保存(见 create 分支里的说明)
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
|
2026-09-30 11:06:22 +08:00
|
|
|
|
(string)($_POST['avatar_crop'] ?? ''));
|
2026-09-30 15:02:27 +08:00
|
|
|
|
$avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : '';
|
2026-09-30 00:49:53 +08:00
|
|
|
|
if ($errors) {
|
|
|
|
|
|
set_old($_POST);
|
|
|
|
|
|
View::render('people/form', [
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'title' => $isSelf ? '我的资料' : '编辑成员',
|
2026-09-30 00:49:53 +08:00
|
|
|
|
'person' => $p,
|
|
|
|
|
|
'errors' => $errors,
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'canEditAll' => $canEditAll,
|
|
|
|
|
|
'isSelf' => $isSelf,
|
2026-09-30 00:49:53 +08:00
|
|
|
|
'phoneValue' => $d['phone'],
|
|
|
|
|
|
'idCardValue' => $d['id_card'],
|
2026-09-30 15:02:27 +08:00
|
|
|
|
'contacts' => $contacts,
|
2026-09-30 00:49:53 +08:00
|
|
|
|
], 'people');
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($up['ok']) {
|
2026-09-30 13:24:26 +08:00
|
|
|
|
Upload::remove($familyId, (string)$p['avatar']);
|
2026-09-30 00:49:53 +08:00
|
|
|
|
$d['avatar'] = $up['file'];
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// 换图就换色:新头像的主色马上覆盖旧的(算不出时保留旧值,不清空)
|
|
|
|
|
|
Person::setAvatarColor($id, (string)($up['color'] ?? ''));
|
2026-09-30 00:49:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
try {
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// 改自己且没有编名册的权限 → 只能动属于自己的字段(Person::updateSelf)。
|
|
|
|
|
|
// 这一条不是"防贼",是为了让访客也能改自己的名字,同时不会顺手把
|
|
|
|
|
|
// 手机号(= 登录凭据)或身份证号改掉。
|
|
|
|
|
|
if ($isSelf && !$canEditAll) {
|
|
|
|
|
|
Person::updateSelf($id, $d);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Person::update($id, $d);
|
|
|
|
|
|
}
|
2026-09-30 00:49:53 +08:00
|
|
|
|
} catch (\PDOException $ex) {
|
|
|
|
|
|
set_old($_POST);
|
|
|
|
|
|
View::render('people/form', [
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'title' => $isSelf ? '我的资料' : '编辑成员',
|
2026-09-30 00:49:53 +08:00
|
|
|
|
'person' => $p,
|
|
|
|
|
|
'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'],
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'canEditAll' => $canEditAll,
|
|
|
|
|
|
'isSelf' => $isSelf,
|
2026-09-30 00:49:53 +08:00
|
|
|
|
'phoneValue' => $d['phone'],
|
|
|
|
|
|
'idCardValue' => $d['id_card'],
|
2026-09-30 15:02:27 +08:00
|
|
|
|
'contacts' => $contacts,
|
2026-09-30 00:49:53 +08:00
|
|
|
|
], 'people');
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
2026-09-30 15:02:27 +08:00
|
|
|
|
Person::replaceContacts($id, $contacts);
|
2026-10-03 19:27:14 +08:00
|
|
|
|
// 工作经历:整组替换(第 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']);
|
|
|
|
|
|
}
|
2026-09-30 00:49:53 +08:00
|
|
|
|
if ((int)$p['id'] === (int)Auth::personId()) {
|
|
|
|
|
|
Auth::refreshAvatar((string)$d['avatar']);
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// 姓名也可能改了 —— 不同步会话的话顶栏还是旧名字
|
|
|
|
|
|
$_SESSION['name'] = (string)$d['name'];
|
2026-09-30 00:49:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
clear_old();
|
2026-09-30 15:02:27 +08:00
|
|
|
|
if ($avatarWarn !== '') {
|
|
|
|
|
|
flash('ok', '已保存。头像没有更换:' . $avatarWarn);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
flash('ok', '已保存。');
|
|
|
|
|
|
}
|
2026-09-30 00:49:53 +08:00
|
|
|
|
redirect('people/show', ['id' => $id]);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-03 19:27:14 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 保存「我对他的私人备注」(用户 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]);
|
|
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
case 'people/show':
|
2026-09-30 08:13:07 +08:00
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
$me = (int)Auth::personId();
|
|
|
|
|
|
$id = (int)($_GET['id'] ?? 0);
|
2026-09-30 09:44:55 +08:00
|
|
|
|
$p = Person::findWithAccount($id, $me);
|
2026-09-30 08:13:07 +08:00
|
|
|
|
if (!$p || (int)$p['family_id'] !== $familyId) {
|
2026-09-30 00:49:53 +08:00
|
|
|
|
flash('err', '找不到这个人。');
|
|
|
|
|
|
redirect('people');
|
|
|
|
|
|
}
|
2026-09-30 08:13:07 +08:00
|
|
|
|
$people = Person::map($familyId);
|
|
|
|
|
|
$graph = Kinship::graph($familyId);
|
|
|
|
|
|
|
|
|
|
|
|
// 这个人在「我」眼里是谁,以及我与他之间的直接关系(可编辑的那些)
|
|
|
|
|
|
$tree = Kinship::tree($graph, $id, $people, 2, 2);
|
|
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
View::render('people/show', [
|
2026-09-30 08:13:07 +08:00
|
|
|
|
'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),
|
2026-09-30 09:44:55 +08:00
|
|
|
|
'options' => Person::options($familyId, $id, $me),
|
2026-09-30 08:13:07 +08:00
|
|
|
|
'kinReady' => Kinship::graph($familyId) !== [],
|
2026-09-30 09:44:55 +08:00
|
|
|
|
// 「我叫他什么」—— 只有我自己能改,改了也只影响我看到的
|
|
|
|
|
|
'myNickname' => $id === $me ? '' : Nickname::get($me, $id),
|
|
|
|
|
|
'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']),
|
2026-10-03 19:27:14 +08:00
|
|
|
|
// 工作经历(第 4 条):可以有多段,详情页整列出来
|
|
|
|
|
|
'jobs' => PersonJob::list($id),
|
|
|
|
|
|
// 我对他的私人备注(第 5 条):**只有我自己**那一份;看自己时为空
|
|
|
|
|
|
'myNote' => $id === $me ? '' : PersonNote::get($id, $me),
|
2026-09-30 09:44:55 +08:00
|
|
|
|
'birthdays' => Birthday::describe($p),
|
|
|
|
|
|
'birthdayNext'=> Birthday::nextOccurrences($p, date('Y-m-d'), 400),
|
2026-09-30 15:02:27 +08:00
|
|
|
|
// 补充联系方式(vCard 对齐):kind=phone 的 value 是密文,模板里用 maskPhone 展示
|
|
|
|
|
|
'contacts' => Person::contactsOf($id),
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// 名册(每人一份):他是不是在我的名册里、可以把他的名片分享给谁
|
|
|
|
|
|
'inMyRoster' => in_array($id, PersonRoster::visibleIds($familyId, $me), true),
|
2026-09-30 00:49:53 +08:00
|
|
|
|
], 'people');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'people/destroy':
|
|
|
|
|
|
Auth::requireAdmin();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
$id = (int)($_POST['id'] ?? 0);
|
|
|
|
|
|
$p = Person::find($id);
|
2026-10-06 12:08:40 +08:00
|
|
|
|
// ---- 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;
|
2026-09-30 00:49:53 +08:00
|
|
|
|
}
|
2026-10-06 12:08:40 +08:00
|
|
|
|
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');
|
2026-09-30 00:49:53 +08:00
|
|
|
|
}
|
2026-10-06 12:08:40 +08:00
|
|
|
|
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 . '」。');
|
2026-09-30 00:49:53 +08:00
|
|
|
|
redirect('people');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-04 08:56:40 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 行政区划级联数据(用户 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;
|
|
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
// ==================== 账号 ====================
|
|
|
|
|
|
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]);
|
|
|
|
|
|
}
|
2026-09-30 08:13:07 +08:00
|
|
|
|
$r0 = (string)($_POST['role'] ?? '');
|
|
|
|
|
|
Account::create($personId, $pass, in_array($r0, Account::ROLES, true) ? $r0 : 'member');
|
2026-09-30 16:12:28 +08:00
|
|
|
|
// v7:多了一个「看家谱的人」——他的名册也要按树播种,一登录三处口径就是对的
|
|
|
|
|
|
PersonRoster::syncKinForFamily((int)Auth::familyId());
|
2026-09-30 00:49:53 +08:00
|
|
|
|
flash('ok', '账号已开通,用手机号就能登录。');
|
|
|
|
|
|
redirect('people/show', ['id' => $personId]);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'accounts/role':
|
|
|
|
|
|
Auth::requireAdmin();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
$personId = (int)($_POST['person_id'] ?? 0);
|
2026-09-30 08:13:07 +08:00
|
|
|
|
$role = in_array(($r1 = (string)($_POST['role'] ?? '')), Account::ROLES, true) ? $r1 : 'member';
|
2026-09-30 00:49:53 +08:00
|
|
|
|
$p = Person::find($personId);
|
|
|
|
|
|
$acc = Account::findByPerson($personId);
|
|
|
|
|
|
if ($p && $acc) {
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// ⚠️ 判的是 `$role !== 'admin'` 而不是 `$role === 'member'`:
|
|
|
|
|
|
// 早先只拦"降成家庭成员",于是把**最后一位管理员**降成访客能一路通过 ——
|
|
|
|
|
|
// 降完家里一个管理员都不剩,谁也进不了系统设置了。
|
|
|
|
|
|
// 允许改自己的权限(包括给自己降级),只在"会一个管理员都不剩"时拦。
|
|
|
|
|
|
if ($role !== 'admin' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
|
|
|
|
|
|
flash('err', '至少要留一位管理员 —— 把最后一位管理员降级,全家就没人能管理系统了。');
|
2026-09-30 00:49:53 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-09-30 08:13:07 +08:00
|
|
|
|
// ==================== 亲属关系 ====================
|
|
|
|
|
|
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) 的第二个参数是**孩子**。
|
2026-10-01 17:42:15 +08:00
|
|
|
|
//
|
|
|
|
|
|
// ⚠️⚠️ 这里是**白名单**,不是 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]);
|
|
|
|
|
|
}
|
2026-09-30 08:13:07 +08:00
|
|
|
|
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);
|
2026-09-30 16:12:28 +08:00
|
|
|
|
$kinRemoved = Relations::remove($familyId, $id);
|
|
|
|
|
|
// v7:关系没了,因这条关系自动进两端名册的条目也收回 —— 把这件事说破,
|
|
|
|
|
|
// 不然用户会以为名册里的人凭空消失了
|
|
|
|
|
|
flash('ok', $kinRemoved
|
|
|
|
|
|
? '这条关系已解除,两人名册里因此自动加入的对方也一并移出了。'
|
|
|
|
|
|
: '这条关系已解除。');
|
2026-09-30 08:13:07 +08:00
|
|
|
|
redirect('people/show', ['id' => $who]);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 访客邀请 ====================
|
|
|
|
|
|
case 'accounts/invite':
|
|
|
|
|
|
Auth::requireAdmin();
|
|
|
|
|
|
clear_old();
|
2026-09-30 09:44:55 +08:00
|
|
|
|
// ⚠️ 访客建出来是 kind='contact'(不进家谱),而 Person::all() 默认只取 'member' ——
|
|
|
|
|
|
// 早先这里没传 kind,于是"已有访客"永远列不出人来(页面看着像是从没开通过)。
|
2026-09-30 08:13:07 +08:00
|
|
|
|
View::render('people/invite', [
|
|
|
|
|
|
'title' => '邀请访客',
|
2026-09-30 09:44:55 +08:00
|
|
|
|
'people' => Person::all((int)Auth::familyId(), '', 'contact', (int)Auth::personId()),
|
2026-09-30 08:13:07 +08:00
|
|
|
|
'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, [
|
2026-09-30 09:44:55 +08:00
|
|
|
|
'name' => $name, 'gender' => '', 'avatar' => '', 'phone' => $phone,
|
|
|
|
|
|
'id_card' => '', 'email' => '', 'address' => '', 'note' => $note,
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'slogan' => '', 'kind' => 'contact',
|
2026-09-30 08:13:07 +08:00
|
|
|
|
]);
|
|
|
|
|
|
Account::create($pid, $pass, 'guest');
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// 我邀的访客也进**我的**名册(名册是每人一份的;访客自己那份由
|
|
|
|
|
|
// Account::create 给他补上他自己)
|
|
|
|
|
|
PersonRoster::ensure($familyId, (int)Auth::personId(), $pid, null, 'self');
|
2026-09-30 08:13:07 +08:00
|
|
|
|
clear_old();
|
|
|
|
|
|
flash('ok', '访客「' . $name . '」已开通,把手机号和密码告诉他就能登录。');
|
|
|
|
|
|
redirect('accounts/invite');
|
|
|
|
|
|
} catch (\PDOException $ex) {
|
|
|
|
|
|
$errors['phone'] = '这个手机号已经有人用了';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
set_old($_POST);
|
|
|
|
|
|
View::render('people/invite', [
|
|
|
|
|
|
'title' => '邀请访客',
|
2026-09-30 09:44:55 +08:00
|
|
|
|
'people' => Person::all($familyId, '', 'contact', (int)Auth::personId()),
|
2026-09-30 08:13:07 +08:00
|
|
|
|
'options' => Person::options($familyId),
|
|
|
|
|
|
'errors' => $errors,
|
|
|
|
|
|
], 'people');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
// ==================== 设置 ====================
|
|
|
|
|
|
case 'settings':
|
|
|
|
|
|
Auth::requireAdmin();
|
|
|
|
|
|
$dbVersion = '未知';
|
|
|
|
|
|
try {
|
|
|
|
|
|
$dbVersion = Database::get()->query('SELECT VERSION()')->fetchColumn();
|
|
|
|
|
|
} catch (\Throwable $ex) {
|
|
|
|
|
|
// 拿不到版本不影响页面
|
|
|
|
|
|
}
|
|
|
|
|
|
$rawKey = (string)fb_config('app_key', '');
|
2026-09-30 08:13:07 +08:00
|
|
|
|
$acc = Account::roleCounts();
|
2026-09-30 09:44:55 +08:00
|
|
|
|
// 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET)
|
|
|
|
|
|
$diag = $_SESSION['_oss_diag'] ?? null;
|
|
|
|
|
|
unset($_SESSION['_oss_diag']);
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// 公共分组(owner_id = 0)—— 管理员在这里维护全家共用的那一套。
|
|
|
|
|
|
// 人数按"全家名册"统计(这里要看的是"这一组里全家有多少人",不是我个人那份)。
|
2026-09-30 00:49:53 +08:00
|
|
|
|
View::render('settings', [
|
2026-09-30 08:13:07 +08:00
|
|
|
|
'title' => '设置',
|
|
|
|
|
|
'family' => Family::find((int)Auth::familyId()),
|
|
|
|
|
|
'dbVersion' => $dbVersion,
|
|
|
|
|
|
'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0,
|
|
|
|
|
|
'roleCounts' => $acc,
|
|
|
|
|
|
'relCount' => Relations::count((int)Auth::familyId()),
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'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', ''),
|
2026-09-30 08:13:07 +08:00
|
|
|
|
'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()),
|
2026-09-30 09:44:55 +08:00
|
|
|
|
'ossDiag' => $diag,
|
2026-09-30 00:49:53 +08:00
|
|
|
|
], 'settings');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-09-30 09:44:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 阿里云 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;
|
|
|
|
|
|
|
2026-09-30 08:13:07 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
case 'settings/update':
|
|
|
|
|
|
Auth::requireAdmin();
|
|
|
|
|
|
csrf_check();
|
2026-09-30 09:44:55 +08:00
|
|
|
|
$fid = (int)Auth::familyId();
|
|
|
|
|
|
// ⚠️ 「家庭名称」已从设置里撤掉(见 settings 模板的说明):
|
|
|
|
|
|
// 首页标题现在是「某某某的家书」,一个家庭级名字既没人看、也容易和它打架。
|
|
|
|
|
|
// 这里沿用已有值,不再接受表单输入 —— 免得"某次提交把名字清空"。
|
|
|
|
|
|
$cur = Family::find($fid);
|
2026-09-30 00:49:53 +08:00
|
|
|
|
Family::update(
|
2026-09-30 09:44:55 +08:00
|
|
|
|
$fid,
|
|
|
|
|
|
(string)($cur['name'] ?? '家书'),
|
2026-09-30 00:49:53 +08:00
|
|
|
|
trim((string)($_POST['motto'] ?? ''))
|
|
|
|
|
|
);
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// ⚠️ 天气坐标**不再从系统设置收**(用户 2026-10-03 第 1 条:改到个人设置填城市)。
|
|
|
|
|
|
// 这里也不再 Set 那三个键 —— 但**不删**库里已有的旧值:
|
|
|
|
|
|
// 万一有人还没去个人设置填城市,旧的家庭级坐标留着还能兜底(见下)。
|
|
|
|
|
|
// (Home::weatherLocation 只读 user_settings,旧的家庭值已经不参与;
|
|
|
|
|
|
// 留着只是为了"哪天想回滚"时有据可查,代价是三个字符串。)
|
2026-09-30 00:49:53 +08:00
|
|
|
|
flash('ok', '已保存。');
|
|
|
|
|
|
redirect('settings');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-09-30 20:22:46 +08:00
|
|
|
|
// ==================== 观影(v8) ====================
|
|
|
|
|
|
//
|
|
|
|
|
|
// MovieLog 整体迁入后的新家。数据落在统一事件原语上:
|
|
|
|
|
|
// events(骨架)+ movie_details(卫星)+ event_attendees + event_photos,
|
|
|
|
|
|
// 见 App\Movie。可见性与名册同一口径:能登录就能看,能写才能改;
|
|
|
|
|
|
// 迁进来的历史记录 source_ref = 'movielog:movie:<id>'(重跑迁移的幂等锚点)。
|
|
|
|
|
|
|
2026-10-01 00:12:04 +08:00
|
|
|
|
/** 观影瀑布流(v9,MovieLog 同款海报墙;带关键词搜索)。 */
|
2026-09-30 20:22:46 +08:00
|
|
|
|
case 'movies':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
2026-10-01 00:12:04 +08:00
|
|
|
|
$keyword = trim((string)($_GET['keyword'] ?? ''));
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$rows = Movie::all($familyId, $keyword, (int)Auth::personId());
|
2026-09-30 20:22:46 +08:00
|
|
|
|
View::render('movies/index', [
|
2026-10-01 00:12:04 +08:00
|
|
|
|
'title' => '观影记录',
|
|
|
|
|
|
'rows' => $rows,
|
|
|
|
|
|
'total' => count($rows),
|
|
|
|
|
|
'keyword' => $keyword,
|
2026-09-30 20:22:46 +08:00
|
|
|
|
], 'movies');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'movies/show':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$meId = (int)Auth::personId();
|
|
|
|
|
|
$m = Movie::find($familyId, (int)($_GET['id'] ?? 0), $meId);
|
2026-09-30 20:22:46 +08:00
|
|
|
|
if (!$m) {
|
|
|
|
|
|
flash('err', '找不到这条观影记录。');
|
|
|
|
|
|
redirect('movies');
|
|
|
|
|
|
}
|
2026-10-01 00:12:04 +08:00
|
|
|
|
$rvError = '';
|
2026-09-30 20:22:46 +08:00
|
|
|
|
View::render('movies/show', [
|
2026-10-01 00:12:04 +08:00
|
|
|
|
'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,
|
2026-09-30 20:22:46 +08:00
|
|
|
|
], 'movies');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'movies/new':
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
View::render('movies/form', [
|
2026-10-01 00:12:04 +08:00
|
|
|
|
'title' => '记一场电影',
|
|
|
|
|
|
'm' => null,
|
|
|
|
|
|
'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''),
|
|
|
|
|
|
'cinemas' => Cinema::all((int)Auth::familyId()),
|
|
|
|
|
|
'errors' => [],
|
2026-09-30 20:22:46 +08:00
|
|
|
|
], 'movies');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'movies/edit':
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$m = Movie::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId());
|
2026-09-30 20:22:46 +08:00
|
|
|
|
if (!$m) {
|
|
|
|
|
|
flash('err', '找不到这条观影记录。');
|
|
|
|
|
|
redirect('movies');
|
|
|
|
|
|
}
|
|
|
|
|
|
View::render('movies/form', [
|
2026-10-01 00:12:04 +08:00
|
|
|
|
'title' => '编辑观影记录',
|
|
|
|
|
|
'm' => $m,
|
|
|
|
|
|
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
|
|
|
|
|
|
'cinemas' => Cinema::all($familyId),
|
|
|
|
|
|
'errors' => [],
|
2026-09-30 20:22:46 +08:00
|
|
|
|
], '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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 00:12:04 +08:00
|
|
|
|
// 起止时刻精确到秒(_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; // 表单可改:只认本家的影院
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 20:22:46 +08:00
|
|
|
|
$d = [
|
|
|
|
|
|
'title' => trim((string)($_POST['title'] ?? '')),
|
2026-10-01 00:12:04 +08:00
|
|
|
|
'starts_at' => (string)($startsAt ?? ''),
|
|
|
|
|
|
'ends_at' => (string)($endsAt ?? ''),
|
2026-09-30 20:22:46 +08:00
|
|
|
|
'location' => trim((string)($_POST['location'] ?? '')),
|
|
|
|
|
|
'note' => trim((string)($_POST['note'] ?? '')),
|
|
|
|
|
|
'info' => trim((string)($_POST['info'] ?? '')),
|
2026-10-01 00:12:04 +08:00
|
|
|
|
'cinema_id' => $cinemaId,
|
2026-09-30 20:22:46 +08:00
|
|
|
|
// 同行的人:只认「确实存在且属于这个家」的 id —— 表单可以改,
|
|
|
|
|
|
// 不能凭它把别人家的成员挂进自己家的观影记录里(会顺着详情页漏人名)。
|
|
|
|
|
|
'attendees' => array_map('intval', (array)($_POST['attendees'] ?? [])),
|
|
|
|
|
|
];
|
2026-10-01 10:21:45 +08:00
|
|
|
|
|
|
|
|
|
|
// 豆瓣元数据(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;
|
2026-09-30 20:22:46 +08:00
|
|
|
|
$_familyPeople = Person::map($familyId);
|
|
|
|
|
|
$d['attendees'] = array_values(array_filter($d['attendees'],
|
|
|
|
|
|
static fn($pid) => isset($_familyPeople[$pid])));
|
2026-10-01 00:12:04 +08:00
|
|
|
|
$errors = [];
|
|
|
|
|
|
if ($d['title'] === '') {
|
|
|
|
|
|
$errors['title'] = '片名不能为空';
|
|
|
|
|
|
} elseif ($d['starts_at'] !== '' && $d['ends_at'] !== ''
|
|
|
|
|
|
&& strcmp($d['ends_at'], $d['starts_at']) < 0) {
|
|
|
|
|
|
$errors['ends_at'] = '散场时间不能早于开场时间';
|
|
|
|
|
|
}
|
2026-09-30 20:22:46 +08:00
|
|
|
|
|
|
|
|
|
|
// ⚠️ 图片上传失败**不挡保存**(与头像同一思路:一条记录因为一张 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'] ?? '');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-01 10:21:45 +08:00
|
|
|
|
// 豆瓣抓取的海报(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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-06 03:14:18 +08:00
|
|
|
|
// 现场照片(多选)。
|
|
|
|
|
|
// ⚠️ 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],
|
|
|
|
|
|
];
|
2026-09-30 20:22:46 +08:00
|
|
|
|
}
|
2026-10-06 03:14:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
if ($photoFiles) {
|
|
|
|
|
|
$photoKeys = $isEdit
|
|
|
|
|
|
? array_map('strval', array_column(Movie::photos($id), 'key')) // 保住已有的
|
|
|
|
|
|
: [];
|
2026-09-30 20:22:46 +08:00
|
|
|
|
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;
|
2026-10-06 03:14:18 +08:00
|
|
|
|
} 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'];
|
|
|
|
|
|
}
|
2026-09-30 20:22:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($errors) {
|
|
|
|
|
|
set_old($_POST);
|
|
|
|
|
|
View::render('movies/form', [
|
2026-10-01 00:12:04 +08:00
|
|
|
|
'title' => $isEdit ? '编辑观影记录' : '记一场电影',
|
|
|
|
|
|
'm' => $m,
|
|
|
|
|
|
'people' => Person::roster($familyId, $me, 'member', ''),
|
|
|
|
|
|
'cinemas' => Cinema::all($familyId),
|
|
|
|
|
|
'errors' => $errors,
|
2026-09-30 20:22:46 +08:00
|
|
|
|
], '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', '照片已删除。');
|
2026-10-06 03:14:18 +08:00
|
|
|
|
// ⚠️ v24 第 20 批 第 1 条:编辑页也能删照片了,所以按来路回去 ——
|
|
|
|
|
|
// 从编辑页删的就回编辑页(否则用户一删就被甩回详情页,还得再点一次编辑)。
|
|
|
|
|
|
// 来路用表单里的 `back` 判断(由模板给出,白名单,免得开放重定向)。
|
|
|
|
|
|
$back = (string)($_POST['back'] ?? '');
|
|
|
|
|
|
if ($back === 'edit') {
|
|
|
|
|
|
redirect('movies/edit', ['id' => $id]);
|
|
|
|
|
|
}
|
2026-09-30 20:22:46 +08:00
|
|
|
|
redirect('movies/show', ['id' => $id]);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-06 03:14:18 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 删掉海报 / 票根(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;
|
|
|
|
|
|
|
2026-09-30 20:22:46 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-10-01 10:21:45 +08:00
|
|
|
|
// ---------------- 豆瓣自动补充(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'],
|
2026-10-03 20:23:22 +08:00
|
|
|
|
// 拼好的简介(含导演/主演/年份/类型/片长/国家/评分 + 剧情原文)。
|
|
|
|
|
|
// 放在服务端拼、由前端填空 —— 见 Douban::summaryText 的注释:
|
|
|
|
|
|
// 这份文本会**落库**(movie_details.info),所以拼法要可测、只有一处。
|
|
|
|
|
|
'summary' => Douban::summaryText(
|
|
|
|
|
|
(array)$resDb['meta'],
|
|
|
|
|
|
(string)($resDb['title'] ?? ''),
|
|
|
|
|
|
true
|
|
|
|
|
|
),
|
2026-10-01 10:21:45 +08:00
|
|
|
|
'poster_key' => $posterKey,
|
|
|
|
|
|
'poster' => $posterShow,
|
|
|
|
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-01 00:12:04 +08:00
|
|
|
|
// ---------------- 影院管理(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,
|
2026-10-01 12:11:43 +08:00
|
|
|
|
], 'movies');
|
2026-10-01 00:12:04 +08:00
|
|
|
|
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 17:42:21 +08:00
|
|
|
|
// 用户 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] : []);
|
|
|
|
|
|
}
|
2026-10-01 00:12:04 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-10-01 12:11:43 +08:00
|
|
|
|
// ---------------- 统计 / 高级搜索 / 年度报告(v11,从 MovieLog 迁入) ----------------
|
|
|
|
|
|
// 三个页面都挂在观影模块「设置」按钮下(顶栏不再单独放影院/统计入口)。
|
|
|
|
|
|
// 只读,不需要 requireWrite —— 访客也能看统计,与观影列表同一口径。
|
|
|
|
|
|
|
|
|
|
|
|
/** 统计仪表板 */
|
|
|
|
|
|
case 'movies/stats':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
View::render('movies/stats', [
|
|
|
|
|
|
'title' => '观影统计',
|
|
|
|
|
|
'ov' => MovieStats::overview($familyId),
|
|
|
|
|
|
'byCinema' => MovieStats::byCinema($familyId),
|
2026-10-02 09:28:07 +08:00
|
|
|
|
'byPerson' => MovieStats::byPerson($familyId, (int)Auth::personId()),
|
2026-10-01 12:11:43 +08:00
|
|
|
|
'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,
|
2026-10-02 09:28:07 +08:00
|
|
|
|
'rows' => $hasCond ? MovieStats::search($familyId, $p, (int)Auth::personId()) : [],
|
2026-10-01 12:11:43 +08:00
|
|
|
|
], '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;
|
|
|
|
|
|
|
2026-10-01 00:12:04 +08:00
|
|
|
|
// ---------------- 评价与讨论(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;
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// ==================== 旅程(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', [
|
2026-10-05 13:26:49 +08:00
|
|
|
|
'title' => '新建旅程',
|
2026-10-02 09:28:07 +08:00
|
|
|
|
'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) {
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('err', '找不到这个旅程。');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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) {
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('err', '找不到这个旅程。');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
redirect('trips');
|
|
|
|
|
|
}
|
|
|
|
|
|
$d = [
|
|
|
|
|
|
'title' => trim((string)($_POST['title'] ?? '')),
|
2026-10-05 10:58:17 +08:00
|
|
|
|
// ⚠️ 用户第 15 批 第 2 条:活动**不收**起止时间与目的地 ——
|
|
|
|
|
|
// 时间由各段旅程的起止算出来、城市由各段旅程的 place 去重得到。
|
|
|
|
|
|
// 表单里已经没有这些字段;这里不是"忘了读",是**故意不读**。
|
|
|
|
|
|
// ($startsAt / $endsAt 的 read_dt_input 一并删掉了,
|
|
|
|
|
|
// 否则"表单没这两个字段 → 读出 null → 把库里老值冲成 NULL",
|
|
|
|
|
|
// 会静默毁掉迁移前的数据。)
|
2026-10-02 09:28:07 +08:00
|
|
|
|
'note' => trim((string)($_POST['note'] ?? '')),
|
|
|
|
|
|
'summary' => trim((string)($_POST['summary'] ?? '')),
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// 用户 2026-10-03 第 4 条:旅程是否公开(只影响列表与日历,不影响能否直接打开)
|
|
|
|
|
|
// 用户第 4 条:是否公开。
|
|
|
|
|
|
// ⚠️ 三种情况必须分清(少了任何一种都会错):
|
|
|
|
|
|
// ① 表单**没这个字段**(程序化调用 / 老表单 / 迁移)→ 连键都不放,
|
|
|
|
|
|
// 让 Trip::saveDetails 按"默认公开"处理;
|
|
|
|
|
|
// ② 表单提交了 hidden=0(用户取消勾选)→ 明确的 0;
|
|
|
|
|
|
// ③ 勾选 → '1'。
|
|
|
|
|
|
// 之前写成 `?? ''` 会把 ① 也变成"键存在且为空" → 被当成不公开,
|
|
|
|
|
|
// 于是数据在列表里**静默消失**(实测:新建的旅程从列表与日历上都没了)。
|
2026-10-05 13:26:49 +08:00
|
|
|
|
// ⚠️ v24 第 16 批 用户第 10 条:建/编辑旅程的表单里**没有**同行的人了
|
|
|
|
|
|
// ("这些都是从行程里面总结出来的")。所以这里**不再收** attendees。
|
|
|
|
|
|
// ⚠️ 但**不能**就此把活动上那份名单清空:老数据里很多人的参与人
|
|
|
|
|
|
// 只登记在这一层(那时还没有行程级参与人),清掉就"人没了"。
|
|
|
|
|
|
// 所以"键不存在"时保持原样(Trip::saveAttendees 收到空数组会全删,
|
|
|
|
|
|
// 这里刻意不传这个键)。新数据的参与人一律从行程里长出来。
|
2026-10-02 09:28:07 +08:00
|
|
|
|
];
|
2026-10-03 11:30:48 +08:00
|
|
|
|
if (array_key_exists('is_public', $_POST)) {
|
|
|
|
|
|
$d['is_public'] = (string)$_POST['is_public'];
|
|
|
|
|
|
}
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$errors = [];
|
|
|
|
|
|
if ($d['title'] === '') {
|
2026-10-05 13:26:49 +08:00
|
|
|
|
$errors['title'] = '旅程名字不能为空';
|
2026-10-02 09:28:07 +08:00
|
|
|
|
}
|
2026-10-05 10:58:17 +08:00
|
|
|
|
// 「回来不能早于出发」的校验一并删掉:时间不再由这里填,
|
|
|
|
|
|
// 而是从各段旅程算 max/min —— 二者反了也不可能(min ≤ max 恒成立)。
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// 封面图(编辑时换新图才动它;失败不挡保存,与观影海报同一个口径)
|
|
|
|
|
|
$oldCover = $isEdit ? (string)($t['cover_key'] ?? '') : '';
|
|
|
|
|
|
$d['cover_key'] = $oldCover;
|
|
|
|
|
|
$warns = [];
|
2026-10-05 10:58:17 +08:00
|
|
|
|
// 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'] ?? ''));
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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', [
|
2026-10-05 13:26:49 +08:00
|
|
|
|
'title' => $isEdit ? '编辑旅程' : '新建旅程',
|
2026-10-02 09:28:07 +08:00
|
|
|
|
'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();
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('ok', '这个旅程记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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) {
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('err', '找不到这个旅程。');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
redirect('trips');
|
|
|
|
|
|
}
|
|
|
|
|
|
View::render('trips/show', [
|
|
|
|
|
|
'title' => (string)$t['title'],
|
|
|
|
|
|
't' => $t,
|
|
|
|
|
|
'nodes' => Trip::nodes((int)$t['id'], $meId),
|
2026-10-05 13:26:49 +08:00
|
|
|
|
// 用户第 2 条:题图右侧那五项统计(参与人/城市/天数/段数/花销)
|
|
|
|
|
|
'sm' => Trip::summary((int)$t['id'], $meId),
|
2026-10-02 09:28:07 +08:00
|
|
|
|
], 'trips');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-05 13:26:49 +08:00
|
|
|
|
// ---------------- 行程:新增 / 编辑(独立页面,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) {
|
2026-10-05 22:56:49 +08:00
|
|
|
|
// ⚠️ 必须带上 $viewerId:Trip::node() 现在会一并挂上 members(第 19 批 第 5 条:
|
|
|
|
|
|
// "编辑行程参与人是空的")。成员读取要在**看的人**视角下取昵称。
|
|
|
|
|
|
$nd = Trip::node((int)($_GET['id'] ?? 0), (int)Auth::personId());
|
2026-10-05 13:26:49 +08:00
|
|
|
|
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' => '车辆管理',
|
2026-10-05 16:45:10 +08:00
|
|
|
|
// ⚠️ v24 第 17 批 第 3 条:**不再传** ledger(财务里的车)——
|
|
|
|
|
|
// 用户原话「车辆下拉框,不需要出现什么财务里登记的车」。
|
|
|
|
|
|
// 模板里那一整块也删掉了。fromLedger() 方法仍在模型层保留
|
|
|
|
|
|
// (将来按车统计油费/保养要用它跟 vehicles.ledger_id 对上)。
|
2026-10-05 13:26:49 +08:00
|
|
|
|
'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;
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
case 'trips/destroy':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
$id = (int)($_POST['id'] ?? 0);
|
|
|
|
|
|
if (!Trip::find($familyId, $id)) {
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('err', '找不到这个旅程。');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
redirect('trips');
|
|
|
|
|
|
}
|
|
|
|
|
|
Trip::destroy($familyId, $id);
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('ok', '这个旅程已删除。');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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)) {
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('err', '找不到这个旅程。');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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)) {
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('err', '找不到这个旅程。');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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) {
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('err', '找不到这个旅程。');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
redirect('trips');
|
|
|
|
|
|
}
|
|
|
|
|
|
$isEdit = $r === 'trips/node_update';
|
|
|
|
|
|
$nid = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
|
2026-10-06 12:08:40 +08:00
|
|
|
|
|
|
|
|
|
|
/* ---- 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;
|
|
|
|
|
|
}
|
2026-10-05 13:26:49 +08:00
|
|
|
|
// v24 第 16 批:表单照 TripLog 的节点,字段全量(用户第 7 条)。
|
2026-10-05 16:45:10 +08:00
|
|
|
|
// ⚠️ 第 17 批 用户第 3 条把「角色标注」去掉了("这个功能去掉,不要了"),
|
|
|
|
|
|
// 所以这里**不再读** roles;座位/登机口也不再从表单收(字段已从界面移除,
|
|
|
|
|
|
// 数据库那两列保留,详见 Trip::saveNode 的处理)。
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$d = [
|
2026-10-05 13:26:49 +08:00
|
|
|
|
'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'),
|
2026-10-06 12:08:40 +08:00
|
|
|
|
// 新建:认表单里选的「计划 / 实际」;**编辑:一律沿用库里的**(见上面那段说明)
|
|
|
|
|
|
'stage' => $stageForSave,
|
2026-10-05 13:26:49 +08:00
|
|
|
|
'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId),
|
2026-10-05 16:45:10 +08:00
|
|
|
|
// ---- v21:交通(TripLog 同款)----
|
2026-10-05 13:26:49 +08:00
|
|
|
|
'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'] ?? '')),
|
2026-10-05 16:45:10 +08:00
|
|
|
|
// ---- v22:出发地 / 到达地(地点名 + 详细地址 + 坐标 + 城市)----
|
|
|
|
|
|
// 用户第 17 批 第 3 条:「出发地和到达地,都用高德地图的接口,记录详细地址」。
|
|
|
|
|
|
// 城市由前端联想带回(`*_city`),带不回时 Trip::saveNode 里用 App\Geo 解析。
|
2026-10-05 13:26:49 +08:00
|
|
|
|
'from_place' => trim((string)($_POST['from_place'] ?? '')),
|
|
|
|
|
|
'to_place' => trim((string)($_POST['to_place'] ?? '')),
|
2026-10-05 16:45:10 +08:00
|
|
|
|
'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'] ?? '')),
|
2026-10-02 09:28:07 +08:00
|
|
|
|
];
|
|
|
|
|
|
if ($d['title'] === '') {
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('err', '这段行程得有个名字。');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
redirect('trips/show', ['id' => $tripId]);
|
|
|
|
|
|
}
|
|
|
|
|
|
$newId = Trip::saveNode($familyId, $tripId, $d, $isEdit ? $nid : null);
|
2026-10-05 13:26:49 +08:00
|
|
|
|
$warns = attach_save_files($familyId, 'trip_node', $newId, 'photo', (int)Auth::personId());
|
|
|
|
|
|
flash('ok', ($isEdit ? '这段行程已保存。' : '行程已加进来。')
|
2026-10-02 09:28:07 +08:00
|
|
|
|
. ($warns ? '(' . implode(';', $warns) . ')' : ''));
|
2026-10-05 13:26:49 +08:00
|
|
|
|
// ⚠️ 用户第 5 条:编辑是**独立页面**,保存后回详情页(而不是留在表单上)
|
|
|
|
|
|
redirect('trips/show', ['id' => $tripId], 'node-' . $newId);
|
2026-10-02 09:28:07 +08:00
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'trips/node_del':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
$tripId = (int)($_POST['trip_id'] ?? 0);
|
|
|
|
|
|
if (!Trip::find($familyId, $tripId)) {
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('err', '找不到这个旅程。');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
redirect('trips');
|
|
|
|
|
|
}
|
|
|
|
|
|
Trip::deleteNode((int)($_POST['id'] ?? 0));
|
|
|
|
|
|
flash('ok', '这一段已删除。');
|
|
|
|
|
|
redirect('trips/show', ['id' => $tripId]);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-04 20:57:40 +08:00
|
|
|
|
// ---- 计划行程的三个动作(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)) {
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('err', '找不到这个旅程。');
|
2026-10-04 20:57:40 +08:00
|
|
|
|
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)) {
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('err', '找不到这个旅程。');
|
2026-10-04 20:57:40 +08:00
|
|
|
|
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)) {
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('err', '找不到这个旅程。');
|
2026-10-04 20:57:40 +08:00
|
|
|
|
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)) {
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('err', '找不到这个旅程。');
|
2026-10-04 20:57:40 +08:00
|
|
|
|
redirect('trips');
|
|
|
|
|
|
}
|
|
|
|
|
|
Trip::skipNode((int)($_POST['id'] ?? 0));
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('ok', '已标记为未成行。它不再出现在日程与往年今日,但在这个旅程里仍然看得到。');
|
2026-10-04 20:57:40 +08:00
|
|
|
|
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)) {
|
2026-10-05 13:26:49 +08:00
|
|
|
|
flash('err', '找不到这个旅程。');
|
2026-10-04 20:57:40 +08:00
|
|
|
|
redirect('trips');
|
|
|
|
|
|
}
|
|
|
|
|
|
Trip::restoreNode((int)($_POST['id'] ?? 0));
|
|
|
|
|
|
flash('ok', '已恢复为计划行程,它会重新出现在日程里。');
|
|
|
|
|
|
redirect('trips/show', ['id' => $tripId]);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// ==================== 日程 / 日历(v12) ====================
|
|
|
|
|
|
// 自定义日程落在 calendar_events;**观影与旅行两本是派生的**(从 events 实时汇总,
|
|
|
|
|
|
// 不复制),给这两本"加日程"= 真的建一条观影 / 旅程记录。
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 财务(v24 第 15 条):账本 + 账目 + 共享。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用户原话要的是「一个可以共享的家庭总账」:先有账本,账本共享给某人,
|
|
|
|
|
|
* 双方都能往里记账、都能看到全部账目,最后算出总数。
|
|
|
|
|
|
* ⚠️ 金额一律以**分**存取(见 Ledger::toCents 的注释),显示时再转元。
|
|
|
|
|
|
*/
|
|
|
|
|
|
case 'ledger':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
$me = (int)Auth::personId();
|
|
|
|
|
|
$books = Ledger::mine($familyId, $me);
|
|
|
|
|
|
// 一本都没有 → 自动建一本(不然新用户进来一片空白,也不知道要先"创建家庭")
|
|
|
|
|
|
if (!$books) {
|
|
|
|
|
|
Ledger::ensureDefault($familyId, $me);
|
|
|
|
|
|
$books = Ledger::mine($familyId, $me);
|
|
|
|
|
|
}
|
|
|
|
|
|
$lid = (int)($_GET['book'] ?? 0);
|
|
|
|
|
|
$cur = null;
|
|
|
|
|
|
foreach ($books as $b) {
|
|
|
|
|
|
if ($lid > 0 && (int)$b['id'] === $lid) {
|
|
|
|
|
|
$cur = $b;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!$cur) {
|
|
|
|
|
|
$cur = $books[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
$entries = Ledger::entries((int)$cur['id']);
|
|
|
|
|
|
View::render('ledger/index', [
|
|
|
|
|
|
'title' => '财务',
|
|
|
|
|
|
'books' => $books,
|
|
|
|
|
|
'cur' => $cur,
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// ⚠️ `me` 必须传进去:模板里「挂在谁名下默认是自己」(用户第 8 条)
|
|
|
|
|
|
// 要用它当默认选中值。漏传的话默认值会静默变成"不选" ——
|
|
|
|
|
|
// 功能"看起来在",只是每次都要手动点一次选人框。
|
|
|
|
|
|
'me' => $me,
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'entries' => $entries,
|
|
|
|
|
|
'summary' => Ledger::summary($entries),
|
|
|
|
|
|
'shares' => Ledger::shares((int)$cur['id']),
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 用户 2026-10-03 第 8 条:上面画"金额随时间怎么变",所以要历史。
|
|
|
|
|
|
'charts' => Ledger::charts((int)$cur['id']),
|
|
|
|
|
|
'timeline'=> Ledger::timeline((int)$cur['id']),
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'people' => Person::roster($familyId, $me, 'member', ''),
|
|
|
|
|
|
], 'ledger');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'ledger/book_add':
|
|
|
|
|
|
case 'ledger/book_rename':
|
|
|
|
|
|
case 'ledger/book_del':
|
|
|
|
|
|
case 'ledger/entry_add':
|
|
|
|
|
|
case 'ledger/entry_update':
|
|
|
|
|
|
case 'ledger/entry_del':
|
2026-10-03 15:01:38 +08:00
|
|
|
|
case 'ledger/reconcile':
|
2026-10-03 08:18:05 +08:00
|
|
|
|
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', '账目已删除。');
|
2026-10-03 15:01:38 +08:00
|
|
|
|
} 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 ? '盘点已记下(历史保留)。' : '找不到这笔账目。');
|
2026-10-03 08:18:05 +08:00
|
|
|
|
} 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 15:01:38 +08:00
|
|
|
|
// 用户 2026-10-03 第 9 条:手动添加的与模块派生的**分开**,手动的在上。
|
|
|
|
|
|
$sp = Anniversary::split($familyId, $me);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
View::render('days/index', [
|
|
|
|
|
|
'title' => '纪念日',
|
2026-10-03 15:01:38 +08:00
|
|
|
|
'custom' => $sp['custom'],
|
|
|
|
|
|
'derived' => $sp['derived'],
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'upcoming' => Anniversary::upcoming($familyId, $me, 60),
|
2026-10-03 15:01:38 +08:00
|
|
|
|
'kinds' => Anniversary::kinds($familyId),
|
|
|
|
|
|
'kindCounts' => Anniversary::kindCounts($familyId),
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'people' => Person::roster($familyId, $me, 'member', ''),
|
|
|
|
|
|
], 'days');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-03 15:01:38 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 纪念日分类设置(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;
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
case 'days/new':
|
|
|
|
|
|
case 'days/edit':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
$me = (int)Auth::personId();
|
|
|
|
|
|
$id = (int)($_GET['id'] ?? 0);
|
|
|
|
|
|
$editing = $id > 0 ? Anniversary::find($familyId, $id) : null;
|
|
|
|
|
|
if ($id > 0 && !$editing) {
|
|
|
|
|
|
flash('err', '找不到这条纪念日。');
|
|
|
|
|
|
redirect('days');
|
|
|
|
|
|
}
|
|
|
|
|
|
View::render('days/form', [
|
|
|
|
|
|
'title' => $id > 0 ? '编辑纪念日' : '新建纪念日',
|
|
|
|
|
|
'editing' => $editing,
|
2026-10-03 15:01:38 +08:00
|
|
|
|
'kinds' => Anniversary::kinds($familyId),
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'people' => Person::roster($familyId, $me, 'member', ''),
|
|
|
|
|
|
], 'days');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'days/save':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
$id = (int)($_POST['id'] ?? 0);
|
|
|
|
|
|
Anniversary::save($familyId, [
|
|
|
|
|
|
'title' => (string)($_POST['title'] ?? ''),
|
|
|
|
|
|
'repeat_type' => (string)($_POST['repeat_type'] ?? 'solar'),
|
|
|
|
|
|
'month' => (int)($_POST['month'] ?? 1),
|
|
|
|
|
|
'day' => (int)($_POST['day'] ?? 1),
|
|
|
|
|
|
'is_leap' => !empty($_POST['is_leap']),
|
|
|
|
|
|
'kind' => (string)($_POST['kind'] ?? ''),
|
|
|
|
|
|
'note' => (string)($_POST['note'] ?? ''),
|
|
|
|
|
|
'person_id' => (int)($_POST['person_id'] ?? 0),
|
|
|
|
|
|
], $id);
|
|
|
|
|
|
flash('ok', $id > 0 ? '纪念日已保存。' : '纪念日已添加。');
|
|
|
|
|
|
redirect('days');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'days/destroy':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
Anniversary::remove($familyId, (int)($_POST['id'] ?? 0));
|
|
|
|
|
|
flash('ok', '纪念日已删除。');
|
|
|
|
|
|
redirect('days');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
case 'calendar':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
$me = (int)Auth::personId();
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 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';
|
2026-10-02 09:28:07 +08:00
|
|
|
|
}
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$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);
|
2026-10-06 13:44:24 +08:00
|
|
|
|
/* 🔴 v24 第 21 批 第 7 条:**打开日历顺手补一本「我的日程」**。
|
|
|
|
|
|
为什么必须在这儿补(而不是只改 DEFAULT_KINDS):
|
|
|
|
|
|
`Calendar::seedDefaults()` 只在**安装**与**结构升级**两条路径上跑过,
|
|
|
|
|
|
线上这套库早就升完级了 → 老用户永远拿不到那一本。
|
|
|
|
|
|
结果:可写的普通本 0 本、"放在哪本"全是派生本、默认还选中排最前的
|
|
|
|
|
|
**观影** → 用户直接点「加上」建出来的是一条**观影记录**(建错东西)。
|
|
|
|
|
|
⚠️ 幂等(owner_id + kind='custom' 有就不再补),一次请求只多一条 SELECT。 */
|
|
|
|
|
|
Calendar::ensurePersonalBook($familyId, $me);
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$cals = Calendar::mine($familyId, $me);
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 用户第 4 条:哪些本子显示,改由设置里的复选框决定(以前是左侧那一栏)。
|
|
|
|
|
|
// 藏掉的本子**不参与取数**,而不是"取出来再 display:none" ——
|
|
|
|
|
|
// 前端藏的话,关掉一本之后月视图的"+N 条"仍然按它算,数字对不上。
|
|
|
|
|
|
$hiddenCals = Calendar::hiddenCalendars($me);
|
|
|
|
|
|
$visCals = [];
|
|
|
|
|
|
foreach ($cals as $c) {
|
|
|
|
|
|
if (!isset($hiddenCals[(int)$c['id']])) {
|
|
|
|
|
|
$visCals[] = $c;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-03 08:18:05 +08:00
|
|
|
|
|
|
|
|
|
|
if ($view === 'month') {
|
|
|
|
|
|
$gym = trim((string)($_GET['ym'] ?? ''));
|
|
|
|
|
|
if (preg_match('/^\d{4}-\d{2}$/', $gym)) {
|
|
|
|
|
|
$ym = $gym;
|
|
|
|
|
|
}
|
|
|
|
|
|
[$from, $to] = month_grid_range($ym);
|
2026-10-03 15:01:38 +08:00
|
|
|
|
$events = Calendar::month($familyId, $visCals, $me, $from, $to);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$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'];
|
2026-10-03 15:01:38 +08:00
|
|
|
|
$events = Calendar::month($familyId, $visCals, $me, $from, $to);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$grid = build_time_grid($events, $cols);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 用户第 5 条:在日历里点一下就建日程,**开始时间 = 点的那一格**。
|
|
|
|
|
|
// 点空白处只带日期/时刻过来,标题等还是空的(不能替用户瞎填)。
|
|
|
|
|
|
$newAt = trim((string)($_GET['new_at'] ?? ''));
|
|
|
|
|
|
|
2026-10-06 03:14:18 +08:00
|
|
|
|
/* ---- 当前时间红线(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);
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
View::render('calendar/index', [
|
|
|
|
|
|
'title' => '日程',
|
|
|
|
|
|
'ym' => $ym,
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'view' => $view,
|
|
|
|
|
|
'anchor' => $anchor,
|
2026-10-06 03:14:18 +08:00
|
|
|
|
'nowTop' => $nowTop,
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'cols' => $view === 'month' ? [] : ($cols ?? []),
|
|
|
|
|
|
'weeks' => $view === 'month' ? $grid : [],
|
|
|
|
|
|
'tgrid' => $view === 'month' ? null : $grid,
|
2026-10-02 09:28:07 +08:00
|
|
|
|
'cals' => $cals,
|
2026-10-03 15:01:38 +08:00
|
|
|
|
'visCals'=> array_map('intval', array_column($visCals, 'id')),
|
|
|
|
|
|
'hiddenCals' => array_keys($hiddenCals),
|
|
|
|
|
|
'newAt' => $newAt,
|
2026-10-02 09:28:07 +08:00
|
|
|
|
'people' => Person::roster($familyId, $me, 'member', ''),
|
|
|
|
|
|
], 'calendar');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-03 15:01:38 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 保存「日程显示设置」(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;
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
/**
|
2026-10-06 17:33:48 +08:00
|
|
|
|
* 旧地址 → 新地址(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,都增加一个创建口令的功能,这样,就使用用户名和口令
|
|
|
|
|
|
* 来同步,避免在第三方客户端进行同步时自己的密码泄露。」
|
2026-10-03 08:18:05 +08:00
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 明文口令只在这**一次**跳转里带回页面显示(存 $_SESSION 一次性取用),
|
|
|
|
|
|
* 库里只有 sha256。不这么做的话,要么明文落库(一旦泄露全暴露),
|
|
|
|
|
|
* 要么用户永远看不到自己刚设的花口令。
|
2026-10-06 17:33:48 +08:00
|
|
|
|
* ⚠️ 生成与撤销共用 `settings/dav_plain` 这个槽位:撤销时**必须把它清掉**,
|
|
|
|
|
|
* 否则"刚撤销完还显示着上一串口令",用户会以为撤销没生效。
|
2026-10-03 08:18:05 +08:00
|
|
|
|
*/
|
2026-10-06 17:33:48 +08:00
|
|
|
|
case 'settings/dav_token':
|
|
|
|
|
|
case 'settings/dav_revoke':
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$me = (int)Auth::personId();
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
csrf_check();
|
2026-10-06 17:33:48 +08:00
|
|
|
|
if ($r === 'settings/dav_token') {
|
|
|
|
|
|
$_SESSION['dav_plain'] = Auth::davIssue($me);
|
|
|
|
|
|
flash('ok', 'DAV 口令已生成(日历与通讯录共用这一把;旧口令立即失效,请在客户端里更新)。');
|
2026-10-03 08:18:05 +08:00
|
|
|
|
} else {
|
2026-10-06 17:33:48 +08:00
|
|
|
|
Auth::davRevoke($me);
|
|
|
|
|
|
unset($_SESSION['dav_plain']);
|
|
|
|
|
|
flash('ok', 'DAV 口令已撤销,所有已同步的客户端(日历与通讯录)都会失去访问。');
|
2026-10-03 08:18:05 +08:00
|
|
|
|
}
|
2026-10-06 17:33:48 +08:00
|
|
|
|
redirect('settings/me');
|
2026-10-03 08:18:05 +08:00
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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']);
|
|
|
|
|
|
}
|
2026-10-06 17:33:48 +08:00
|
|
|
|
// v24 第 22 批:口令改成**通用 DAV 口令**(日历 + 通讯录共用),
|
|
|
|
|
|
// 管理入口也搬到了「个人设置 → DAV 同步」。所以这一页**不再处理口令表单**
|
|
|
|
|
|
// —— 只显示它的状态与入口(点过去配置)。口令的明文仍走一次性会话槽位,
|
|
|
|
|
|
// 但那一页在 settings/me,这一页读不到(也没必要读:这里只提示"去那边配")。
|
|
|
|
|
|
$cdHas = Auth::hasDav($me);
|
|
|
|
|
|
$cdPlain = '';
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$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', ''),
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'cdHas' => $cdHas,
|
|
|
|
|
|
'cdPlain' => $cdPlain,
|
2026-10-02 09:28:07 +08:00
|
|
|
|
], '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();
|
2026-10-06 12:08:40 +08:00
|
|
|
|
/* 🔴 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]);
|
|
|
|
|
|
};
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// ⚠️ 走 mineOne 而不是 Calendar::find():要带 perm,共享本的可读写才判得对
|
|
|
|
|
|
$cal = Calendar::mineOne($familyId, $me, $calId);
|
2026-10-04 10:46:50 +08:00
|
|
|
|
// v24 第 10 批 #4:农历 / 节假日(还有观影 / 旅行)是**派生只读本**,
|
|
|
|
|
|
// 内容由别处算出来,不能往里写日程 —— 写了也不会显示(Calendar::month 只从
|
|
|
|
|
|
// kind='custom' 取 calendar_events),却会在库里留下一堆"看不见的日程"。
|
|
|
|
|
|
// 用户的要求是"不可删除",这里顺带把"不可写入"也钉死。
|
|
|
|
|
|
if ($cal && Calendar::isReadonlyKind((string)$cal['kind'])) {
|
2026-10-06 12:08:40 +08:00
|
|
|
|
$jsonFail('「' . $cal['name'] . '」是系统日历,内容自动生成,不能往里加日程。');
|
2026-10-04 10:46:50 +08:00
|
|
|
|
}
|
2026-10-02 09:28:07 +08:00
|
|
|
|
if (!$cal || !Calendar::canWrite($cal, $me)) {
|
2026-10-06 12:08:40 +08:00
|
|
|
|
$jsonFail('这本日历你没有写权限。');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
}
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// v24 第 5、6 条:起止各是一个「日期+时分秒」的完整时刻(_datetime.php 控件),
|
|
|
|
|
|
// 不再拆成 date / end_date / start_time / end_time 四格。
|
|
|
|
|
|
$startsAt = read_dt_input('starts_at');
|
|
|
|
|
|
if ($startsAt === null) {
|
2026-10-06 12:08:40 +08:00
|
|
|
|
$jsonFail('开始时间不对。');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
}
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$endsAt = read_dt_input('ends_at');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$title = trim((string)($_POST['title'] ?? ''));
|
|
|
|
|
|
if ($title === '') {
|
2026-10-06 12:08:40 +08:00
|
|
|
|
$jsonFail('日程得有个标题。');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
$allDay = !empty($_POST['all_day']);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
if ($allDay) {
|
|
|
|
|
|
// 全天日程:时刻部分一律归零(跨天的全天日程截止时刻含到当天末尾)
|
|
|
|
|
|
$startsAt = substr($startsAt, 0, 10) . ' 00:00:00';
|
|
|
|
|
|
$endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null;
|
|
|
|
|
|
}
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$note = trim((string)($_POST['note'] ?? ''));
|
|
|
|
|
|
|
|
|
|
|
|
// ⚠️ 观影 / 旅行两本是派生的:往这两本里加日程 = 建一条真正的观影 / 旅程记录,
|
|
|
|
|
|
// 而不是往 calendar_events 里塞一行(那样日历里有、观影列表里没有)。
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$kind = (string)$cal['kind'];
|
2026-10-06 12:08:40 +08:00
|
|
|
|
|
|
|
|
|
|
/* 🔴 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]);
|
2026-10-02 09:28:07 +08:00
|
|
|
|
}
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$evId = Calendar::addEvent((int)$cal['id'], [
|
|
|
|
|
|
'title' => $title,
|
|
|
|
|
|
'location' => trim((string)($_POST['location'] ?? '')),
|
|
|
|
|
|
'starts_at' => $startsAt,
|
|
|
|
|
|
'ends_at' => $endsAt,
|
|
|
|
|
|
'all_day' => $allDay,
|
2026-10-06 03:14:18 +08:00
|
|
|
|
// v24 第 20 批 第 8 条:提醒**多选** → 表单来的是数组 `reminder_minutes[]`。
|
|
|
|
|
|
// ⚠️ 不要再 `(string)` 强转 —— 那会得到 "Array" 字样(PHP 的经典坑),
|
|
|
|
|
|
// Calendar::reminderMinutes() 收到它就一份都认不出来,提醒静默丢光。
|
|
|
|
|
|
// 直接原样把数组交下去,由 Calendar 那边做白名单 + 规范化。
|
|
|
|
|
|
'reminder_minutes' => ($_POST['reminder_minutes'] ?? ''),
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'status' => (string)($_POST['status'] ?? 'confirmed'),
|
|
|
|
|
|
'transp' => !empty($_POST['transp']),
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// 用户 2026-10-03 第 5、6 条:重复规则(RFC 5545 RRULE 原文)
|
|
|
|
|
|
'rrule' => Repeat::build($_POST),
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'note' => $note,
|
|
|
|
|
|
'created_by' => $me,
|
2026-10-02 09:28:07 +08:00
|
|
|
|
]);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$warns = attach_save_files($familyId, Calendar::ATTACH, $evId, 'attach', $me);
|
2026-10-06 12:08:40 +08:00
|
|
|
|
|
|
|
|
|
|
/* 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;
|
|
|
|
|
|
}
|
2026-10-03 08:18:05 +08:00
|
|
|
|
if ($warns) {
|
|
|
|
|
|
flash('err', '有附件没有保存:' . implode(';', $warns));
|
|
|
|
|
|
}
|
2026-10-02 09:28:07 +08:00
|
|
|
|
flash('ok', '日程已加上。');
|
2026-10-03 08:18:05 +08:00
|
|
|
|
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$ev['_can_write'] = Calendar::canWriteEvent($ev, $me);
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// 用户第 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']);
|
2026-10-02 09:28:07 +08:00
|
|
|
|
View::render('calendar/event', [
|
|
|
|
|
|
'title' => (string)$ev['title'],
|
|
|
|
|
|
'ev' => $ev,
|
|
|
|
|
|
'cal' => $cal,
|
2026-10-03 11:30:48 +08:00
|
|
|
|
'occ' => $occ,
|
|
|
|
|
|
// ⚠️ 不要再传 'ym':模板自己从 $ev['starts_at'] 算(模板里那行更权威,
|
|
|
|
|
|
// 传进来反而会在"改过日期后"与模板算出的值不一致)。
|
2026-10-02 09:28:07 +08:00
|
|
|
|
], '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');
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// ⚠️ 这里要把**整行**取回来(含 rrule / starts_at / 各字段):
|
|
|
|
|
|
// 改重复日程时,"仅此一次 / 这一次及以后"都要拿旧内容来裁剪或复制,
|
|
|
|
|
|
// 只取 id 的话还得再查一遍(而且容易漏字段)。
|
2026-10-02 09:28:07 +08:00
|
|
|
|
$st = Database::get()->prepare(
|
2026-10-03 11:30:48 +08:00
|
|
|
|
'SELECT e.*, c.owner_id FROM calendar_events e
|
2026-10-02 09:28:07 +08:00
|
|
|
|
JOIN calendars c ON c.id = e.calendar_id
|
|
|
|
|
|
WHERE e.id = ? AND c.family_id = ?'
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$id, $familyId]);
|
2026-10-03 11:30:48 +08:00
|
|
|
|
$ev = $st->fetch();
|
|
|
|
|
|
$ev2 = $ev; // 旧内容(scope 分支要用,别被后面的 $d 覆盖)
|
2026-10-02 09:28:07 +08:00
|
|
|
|
if (!$ev) {
|
|
|
|
|
|
flash('err', '找不到这条日程。');
|
|
|
|
|
|
redirect('calendar', ['ym' => $ym]);
|
|
|
|
|
|
}
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: [];
|
2026-10-02 09:28:07 +08:00
|
|
|
|
if (!Calendar::canWrite($cal, $me)) {
|
|
|
|
|
|
flash('err', '这本日历你没有写权限。');
|
|
|
|
|
|
redirect('calendar', ['ym' => $ym]);
|
|
|
|
|
|
}
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// v24 第 5、6 条:起止各是一个完整时刻(精确到秒),见上面 event_add 的说明。
|
|
|
|
|
|
$startsAt = read_dt_input('starts_at');
|
|
|
|
|
|
if ($startsAt === null) {
|
|
|
|
|
|
flash('err', '开始时间不对。');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
redirect('calendar', ['ym' => $ym]);
|
|
|
|
|
|
}
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$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;
|
|
|
|
|
|
}
|
2026-10-03 11:30:48 +08:00
|
|
|
|
$d = [
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'title' => trim((string)($_POST['title'] ?? '')),
|
|
|
|
|
|
'location' => trim((string)($_POST['location'] ?? '')),
|
|
|
|
|
|
'starts_at' => $startsAt,
|
|
|
|
|
|
'ends_at' => $endsAt,
|
|
|
|
|
|
'all_day' => $allDay,
|
2026-10-06 03:14:18 +08:00
|
|
|
|
// v24 第 20 批 第 8 条:提醒**多选** → 表单来的是数组 `reminder_minutes[]`。
|
|
|
|
|
|
// ⚠️ 不要再 `(string)` 强转 —— 那会得到 "Array" 字样(PHP 的经典坑),
|
|
|
|
|
|
// Calendar::reminderMinutes() 收到它就一份都认不出来,提醒静默丢光。
|
|
|
|
|
|
// 直接原样把数组交下去,由 Calendar 那边做白名单 + 规范化。
|
|
|
|
|
|
'reminder_minutes' => ($_POST['reminder_minutes'] ?? ''),
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'status' => (string)($_POST['status'] ?? 'confirmed'),
|
|
|
|
|
|
'transp' => !empty($_POST['transp']),
|
2026-10-03 11:30:48 +08:00
|
|
|
|
'rrule' => Repeat::build($_POST),
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'note' => trim((string)($_POST['note'] ?? '')),
|
2026-10-03 11:30:48 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
/* ---- 用户 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);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$warns = attach_save_files($familyId, Calendar::ATTACH, $id, 'attach', $me);
|
|
|
|
|
|
if ($warns) {
|
|
|
|
|
|
flash('err', '有附件没有保存:' . implode(';', $warns));
|
|
|
|
|
|
}
|
2026-10-02 09:28:07 +08:00
|
|
|
|
flash('ok', '日程已保存。');
|
2026-10-03 08:18:05 +08:00
|
|
|
|
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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(
|
2026-10-06 03:14:18 +08:00
|
|
|
|
'SELECT e.id, e.calendar_id, e.rrule, e.starts_at FROM calendar_events e
|
2026-10-02 09:28:07 +08:00
|
|
|
|
JOIN calendars c ON c.id = e.calendar_id
|
|
|
|
|
|
WHERE e.id = ? AND c.family_id = ?'
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$id, $familyId]);
|
|
|
|
|
|
$ev = $st->fetch();
|
2026-10-06 03:14:18 +08:00
|
|
|
|
if (!$ev) {
|
|
|
|
|
|
flash('err', '找不到这条日程。');
|
|
|
|
|
|
redirect('calendar', ['ym' => $ym]);
|
2026-10-02 09:28:07 +08:00
|
|
|
|
}
|
2026-10-06 03:14:18 +08:00
|
|
|
|
$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', '日程已删除。');
|
2026-10-02 09:28:07 +08:00
|
|
|
|
redirect('calendar', ['ym' => $ym]);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// ==================== 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;
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// ==================== 资源(v12) ====================
|
|
|
|
|
|
// 挂在**某个人名下**的东西(证件 / 照片 / 账号),可共享给别人(只读或读写)。
|
|
|
|
|
|
|
|
|
|
|
|
case 'resources':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
$me = (int)Auth::personId();
|
|
|
|
|
|
$keyword = trim((string)($_GET['keyword'] ?? ''));
|
|
|
|
|
|
$ownerId = (int)($_GET['owner'] ?? 0);
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// 用户 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);
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// 附件与共享一次取齐(列表里每条都要显示"几个附件""共享给谁")
|
|
|
|
|
|
$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,
|
2026-10-03 15:01:38 +08:00
|
|
|
|
'sort' => $sort,
|
|
|
|
|
|
'limit' => $limit,
|
|
|
|
|
|
'totalAll'=> $totalAll,
|
|
|
|
|
|
'showAll' => $showAll,
|
2026-10-02 09:28:07 +08:00
|
|
|
|
'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();
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// v24(用户 2026-10-03 第 7 条):**列 = 用户自定义的看板本身**。
|
|
|
|
|
|
// 原话:「每个人自定义的看板,不是显示在现在的看板上面,而是直接代替现在的
|
|
|
|
|
|
// 待办和进行中,是看板,而不是看板的分类。」
|
|
|
|
|
|
// 所以不再有「待办 / 进行中」两列、也不再有顶上那个"切换看板"的横条 ——
|
|
|
|
|
|
// 一列就是一个看板,都在同一屏上,一眼看全。
|
|
|
|
|
|
$bc = Task::boardColumns($familyId, $me);
|
|
|
|
|
|
$cols = $bc['cols'];
|
|
|
|
|
|
$total = 0;
|
|
|
|
|
|
foreach ($cols as $list) {
|
|
|
|
|
|
$total += count($list);
|
|
|
|
|
|
}
|
2026-10-02 09:28:07 +08:00
|
|
|
|
View::render('tasks/index', [
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'title' => '任务',
|
2026-10-03 15:01:38 +08:00
|
|
|
|
'cols' => $cols,
|
|
|
|
|
|
'boards' => $bc['boards'],
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'categories' => Task::categories($familyId),
|
2026-10-03 15:01:38 +08:00
|
|
|
|
'total' => $total,
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'people' => Person::roster($familyId, $me, 'member', ''),
|
2026-10-02 09:28:07 +08:00
|
|
|
|
], 'tasks');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 任务设置(v24 第 9、10 条):分类 / 看板 / 已完成的归档。
|
|
|
|
|
|
* 入口在「新建任务」旁边那个 ☰ 下拉里,与观影的 ☰ 是同一套做法。
|
|
|
|
|
|
*/
|
|
|
|
|
|
case 'tasks/settings':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
View::render('tasks/settings', [
|
|
|
|
|
|
'title' => '任务设置',
|
|
|
|
|
|
'boards' => Task::boards($familyId),
|
|
|
|
|
|
'categories' => Task::categories($familyId),
|
|
|
|
|
|
], 'tasks');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
/** 已完成的任务(按分类查看 / 按关键词搜) */
|
|
|
|
|
|
case 'tasks/done':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
$me = (int)Auth::personId();
|
|
|
|
|
|
$cid = (int)($_GET['cat'] ?? 0);
|
|
|
|
|
|
$kw = trim((string)($_GET['kw'] ?? ''));
|
|
|
|
|
|
View::render('tasks/done', [
|
|
|
|
|
|
'title' => '已完成',
|
|
|
|
|
|
'rows' => Task::doneList($familyId, $me, $cid, $kw),
|
|
|
|
|
|
'categories' => Task::categories($familyId),
|
|
|
|
|
|
'cat' => $cid,
|
|
|
|
|
|
'kw' => $kw,
|
|
|
|
|
|
], 'tasks');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'tasks/board_add':
|
|
|
|
|
|
case 'tasks/board_update':
|
|
|
|
|
|
case 'tasks/board_del':
|
|
|
|
|
|
case 'tasks/cat_add':
|
|
|
|
|
|
case 'tasks/cat_update':
|
|
|
|
|
|
case 'tasks/cat_del':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
$id = (int)($_POST['id'] ?? 0);
|
|
|
|
|
|
$nm = trim((string)($_POST['name'] ?? ''));
|
|
|
|
|
|
$act = (string)($_GET['r'] ?? '');
|
|
|
|
|
|
if ($act === 'tasks/board_add') {
|
|
|
|
|
|
if ($nm === '') {
|
|
|
|
|
|
flash('err', '看板名不能为空。');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Task::createBoard($familyId, $nm);
|
|
|
|
|
|
flash('ok', '看板已添加。');
|
|
|
|
|
|
}
|
|
|
|
|
|
} elseif ($act === 'tasks/board_update') {
|
|
|
|
|
|
if ($nm === '') {
|
|
|
|
|
|
flash('err', '看板名不能为空。');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Task::renameBoard($familyId, $id, $nm);
|
|
|
|
|
|
flash('ok', '看板已改名。');
|
|
|
|
|
|
}
|
|
|
|
|
|
} elseif ($act === 'tasks/board_del') {
|
|
|
|
|
|
Task::deleteBoard($familyId, $id);
|
|
|
|
|
|
flash('ok', '看板已删除,里头的任务挪到了默认看板。');
|
|
|
|
|
|
} elseif ($act === 'tasks/cat_add') {
|
|
|
|
|
|
if ($nm === '') {
|
|
|
|
|
|
flash('err', '分类名不能为空。');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Task::createCategory($familyId, $nm);
|
|
|
|
|
|
flash('ok', '分类已添加。');
|
|
|
|
|
|
}
|
|
|
|
|
|
} elseif ($act === 'tasks/cat_update') {
|
|
|
|
|
|
if ($nm === '') {
|
|
|
|
|
|
flash('err', '分类名不能为空。');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Task::renameCategory($familyId, $id, $nm);
|
|
|
|
|
|
flash('ok', '分类已改名。');
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Task::deleteCategory($familyId, $id);
|
|
|
|
|
|
flash('ok', '分类已删除,用它标过的任务变成「未分类」。');
|
|
|
|
|
|
}
|
|
|
|
|
|
redirect('tasks/settings');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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', [
|
2026-10-03 08:18:05 +08:00
|
|
|
|
'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),
|
2026-10-02 09:28:07 +08:00
|
|
|
|
], '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),
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// v24 第 9、10 条:分类与看板。Task 侧会校验归属(不认别的家庭的 id)。
|
|
|
|
|
|
'category_id' => (int)($_POST['category_id'] ?? 0),
|
|
|
|
|
|
'board_id' => (int)($_POST['board_id'] ?? 0),
|
2026-10-05 10:58:17 +08:00
|
|
|
|
// v24 第 14 批 用户第 2 条:新建时可以**直接定状态**(表单里有这一项)。
|
|
|
|
|
|
// ⚠️ 编辑时**不**在这里改状态:状态由专门的 tasks/status 路由管
|
|
|
|
|
|
// (那个路由还要处理循环任务的"下一次也排上"),这里保持原值 ——
|
|
|
|
|
|
// 否则"只改个标题"会把进行中的任务悄悄打回待办。
|
|
|
|
|
|
'status' => $isEdit ? (string)$old['status'] : (string)($_POST['status'] ?? 'todo'),
|
2026-10-02 09:28:07 +08:00
|
|
|
|
];
|
|
|
|
|
|
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);
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// ⚠️ 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;
|
|
|
|
|
|
}
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// 子任务只在 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;
|
|
|
|
|
|
|
2026-10-05 10:58:17 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 改一条任务的**状态**(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)) {
|
|
|
|
|
|
flash('err', '这项任务不能改。');
|
|
|
|
|
|
redirect('tasks');
|
|
|
|
|
|
}
|
|
|
|
|
|
Task::setStatus($familyId, $id, $status);
|
|
|
|
|
|
$word = Task::statusWord(Task::normalizeStatus($status));
|
|
|
|
|
|
if ($status === 'done' && !empty($t['repeat_rule']) && ($t['due_at'] ?? '') !== '') {
|
|
|
|
|
|
flash('ok', '已标成「' . $word . '」,下一次的也排上了。');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
flash('ok', '已标成「' . $word . '」。');
|
|
|
|
|
|
}
|
|
|
|
|
|
redirect((string)($_POST['back'] ?? '') === 'show' ? 'tasks/show' : 'tasks',
|
|
|
|
|
|
(string)($_POST['back'] ?? '') === 'show' ? ['id' => $id] : []);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-10-05 10:58:17 +08:00
|
|
|
|
// ==================== 读书(v24 第 14 批 用户第 5 条) ====================
|
|
|
|
|
|
// ⚠️ 与观影 / 旅程不同,读书**不走 events 骨架**:一本书是"一段持续几个月的状态",
|
|
|
|
|
|
// 不是"某一天发生的事"(详见 app/Book.php 的头注释)。真要上日历的那天是"读完那天"。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 入口就两个:书架(books)与一本书(books/show);阅读器(books/read)从书进去。
|
|
|
|
|
|
// 统计与笔记导入挂在读书页右上角的「☰」里,与观影同一套规矩。
|
|
|
|
|
|
|
|
|
|
|
|
case 'books':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
$me = (int)Auth::personId();
|
|
|
|
|
|
$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;
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
default:
|
|
|
|
|
|
http_response_code(404);
|
|
|
|
|
|
View::render('error', [
|
|
|
|
|
|
'title' => '页面不存在',
|
|
|
|
|
|
'message' => '找不到这一页。',
|
|
|
|
|
|
], 'home');
|
|
|
|
|
|
break;
|
2026-09-29 23:48:45 +08:00
|
|
|
|
}
|