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;
|
|
|
|
|
|
use App\Auth;
|
2026-09-30 09:44:55 +08:00
|
|
|
|
use App\Birthday;
|
2026-09-30 00:49:53 +08:00
|
|
|
|
use App\Crypto;
|
|
|
|
|
|
use App\Database;
|
|
|
|
|
|
use App\Family;
|
2026-09-30 08:13:07 +08:00
|
|
|
|
use App\Kinship;
|
2026-09-30 20:22:46 +08:00
|
|
|
|
use App\Movie;
|
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-09-30 13:24:26 +08:00
|
|
|
|
use App\PersonRoster;
|
2026-09-30 08:13:07 +08:00
|
|
|
|
use App\Relations;
|
|
|
|
|
|
use App\Schema;
|
2026-09-30 00:49:53 +08:00
|
|
|
|
use App\Setting;
|
|
|
|
|
|
use App\Upload;
|
|
|
|
|
|
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-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) {
|
|
|
|
|
|
$have = (string)Setting::get($familyIdForSchema, 'schema_version', '1');
|
|
|
|
|
|
if ($have !== FB_SCHEMA_VERSION) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
Schema::ensure();
|
|
|
|
|
|
Setting::set($familyIdForSchema, 'schema_version', FB_SCHEMA_VERSION);
|
|
|
|
|
|
} catch (\Throwable $ex) {
|
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 丢弃;校验失败重画表单时还要用它回填)
|
|
|
|
|
|
*/
|
|
|
|
|
|
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-09-30 11:06:22 +08:00
|
|
|
|
], 'settings');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
switch ($r) {
|
2026-09-29 23:48:45 +08:00
|
|
|
|
|
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-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-09-30 09:44:55 +08:00
|
|
|
|
// 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页)
|
|
|
|
|
|
'mySlogan' => trim((string)($people[$me]['slogan'] ?? '')),
|
|
|
|
|
|
'myNicknames'=> Nickname::mapFor($me),
|
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);
|
|
|
|
|
|
$tree = Kinship::tree($graph, $me, $people, 3, 3);
|
|
|
|
|
|
|
|
|
|
|
|
View::render('kin/tree', [
|
|
|
|
|
|
'title' => '我的家谱',
|
|
|
|
|
|
'tree' => $tree,
|
|
|
|
|
|
'meId' => $me,
|
|
|
|
|
|
'people' => $people,
|
|
|
|
|
|
'graph' => $graph,
|
|
|
|
|
|
], '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-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-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-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;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 「从家里已有的人里挑」—— 添加成员的第二个入口。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 存在的理由:同一个人不该被录两遍。录两遍会变成两条 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-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-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-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-09-30 09:44:55 +08:00
|
|
|
|
'slogan' => trim((string)($_POST['slogan'] ?? '')),
|
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-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-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-09-30 00:49:53 +08:00
|
|
|
|
], 'people');
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。
|
|
|
|
|
|
// 分组先留空 —— 用户可以在名册里拖到对应那一栏,也可以在那张卡片的「调整分组」里选。
|
|
|
|
|
|
PersonRoster::ensure($familyId, (int)Auth::personId(), $id, null, 'self');
|
2026-09-30 15:02:27 +08:00
|
|
|
|
Person::replaceContacts($id, $contacts);
|
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-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-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-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-09-30 09:44:55 +08:00
|
|
|
|
'slogan' => trim((string)($_POST['slogan'] ?? '')),
|
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'];
|
|
|
|
|
|
}
|
|
|
|
|
|
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-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;
|
|
|
|
|
|
|
|
|
|
|
|
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']),
|
|
|
|
|
|
'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);
|
|
|
|
|
|
if ($p && (int)$p['family_id'] === (int)Auth::familyId()) {
|
|
|
|
|
|
if ((int)$p['id'] === (int)Auth::personId()) {
|
|
|
|
|
|
flash('err', '不能把自己从名册里移除。');
|
|
|
|
|
|
redirect('people/show', ['id' => $id]);
|
|
|
|
|
|
}
|
|
|
|
|
|
// 不让最后一个管理员消失:系统里没人能管理家庭就锁死了
|
|
|
|
|
|
$acc = Account::findByPerson($id);
|
|
|
|
|
|
if ($acc && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
|
|
|
|
|
|
flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。');
|
|
|
|
|
|
redirect('people/show', ['id' => $id]);
|
|
|
|
|
|
}
|
2026-09-30 08:13:07 +08:00
|
|
|
|
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
|
2026-09-30 00:49:53 +08:00
|
|
|
|
Person::delete($id);
|
|
|
|
|
|
flash('ok', '已从名册移除「' . $p['name'] . '」。');
|
|
|
|
|
|
}
|
|
|
|
|
|
redirect('people');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 账号 ====================
|
|
|
|
|
|
case 'accounts/create':
|
|
|
|
|
|
Auth::requireAdmin();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
$personId = (int)($_POST['person_id'] ?? 0);
|
|
|
|
|
|
$p = Person::find($personId);
|
|
|
|
|
|
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
|
|
|
|
|
flash('err', '找不到这个人。');
|
|
|
|
|
|
redirect('people');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Account::findByPerson($personId)) {
|
|
|
|
|
|
flash('err', '这个人的账号已经开通过了。');
|
|
|
|
|
|
redirect('people/show', ['id' => $personId]);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Crypto::decrypt((string)$p['phone_encrypted']) === '') {
|
|
|
|
|
|
flash('err', '要先填手机号,才能开通登录。');
|
|
|
|
|
|
redirect('people/show', ['id' => $personId]);
|
|
|
|
|
|
}
|
|
|
|
|
|
$pass = (string)($_POST['password'] ?? '');
|
|
|
|
|
|
$errs = Account::validatePassword($pass);
|
|
|
|
|
|
if ($errs) {
|
|
|
|
|
|
flash('err', implode(';', $errs));
|
|
|
|
|
|
redirect('people/show', ['id' => $personId]);
|
|
|
|
|
|
}
|
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) 的第二个参数是**孩子**。
|
|
|
|
|
|
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'] ?? ''))
|
|
|
|
|
|
);
|
|
|
|
|
|
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>'(重跑迁移的幂等锚点)。
|
|
|
|
|
|
|
|
|
|
|
|
/** 观影时间线 —— 按年分组倒序。 */
|
|
|
|
|
|
case 'movies':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
$rows = Movie::all($familyId);
|
|
|
|
|
|
$years = [];
|
|
|
|
|
|
foreach ($rows as $r) {
|
|
|
|
|
|
$y = $r['starts_at'] !== null ? (int)substr((string)$r['starts_at'], 0, 4) : 0;
|
|
|
|
|
|
$years[$y][] = $r;
|
|
|
|
|
|
}
|
|
|
|
|
|
unset($r);
|
|
|
|
|
|
krsort($years);
|
|
|
|
|
|
View::render('movies/index', [
|
|
|
|
|
|
'title' => '观影记录',
|
|
|
|
|
|
'years' => $years,
|
|
|
|
|
|
'total' => count($rows),
|
|
|
|
|
|
], 'movies');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'movies/show':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
$m = Movie::find($familyId, (int)($_GET['id'] ?? 0));
|
|
|
|
|
|
if (!$m) {
|
|
|
|
|
|
flash('err', '找不到这条观影记录。');
|
|
|
|
|
|
redirect('movies');
|
|
|
|
|
|
}
|
|
|
|
|
|
View::render('movies/show', [
|
|
|
|
|
|
'title' => (string)$m['title'],
|
|
|
|
|
|
'm' => $m,
|
|
|
|
|
|
], 'movies');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'movies/new':
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
View::render('movies/form', [
|
|
|
|
|
|
'title' => '记一场电影',
|
|
|
|
|
|
'm' => null,
|
|
|
|
|
|
'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''),
|
|
|
|
|
|
'errors' => [],
|
|
|
|
|
|
], 'movies');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'movies/edit':
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
$m = Movie::find($familyId, (int)($_GET['id'] ?? 0));
|
|
|
|
|
|
if (!$m) {
|
|
|
|
|
|
flash('err', '找不到这条观影记录。');
|
|
|
|
|
|
redirect('movies');
|
|
|
|
|
|
}
|
|
|
|
|
|
View::render('movies/form', [
|
|
|
|
|
|
'title' => '编辑观影记录',
|
|
|
|
|
|
'm' => $m,
|
|
|
|
|
|
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
|
|
|
|
|
|
'errors' => [],
|
|
|
|
|
|
], 'movies');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
/** 收集表单里和观影有关的字段(create / update 共用一份)。 */
|
|
|
|
|
|
case 'movies/create':
|
|
|
|
|
|
case 'movies/update':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
$me = (int)Auth::personId();
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
|
|
|
|
|
|
$isEdit = $r === 'movies/update';
|
|
|
|
|
|
$id = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
|
|
|
|
|
|
$m = $isEdit ? Movie::find($familyId, $id) : null;
|
|
|
|
|
|
if ($isEdit && !$m) {
|
|
|
|
|
|
flash('err', '找不到这条观影记录。');
|
|
|
|
|
|
redirect('movies');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$d = [
|
|
|
|
|
|
'title' => trim((string)($_POST['title'] ?? '')),
|
|
|
|
|
|
'starts_at' => trim((string)($_POST['starts_at'] ?? '')),
|
|
|
|
|
|
'location' => trim((string)($_POST['location'] ?? '')),
|
|
|
|
|
|
'note' => trim((string)($_POST['note'] ?? '')),
|
|
|
|
|
|
'info' => trim((string)($_POST['info'] ?? '')),
|
|
|
|
|
|
'rating' => trim((string)($_POST['rating'] ?? '')),
|
|
|
|
|
|
'review' => trim((string)($_POST['review'] ?? '')),
|
|
|
|
|
|
'feelings' => array_values((array)($_POST['feelings'] ?? [])),
|
|
|
|
|
|
'cinema_place' => trim((string)($_POST['cinema_place'] ?? '')),
|
|
|
|
|
|
'cinema_name' => trim((string)($_POST['cinema_name'] ?? '')),
|
|
|
|
|
|
'cinema_address' => trim((string)($_POST['cinema_address'] ?? '')),
|
|
|
|
|
|
// 同行的人:只认「确实存在且属于这个家」的 id —— 表单可以改,
|
|
|
|
|
|
// 不能凭它把别人家的成员挂进自己家的观影记录里(会顺着详情页漏人名)。
|
|
|
|
|
|
'attendees' => array_map('intval', (array)($_POST['attendees'] ?? [])),
|
|
|
|
|
|
];
|
|
|
|
|
|
$_familyPeople = Person::map($familyId);
|
|
|
|
|
|
$d['attendees'] = array_values(array_filter($d['attendees'],
|
|
|
|
|
|
static fn($pid) => isset($_familyPeople[$pid])));
|
|
|
|
|
|
$errors = $d['title'] === '' ? ['title' => '片名不能为空'] : [];
|
|
|
|
|
|
|
|
|
|
|
|
// ⚠️ 图片上传失败**不挡保存**(与头像同一思路:一条记录因为一张 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'] ?? '');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 现场照片(多选,仅新增表单提供;编辑页在详情页里逐张补/删)
|
|
|
|
|
|
if (!$isEdit) {
|
|
|
|
|
|
$photoFiles = [];
|
|
|
|
|
|
if (!empty($_FILES['photo']) && is_array($_FILES['photo']['name'])) {
|
|
|
|
|
|
foreach ($_FILES['photo']['name'] as $i => $_n) {
|
|
|
|
|
|
$photoFiles[] = [
|
|
|
|
|
|
'name' => $_FILES['photo']['name'][$i],
|
|
|
|
|
|
'type' => $_FILES['photo']['type'][$i],
|
|
|
|
|
|
'tmp_name' => $_FILES['photo']['tmp_name'][$i],
|
|
|
|
|
|
'error' => $_FILES['photo']['error'][$i],
|
|
|
|
|
|
'size' => $_FILES['photo']['size'][$i],
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
$photoKeys = [];
|
|
|
|
|
|
foreach ($photoFiles as $f) {
|
|
|
|
|
|
$up = Upload::image($familyId, $f, 'photo');
|
|
|
|
|
|
if ($up['ok']) {
|
|
|
|
|
|
$photoKeys[] = (string)$up['file'];
|
|
|
|
|
|
} elseif ((string)($up['error'] ?? '') !== '') {
|
|
|
|
|
|
$warns[] = '有一张照片没有保存:' . $up['error'];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
$d['photos'] = $photoKeys;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($errors) {
|
|
|
|
|
|
set_old($_POST);
|
|
|
|
|
|
View::render('movies/form', [
|
|
|
|
|
|
'title' => $isEdit ? '编辑观影记录' : '记一场电影',
|
|
|
|
|
|
'm' => $m,
|
|
|
|
|
|
'people' => Person::roster($familyId, $me, 'member', ''),
|
|
|
|
|
|
'errors' => $errors,
|
|
|
|
|
|
], 'movies');
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($isEdit) {
|
|
|
|
|
|
Movie::update($familyId, $id, $d);
|
|
|
|
|
|
clear_old();
|
|
|
|
|
|
flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
|
|
|
|
|
|
redirect('movies/show', ['id' => $id]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$newId = Movie::create($familyId, $me, $d);
|
|
|
|
|
|
clear_old();
|
|
|
|
|
|
flash('ok', '已记下这场电影。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
|
|
|
|
|
|
redirect('movies/show', ['id' => $newId]);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
/** 编辑页补传一张现场照片(详情页里的小表单,一次一张)。 */
|
|
|
|
|
|
case 'movies/photo_add':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
$id = (int)($_POST['id'] ?? 0);
|
|
|
|
|
|
if (!Movie::find($familyId, $id)) {
|
|
|
|
|
|
flash('err', '找不到这条观影记录。');
|
|
|
|
|
|
redirect('movies');
|
|
|
|
|
|
}
|
|
|
|
|
|
$up = Upload::image($familyId, $_FILES['photo'] ?? [], 'photo');
|
|
|
|
|
|
if ($up['ok']) {
|
|
|
|
|
|
// savePhotos 是整组替换 —— 现有键取出来再追加一张,位置接着排
|
|
|
|
|
|
$keys = array_map('strval', array_column(Movie::photos($id), 'key'));
|
|
|
|
|
|
$keys[] = (string)$up['file'];
|
|
|
|
|
|
Movie::savePhotos($id, $keys);
|
|
|
|
|
|
flash('ok', '照片已加上。');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
flash('err', '照片没有保存:' . (string)($up['error'] ?: '没选文件'));
|
|
|
|
|
|
}
|
|
|
|
|
|
redirect('movies/show', ['id' => $id]);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'movies/photo_del':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
$id = (int)($_POST['id'] ?? 0);
|
|
|
|
|
|
$pid = (int)($_POST['photo_id'] ?? 0);
|
|
|
|
|
|
if (!Movie::find($familyId, $id)) {
|
|
|
|
|
|
flash('err', '找不到这条观影记录。');
|
|
|
|
|
|
redirect('movies');
|
|
|
|
|
|
}
|
|
|
|
|
|
Movie::deletePhoto($familyId, $id, $pid);
|
|
|
|
|
|
flash('ok', '照片已删除。');
|
|
|
|
|
|
redirect('movies/show', ['id' => $id]);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'movies/destroy':
|
|
|
|
|
|
$familyId = (int)Auth::familyId();
|
|
|
|
|
|
Auth::requireWrite();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
$id = (int)($_POST['id'] ?? 0);
|
|
|
|
|
|
if (!Movie::find($familyId, $id)) {
|
|
|
|
|
|
flash('err', '找不到这条观影记录。');
|
|
|
|
|
|
redirect('movies');
|
|
|
|
|
|
}
|
|
|
|
|
|
Movie::destroy($familyId, $id);
|
|
|
|
|
|
flash('ok', '这条观影记录已删除。');
|
|
|
|
|
|
redirect('movies');
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
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
|
|
|
|
}
|