名册每人一份 · 分组两层 · 拖动排序 · 表单合一 · 裁剪可缩放
按用户十点反馈逐条落地(v5,Schema::VERSION = 5): - 名册改成每人一份:新表 person_roster(owner_id, person_id, group_id, sort_order, source)。默认只看「我添加的 + 别人分享给我的」; source='seeded' 是升级时从老 persons.group_id 播进来的(先播数据再删列)。 - 排序不再手填:名册页直接拖动卡片(整版提交,一次 applyLayout 落库), 无 JS 时卡片上的「调整分组」表单照旧可用。 - 分组分两层:管理员在「系统设置」维护公共分组(owner_id=0), 用户在「个人设置」加自己的分组;显示顺序各人各定、互不影响。 - 名册可共享:把名片分享给别的用户(people/share);添加成员时若系统里 已有这个人,可直接复用(people/pick),不必重复录入。 - 「编辑成员」与「改我自己的资料」合并为同一张表(templates/people/form.php), 两个入口 href 完全相同,h1 按改谁切换文案;个人设置页只留 资料入口 + 称呼 + 我的分组三块,删除 templates/settings/_profile.php。 - 系统设置收进头像下拉,顶栏只剩 首页/家谱/名册。 - 裁剪器加缩放(滑块 1-4x、Ctrl+滚轮、还原):缩放只改视图大小, 落库的归一化矩形保持不变。 - 权限:管理员可以改任何人的权限(含自己),只拦「最后一位管理员」被降级。 - 去掉首页「我的一句话」后的链接、家谱说明段、以及「还没归到任何分组」提示。 迁移安全:数据回填(PersonRoster::seedFromPersons)先于删列执行、且不进 tryStep 容错;失败步骤记入 settings.schema_upgrade_warning 并在系统设置页提示。 线上已验证:schema_upgrade_warning 为空,person_roster/person_groups.owner_id 就位,persons.group_id / sort_order 已删。 验证:php -l 48 文件;本地 e2e 277/0、老库升级 81/0、桌面几何 108/0、 移动端几何 180/0、交互 96/0;反向验证 38 条注入 + 2 条缩放注入全部命中; 部署静态断言 53/0,站点自检 7/7(--resolve 锁本机走 443,探针真的进 PHP)。
This commit is contained in:
@@ -20,6 +20,7 @@ use App\Kinship;
|
||||
use App\Nickname;
|
||||
use App\Person;
|
||||
use App\PersonGroup;
|
||||
use App\PersonRoster;
|
||||
use App\Relations;
|
||||
use App\Schema;
|
||||
use App\Setting;
|
||||
@@ -56,7 +57,13 @@ if (Database::hasTables()) {
|
||||
Schema::ensure();
|
||||
Setting::set($familyIdForSchema, 'schema_version', FB_SCHEMA_VERSION);
|
||||
} catch (\Throwable $ex) {
|
||||
// 升级失败不能把整站挡住;下次请求会再试
|
||||
// 升级失败不能把整站挡住;下次请求会再试。
|
||||
// ⚠️ 但**必须留下痕迹**:这里曾经是空的 catch,结果线上结构升级
|
||||
// 从 2026-09-30 起一次都没跑完(报错被吞、版本号永远停在 2),
|
||||
// 而首页/名册都 200,看着完全正常 —— 直到「个人设置 500」才暴露。
|
||||
// 一行 error_log 就能省掉几小时排查,别再删掉它。
|
||||
error_log('[familybook] Schema::ensure() 失败(已跳过,下次请求重试):'
|
||||
. $ex->getMessage() . ' @ ' . $ex->getFile() . ':' . $ex->getLine());
|
||||
if (!empty($GLOBALS['config']['debug'])) {
|
||||
throw $ex;
|
||||
}
|
||||
@@ -104,12 +111,14 @@ function read_birthday_input(array $existing = []): array
|
||||
/**
|
||||
* 渲染「个人设置」页(settings/me)。
|
||||
*
|
||||
* 抽成函数是因为它有三处调用者:正常打开、以及资料表单校验失败时的两处
|
||||
* ("我的资料"和"改密码"各一次)—— 那三处的数据装配必须完全一致,
|
||||
* 否则校验失败重画的那次会缺东西(比如分组顺序整块消失),且不会有任何报错。
|
||||
* 从 v5 起这一页只剩两件事:**我怎么称呼家人**、**我的分组**。
|
||||
* 「我的资料」表单不在这里 —— 它和「成员资料」是同一张表单
|
||||
* (`templates/people/form.php`,两条入口都指过去),页面顶部给一个按钮过去。
|
||||
* 这是用户明确要求的:「编辑成员」和「修改自己的资料」本来就是一件事,
|
||||
* 两套表单必然填出两种口径。
|
||||
*
|
||||
* @param array $person 当前登录者自己的名册记录
|
||||
* @param array $errors 字段名 => 错误文案
|
||||
* @param array $errors 字段名 => 错误文案(保留给"校验失败就地重画"用)
|
||||
*/
|
||||
function render_me_settings(array $person, array $errors = []): void
|
||||
{
|
||||
@@ -119,14 +128,14 @@ function render_me_settings(array $person, array $errors = []): void
|
||||
'title' => '个人设置',
|
||||
'person' => $person,
|
||||
'errors' => $errors,
|
||||
'familyMotto' => (string)(Family::find($familyId)['motto'] ?? ''),
|
||||
// 我怎么称呼别人(视角属性,只有我自己能定)
|
||||
'nicknames' => Nickname::mineWithNames($familyId, $me),
|
||||
'options' => Person::options($familyId, $me, $me),
|
||||
'birthdays' => Birthday::describe($person),
|
||||
// 名册分组:顺序是「我的」,分类是「全家的」
|
||||
// 我的分组 = 公共的 + 我自己加的,顺序是我的(user_settings.group_order)
|
||||
'groups' => PersonGroup::orderFor($familyId, $me),
|
||||
'groupCounts' => PersonGroup::counts($familyId, 'member'),
|
||||
'groupCounts' => PersonGroup::counts($familyId, $me),
|
||||
'rosterCount' => PersonRoster::countFor($familyId, $me),
|
||||
], 'settings');
|
||||
}
|
||||
|
||||
@@ -266,7 +275,9 @@ switch ($r) {
|
||||
$q = trim((string)($_GET['q'] ?? ''));
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
$rows = Person::all($familyId, $q, 'member', $me);
|
||||
// ⚠️ 用 roster() 而不是 all():名册是「每人一份」——
|
||||
// 只列我加的人 + 别人分享给我的(v5 起,见 App\PersonRoster)。
|
||||
$rows = Person::roster($familyId, $me, 'member', $q);
|
||||
$people = Person::map($familyId);
|
||||
$graph = Kinship::graph($familyId);
|
||||
// 名册里也标出「我该怎么称呼他」
|
||||
@@ -290,18 +301,24 @@ switch ($r) {
|
||||
'groups' => PersonGroup::orderFor($familyId, $me),
|
||||
'q' => $q,
|
||||
'meId' => $me,
|
||||
// 家里已经有人、但还不在我名册里 → 给一个「直接挑」的入口,
|
||||
// 免得为了同一个人又录一遍(录两遍会变成两条记录,账号与亲属关系各存一份)
|
||||
'pickable' => $q === '' ? count(PersonRoster::pickable($familyId, $me)) : 0,
|
||||
], 'people');
|
||||
break;
|
||||
|
||||
/**
|
||||
* 名册卡片上的「调整分组」。
|
||||
*
|
||||
* 单开一个路由而不是让人去「编辑成员」里改,是因为那个表单很长
|
||||
* 从 v5 起改的是**我自己那份名册**里的归类(person_roster.group_id),
|
||||
* 不再动 persons —— 所以「我把张伟归到同事」不会让妈妈那边也变成同事。
|
||||
* 单开一个路由而不是让人去「编辑成员」里改,是因为那张表很长
|
||||
* (三个生日、身份证号、住址…),为了挪一个分组去翻半页是折磨。
|
||||
* 只改 group_id 一列,所以走 Person::setGroup(),不会碰到别的字段。
|
||||
* 而且分组**本来就只能在名册里改**(它是"我和他的关系")。
|
||||
*/
|
||||
case 'people/group':
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
if (!Auth::canWrite()) {
|
||||
flash('err', '你的身份是访客,不能改名册里的分组。');
|
||||
redirect('people');
|
||||
@@ -314,19 +331,152 @@ switch ($r) {
|
||||
flash('err', '找不到这个人。');
|
||||
redirect('people');
|
||||
}
|
||||
// 分组必须也是这个家庭的 —— 否则可以把人挂到别人家的分组上,
|
||||
// 名册按 group_id 分栏时就永远看不见他了
|
||||
if ($gid > 0 && !isset(PersonGroup::byId($familyId)[$gid])) {
|
||||
flash('err', '找不到那个分组。');
|
||||
if (!PersonRoster::assignGroup($familyId, $me, $pid, $gid > 0 ? $gid : null)) {
|
||||
flash('err', '这个人不在你的名册里,或者那个分组你用不了。');
|
||||
redirect('people');
|
||||
}
|
||||
Person::setGroup($pid, $gid > 0 ? $gid : null);
|
||||
flash('ok', $gid > 0
|
||||
? '已把「' . $p['name'] . '」归到「' . PersonGroup::name($familyId, $gid) . '」。'
|
||||
: '已把「' . $p['name'] . '」改成未分类。');
|
||||
redirect('people');
|
||||
break;
|
||||
|
||||
/**
|
||||
* 名册拖动排序 / 拖动换栏 —— 整版提交。
|
||||
*
|
||||
* 前端把每一栏(含「未分类」)的有序 id 列表发过来,服务端一次写完。
|
||||
* 为什么不"移动一条算一次":拖拽的结果是一组**相对顺序**,
|
||||
* 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。
|
||||
* 没有 JS 也能用:卡片上的「调整分组」表单还在(只是没有拖拽那么顺手)。
|
||||
*/
|
||||
case 'people/reorder':
|
||||
Auth::requireLogin();
|
||||
// 拖动是 AJAX 发的(见 app.js)→ 出错也回 JSON 而不是 302:
|
||||
// 不然那句 flash 会攒到"下一次刷新"才冒出来,看着像迟到的鬼影。
|
||||
$isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest');
|
||||
if (!Auth::canWrite()) {
|
||||
if ($isAjax) {
|
||||
header('Content-Type: application/json; charset=utf-8', true, 403);
|
||||
echo json_encode(['ok' => false, 'error' => 'readonly']);
|
||||
exit;
|
||||
}
|
||||
flash('err', '你的身份是访客,不能调整名册顺序。');
|
||||
redirect('people');
|
||||
}
|
||||
csrf_check();
|
||||
$raw = (string)($_POST['layout'] ?? '');
|
||||
$layout = json_decode($raw, true);
|
||||
if (!is_array($layout)) {
|
||||
if ($isAjax) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => 'invalid layout']);
|
||||
exit;
|
||||
}
|
||||
flash('err', '没收到有效的顺序,请刷新页面再试一次。');
|
||||
redirect('people');
|
||||
}
|
||||
$n = PersonRoster::applyLayout((int)Auth::familyId(), (int)Auth::personId(), $layout);
|
||||
if ($isAjax) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => true, 'n' => $n], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('ok', $n > 0 ? '名册顺序已更新。' : '没有需要改动的。');
|
||||
redirect('people');
|
||||
break;
|
||||
|
||||
/**
|
||||
* 「从家里已有的人里挑」—— 添加成员的第二个入口。
|
||||
*
|
||||
* 存在的理由:同一个人不该被录两遍。录两遍会变成两条 persons,
|
||||
* 于是账号、亲属关系、生日各存一份,后面永远对不上。
|
||||
* 所以只要人已经在这个家庭里(别人先录过了),就走这里把他**复用**进我的名册。
|
||||
*/
|
||||
case 'people/pick':
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
if (!Auth::canWrite()) {
|
||||
flash('err', '你的身份是访客,不能往名册里加人。');
|
||||
redirect('people');
|
||||
}
|
||||
$familyPick = PersonRoster::pickable($familyId, $me);
|
||||
if ($method === 'POST') {
|
||||
csrf_check();
|
||||
$ids = array_map('intval', (array)($_POST['ids'] ?? []));
|
||||
$gid = (int)($_POST['group_id'] ?? 0);
|
||||
// 只认「确实可挑」的那些 id —— 表单可以改,不能凭它把别人家的拉进来
|
||||
$ok = array_column($familyPick, 'id');
|
||||
$ok = array_map('intval', $ok);
|
||||
$n = 0;
|
||||
foreach ($ids as $pid) {
|
||||
if (!in_array($pid, $ok, true)) {
|
||||
continue;
|
||||
}
|
||||
if (PersonRoster::ensure($familyId, $me, $pid, $gid > 0 ? $gid : null, 'self')) {
|
||||
$n++;
|
||||
}
|
||||
}
|
||||
flash($n > 0 ? 'ok' : 'err', $n > 0
|
||||
? '已把 ' . $n . ' 个人加进你的名册。'
|
||||
: '一个人都没加上 —— 先勾选要加的人。');
|
||||
redirect('people');
|
||||
}
|
||||
View::render('people/pick', [
|
||||
'title' => '从家里已有的人里挑',
|
||||
'people' => $familyPick,
|
||||
'groups' => PersonGroup::orderFor($familyId, $me),
|
||||
'meId' => $me,
|
||||
], 'people');
|
||||
break;
|
||||
|
||||
/**
|
||||
* 把一张名片分享到别人的名册里(需求原话:「我可以把一个人的名片,分享给一个用户,
|
||||
* 则这个名片,自动出现在这个用户的名册中」)。
|
||||
* 这只是"让他看得见",不改变任何资料本身;对方可以自己决定归到哪一组。
|
||||
*/
|
||||
case 'people/share':
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
if (!Auth::canWrite()) {
|
||||
flash('err', '你的身份是访客,不能分享名片。');
|
||||
redirect('people');
|
||||
}
|
||||
csrf_check();
|
||||
$pid = (int)($_POST['person_id'] ?? 0);
|
||||
$to = (int)($_POST['to_id'] ?? 0);
|
||||
$back = (int)($_POST['back'] ?? 0);
|
||||
$p = Person::find($pid);
|
||||
$t = Person::find($to);
|
||||
if (!$p || !$t || (int)$p['family_id'] !== $familyId || (int)$t['family_id'] !== $familyId) {
|
||||
flash('err', '找不到这个人。');
|
||||
redirect('people');
|
||||
}
|
||||
if ($pid === $to) {
|
||||
flash('err', '不用把名片分享给他自己。');
|
||||
redirect('people/show', ['id' => $back > 0 ? $back : $pid]);
|
||||
}
|
||||
PersonRoster::share($familyId, $to, [$pid], 'shared');
|
||||
flash('ok', '已把「' . $p['name'] . '」的名片放进「' . $t['name'] . '」的名册里。');
|
||||
redirect('people/show', ['id' => $back > 0 ? $back : $pid]);
|
||||
break;
|
||||
|
||||
/** 把一个人从**我的**名册里移掉(人还在名册与家谱里,只是我不再看到他) */
|
||||
case 'people/forget':
|
||||
Auth::requireLogin();
|
||||
if (!Auth::canWrite()) {
|
||||
flash('err', '你的身份是访客,不能改名册。');
|
||||
redirect('people');
|
||||
}
|
||||
csrf_check();
|
||||
$pid = (int)($_POST['person_id'] ?? 0);
|
||||
$p = Person::find($pid);
|
||||
if ($p && (int)$p['family_id'] === (int)Auth::familyId()) {
|
||||
PersonRoster::remove((int)Auth::familyId(), (int)Auth::personId(), $pid);
|
||||
flash('ok', '已把「' . $p['name'] . '」从你的名册里移走(他还在家里)。');
|
||||
}
|
||||
redirect('people');
|
||||
break;
|
||||
|
||||
// ==================== 我自己的资料 / 个人设置 ====================
|
||||
// 顶栏头像点开 →「个人设置」。访客也能改自己的资料 ——
|
||||
// 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。
|
||||
@@ -347,56 +497,21 @@ switch ($r) {
|
||||
render_me_settings($p);
|
||||
break;
|
||||
|
||||
case 'settings/me_save': // 表单现在提交到这里
|
||||
case 'people/me_save': // 兼容旧链接/旧标签页里还没刷新的表单
|
||||
Auth::requireLogin();
|
||||
csrf_check();
|
||||
$me = (int)Auth::personId();
|
||||
$p = Person::find($me);
|
||||
if (!$p) {
|
||||
flash('err', '找不到你自己的名册记录。');
|
||||
redirect('home');
|
||||
}
|
||||
$d = [
|
||||
'name' => trim((string)($_POST['name'] ?? '')),
|
||||
'gender' => (string)($_POST['gender'] ?? ''),
|
||||
'email' => trim((string)($_POST['email'] ?? '')),
|
||||
'address' => trim((string)($_POST['address'] ?? '')),
|
||||
'note' => trim((string)($_POST['note'] ?? '')),
|
||||
'slogan' => trim((string)($_POST['slogan'] ?? '')),
|
||||
'avatar' => (string)$p['avatar'],
|
||||
] + read_birthday_input($p);
|
||||
|
||||
$errors = Person::validate($d);
|
||||
// 归一化裁剪框由前端浮层写进 avatar_crop;留空 = 没裁,退回居中
|
||||
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? [],
|
||||
(string)($_POST['avatar_crop'] ?? ''));
|
||||
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
||||
$errors['avatar'] = $up['error'];
|
||||
}
|
||||
if ($errors) {
|
||||
set_old($_POST);
|
||||
render_me_settings($p, $errors);
|
||||
exit;
|
||||
}
|
||||
if ($up['ok']) {
|
||||
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
|
||||
$d['avatar'] = $up['file'];
|
||||
}
|
||||
$warn = Birthday::normalize($d)['warning'];
|
||||
Person::updateSelf($me, $d);
|
||||
if ($warn !== '') {
|
||||
flash('err', $warn);
|
||||
}
|
||||
// 姓名/头像改了要同步会话,否则顶栏还是旧的
|
||||
$_SESSION['name'] = $d['name'];
|
||||
Auth::refreshAvatar((string)$d['avatar']);
|
||||
clear_old();
|
||||
flash('ok', '已保存。');
|
||||
/**
|
||||
* 旧的「保存我的资料」入口。
|
||||
*
|
||||
* v5 起资料表单只剩一张(`templates/people/form.php`,提交到 people/update),
|
||||
* 所以这两个地址不再处理表单 —— 只把还开着旧标签页的人送回个人设置,
|
||||
* 免得一次提交撞上 404 让人以为"保存坏了"。
|
||||
*/
|
||||
case 'settings/me_save':
|
||||
case 'people/me_save':
|
||||
redirect('settings/me');
|
||||
break;
|
||||
|
||||
// ==================== 分组顺序(只影响我自己) ====================
|
||||
// ==================== 分组的顺序 ====================
|
||||
// 两套:「我自己的顺序」(只影响我看到的)与「公共分组的默认顺序」(全家兜底)。
|
||||
// 靠表单里的 scope 字段区分 —— 同一个动作,落到两个地方。
|
||||
case 'settings/groups/move':
|
||||
Auth::requireLogin();
|
||||
csrf_check();
|
||||
@@ -404,74 +519,106 @@ switch ($r) {
|
||||
$me = (int)Auth::personId();
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$dir = ($_POST['dir'] ?? '') === 'up' ? 'up' : 'down';
|
||||
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
|
||||
if ($scope === 'public') {
|
||||
Auth::requireAdmin();
|
||||
}
|
||||
|
||||
$list = PersonGroup::orderFor($familyId, $me);
|
||||
$list = $scope === 'public'
|
||||
? PersonGroup::forOwner($familyId, PersonGroup::PUBLIC_OWNER)
|
||||
: PersonGroup::orderFor($familyId, $me);
|
||||
$ids = array_map(fn($g) => (int)$g['id'], $list);
|
||||
$at = array_search($id, $ids, true);
|
||||
|
||||
if ($at === false) {
|
||||
flash('err', '找不到这个分组。');
|
||||
redirect('settings/me');
|
||||
redirect($scope === 'public' ? 'settings' : 'settings/me');
|
||||
}
|
||||
// 挪到边界就什么也不做(按钮在两端本来就是 disabled 的,
|
||||
// 但地址栏里手敲一次也能到这儿,别让它把顺序搞乱)
|
||||
$to = $dir === 'up' ? $at - 1 : $at + 1;
|
||||
if ($to >= 0 && $to < count($ids)) {
|
||||
[$ids[$at], $ids[$to]] = [$ids[$to], $ids[$at]];
|
||||
PersonGroup::saveOrder($familyId, $me, $ids);
|
||||
if ($scope === 'public') {
|
||||
PersonGroup::savePositions($familyId, PersonGroup::PUBLIC_OWNER, $ids);
|
||||
} else {
|
||||
PersonGroup::saveOrder($familyId, $me, $ids);
|
||||
}
|
||||
}
|
||||
redirect('settings/me');
|
||||
redirect($scope === 'public' ? 'settings' : 'settings/me');
|
||||
break;
|
||||
|
||||
// ==================== 关系分类的增删改名(全家共用) ====================
|
||||
// 分类写在共享的表里,所以是"能写"的人才能动;访客只读,给个明确的说法再回去,
|
||||
// ==================== 分组的增删改名 ====================
|
||||
// 两套:公共的(管理员在系统设置里维护,全家共用)与我自己的(个人设置里,
|
||||
// 只有自己看得见)。访客只读,给个明确的说法再回去,
|
||||
// 而不是甩一张 403 页面(他明明是点了自己设置里的按钮)。
|
||||
case 'settings/groups/add':
|
||||
Auth::requireLogin();
|
||||
csrf_check();
|
||||
if (!Auth::canWrite()) {
|
||||
flash('err', '你的身份是访客,不能改关系分类。');
|
||||
flash('err', '你的身份是访客,不能加分组。');
|
||||
redirect('settings/me');
|
||||
}
|
||||
$res = PersonGroup::add((int)Auth::familyId(), (string)($_POST['name'] ?? ''));
|
||||
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
|
||||
$owner = PersonGroup::PUBLIC_OWNER;
|
||||
if ($scope === 'public') {
|
||||
Auth::requireAdmin();
|
||||
} else {
|
||||
$owner = (int)Auth::personId();
|
||||
}
|
||||
$res = PersonGroup::add((int)Auth::familyId(), (string)($_POST['name'] ?? ''), $owner);
|
||||
flash($res['ok'] ? 'ok' : 'err', $res['ok']
|
||||
? '已加上分类「' . trim((string)($_POST['name'] ?? '')) . '」。'
|
||||
? '已加上分组「' . trim((string)($_POST['name'] ?? '')) . '」。'
|
||||
: (string)($res['error'] ?? '加不上。'));
|
||||
redirect('settings/me');
|
||||
redirect($scope === 'public' ? 'settings' : 'settings/me');
|
||||
break;
|
||||
|
||||
case 'settings/groups/rename':
|
||||
Auth::requireLogin();
|
||||
csrf_check();
|
||||
if (!Auth::canWrite()) {
|
||||
flash('err', '你的身份是访客,不能改关系分类。');
|
||||
flash('err', '你的身份是访客,不能改分组。');
|
||||
redirect('settings/me');
|
||||
}
|
||||
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
|
||||
$owner = PersonGroup::PUBLIC_OWNER;
|
||||
if ($scope === 'public') {
|
||||
Auth::requireAdmin();
|
||||
} else {
|
||||
$owner = (int)Auth::personId();
|
||||
}
|
||||
$res = PersonGroup::rename((int)Auth::familyId(), (int)($_POST['id'] ?? 0),
|
||||
(string)($_POST['name'] ?? ''));
|
||||
(string)($_POST['name'] ?? ''), $owner);
|
||||
flash($res['ok'] ? 'ok' : 'err', $res['ok']
|
||||
? '分类名已改。'
|
||||
? '分组名已改。'
|
||||
: (string)($res['error'] ?? '改不了。'));
|
||||
redirect('settings/me');
|
||||
redirect($scope === 'public' ? 'settings' : 'settings/me');
|
||||
break;
|
||||
|
||||
case 'settings/groups/delete':
|
||||
Auth::requireLogin();
|
||||
csrf_check();
|
||||
if (!Auth::canWrite()) {
|
||||
flash('err', '你的身份是访客,不能改关系分类。');
|
||||
flash('err', '你的身份是访客,不能删分组。');
|
||||
redirect('settings/me');
|
||||
}
|
||||
$res = PersonGroup::delete((int)Auth::familyId(), (int)($_POST['id'] ?? 0));
|
||||
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
|
||||
$owner = PersonGroup::PUBLIC_OWNER;
|
||||
if ($scope === 'public') {
|
||||
Auth::requireAdmin();
|
||||
} else {
|
||||
$owner = (int)Auth::personId();
|
||||
}
|
||||
$res = PersonGroup::delete((int)Auth::familyId(), (int)($_POST['id'] ?? 0), $owner);
|
||||
if ($res['ok']) {
|
||||
$n = (int)($res['moved'] ?? 0);
|
||||
flash('ok', $n > 0
|
||||
? '分类已删掉,那 ' . $n . ' 个人现在显示在「未分类」里,去名册重新归类即可。'
|
||||
: '分类已删掉(那一组本来也没人)。');
|
||||
? '分组已删掉,那 ' . $n . ' 个人现在显示在「未分类」里,去名册重新归类即可。'
|
||||
: '分组已删掉(那一组本来也没人)。');
|
||||
} else {
|
||||
flash('err', (string)($res['error'] ?? '删不了。'));
|
||||
}
|
||||
redirect('settings/me');
|
||||
redirect($scope === 'public' ? 'settings' : 'settings/me');
|
||||
break;
|
||||
|
||||
// ==================== 别人怎么称呼(只有我自己能定) ====================
|
||||
@@ -503,16 +650,18 @@ switch ($r) {
|
||||
Auth::requireWrite();
|
||||
clear_old();
|
||||
View::render('people/form', [
|
||||
'title' => '添加成员',
|
||||
'person' => null,
|
||||
'errors' => [],
|
||||
'groups' => PersonGroup::byId((int)Auth::familyId()),
|
||||
'title' => '添加成员',
|
||||
'person' => null,
|
||||
'errors' => [],
|
||||
'canEditAll' => true,
|
||||
'isSelf' => false,
|
||||
], 'people');
|
||||
break;
|
||||
|
||||
case 'people/create':
|
||||
Auth::requireWrite();
|
||||
csrf_check();
|
||||
$familyId = (int)Auth::familyId();
|
||||
$d = [
|
||||
'name' => trim((string)($_POST['name'] ?? '')),
|
||||
'gender' => (string)($_POST['gender'] ?? ''),
|
||||
@@ -522,19 +671,16 @@ switch ($r) {
|
||||
'address' => trim((string)($_POST['address'] ?? '')),
|
||||
'note' => trim((string)($_POST['note'] ?? '')),
|
||||
'slogan' => trim((string)($_POST['slogan'] ?? '')),
|
||||
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
|
||||
'avatar' => '',
|
||||
'kind' => 'member',
|
||||
// ⚠️ 这两项必须显式从表单取回:表单里有,但漏了这一步就会被静默丢掉,
|
||||
// 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。
|
||||
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
|
||||
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
|
||||
// 关系分组:下拉里的「未分类」是空 value,于是这里落成 null
|
||||
'group_id' => !empty($_POST['group_id']) ? (int)$_POST['group_id'] : null,
|
||||
] + read_birthday_input();
|
||||
$errors = Person::validate($d);
|
||||
|
||||
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? [],
|
||||
$up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
|
||||
(string)($_POST['avatar_crop'] ?? ''));
|
||||
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
||||
$errors['avatar'] = $up['error'];
|
||||
@@ -547,7 +693,8 @@ switch ($r) {
|
||||
'errors' => $errors,
|
||||
'phoneValue' => $d['phone'],
|
||||
'idCardValue'=> $d['id_card'],
|
||||
'groups' => PersonGroup::byId((int)Auth::familyId()),
|
||||
'canEditAll' => true,
|
||||
'isSelf' => false,
|
||||
], 'people');
|
||||
exit;
|
||||
}
|
||||
@@ -555,7 +702,7 @@ switch ($r) {
|
||||
$d['avatar'] = $up['file'];
|
||||
}
|
||||
try {
|
||||
$id = Person::create((int)Auth::familyId(), $d);
|
||||
$id = Person::create($familyId, $d);
|
||||
} catch (\PDOException $ex) {
|
||||
set_old($_POST);
|
||||
View::render('people/form', [
|
||||
@@ -564,59 +711,82 @@ switch ($r) {
|
||||
'errors' => ['phone' => '这个手机号已经在名册里了'],
|
||||
'phoneValue' => $d['phone'],
|
||||
'idCardValue'=> $d['id_card'],
|
||||
'groups' => PersonGroup::byId((int)Auth::familyId()),
|
||||
'canEditAll' => true,
|
||||
'isSelf' => false,
|
||||
], 'people');
|
||||
exit;
|
||||
}
|
||||
// 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。
|
||||
// 分组先留空 —— 用户可以在名册里拖到对应那一栏,也可以在那张卡片的「调整分组」里选。
|
||||
PersonRoster::ensure($familyId, (int)Auth::personId(), $id, null, 'self');
|
||||
clear_old();
|
||||
flash('ok', '已把「' . $d['name'] . '」加进名册。');
|
||||
redirect('people/show', ['id' => $id]);
|
||||
break;
|
||||
|
||||
/**
|
||||
* 成员资料表单 —— **只有这一张**(需求:「编辑成员」与「改自己的资料」本来就是一件事)。
|
||||
*
|
||||
* 两个入口都指到这里:名册卡片/详情页的「编辑资料」、以及个人设置顶部的
|
||||
* 「编辑我的资料」。区别只有一个:编辑**自己**时不要求能写(访客也能改自己的名字),
|
||||
* 而编辑别人必须是能写的人。
|
||||
*/
|
||||
case 'people/edit':
|
||||
Auth::requireWrite();
|
||||
$p = Person::find((int)($_GET['id'] ?? 0));
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$isSelf = $id > 0 && $id === (int)Auth::personId();
|
||||
if (!$isSelf) {
|
||||
Auth::requireWrite();
|
||||
}
|
||||
$p = Person::find($id);
|
||||
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
||||
flash('err', '找不到这个人。');
|
||||
redirect('people');
|
||||
}
|
||||
clear_old();
|
||||
View::render('people/form', [
|
||||
'title' => '编辑成员',
|
||||
'title' => $isSelf ? '我的资料' : '编辑成员',
|
||||
'person' => $p,
|
||||
'errors' => [],
|
||||
'canEditAll' => Auth::canWrite(),
|
||||
'isSelf' => $isSelf,
|
||||
'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']),
|
||||
'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']),
|
||||
'groups' => PersonGroup::byId((int)Auth::familyId()),
|
||||
], 'people');
|
||||
break;
|
||||
|
||||
case 'people/update':
|
||||
Auth::requireWrite();
|
||||
csrf_check();
|
||||
$familyId = (int)Auth::familyId();
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$p = Person::find($id);
|
||||
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
||||
if (!$p || (int)$p['family_id'] !== $familyId) {
|
||||
flash('err', '找不到这个人。');
|
||||
redirect('people');
|
||||
}
|
||||
$isSelf = $id === (int)Auth::personId();
|
||||
if (!$isSelf) {
|
||||
Auth::requireWrite();
|
||||
}
|
||||
// 改别人(= 能写的人编名册):字段是完整的一套。
|
||||
// 改自己(可能是访客):只允许动属于自己的那些 —— 与 Person::updateSelf 一一对应。
|
||||
$canEditAll = Auth::canWrite();
|
||||
$d = [
|
||||
'name' => trim((string)($_POST['name'] ?? '')),
|
||||
'gender' => (string)($_POST['gender'] ?? ''),
|
||||
'phone' => trim((string)($_POST['phone'] ?? '')),
|
||||
'id_card' => trim((string)($_POST['id_card'] ?? '')),
|
||||
'phone' => $canEditAll ? trim((string)($_POST['phone'] ?? '')) : Crypto::decrypt((string)$p['phone_encrypted']),
|
||||
'id_card' => $canEditAll ? trim((string)($_POST['id_card'] ?? '')) : Crypto::decrypt((string)$p['id_card_encrypted']),
|
||||
'email' => trim((string)($_POST['email'] ?? '')),
|
||||
'address' => trim((string)($_POST['address'] ?? '')),
|
||||
'note' => trim((string)($_POST['note'] ?? '')),
|
||||
'slogan' => trim((string)($_POST['slogan'] ?? '')),
|
||||
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
|
||||
'avatar' => (string)$p['avatar'],
|
||||
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
|
||||
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
|
||||
'group_id' => !empty($_POST['group_id']) ? (int)$_POST['group_id'] : null,
|
||||
'birth_rank' => $canEditAll ? trim((string)($_POST['birth_rank'] ?? '')) : ($p['birth_rank'] ?? ''),
|
||||
'is_placeholder' => $canEditAll
|
||||
? (!empty($_POST['is_placeholder']) ? 1 : 0)
|
||||
: (int)($p['is_placeholder'] ?? 0),
|
||||
] + read_birthday_input($p);
|
||||
$errors = Person::validate($d);
|
||||
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? [],
|
||||
$up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
|
||||
(string)($_POST['avatar_crop'] ?? ''));
|
||||
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
||||
$errors['avatar'] = $up['error'];
|
||||
@@ -624,35 +794,46 @@ switch ($r) {
|
||||
if ($errors) {
|
||||
set_old($_POST);
|
||||
View::render('people/form', [
|
||||
'title' => '编辑成员',
|
||||
'title' => $isSelf ? '我的资料' : '编辑成员',
|
||||
'person' => $p,
|
||||
'errors' => $errors,
|
||||
'canEditAll' => $canEditAll,
|
||||
'isSelf' => $isSelf,
|
||||
'phoneValue' => $d['phone'],
|
||||
'idCardValue' => $d['id_card'],
|
||||
'groups' => PersonGroup::byId((int)Auth::familyId()),
|
||||
], 'people');
|
||||
exit;
|
||||
}
|
||||
if ($up['ok']) {
|
||||
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
|
||||
Upload::remove($familyId, (string)$p['avatar']);
|
||||
$d['avatar'] = $up['file'];
|
||||
}
|
||||
try {
|
||||
Person::update($id, $d);
|
||||
// 改自己且没有编名册的权限 → 只能动属于自己的字段(Person::updateSelf)。
|
||||
// 这一条不是"防贼",是为了让访客也能改自己的名字,同时不会顺手把
|
||||
// 手机号(= 登录凭据)或身份证号改掉。
|
||||
if ($isSelf && !$canEditAll) {
|
||||
Person::updateSelf($id, $d);
|
||||
} else {
|
||||
Person::update($id, $d);
|
||||
}
|
||||
} catch (\PDOException $ex) {
|
||||
set_old($_POST);
|
||||
View::render('people/form', [
|
||||
'title' => '编辑成员',
|
||||
'title' => $isSelf ? '我的资料' : '编辑成员',
|
||||
'person' => $p,
|
||||
'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'],
|
||||
'canEditAll' => $canEditAll,
|
||||
'isSelf' => $isSelf,
|
||||
'phoneValue' => $d['phone'],
|
||||
'idCardValue' => $d['id_card'],
|
||||
'groups' => PersonGroup::byId((int)Auth::familyId()),
|
||||
], 'people');
|
||||
exit;
|
||||
}
|
||||
if ((int)$p['id'] === (int)Auth::personId()) {
|
||||
Auth::refreshAvatar((string)$d['avatar']);
|
||||
// 姓名也可能改了 —— 不同步会话的话顶栏还是旧名字
|
||||
$_SESSION['name'] = (string)$d['name'];
|
||||
}
|
||||
clear_old();
|
||||
flash('ok', '已保存。');
|
||||
@@ -692,6 +873,8 @@ switch ($r) {
|
||||
'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),
|
||||
// 名册(每人一份):他是不是在我的名册里、可以把他的名片分享给谁
|
||||
'inMyRoster' => in_array($id, PersonRoster::visibleIds($familyId, $me), true),
|
||||
], 'people');
|
||||
break;
|
||||
|
||||
@@ -756,8 +939,12 @@ switch ($r) {
|
||||
$p = Person::find($personId);
|
||||
$acc = Account::findByPerson($personId);
|
||||
if ($p && $acc) {
|
||||
if ($role === 'member' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
|
||||
flash('err', '至少要留一位管理员。');
|
||||
// ⚠️ 判的是 `$role !== 'admin'` 而不是 `$role === 'member'`:
|
||||
// 早先只拦"降成家庭成员",于是把**最后一位管理员**降成访客能一路通过 ——
|
||||
// 降完家里一个管理员都不剩,谁也进不了系统设置了。
|
||||
// 允许改自己的权限(包括给自己降级),只在"会一个管理员都不剩"时拦。
|
||||
if ($role !== 'admin' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
|
||||
flash('err', '至少要留一位管理员 —— 把最后一位管理员降级,全家就没人能管理系统了。');
|
||||
redirect('people/show', ['id' => $personId]);
|
||||
}
|
||||
Account::setRole((int)$acc['id'], $role);
|
||||
@@ -873,9 +1060,12 @@ switch ($r) {
|
||||
$pid = Person::create($familyId, [
|
||||
'name' => $name, 'gender' => '', 'avatar' => '', 'phone' => $phone,
|
||||
'id_card' => '', 'email' => '', 'address' => '', 'note' => $note,
|
||||
'slogan' => '', 'sort_order' => 2000, 'kind' => 'contact',
|
||||
'slogan' => '', 'kind' => 'contact',
|
||||
]);
|
||||
Account::create($pid, $pass, 'guest');
|
||||
// 我邀的访客也进**我的**名册(名册是每人一份的;访客自己那份由
|
||||
// Account::create 给他补上他自己)
|
||||
PersonRoster::ensure($familyId, (int)Auth::personId(), $pid, null, 'self');
|
||||
clear_old();
|
||||
flash('ok', '访客「' . $name . '」已开通,把手机号和密码告诉他就能登录。');
|
||||
redirect('accounts/invite');
|
||||
@@ -906,6 +1096,8 @@ switch ($r) {
|
||||
// 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET)
|
||||
$diag = $_SESSION['_oss_diag'] ?? null;
|
||||
unset($_SESSION['_oss_diag']);
|
||||
// 公共分组(owner_id = 0)—— 管理员在这里维护全家共用的那一套。
|
||||
// 人数按"全家名册"统计(这里要看的是"这一组里全家有多少人",不是我个人那份)。
|
||||
View::render('settings', [
|
||||
'title' => '设置',
|
||||
'family' => Family::find((int)Auth::familyId()),
|
||||
@@ -913,6 +1105,9 @@ switch ($r) {
|
||||
'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0,
|
||||
'roleCounts' => $acc,
|
||||
'relCount' => Relations::count((int)Auth::familyId()),
|
||||
'pubGroups' => PersonGroup::forOwner((int)Auth::familyId(), PersonGroup::PUBLIC_OWNER),
|
||||
'pubCounts' => PersonGroup::counts((int)Auth::familyId(), PersonGroup::PUBLIC_OWNER),
|
||||
'upgradeWarn'=> (string)Setting::get((int)Auth::familyId(), 'schema_upgrade_warning', ''),
|
||||
'storageDriver' => \App\Storage\Storage::driver((int)Auth::familyId()),
|
||||
'storageOss' => \App\Storage\Storage::config((int)Auth::familyId()),
|
||||
'storageOk' => \App\Storage\Storage::configComplete((int)Auth::familyId()),
|
||||
|
||||
Reference in New Issue
Block a user