名册每人一份 · 分组两层 · 拖动排序 · 表单合一 · 裁剪可缩放
按用户十点反馈逐条落地(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:
+24
-1
@@ -26,7 +26,30 @@ final class Account
|
|||||||
'INSERT INTO accounts (person_id, password_hash, role) VALUES (?, ?, ?)'
|
'INSERT INTO accounts (person_id, password_hash, role) VALUES (?, ?, ?)'
|
||||||
);
|
);
|
||||||
$st->execute([$personId, password_hash($password, PASSWORD_DEFAULT), $role]);
|
$st->execute([$personId, password_hash($password, PASSWORD_DEFAULT), $role]);
|
||||||
return (int)Database::get()->lastInsertId();
|
$id = (int)Database::get()->lastInsertId();
|
||||||
|
self::seedSelfRoster($personId);
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刚开通账号的人,自己的名册里先放上他自己。
|
||||||
|
*
|
||||||
|
* 名册是「每人一份」(v5 起),所以新账号的名册默认是空的 ——
|
||||||
|
* 那是对的(他再加/再复用别人),但连「我」都看不见就太奇怪了:
|
||||||
|
* 首页/家谱里都有他自己,名册里却没有,看着像自己不在家里。
|
||||||
|
* 所以这里只补他自己,别人一概不补。
|
||||||
|
*
|
||||||
|
* ⚠️ 故意**不** try/catch 吞异常:这是开账号链路的一部分,
|
||||||
|
* 出了问题要立刻看得见(静默失败正是这个项目踩过的坑)。
|
||||||
|
*/
|
||||||
|
private static function seedSelfRoster(int $personId): void
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare('SELECT family_id FROM persons WHERE id = ?');
|
||||||
|
$st->execute([$personId]);
|
||||||
|
$fid = (int)$st->fetchColumn();
|
||||||
|
if ($fid > 0) {
|
||||||
|
PersonRoster::ensure($fid, $personId, $personId, null, 'self');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function setPassword(int $accountId, string $password): void
|
public static function setPassword(int $accountId, string $password): void
|
||||||
|
|||||||
+1
-1
@@ -68,7 +68,7 @@ final class Auth
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 首页标题 —— 「以我为中心」最直接的一处落点。
|
* 首页标题 —— 「以我为中心」最直接的一处落点。
|
||||||
* 登录后看到的不是「家书」,而是「老杨的家书」。
|
* 登录后看到的不是「家书」,而是「<我的名字>的家书」。
|
||||||
*/
|
*/
|
||||||
public static function homeTitle(): string
|
public static function homeTitle(): string
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -149,7 +149,7 @@ final class Nickname
|
|||||||
'SELECT n.target_id, n.nickname, p.name, p.avatar, p.is_placeholder
|
'SELECT n.target_id, n.nickname, p.name, p.avatar, p.is_placeholder
|
||||||
FROM nicknames n JOIN persons p ON p.id = n.target_id
|
FROM nicknames n JOIN persons p ON p.id = n.target_id
|
||||||
WHERE n.viewer_id = ? AND n.family_id = ?
|
WHERE n.viewer_id = ? AND n.family_id = ?
|
||||||
ORDER BY p.sort_order ASC, p.id ASC'
|
ORDER BY p.id ASC'
|
||||||
);
|
);
|
||||||
$st->execute([$viewerId, $familyId]);
|
$st->execute([$viewerId, $familyId]);
|
||||||
return $st->fetchAll();
|
return $st->fetchAll();
|
||||||
|
|||||||
+48
-26
@@ -41,7 +41,44 @@ final class Person
|
|||||||
$args[] = $like;
|
$args[] = $like;
|
||||||
$args[] = $like;
|
$args[] = $like;
|
||||||
}
|
}
|
||||||
$sql .= ' ORDER BY p.sort_order ASC, p.id ASC';
|
$sql .= ' ORDER BY p.id ASC';
|
||||||
|
$st = Database::get()->prepare($sql);
|
||||||
|
$st->execute($args);
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **我的名册**:只列我自己的名册里的家庭成员(带分组与顺序)。
|
||||||
|
*
|
||||||
|
* 与 all() 的分工(v5 起两者不再是一回事):
|
||||||
|
* · all() —— 全家庭的成员,不管是谁的名册(首页徽标、邀请访客等内部用途);
|
||||||
|
* · roster() —— 名册页该显示的那些:我加的人 + 别人分享给我的,顺序是我的。
|
||||||
|
*
|
||||||
|
* 分组与顺序都从 person_roster 取(列名故意与老库的 persons.group_id /
|
||||||
|
* persons.sort_order 保持一致,调用方几乎不用改)。
|
||||||
|
*/
|
||||||
|
public static function roster(int $familyId, int $viewerId, string $kind = 'member', string $q = ''): array
|
||||||
|
{
|
||||||
|
$sql = 'SELECT p.*, r.group_id, r.sort_order, r.source AS roster_source,
|
||||||
|
a.id AS account_id, a.role AS account_role, a.status AS account_status,
|
||||||
|
a.last_login_at, n.nickname AS my_nickname
|
||||||
|
FROM person_roster r
|
||||||
|
JOIN persons p ON p.id = r.person_id
|
||||||
|
LEFT JOIN accounts a ON a.person_id = p.id
|
||||||
|
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?';
|
||||||
|
$args = [$viewerId];
|
||||||
|
$sql .= ' WHERE r.family_id = ? AND r.owner_id = ? AND p.kind = ?';
|
||||||
|
$args[] = $familyId;
|
||||||
|
$args[] = $viewerId;
|
||||||
|
$args[] = $kind;
|
||||||
|
if ($q !== '') {
|
||||||
|
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
|
||||||
|
$like = '%' . $q . '%';
|
||||||
|
$args[] = $like;
|
||||||
|
$args[] = $like;
|
||||||
|
$args[] = $like;
|
||||||
|
}
|
||||||
|
$sql .= ' ORDER BY r.sort_order ASC, p.id ASC';
|
||||||
$st = Database::get()->prepare($sql);
|
$st = Database::get()->prepare($sql);
|
||||||
$st->execute($args);
|
$st->execute($args);
|
||||||
return $st->fetchAll();
|
return $st->fetchAll();
|
||||||
@@ -91,8 +128,8 @@ final class Person
|
|||||||
(family_id, kind, name, gender, birth_rank, birthday,
|
(family_id, kind, name, gender, birth_rank, birthday,
|
||||||
birthday_id_card, birthday_lunar, birthday_lunar_leap, slogan, avatar,
|
birthday_id_card, birthday_lunar, birthday_lunar_leap, slogan, avatar,
|
||||||
phone_hash, phone_encrypted, id_card_encrypted, email, address, note,
|
phone_hash, phone_encrypted, id_card_encrypted, email, address, note,
|
||||||
is_placeholder, group_id, sort_order)
|
is_placeholder)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
|
||||||
$st = Database::get()->prepare($sql);
|
$st = Database::get()->prepare($sql);
|
||||||
$st->execute([
|
$st->execute([
|
||||||
$familyId,
|
$familyId,
|
||||||
@@ -113,8 +150,6 @@ final class Person
|
|||||||
($d['address'] ?? '') !== '' ? $d['address'] : null,
|
($d['address'] ?? '') !== '' ? $d['address'] : null,
|
||||||
($d['note'] ?? '') !== '' ? $d['note'] : null,
|
($d['note'] ?? '') !== '' ? $d['note'] : null,
|
||||||
!empty($d['is_placeholder']) ? 1 : 0,
|
!empty($d['is_placeholder']) ? 1 : 0,
|
||||||
!empty($d['group_id']) ? (int)$d['group_id'] : null,
|
|
||||||
(int)($d['sort_order'] ?? 1000),
|
|
||||||
]);
|
]);
|
||||||
return (int)Database::get()->lastInsertId();
|
return (int)Database::get()->lastInsertId();
|
||||||
}
|
}
|
||||||
@@ -127,7 +162,7 @@ final class Person
|
|||||||
name = ?, gender = ?, birth_rank = ?, birthday = ?,
|
name = ?, gender = ?, birth_rank = ?, birthday = ?,
|
||||||
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
|
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
|
||||||
phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?,
|
phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?,
|
||||||
email = ?, address = ?, note = ?, is_placeholder = ?, group_id = ?, sort_order = ?
|
email = ?, address = ?, note = ?, is_placeholder = ?
|
||||||
WHERE id = ?';
|
WHERE id = ?';
|
||||||
Database::get()->prepare($sql)->execute([
|
Database::get()->prepare($sql)->execute([
|
||||||
$d['name'],
|
$d['name'],
|
||||||
@@ -146,32 +181,19 @@ final class Person
|
|||||||
($d['address'] ?? '') !== '' ? $d['address'] : null,
|
($d['address'] ?? '') !== '' ? $d['address'] : null,
|
||||||
($d['note'] ?? '') !== '' ? $d['note'] : null,
|
($d['note'] ?? '') !== '' ? $d['note'] : null,
|
||||||
!empty($d['is_placeholder']) ? 1 : 0,
|
!empty($d['is_placeholder']) ? 1 : 0,
|
||||||
// ⚠️ 这里不能用 `!empty($d['group_id'])` 之外的花样:
|
|
||||||
// "清空分组"(改成未分类)也是一个合法意图,所以 0/'' 一律落成 NULL。
|
|
||||||
!empty($d['group_id']) ? (int)$d['group_id'] : null,
|
|
||||||
(int)($d['sort_order'] ?? 1000),
|
|
||||||
$id,
|
$id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 只改「这个人属于哪一组」(名册上的快捷归类用)。
|
* 自助改自己的资料。
|
||||||
*
|
*
|
||||||
* 单独一个方法,是为了让名册那一小块表单**不必**把整条记录回传 ——
|
* 与上面的 update() 分开是有意的:那个是"能写的人编名册",字段全、权限高;
|
||||||
* 否则一次归类提交就要带上姓名/生日/身份证号,任何一处没带全就会把数据改坏。
|
|
||||||
*/
|
|
||||||
public static function setGroup(int $id, ?int $groupId): void
|
|
||||||
{
|
|
||||||
Database::get()->prepare('UPDATE persons SET group_id = ? WHERE id = ?')
|
|
||||||
->execute([$groupId ?: null, $id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自助改自己的资料(个人信息页用)。
|
|
||||||
*
|
|
||||||
* 与上面的 update() 分开是有意的:那个是"管理员编名册",字段全、权限高;
|
|
||||||
* 这个只允许改**属于自己的**东西 —— 姓名、性别、三个生日、一句话、头像、联系方式。
|
* 这个只允许改**属于自己的**东西 —— 姓名、性别、三个生日、一句话、头像、联系方式。
|
||||||
* 排行、排序号、虚拟人物标记、以及身份证号(会牵动身份证生日)都只能由管理员改名册时动。
|
* 排行、虚拟人物标记、以及身份证号(会牵动身份证生日)都只能由管理员改名册时动。
|
||||||
|
*
|
||||||
|
* ⚠️ v5 起这里**不再有**分组:分组是「我和他的关系」,挂在 person_roster 上,
|
||||||
|
* 只能在名册里拖或者在那张卡片的「调整分组」里改(见 PersonRoster::assignGroup)。
|
||||||
*/
|
*/
|
||||||
public static function updateSelf(int $id, array $d): void
|
public static function updateSelf(int $id, array $d): void
|
||||||
{
|
{
|
||||||
@@ -230,7 +252,7 @@ final class Person
|
|||||||
FROM persons p
|
FROM persons p
|
||||||
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
|
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
|
||||||
WHERE p.family_id = ? AND p.kind = "member" AND p.id <> ?
|
WHERE p.family_id = ? AND p.kind = "member" AND p.id <> ?
|
||||||
ORDER BY p.sort_order ASC, p.id ASC'
|
ORDER BY p.id ASC'
|
||||||
);
|
);
|
||||||
$st->execute([$viewerId, $familyId, $exceptId]);
|
$st->execute([$viewerId, $familyId, $exceptId]);
|
||||||
return $st->fetchAll();
|
return $st->fetchAll();
|
||||||
|
|||||||
+159
-89
@@ -1,17 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 关系分组:一个人属于哪一组(家人 / 亲戚 / 同事 / 朋友 …),名册按它分组展示。
|
* 关系分组:一个人属于哪一组(家人 / 亲戚 / 同事 / 朋友 …),名册按它分栏展示。
|
||||||
*
|
*
|
||||||
* 三条刻意的设计:
|
* 四条刻意的设计(第一、二条是被纠正过之后定下来的):
|
||||||
* ① **分类是全家共用的,顺序是各人各定的。**
|
* ① **一套是公共的,一套是我自己的。** `owner_id = 0` 是管理员在「系统设置」
|
||||||
* 老杨的同事,对全家来说都是同事 —— 所以分类只存一份(persons.group_id)。
|
* 里维护的公共分组(全家都能用);`owner_id = 我` 是我在「个人设置」里加的,
|
||||||
* 但「哪一组排前面」纯属个人习惯,存在 user_settings.group_order 里,
|
* 只有我自己看得见。所以「某人的同事」如果全家都认,管理员建公共的;
|
||||||
* 每个人可以不一样(爸爸把「家人」排第一,妈妈把「亲戚」排第一)。
|
* 只有我觉得该有「球友」,我自己加一个,不影响别人。
|
||||||
* ② **分类可以由用户自己加。** 所以是表而不是 ENUM —— 预置的七个只是初始值,
|
* ② **顺序是各人各定的。** 「哪一组排前面」纯属个人习惯,存在
|
||||||
* 觉得不够就自己加,加错了可以改名或删掉。
|
* user_settings.group_order 里(每人一份),改它不会动到公共的默认顺序。
|
||||||
* ③ **不用外键约束 group_id。** 老库升级只能 ADD COLUMN,加不上外键,
|
* ③ **分类可以由用户自己加。** 所以是表而不是 ENUM —— 预置的七个只是初始值。
|
||||||
|
* ④ **不用外键约束 person_roster.group_id。** 老库升级只能 ADD COLUMN、加不上外键,
|
||||||
* 新装的又加了就会两边结构不一致;索性统一由程序保证 ——
|
* 新装的又加了就会两边结构不一致;索性统一由程序保证 ——
|
||||||
* 删除分组时把挂靠的人置为「未分类」(NULL),不留悬空 id。
|
* 删除分组时把挂靠的名册条目置为「未分类」(NULL),不留悬空 id。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -20,27 +21,46 @@ namespace App;
|
|||||||
|
|
||||||
final class PersonGroup
|
final class PersonGroup
|
||||||
{
|
{
|
||||||
|
/** 公共分组(管理员维护,全家共用)的 owner_id */
|
||||||
|
public const PUBLIC_OWNER = 0;
|
||||||
|
|
||||||
/** 预置分组的来源在 Schema::DEFAULT_GROUPS(安装与升级共用一份,避免两处漂移) */
|
/** 预置分组的来源在 Schema::DEFAULT_GROUPS(安装与升级共用一份,避免两处漂移) */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 给一个家庭补齐预置分组。幂等 —— 靠 uk_group_name 唯一键,重跑不会重复插入。
|
* 给一个家庭补齐**公共**分组。幂等 —— 靠 uk_group_owner_name 唯一键,重跑不会重复插入。
|
||||||
* 也不覆盖用户改过的名字(同名才算重复,改名后重跑会**补回**那个默认名,
|
* 也不覆盖用户改过的名字(同名才算重复,改名后重跑会**补回**那个默认名,
|
||||||
* 这正是想要的:删掉的默认分组下次升级会回来,用户自定义的则不受影响)。
|
* 这正是想要的:删掉的默认分组下次升级会回来,用户自定义的则不受影响)。
|
||||||
*/
|
*/
|
||||||
public static function seedDefaults(int $familyId): void
|
public static function seedDefaults(int $familyId, int $ownerId = self::PUBLIC_OWNER): void
|
||||||
{
|
{
|
||||||
if ($familyId <= 0) {
|
if ($familyId <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$st = Database::get()->prepare(
|
$st = Database::get()->prepare(
|
||||||
'INSERT IGNORE INTO person_groups (family_id, name, position, is_builtin) VALUES (?, ?, ?, 1)'
|
'INSERT IGNORE INTO person_groups (family_id, owner_id, name, position, is_builtin)
|
||||||
|
VALUES (?, ?, ?, ?, 1)'
|
||||||
);
|
);
|
||||||
foreach (Schema::DEFAULT_GROUPS as $name => $pos) {
|
foreach (Schema::DEFAULT_GROUPS as $name => $pos) {
|
||||||
$st->execute([$familyId, $name, $pos]);
|
$st->execute([$familyId, max(0, $ownerId), $name, $pos]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 全部分组,id => 行(按默认顺序) */
|
/** 某一套分组(公共传 0,个人传自己的 person_id),id => 行,按默认顺序 */
|
||||||
|
public static function forOwner(int $familyId, int $ownerId): array
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT * FROM person_groups WHERE family_id = ? AND owner_id = ?
|
||||||
|
ORDER BY position ASC, id ASC'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId, max(0, $ownerId)]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
$out[(int)$r['id']] = $r;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这个家庭里的全部分组(含所有人的私自分组)—— 只用来校验"这个 id 是本家庭的" */
|
||||||
public static function byId(int $familyId): array
|
public static function byId(int $familyId): array
|
||||||
{
|
{
|
||||||
$st = Database::get()->prepare(
|
$st = Database::get()->prepare(
|
||||||
@@ -54,6 +74,34 @@ final class PersonGroup
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **这个人在名册里能用哪些分组** = 公共的 + 他自己的私自分组。
|
||||||
|
*
|
||||||
|
* 访客 / 还没定过个人分组的人也至少能拿到公共的那一套,
|
||||||
|
* 所以"管理员建了分组但没人用得上"不会发生。
|
||||||
|
*/
|
||||||
|
public static function visible(int $familyId, int $viewerId): array
|
||||||
|
{
|
||||||
|
$viewerId = max(0, $viewerId);
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT * FROM person_groups
|
||||||
|
WHERE family_id = ? AND (owner_id = 0 OR owner_id = ?)
|
||||||
|
ORDER BY owner_id ASC, position ASC, id ASC'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId, $viewerId]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
$out[(int)$r['id']] = $r;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** visible() 的 id 集合版本,用来做「这个分组 id 合不合法」的判断 */
|
||||||
|
public static function visibleIds(int $familyId, int $viewerId): array
|
||||||
|
{
|
||||||
|
return array_fill_keys(array_keys(self::visible($familyId, $viewerId)), true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按**当前用户自定义的顺序**返回分组(用户没自定义过就按默认位置)。
|
* 按**当前用户自定义的顺序**返回分组(用户没自定义过就按默认位置)。
|
||||||
*
|
*
|
||||||
@@ -62,7 +110,7 @@ final class PersonGroup
|
|||||||
*/
|
*/
|
||||||
public static function orderFor(int $familyId, int $viewerId = 0): array
|
public static function orderFor(int $familyId, int $viewerId = 0): array
|
||||||
{
|
{
|
||||||
$rest = self::byId($familyId);
|
$rest = self::visible($familyId, $viewerId);
|
||||||
if (!$rest) {
|
if (!$rest) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -90,7 +138,7 @@ final class PersonGroup
|
|||||||
/** 保存「我」的分组顺序(只影响我自己) */
|
/** 保存「我」的分组顺序(只影响我自己) */
|
||||||
public static function saveOrder(int $familyId, int $viewerId, array $ids): void
|
public static function saveOrder(int $familyId, int $viewerId, array $ids): void
|
||||||
{
|
{
|
||||||
$valid = self::byId($familyId);
|
$valid = self::visible($familyId, $viewerId);
|
||||||
$clean = [];
|
$clean = [];
|
||||||
foreach ($ids as $id) {
|
foreach ($ids as $id) {
|
||||||
$id = (int)$id;
|
$id = (int)$id;
|
||||||
@@ -107,6 +155,20 @@ final class PersonGroup
|
|||||||
UserSetting::set($viewerId, 'group_order', json_encode($clean, JSON_UNESCAPED_UNICODE));
|
UserSetting::set($viewerId, 'group_order', json_encode($clean, JSON_UNESCAPED_UNICODE));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按给定的 id 顺序重写某一套分组的 position(公共分组的「全家默认顺序」靠它) */
|
||||||
|
public static function savePositions(int $familyId, int $ownerId, array $ids): void
|
||||||
|
{
|
||||||
|
$db = Database::get();
|
||||||
|
$up = $db->prepare(
|
||||||
|
'UPDATE person_groups SET position = ? WHERE family_id = ? AND owner_id = ? AND id = ?'
|
||||||
|
);
|
||||||
|
$pos = 0;
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$pos += 10;
|
||||||
|
$up->execute([$pos, $familyId, max(0, $ownerId), (int)$id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 分组名(找不到就返回空串,调用方自己决定回退文案) */
|
/** 分组名(找不到就返回空串,调用方自己决定回退文案) */
|
||||||
public static function name(int $familyId, ?int $id): string
|
public static function name(int $familyId, ?int $id): string
|
||||||
{
|
{
|
||||||
@@ -120,81 +182,107 @@ final class PersonGroup
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 新增一个分组。
|
* 新增一个分组。
|
||||||
|
* @param int $ownerId 0=公共(只有管理员能建,调用方把关);否则=建给自己的
|
||||||
* @return array{ok:bool,id?:int,error?:string}
|
* @return array{ok:bool,id?:int,error?:string}
|
||||||
*/
|
*/
|
||||||
public static function add(int $familyId, string $name): array
|
public static function add(int $familyId, string $name, int $ownerId = self::PUBLIC_OWNER): array
|
||||||
{
|
{
|
||||||
|
$ownerId = max(0, $ownerId);
|
||||||
$name = trim($name);
|
$name = trim($name);
|
||||||
$bad = self::validateName($name);
|
$bad = self::validateName($name);
|
||||||
if ($bad !== '') {
|
if ($bad !== '') {
|
||||||
return ['ok' => false, 'error' => $bad];
|
return ['ok' => false, 'error' => $bad];
|
||||||
}
|
}
|
||||||
$st = Database::get()->prepare(
|
$db = Database::get();
|
||||||
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND name = ?'
|
$st = $db->prepare(
|
||||||
|
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ?'
|
||||||
);
|
);
|
||||||
$st->execute([$familyId, $name]);
|
$st->execute([$familyId, $ownerId, $name]);
|
||||||
if ((int)$st->fetchColumn() > 0) {
|
if ((int)$st->fetchColumn() > 0) {
|
||||||
return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。'];
|
return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。'];
|
||||||
}
|
}
|
||||||
// ⚠️ 别写成 `$max = (int)$db->prepare(...)->execute([...])`:
|
// ⚠️ 别写成 `$max = (int)$db->prepare(...)->execute([...])`:
|
||||||
// PDOStatement::execute() 返回 bool,强转永远是 1 —— 位置会算错。
|
// PDOStatement::execute() 返回 bool,强转永远是 1 —— 位置会算错。
|
||||||
$st2 = Database::get()->prepare('SELECT COALESCE(MAX(position), 0) FROM person_groups WHERE family_id = ?');
|
$st2 = $db->prepare(
|
||||||
$st2->execute([$familyId]);
|
'SELECT COALESCE(MAX(position), 0) FROM person_groups WHERE family_id = ? AND owner_id = ?'
|
||||||
|
);
|
||||||
|
$st2->execute([$familyId, $ownerId]);
|
||||||
$max = (int)$st2->fetchColumn();
|
$max = (int)$st2->fetchColumn();
|
||||||
Database::get()->prepare(
|
$db->prepare(
|
||||||
'INSERT INTO person_groups (family_id, name, position, is_builtin) VALUES (?, ?, ?, 0)'
|
'INSERT INTO person_groups (family_id, owner_id, name, position, is_builtin)
|
||||||
)->execute([$familyId, $name, max($max + 10, 80)]);
|
VALUES (?, ?, ?, ?, 0)'
|
||||||
return ['ok' => true, 'id' => (int)Database::get()->lastInsertId()];
|
)->execute([$familyId, $ownerId, $name, max($max + 10, 80)]);
|
||||||
|
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{ok:bool,error?:string} */
|
/**
|
||||||
public static function rename(int $familyId, int $id, string $name): array
|
* 改名。$ownerId 用来确认"你改的是你自己那一套里的分组"——
|
||||||
|
* 公共分组只有管理员能改(调用方把关),别人的私自分组连管理员也不该动。
|
||||||
|
* @return array{ok:bool,error?:string}
|
||||||
|
*/
|
||||||
|
public static function rename(int $familyId, int $id, string $name, int $ownerId): array
|
||||||
{
|
{
|
||||||
|
$ownerId = max(0, $ownerId);
|
||||||
$name = trim($name);
|
$name = trim($name);
|
||||||
$bad = self::validateName($name);
|
$bad = self::validateName($name);
|
||||||
if ($bad !== '') {
|
if ($bad !== '') {
|
||||||
return ['ok' => false, 'error' => $bad];
|
return ['ok' => false, 'error' => $bad];
|
||||||
}
|
}
|
||||||
$st = Database::get()->prepare(
|
$db = Database::get();
|
||||||
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND name = ? AND id <> ?'
|
$own = $db->prepare(
|
||||||
|
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND id = ?'
|
||||||
);
|
);
|
||||||
$st->execute([$familyId, $name, $id]);
|
$own->execute([$familyId, $ownerId, $id]);
|
||||||
|
if ((int)$own->fetchColumn() === 0) {
|
||||||
|
return ['ok' => false, 'error' => '找不到这个分组(可能不是你那一套里的)。'];
|
||||||
|
}
|
||||||
|
$st = $db->prepare(
|
||||||
|
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ? AND id <> ?'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId, $ownerId, $name, $id]);
|
||||||
if ((int)$st->fetchColumn() > 0) {
|
if ((int)$st->fetchColumn() > 0) {
|
||||||
return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。'];
|
return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。'];
|
||||||
}
|
}
|
||||||
$st = Database::get()->prepare('UPDATE person_groups SET name = ? WHERE family_id = ? AND id = ?');
|
$up = $db->prepare(
|
||||||
$st->execute([$name, $familyId, $id]);
|
'UPDATE person_groups SET name = ? WHERE family_id = ? AND owner_id = ? AND id = ?'
|
||||||
if ($st->rowCount() === 0) {
|
);
|
||||||
|
$up->execute([$name, $familyId, $ownerId, $id]);
|
||||||
|
if ($up->rowCount() === 0) {
|
||||||
return ['ok' => false, 'error' => '找不到这个分组。'];
|
return ['ok' => false, 'error' => '找不到这个分组。'];
|
||||||
}
|
}
|
||||||
return ['ok' => true];
|
return ['ok' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除一个分组。挂在这个分组下的人**不会消失**,只是变成「未分类」——
|
* 删除一个分组。名册里挂在这个分组下的人**不会消失**,只是变成「未分类」——
|
||||||
* 名册上会多出一个「未分类」组提醒去归类。
|
* 名册上会多出一个「未分类」栏提醒去归类。
|
||||||
* 最后一个分组不给删:全删光就没有任何分类可用了,那是死路。
|
*
|
||||||
|
* 最后一个**公共**分组不给删:全删光就没有任何分类可用了,那是死路。
|
||||||
|
* 私自分组可以删到零(公共那套还在,不会没得选)。
|
||||||
*
|
*
|
||||||
* @return array{ok:bool,error?:string,moved?:int}
|
* @return array{ok:bool,error?:string,moved?:int}
|
||||||
*/
|
*/
|
||||||
public static function delete(int $familyId, int $id): array
|
public static function delete(int $familyId, int $id, int $ownerId): array
|
||||||
{
|
{
|
||||||
$all = self::byId($familyId);
|
$ownerId = max(0, $ownerId);
|
||||||
|
$all = self::forOwner($familyId, $ownerId);
|
||||||
if (!isset($all[$id])) {
|
if (!isset($all[$id])) {
|
||||||
return ['ok' => false, 'error' => '找不到这个分组。'];
|
return ['ok' => false, 'error' => '找不到这个分组(可能不是你那一套里的)。'];
|
||||||
}
|
}
|
||||||
if (count($all) <= 1) {
|
if ($ownerId === self::PUBLIC_OWNER && count($all) <= 1) {
|
||||||
return ['ok' => false, 'error' => '至少要留一个分组 —— 全删光就没法给成员归类了。'];
|
return ['ok' => false, 'error' => '至少要留一个公共分组 —— 全删光就没法给成员归类了。'];
|
||||||
}
|
}
|
||||||
$db = Database::get();
|
$db = Database::get();
|
||||||
$st = $db->prepare('SELECT COUNT(*) FROM persons WHERE family_id = ? AND group_id = ?');
|
$st = $db->prepare('SELECT COUNT(*) FROM person_roster WHERE group_id = ?');
|
||||||
$st->execute([$familyId, $id]);
|
$st->execute([$id]);
|
||||||
$moved = (int)$st->fetchColumn();
|
$moved = (int)$st->fetchColumn();
|
||||||
|
|
||||||
$db->prepare('UPDATE persons SET group_id = NULL WHERE family_id = ? AND group_id = ?')
|
// ⚠️ 释放范围是**所有**名册条目(不限 owner):公共分组人人都能用,
|
||||||
->execute([$familyId, $id]);
|
// 只清自己那几条会让别人名册里留下指向已删分组的 id(那一栏就永远空着、
|
||||||
$db->prepare('DELETE FROM person_groups WHERE family_id = ? AND id = ?')
|
// 而对应的卡片在同一次渲染里彻底消失)。
|
||||||
->execute([$familyId, $id]);
|
$db->prepare('UPDATE person_roster SET group_id = NULL WHERE group_id = ?')->execute([$id]);
|
||||||
|
$db->prepare('DELETE FROM person_groups WHERE family_id = ? AND owner_id = ? AND id = ?')
|
||||||
|
->execute([$familyId, $ownerId, $id]);
|
||||||
|
|
||||||
// 各人自定义顺序里可能留着这个 id,清掉免得越积越脏
|
// 各人自定义顺序里可能留着这个 id,清掉免得越积越脏
|
||||||
self::pruneOrder($familyId, $id);
|
self::pruneOrder($familyId, $id);
|
||||||
@@ -229,56 +317,38 @@ final class PersonGroup
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$keep = array_values(array_filter($ids, fn($x) => (int)$x !== $removedId));
|
$keep = array_values(array_filter($ids, fn($x) => (int)$x !== $removedId));
|
||||||
$db->prepare('UPDATE user_settings SET v = ? WHERE person_id = ? AND k = ?')
|
$prep = $db->prepare('UPDATE user_settings SET v = ? WHERE person_id = ? AND k = ?');
|
||||||
->execute([json_encode(array_map('intval', $keep)), (int)$row['person_id'], 'group_order']);
|
$prep->execute([json_encode(array_map('intval', $keep)), (int)$row['person_id'], 'group_order']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 按名字找分组 id(找不到返回 0)。默认在**公共**分组里找。 */
|
||||||
* 老库升级:把还没有分组的人按名册类型补一个合理默认。
|
public static function idByName(int $familyId, string $name, int $ownerId = self::PUBLIC_OWNER): int
|
||||||
* 家庭成员 → 家人;外部联系人 → 认识。
|
|
||||||
* 只补 NULL,用户自己改过的一律不动(幂等)。
|
|
||||||
*/
|
|
||||||
public static function backfillMissing(\PDO $db): void
|
|
||||||
{
|
{
|
||||||
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
|
$st = Database::get()->prepare(
|
||||||
$fid = (int)$fam['id'];
|
'SELECT id FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ?'
|
||||||
// 家庭可能还没套用预置分组(ensure 的 seed 在表建好之后才跑)
|
);
|
||||||
$st = $db->prepare('SELECT COUNT(*) FROM person_groups WHERE family_id = ?');
|
$st->execute([$familyId, max(0, $ownerId), $name]);
|
||||||
$st->execute([$fid]);
|
|
||||||
if ((int)$st->fetchColumn() === 0) {
|
|
||||||
self::seedDefaults($fid);
|
|
||||||
}
|
|
||||||
foreach ([['member', '家人'], ['contact', '认识']] as [$kind, $name]) {
|
|
||||||
$gid = self::idByName($fid, $name);
|
|
||||||
if ($gid === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$db->prepare(
|
|
||||||
'UPDATE persons SET group_id = ?
|
|
||||||
WHERE family_id = ? AND kind = ? AND group_id IS NULL'
|
|
||||||
)->execute([$gid, $fid, $kind]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按名字找分组 id(找不到返回 0) */
|
|
||||||
public static function idByName(int $familyId, string $name): int
|
|
||||||
{
|
|
||||||
$st = Database::get()->prepare('SELECT id FROM person_groups WHERE family_id = ? AND name = ?');
|
|
||||||
$st->execute([$familyId, $name]);
|
|
||||||
return (int)($st->fetchColumn() ?: 0);
|
return (int)($st->fetchColumn() ?: 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 每个分组里有多少人(没分组的人记在键 0 上) */
|
/**
|
||||||
public static function counts(int $familyId, string $kind = 'member'): array
|
* 某个分组下有多少人 —— **从我自己的名册里数**。
|
||||||
|
*
|
||||||
|
* 这是 v5 的关键语义变化:老版本数的是"全家名册里 group_id = 它的有几个人",
|
||||||
|
* 那种数字对"我自己该怎么归类"没有指导意义(别人把人归到哪一组关我什么事)。
|
||||||
|
* 现在数的是"我名册里归到这一组的有几个",用来给个人设置里的分组列表做提示。
|
||||||
|
*/
|
||||||
|
public static function counts(int $familyId, int $ownerId, string $kind = 'member'): array
|
||||||
{
|
{
|
||||||
$st = Database::get()->prepare(
|
$st = Database::get()->prepare(
|
||||||
'SELECT COALESCE(group_id, 0) AS g, COUNT(*) AS n
|
'SELECT COALESCE(r.group_id, 0) AS g, COUNT(*) AS n
|
||||||
FROM persons WHERE family_id = ? AND kind = ?
|
FROM person_roster r
|
||||||
GROUP BY COALESCE(group_id, 0)'
|
JOIN persons p ON p.id = r.person_id
|
||||||
|
WHERE r.family_id = ? AND r.owner_id = ? AND p.kind = ?
|
||||||
|
GROUP BY COALESCE(r.group_id, 0)'
|
||||||
);
|
);
|
||||||
$st->execute([$familyId, $kind]);
|
$st->execute([$familyId, $ownerId, $kind]);
|
||||||
$out = [];
|
$out = [];
|
||||||
foreach ($st->fetchAll() as $r) {
|
foreach ($st->fetchAll() as $r) {
|
||||||
$out[(int)$r['g']] = (int)$r['n'];
|
$out[(int)$r['g']] = (int)$r['n'];
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 每人一份名册。
|
||||||
|
*
|
||||||
|
* 老模型是「一份全家共用的名册」:`persons.group_id` 决定归类、`persons.sort_order`
|
||||||
|
* 决定顺序 —— 于是妈妈想把「亲戚」排前面,就得把全家人的视图一起改掉。
|
||||||
|
*
|
||||||
|
* 新模型把「**我和他的关系**」单独拿出来存:
|
||||||
|
* · `person_roster(owner_id, person_id, group_id, sort_order, source)`
|
||||||
|
* · owner 是我的名册,person 是躺在我名册里的人,group 是我把他归到哪一组,
|
||||||
|
* sort 是在**我的名册里**的先后。
|
||||||
|
* 同一个「张伟」,在我的名册里是「同事」、在妈妈的名册里是「亲戚」,互不打扰。
|
||||||
|
*
|
||||||
|
* 谁看得到谁(这是"每人一份"的核心):
|
||||||
|
* · 我自己加的人(source=self)
|
||||||
|
* · 别人分享给我的名片(source=shared)
|
||||||
|
* · 升级时按当时"人人在自己名册里都能看到全家"的现状补的(source=seeded)
|
||||||
|
* 除此之外一概看不见。所以「添加成员」时若这个人系统里已经有了,
|
||||||
|
* 要引导用户走「从家里已有的人里挑」(见 pickable()),而不是又录一遍。
|
||||||
|
*
|
||||||
|
* ⚠️ 与 `persons.kind` 的关系:roster 只管"看不看得见",
|
||||||
|
* 家谱(Kinship)仍然按全家庭的 persons + relations 推导 ——
|
||||||
|
* 名册是通讯录,家谱是血缘图,两者故意的不是一回事。
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class PersonRoster
|
||||||
|
{
|
||||||
|
/** 名册里一条记录的来源 */
|
||||||
|
public const SOURCES = ['self', 'shared', 'seeded'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把某人加进某人的名册(幂等:已经在里面就什么都不做)。
|
||||||
|
*
|
||||||
|
* @param string $source self=我加的 / shared=别人分享的 / seeded=升级补的
|
||||||
|
* @return bool 真的插入了才返回 true
|
||||||
|
*/
|
||||||
|
public static function ensure(
|
||||||
|
int $familyId,
|
||||||
|
int $ownerId,
|
||||||
|
int $personId,
|
||||||
|
?int $groupId = null,
|
||||||
|
string $source = 'self'
|
||||||
|
): bool {
|
||||||
|
if ($familyId <= 0 || $ownerId <= 0 || $personId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!in_array($source, self::SOURCES, true)) {
|
||||||
|
$source = 'self';
|
||||||
|
}
|
||||||
|
$db = Database::get();
|
||||||
|
$st = $db->prepare('SELECT COUNT(*) FROM person_roster WHERE owner_id = ? AND person_id = ?');
|
||||||
|
$st->execute([$ownerId, $personId]);
|
||||||
|
if ((int)$st->fetchColumn() > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 分组必须是**这个人自己能用**的(公共的 + 他自己的)——
|
||||||
|
// 传进来一个别人家的私自分组就落成未分类,绝不静默挂错
|
||||||
|
// (挂上去的后果是:那一栏永远显示不出来他,而他在别的栏里也不见了)。
|
||||||
|
if ($groupId !== null && $groupId > 0
|
||||||
|
&& !isset(PersonGroup::visibleIds($familyId, $ownerId)[$groupId])) {
|
||||||
|
$groupId = null;
|
||||||
|
}
|
||||||
|
// ⚠️ 新条目排在末尾:不是取 COUNT(*),而是取当前最大 sort_order + 10 ——
|
||||||
|
// 中间被拖走/删掉的人会在序号里留下空档,用 COUNT 会插到别人前面去。
|
||||||
|
$st2 = $db->prepare('SELECT COALESCE(MAX(sort_order), 0) FROM person_roster WHERE owner_id = ?');
|
||||||
|
$st2->execute([$ownerId]);
|
||||||
|
$next = (int)$st2->fetchColumn() + 10;
|
||||||
|
|
||||||
|
$db->prepare(
|
||||||
|
'INSERT INTO person_roster (family_id, owner_id, person_id, group_id, sort_order, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
)->execute([$familyId, $ownerId, $personId, $groupId ?: null, $next, $source]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的名册:person_id => ['group_id' => ?int, 'sort_order' => int, 'source' => string]
|
||||||
|
*/
|
||||||
|
public static function mine(int $familyId, int $ownerId): array
|
||||||
|
{
|
||||||
|
if ($ownerId <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT person_id, group_id, sort_order, source FROM person_roster
|
||||||
|
WHERE family_id = ? AND owner_id = ?
|
||||||
|
ORDER BY sort_order ASC, person_id ASC'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId, $ownerId]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
$out[(int)$r['person_id']] = [
|
||||||
|
'group_id' => $r['group_id'] === null ? null : (int)$r['group_id'],
|
||||||
|
'sort_order' => (int)$r['sort_order'],
|
||||||
|
'source' => (string)$r['source'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只取 id 列表(判断"看不看得见"时用) */
|
||||||
|
public static function visibleIds(int $familyId, int $ownerId): array
|
||||||
|
{
|
||||||
|
return array_keys(self::mine($familyId, $ownerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function countFor(int $familyId, int $ownerId): int
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT COUNT(*) FROM person_roster WHERE family_id = ? AND owner_id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId, $ownerId]);
|
||||||
|
return (int)$st->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把「我名册里的某个人」改到一个分组(名册卡片上的快捷归类 / 拖动改栏都用它)。
|
||||||
|
*
|
||||||
|
* $groupId 必须是**我能用的**分组(公共的 + 我自己的),否则会被拒绝 ——
|
||||||
|
* 不然可以把人挂到别人家的分组上,那一栏就永远显示不出来他。
|
||||||
|
*/
|
||||||
|
public static function assignGroup(int $familyId, int $ownerId, int $personId, ?int $groupId): bool
|
||||||
|
{
|
||||||
|
if ($groupId !== null && $groupId > 0
|
||||||
|
&& !isset(PersonGroup::visibleIds($familyId, $ownerId)[$groupId])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'UPDATE person_roster SET group_id = ?
|
||||||
|
WHERE family_id = ? AND owner_id = ? AND person_id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$groupId ?: null, $familyId, $ownerId, $personId]);
|
||||||
|
return $st->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次性把「每一栏有哪些人、什么顺序」落库 —— 拖动卡片排序/换栏之后调它。
|
||||||
|
*
|
||||||
|
* 为什么整版提交而不是"移动一条算一次":
|
||||||
|
* 拖拽的结果是**一组相对顺序**,逐条提交会在中途留下一个自相矛盾的中间态
|
||||||
|
* (比如两个人暂时同号),刷新一下就变样。整版提交 = 一次事务、一个确定结果。
|
||||||
|
*
|
||||||
|
* @param array $columns [['g' => 分组id(0=未分类), 'ids' => [personId, ...]], ...]
|
||||||
|
* @return int 实际写入的条数
|
||||||
|
*/
|
||||||
|
public static function applyLayout(int $familyId, int $ownerId, array $columns): int
|
||||||
|
{
|
||||||
|
$mine = self::mine($familyId, $ownerId);
|
||||||
|
if (!$mine) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$okGroups = PersonGroup::visibleIds($familyId, $ownerId);
|
||||||
|
$db = Database::get();
|
||||||
|
$up = $db->prepare(
|
||||||
|
'UPDATE person_roster SET group_id = ?, sort_order = ?
|
||||||
|
WHERE family_id = ? AND owner_id = ? AND person_id = ?'
|
||||||
|
);
|
||||||
|
$written = 0;
|
||||||
|
$sort = 0;
|
||||||
|
foreach ($columns as $col) {
|
||||||
|
if (!is_array($col)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$gid = (int)($col['g'] ?? 0);
|
||||||
|
// 分组不合法(别人家的私自分组 / 已删掉的分组)→ 落成未分类,绝不静默挂错
|
||||||
|
if ($gid !== 0 && !isset($okGroups[$gid])) {
|
||||||
|
$gid = 0;
|
||||||
|
}
|
||||||
|
foreach ((array)($col['ids'] ?? []) as $pid) {
|
||||||
|
$pid = (int)$pid;
|
||||||
|
if ($pid <= 0 || !isset($mine[$pid])) {
|
||||||
|
continue; // 不在我名册里的人,忽略
|
||||||
|
}
|
||||||
|
$sort += 10;
|
||||||
|
$up->execute([$gid ?: null, $sort, $familyId, $ownerId, $pid]);
|
||||||
|
$written++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $written;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把一条(或几条)名片分享到别人的名册里。返回真的新增了几条。 */
|
||||||
|
public static function share(int $familyId, int $toOwnerId, array $personIds, string $source = 'shared'): int
|
||||||
|
{
|
||||||
|
$n = 0;
|
||||||
|
foreach ($personIds as $pid) {
|
||||||
|
if (self::ensure($familyId, $toOwnerId, (int)$pid, null, $source)) {
|
||||||
|
$n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从我的名册里移掉一个人(只是我不再看到他,人还在名册里、还在家谱上) */
|
||||||
|
public static function remove(int $familyId, int $ownerId, int $personId): bool
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'DELETE FROM person_roster WHERE family_id = ? AND owner_id = ? AND person_id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId, $ownerId, $personId]);
|
||||||
|
return $st->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统里已经有的人 —— 但不在我的名册里。
|
||||||
|
*
|
||||||
|
* 「添加成员」的第二个入口就靠它:同一个人不该被录两遍
|
||||||
|
* (录两遍会变成两条 persons,账号、亲属关系、生日全都各存一份)。
|
||||||
|
*
|
||||||
|
* @return array 每行含 id/name/gender/avatar + 已有账号信息(好让人认得出是谁)
|
||||||
|
*/
|
||||||
|
public static function pickable(int $familyId, int $ownerId, string $kind = 'member'): array
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT p.id, p.name, p.gender, p.avatar, p.is_placeholder,
|
||||||
|
a.role AS account_role, a.status AS account_status,
|
||||||
|
n.nickname AS my_nickname
|
||||||
|
FROM persons p
|
||||||
|
LEFT JOIN accounts a ON a.person_id = p.id
|
||||||
|
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
|
||||||
|
WHERE p.family_id = ? AND p.kind = ?
|
||||||
|
AND p.id NOT IN (SELECT person_id FROM person_roster WHERE owner_id = ? AND family_id = ?)
|
||||||
|
ORDER BY p.id ASC'
|
||||||
|
);
|
||||||
|
$st->execute([$ownerId, $familyId, $kind, $ownerId, $familyId]);
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结构升级专用:把老库「人人在自己名册里都能看到全家」的现状原样搬过来。
|
||||||
|
*
|
||||||
|
* ⚠️ 这一步必须在**删 persons.group_id 之前**跑完,而且**不能容错**:
|
||||||
|
* 它是删列的唯一前提,半途失败还继续删 = 直接丢数据(见 Schema::ensure)。
|
||||||
|
*
|
||||||
|
* 谁的名册要补:**有账号的人**(没账号的人永远登不进来,给他补名册只是一堆死数据)。
|
||||||
|
* 补谁进去:家庭全部成员(kind='member')。这正是老代码 `Person::all(...)` 的范围,
|
||||||
|
* 所以升级前后每个人看到的名册**一模一样**,不会有人觉得"人丢了"。
|
||||||
|
* 老库里的 group_id / sort_order 也一并搬——分类语义从"人的属性"变成"我和他的关系",
|
||||||
|
* 但**落在每个人名册里的值不变**,所以看起来完全一样。
|
||||||
|
*
|
||||||
|
* 幂等:靠 uk_roster_owner_person 唯一键 + INSERT IGNORE,重复跑不会插重。
|
||||||
|
*/
|
||||||
|
public static function seedFromPersons(\PDO $db): void
|
||||||
|
{
|
||||||
|
$hasGroup = self::hasColumn($db, 'persons', 'group_id');
|
||||||
|
$hasSort = self::hasColumn($db, 'persons', 'sort_order');
|
||||||
|
$oldGroup = $hasGroup ? 'p.group_id' : 'NULL';
|
||||||
|
$oldSort = $hasSort ? 'p.sort_order' : 'p.id';
|
||||||
|
|
||||||
|
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
|
||||||
|
$fid = (int)$fam['id'];
|
||||||
|
$members = $db->prepare(
|
||||||
|
"SELECT p.id, {$oldGroup} AS g, {$oldSort} AS s
|
||||||
|
FROM persons p
|
||||||
|
WHERE p.family_id = ? AND p.kind = 'member'
|
||||||
|
ORDER BY p.id ASC"
|
||||||
|
);
|
||||||
|
$members->execute([$fid]);
|
||||||
|
$rows = $members->fetchAll();
|
||||||
|
if (!$rows) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 有账号的人 = 可能登进来的人
|
||||||
|
$owners = $db->prepare(
|
||||||
|
'SELECT a.person_id FROM accounts a
|
||||||
|
JOIN persons p ON p.id = a.person_id
|
||||||
|
WHERE p.family_id = ?'
|
||||||
|
);
|
||||||
|
$owners->execute([$fid]);
|
||||||
|
// ⚠️ 必须在这里一次取完:PDOStatement::fetchAll() 取的是"剩余行",
|
||||||
|
// 放进下面的循环里调第二次就会是空数组 —— 于是第一个人之后
|
||||||
|
// 谁的名册都补不上,而且不报任何错(线上表现是"升级完只有我自己的名册有人")。
|
||||||
|
$ownerIds = array_map(static fn($o) => (int)$o['person_id'], $owners->fetchAll());
|
||||||
|
|
||||||
|
$ins = $db->prepare(
|
||||||
|
'INSERT IGNORE INTO person_roster
|
||||||
|
(family_id, owner_id, person_id, group_id, sort_order, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$seq = 0;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$seq += 10;
|
||||||
|
$pid = (int)$r['id'];
|
||||||
|
$gid = ((int)($r['g'] ?? 0)) ?: null;
|
||||||
|
// 顺序优先沿用老库的 sort_order;重号了就靠位次兜底,保证结果稳定
|
||||||
|
$sort = (int)($r['s'] ?? 0);
|
||||||
|
if ($sort <= 0) {
|
||||||
|
$sort = $seq;
|
||||||
|
}
|
||||||
|
foreach ($ownerIds as $oid) {
|
||||||
|
$ins->execute([$fid, $oid, $pid, $gid, $sort, 'seeded']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断某列是否存在(升级中途用,Schema 的私有方法不便跨类调用) */
|
||||||
|
private static function hasColumn(\PDO $db, string $table, string $column): bool
|
||||||
|
{
|
||||||
|
$st = $db->prepare(
|
||||||
|
'SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$table, $column]);
|
||||||
|
return (int)$st->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
+149
-26
@@ -26,8 +26,11 @@ final class Schema
|
|||||||
* 3 → 三个生日(birthday 升 DATETIME + id_card/lunar/leap)、slogan、nicknames 表
|
* 3 → 三个生日(birthday 升 DATETIME + id_card/lunar/leap)、slogan、nicknames 表
|
||||||
* 4 → person_groups(关系分组,全家统一但可自定义)、persons.group_id、
|
* 4 → person_groups(关系分组,全家统一但可自定义)、persons.group_id、
|
||||||
* user_settings(「我的」维度设置:分组顺序等)
|
* user_settings(「我的」维度设置:分组顺序等)
|
||||||
|
* 5 → **名册改成「每人一份」**:person_roster(谁在我的名册里 / 归哪一组 / 排第几)、
|
||||||
|
* person_groups.owner_id(0=管理员维护的公共分组,否则=某人的私自分组);
|
||||||
|
* 同时**删掉 persons.group_id**(分组从此是「我和他的关系」,挂在 roster 上)
|
||||||
*/
|
*/
|
||||||
public const VERSION = '4';
|
public const VERSION = '5';
|
||||||
|
|
||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
public static function statements(): array
|
public static function statements(): array
|
||||||
@@ -60,14 +63,11 @@ CREATE TABLE IF NOT EXISTS `persons` (
|
|||||||
`address` VARCHAR(200) DEFAULT NULL,
|
`address` VARCHAR(200) DEFAULT NULL,
|
||||||
`note` TEXT DEFAULT NULL COMMENT '备注',
|
`note` TEXT DEFAULT NULL COMMENT '备注',
|
||||||
`is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)',
|
`is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)',
|
||||||
`group_id` INT UNSIGNED DEFAULT NULL COMMENT '关系分组(人属于哪一组:家人/同事/朋友…),全家人共用一套分类',
|
|
||||||
`sort_order` INT NOT NULL DEFAULT 1000 COMMENT '名册排序,小的在前',
|
|
||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uk_phone_hash` (`phone_hash`),
|
UNIQUE KEY `uk_phone_hash` (`phone_hash`),
|
||||||
KEY `idx_family_kind` (`family_id`, `kind`, `sort_order`),
|
KEY `idx_family_kind` (`family_id`, `kind`),
|
||||||
KEY `idx_person_group` (`group_id`),
|
|
||||||
CONSTRAINT `fk_persons_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
|
CONSTRAINT `fk_persons_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='家庭名册'
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='家庭名册'
|
||||||
SQL,
|
SQL,
|
||||||
@@ -132,15 +132,35 @@ SQL,
|
|||||||
CREATE TABLE IF NOT EXISTS `person_groups` (
|
CREATE TABLE IF NOT EXISTS `person_groups` (
|
||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
`family_id` INT UNSIGNED NOT NULL,
|
`family_id` INT UNSIGNED NOT NULL,
|
||||||
|
`owner_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=全家的公共分组(管理员在系统设置里维护);否则=这个人的私自分组,只有他自己看得到',
|
||||||
`name` VARCHAR(20) NOT NULL COMMENT '分组名,如「家人」「同事」',
|
`name` VARCHAR(20) NOT NULL COMMENT '分组名,如「家人」「同事」',
|
||||||
`position` INT NOT NULL DEFAULT 100 COMMENT '默认顺序(用户没自定义顺序时按它排)',
|
`position` INT NOT NULL DEFAULT 100 COMMENT '默认顺序(用户没自定义顺序时按它排)',
|
||||||
`is_builtin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=安装时预置的分组',
|
`is_builtin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=安装时预置的分组',
|
||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uk_group_name` (`family_id`, `name`),
|
UNIQUE KEY `uk_group_owner_name` (`family_id`, `owner_id`, `name`),
|
||||||
KEY `idx_group_family` (`family_id`, `position`),
|
KEY `idx_group_family` (`family_id`, `position`),
|
||||||
CONSTRAINT `fk_group_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
|
CONSTRAINT `fk_group_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='关系分组:全家人共用一套(一个人只属于一个分组),但每个人可以各自决定分组的先后'
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='关系分组:一套公共的(管理员维护)+每人可加只给自己看的分组'
|
||||||
|
SQL,
|
||||||
|
<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `person_roster` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`family_id` INT UNSIGNED NOT NULL,
|
||||||
|
`owner_id` INT UNSIGNED NOT NULL COMMENT '这是谁的名册(persons.id)',
|
||||||
|
`person_id` INT UNSIGNED NOT NULL COMMENT '躺在他名册里的人(persons.id)',
|
||||||
|
`group_id` INT UNSIGNED DEFAULT NULL COMMENT '我把他归到哪一组(公共同我的私自分组都行);NULL=未分类',
|
||||||
|
`sort_order` INT NOT NULL DEFAULT 1000 COMMENT '**在我的名册里**的先后,拖动卡片改的就是它',
|
||||||
|
`source` ENUM('self','shared','seeded') NOT NULL DEFAULT 'self' COMMENT 'self=我自己加的 / shared=别人分享给我的 / seeded=升级时按当时的可见范围补的',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_roster_owner_person` (`owner_id`, `person_id`),
|
||||||
|
KEY `idx_roster_owner_sort` (`owner_id`, `sort_order`),
|
||||||
|
KEY `idx_roster_person` (`person_id`),
|
||||||
|
CONSTRAINT `fk_roster_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_roster_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_roster_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='每人一份名册:谁在我的名册里、归到哪一组、排第几 —— 都只影响我自己'
|
||||||
SQL,
|
SQL,
|
||||||
<<<'SQL'
|
<<<'SQL'
|
||||||
CREATE TABLE IF NOT EXISTS `user_settings` (
|
CREATE TABLE IF NOT EXISTS `user_settings` (
|
||||||
@@ -166,6 +186,37 @@ SQL,
|
|||||||
'不熟' => 70,
|
'不熟' => 70,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结构升级里「数据回填」类步骤的失败记录。
|
||||||
|
*
|
||||||
|
* 为什么要把「回填」和「改结构」分开对待:
|
||||||
|
* 改结构失败 = 代码与库对不上,必须让整站炸出来(看得见才好修);
|
||||||
|
* 回填失败 = 往往只跟**某一行老数据**有关(比如某条空日期),
|
||||||
|
* 它不该拦住后面所有结构变更 —— 2026-09-30 就是反过来:
|
||||||
|
* 一个回填里的 SQL 在 MySQL 严格模式下报错,把 addColumn(group_id)、
|
||||||
|
* 预置分组、称呼迁移**全部**挡在后面,而异常又被 route 层吞掉,
|
||||||
|
* 于是「线上看着是好的、其实升级从没跑完」,拖了两轮才发现。
|
||||||
|
*/
|
||||||
|
private static array $stepErrors = [];
|
||||||
|
|
||||||
|
/** @return string[] 最近一次 ensure() 里被容错吞掉的数据步骤失败 */
|
||||||
|
public static function stepErrors(): array
|
||||||
|
{
|
||||||
|
return self::$stepErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function tryStep(string $label, callable $fn): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$fn();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::$stepErrors[] = $label . ':' . $e->getMessage();
|
||||||
|
// log_errors=1 → 落到服务器 PHP 错误日志,别让它只活在内存里
|
||||||
|
error_log('[familybook] 升级步骤失败(已跳过,其余步骤继续)' . $label
|
||||||
|
. ':' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 幂等结构升级:建表 + 补齐后加的列/枚举值。
|
* 幂等结构升级:建表 + 补齐后加的列/枚举值。
|
||||||
*
|
*
|
||||||
@@ -176,6 +227,7 @@ SQL,
|
|||||||
public static function ensure(): void
|
public static function ensure(): void
|
||||||
{
|
{
|
||||||
$db = Database::get();
|
$db = Database::get();
|
||||||
|
self::$stepErrors = [];
|
||||||
foreach (self::statements() as $sql) {
|
foreach (self::statements() as $sql) {
|
||||||
$db->exec($sql);
|
$db->exec($sql);
|
||||||
}
|
}
|
||||||
@@ -200,9 +252,11 @@ SQL,
|
|||||||
"ADD COLUMN `slogan` VARCHAR(200) DEFAULT NULL COMMENT '个人的一句话;没填就用家庭统一的那句' AFTER `note`");
|
"ADD COLUMN `slogan` VARCHAR(200) DEFAULT NULL COMMENT '个人的一句话;没填就用家庭统一的那句' AFTER `note`");
|
||||||
|
|
||||||
// 老数据里 birthday 若是 DATE,升成 DATETIME 后时间部分自动是 00:00:00,无需回填。
|
// 老数据里 birthday 若是 DATE,升成 DATETIME 后时间部分自动是 00:00:00,无需回填。
|
||||||
// 把老数据补出农历生日:只在还没算过时算一次(幂等)。
|
// 把老数据补出农历生日 / 身份证生日:只在还没算过时算一次(幂等)。
|
||||||
self::backfillLunarBirthday($db);
|
// 这两步**只动已有数据、不改结构**,所以容错跑:某一行老数据有问题
|
||||||
self::backfillIdCardBirthday($db);
|
// 不该把下面的改结构步骤全挡住(原因见 tryStep 的注释)。
|
||||||
|
self::tryStep('回填农历生日', static fn() => self::backfillLunarBirthday($db));
|
||||||
|
self::tryStep('回填身份证生日', static fn() => self::backfillIdCardBirthday($db));
|
||||||
|
|
||||||
// ---- 称呼从「人的属性」改成「视角属性」----
|
// ---- 称呼从「人的属性」改成「视角属性」----
|
||||||
// 老语义:persons.nickname 是全家共用的小名。
|
// 老语义:persons.nickname 是全家共用的小名。
|
||||||
@@ -211,30 +265,65 @@ SQL,
|
|||||||
// 用户会觉得"以前填的小名一夜之间全没了"。搬的时候给每个账号都写一份,
|
// 用户会觉得"以前填的小名一夜之间全没了"。搬的时候给每个账号都写一份,
|
||||||
// 迁移前后大家看到的叫法完全一致(`Nickname::migrateLegacy` 里说明了原因)。
|
// 迁移前后大家看到的叫法完全一致(`Nickname::migrateLegacy` 里说明了原因)。
|
||||||
// 放在结构升级里、而不是"某次请求里顺手做",是为了升级完就已经搬好。
|
// 放在结构升级里、而不是"某次请求里顺手做",是为了升级完就已经搬好。
|
||||||
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
|
self::tryStep('迁移旧称呼到 nicknames 表', static function () use ($db) {
|
||||||
\App\Nickname::migrateLegacy((int)$fam['id']);
|
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
|
||||||
}
|
\App\Nickname::migrateLegacy((int)$fam['id']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---- 关系分组(版本 4)----
|
// ---- 关系分组:从「全家共用一套」改成「公共一套 + 每人可加自己的」(版本 5)----
|
||||||
// persons 新增 group_id。这里**不加外键**:老库只能 ADD COLUMN,加不上约束,
|
// person_groups 加 owner_id(0 = 公共)。新装的建表语句已是新结构,老库靠这里补。
|
||||||
// 新装的加了就会两边结构不一致;统一由 PersonGroup 保证不留悬空 id。
|
self::addColumn($db, 'person_groups', 'owner_id',
|
||||||
self::addColumn($db, 'persons', 'group_id',
|
"ADD COLUMN `owner_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=全家的公共分组(管理员在系统设置里维护);否则=这个人的私自分组,只有他自己看得到' AFTER `family_id`");
|
||||||
"ADD COLUMN `group_id` INT UNSIGNED DEFAULT NULL COMMENT '关系分组(人属于哪一组:家人/同事/朋友…),全家人共用一套分类' AFTER `is_placeholder`");
|
// 唯一键要从 (family_id,name) 换成 (family_id,owner_id,name):
|
||||||
// 每个家庭都要有一套预置分组(新装的由 install.php 建,老的在这里补)
|
// 否则「我的家人」和公共的「家人」会被判重名,没法各存一份。
|
||||||
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
|
self::dropIndexIfExists($db, 'person_groups', 'uk_group_name');
|
||||||
\App\PersonGroup::seedDefaults((int)$fam['id']);
|
if (!self::hasIndex($db, 'person_groups', 'uk_group_owner_name')) {
|
||||||
|
$db->exec('ALTER TABLE `person_groups`
|
||||||
|
ADD UNIQUE KEY `uk_group_owner_name` (`family_id`, `owner_id`, `name`)');
|
||||||
|
}
|
||||||
|
// 公共分组(owner_id = 0):新装的由 install.php 建,老的在这里补
|
||||||
|
self::tryStep('预置公共分组', static function () use ($db) {
|
||||||
|
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
|
||||||
|
\App\PersonGroup::seedDefaults((int)$fam['id']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 名册改成「每人一份」:先把老库「当时人人可见」的现状原样搬进每个人的名册,
|
||||||
|
// 再把 persons 上那两个「全家人共用」的列删掉。
|
||||||
|
//
|
||||||
|
// ⚠️ 这一步**绝不能**放进 tryStep:它是下面删列的**唯一前提**,
|
||||||
|
// 半途失败还继续删列 = 直接丢数据。宁可让异常把整站炸出来(下次请求会重试)。
|
||||||
|
\App\PersonRoster::seedFromPersons($db);
|
||||||
|
self::dropColumnIfExists($db, 'persons', 'group_id');
|
||||||
|
self::dropColumnIfExists($db, 'persons', 'sort_order');
|
||||||
|
|
||||||
|
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
|
||||||
|
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
|
||||||
|
if (self::$stepErrors) {
|
||||||
|
try {
|
||||||
|
\App\Setting::set((int)$db->query('SELECT MIN(id) FROM families')->fetchColumn(),
|
||||||
|
'schema_upgrade_warning', implode(';', self::$stepErrors));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 连这条都记不上就算了,至少 error_log 里有
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 老库的人还没有分组 → 按名册类型补:家庭成员=家人、外部联系人=认识。
|
|
||||||
// 只补空的,用户自己改过的一律不动。
|
|
||||||
\App\PersonGroup::backfillMissing($db);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 老库已有阳历生日但没有农历生日 → 补算一次。用「为空」作条件,天然幂等。 */
|
/**
|
||||||
|
* ⚠️ `birthday_lunar = ''` 这种写法**只在本机的 MariaDB 上能跑**:
|
||||||
|
* MySQL 严格模式(线上是 8.0 + STRICT_TRANS_TABLES)会把右边的 `''`
|
||||||
|
* 按 DATETIME 解析,直接抛 `SQLSTATE[HY000] 1525 Incorrect DATETIME value: ''`。
|
||||||
|
* 而 DATETIME 列加了之后默认是 NULL、不可能是空串,所以 `IS NULL` 就够了。
|
||||||
|
* (2026-09-30 线上事故:这条比较让整个结构升级从这里抛出去,
|
||||||
|
* 被 index.php 静默吞掉 → schema_version 永远停在 2、
|
||||||
|
* persons.group_id 加不上 → 名册分组、保存成员、头像上传全废。)
|
||||||
|
* 用「为空」作条件,天然幂等。
|
||||||
|
*/
|
||||||
private static function backfillLunarBirthday(\PDO $db): void
|
private static function backfillLunarBirthday(\PDO $db): void
|
||||||
{
|
{
|
||||||
$st = $db->query(
|
$st = $db->query(
|
||||||
"SELECT id, birthday FROM persons
|
"SELECT id, birthday FROM persons
|
||||||
WHERE birthday IS NOT NULL AND (birthday_lunar IS NULL OR birthday_lunar = '')"
|
WHERE birthday IS NOT NULL AND birthday_lunar IS NULL"
|
||||||
);
|
);
|
||||||
$rows = $st->fetchAll();
|
$rows = $st->fetchAll();
|
||||||
if (!$rows) {
|
if (!$rows) {
|
||||||
@@ -312,4 +401,38 @@ SQL,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function hasIndex(\PDO $db, string $table, string $index): bool
|
||||||
|
{
|
||||||
|
$st = $db->prepare(
|
||||||
|
'SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$table, $index]);
|
||||||
|
return (int)$st->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function dropIndexIfExists(\PDO $db, string $table, string $index): void
|
||||||
|
{
|
||||||
|
if (self::hasIndex($db, $table, $index)) {
|
||||||
|
$db->exec("ALTER TABLE `{$table}` DROP INDEX `{$index}`");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hasColumn(\PDO $db, string $table, string $column): bool
|
||||||
|
{
|
||||||
|
$st = $db->prepare(
|
||||||
|
'SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$table, $column]);
|
||||||
|
return (int)$st->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function dropColumnIfExists(\PDO $db, string $table, string $column): void
|
||||||
|
{
|
||||||
|
if (self::hasColumn($db, $table, $column)) {
|
||||||
|
$db->exec("ALTER TABLE `{$table}` DROP COLUMN `{$column}`");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+71
-3
@@ -166,7 +166,7 @@ a:hover { text-decoration: underline; }
|
|||||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||||
}
|
}
|
||||||
.person {
|
.person {
|
||||||
display: block; color: var(--ink);
|
display: block; color: var(--ink); position: relative;
|
||||||
background: var(--surface); border: 1px solid var(--line);
|
background: var(--surface); border: 1px solid var(--line);
|
||||||
border-radius: var(--radius); overflow: hidden;
|
border-radius: var(--radius); overflow: hidden;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
@@ -596,6 +596,65 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
.person-move .select { flex: 1 1 auto; min-width: 0; padding: 6px 8px; font-size: 13px; }
|
.person-move .select { flex: 1 1 auto; min-width: 0; padding: 6px 8px; font-size: 13px; }
|
||||||
.person-move .btn { flex: 0 0 auto; }
|
.person-move .btn { flex: 0 0 auto; }
|
||||||
|
|
||||||
|
/* ---------------- 名册:拖动排序 / 拖到别栏改分组 ----------------
|
||||||
|
拖动是"锦上添花":没有它,卡片上的「⋯ → 调整分组」表单照样能改分组,
|
||||||
|
手机上 HTML5 拖放本来也不生效,那套表单就是窄屏的正路。 */
|
||||||
|
.roster.is-droppable .person { cursor: grab; }
|
||||||
|
.roster.is-droppable .person.is-dragged { opacity: .45; cursor: grabbing; }
|
||||||
|
.drag-handle {
|
||||||
|
position: absolute; left: 8px; top: 8px; z-index: 2;
|
||||||
|
width: 22px; height: 22px; line-height: 22px; text-align: center;
|
||||||
|
border-radius: 6px; font-size: 14px;
|
||||||
|
color: var(--muted); background: rgba(255, 255, 255, .82);
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
pointer-events: none; /* 手柄只是个记号,抓取要落在整张卡上 */
|
||||||
|
}
|
||||||
|
.drag-handle-inline { color: var(--muted); }
|
||||||
|
|
||||||
|
/* 空栏平时收起来(七个预置分组可能只用到三个,摊开会显得名册破破烂烂)——
|
||||||
|
但**拖动时必须露出来**,否则"想拖到一个现在没人的分组里"就没地方放。 */
|
||||||
|
.group-block.is-empty { display: none; }
|
||||||
|
.roster.is-dragging .group-block.is-empty {
|
||||||
|
display: block; opacity: .9;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: var(--radius); padding: 10px; margin-top: 18px;
|
||||||
|
background: rgba(250, 247, 241, .6);
|
||||||
|
}
|
||||||
|
.group-drop-hint { display: none; margin-left: auto; font-weight: 400; font-size: 12px; color: var(--brand); }
|
||||||
|
.roster.is-dragging .group-block .group-drop-hint { display: inline; }
|
||||||
|
/* 拖动时把"有多少人"让出位置给落点提示,两者都挤在右上角会很乱 */
|
||||||
|
.roster.is-dragging .group-block .group-n { display: none; }
|
||||||
|
.roster.is-dragging .people-grid { min-height: 24px; }
|
||||||
|
|
||||||
|
/* 拖完/没拖成的轻提示(2 秒自己消失,不打断操作) */
|
||||||
|
.roster-note {
|
||||||
|
position: fixed; left: 50%; bottom: 28px; transform: translate(-50%, 12px);
|
||||||
|
z-index: 200; padding: 8px 16px; border-radius: 999px;
|
||||||
|
background: rgba(44, 35, 29, .88); color: #fff; font-size: 13px;
|
||||||
|
opacity: 0; pointer-events: none; transition: opacity .18s ease, transform .18s ease;
|
||||||
|
}
|
||||||
|
.roster-note.is-show { opacity: 1; transform: translate(-50%, 0); }
|
||||||
|
.roster-note.is-err { background: rgba(178, 45, 45, .92); }
|
||||||
|
|
||||||
|
/* ---------------- 分享名片(成员详情页) ---------------- */
|
||||||
|
.share-wrap { display: inline-block; }
|
||||||
|
.share-wrap > summary { list-style: none; cursor: pointer; display: inline-block; }
|
||||||
|
.share-wrap > summary::-webkit-details-marker { display: none; }
|
||||||
|
.share-wrap .inline-form { margin-top: 10px; }
|
||||||
|
|
||||||
|
/* ---------------- 「从家里已有的人里挑」 ---------------- */
|
||||||
|
.pick-list { list-style: none; margin: 0 0 4px; padding: 0; display: grid; gap: 8px; }
|
||||||
|
.pick-row { margin: 0; }
|
||||||
|
.pick-item {
|
||||||
|
display: flex; align-items: center; gap: 10px; cursor: pointer;
|
||||||
|
padding: 8px 12px; border-radius: var(--radius-s);
|
||||||
|
background: #FAF7F1; border: 1px solid var(--line-2);
|
||||||
|
}
|
||||||
|
.pick-item:hover { border-color: var(--brand); }
|
||||||
|
.pick-item input[type="checkbox"] { width: 16px; height: 16px; flex: 0 0 auto; accent-color: var(--brand); }
|
||||||
|
.pick-ph { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; background: #F1E7D6; }
|
||||||
|
.pick-name { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
|
|
||||||
/* ---------------- 个人设置:分组顺序 ---------------- */
|
/* ---------------- 个人设置:分组顺序 ---------------- */
|
||||||
.order-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
.order-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
.order-row {
|
.order-row {
|
||||||
@@ -651,12 +710,21 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
}
|
}
|
||||||
.crop-head .crop-shape { font-size: 12px; font-weight: 400; color: var(--muted); }
|
.crop-head .crop-shape { font-size: 12px; font-weight: 400; color: var(--muted); }
|
||||||
.crop-stage {
|
.crop-stage {
|
||||||
position: relative; margin: 0 auto; overflow: hidden;
|
position: relative; margin: 0 auto; overflow: auto;
|
||||||
background: #F1E7D6; border-radius: var(--radius-s);
|
background: #F1E7D6; border-radius: var(--radius-s);
|
||||||
touch-action: none; /* 手机上拖框时别把页面一起滚走 */
|
touch-action: none; /* 手机上拖框时别把页面一起滚走 */
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.crop-img { display: block; width: 100%; height: 100%; }
|
/* 图片的宽高由 JS 按倍率写死(放大后比舞台大,靠舞台自身滚动看别处)——
|
||||||
|
所以这里**不能**写 width/height:100%,否则缩放就永远没效果。 */
|
||||||
|
.crop-img { display: block; max-width: none; }
|
||||||
|
/* 缩放条 —— 用户明确要求"裁剪的时候要能缩放" */
|
||||||
|
.crop-zoom-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
margin-top: 12px; font-size: 13px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.crop-zoom { flex: 1; min-width: 120px; accent-color: var(--brand); }
|
||||||
|
.crop-zoom-val { font-variant-numeric: tabular-nums; min-width: 42px; text-align: right; }
|
||||||
/* 框外的部分压暗 —— 这是"你只留下框里这块"最直观的表达 */
|
/* 框外的部分压暗 —— 这是"你只留下框里这块"最直观的表达 */
|
||||||
.crop-box {
|
.crop-box {
|
||||||
position: absolute; cursor: move;
|
position: absolute; cursor: move;
|
||||||
|
|||||||
+246
-12
@@ -30,6 +30,13 @@
|
|||||||
// 比例由**这个位置显示成什么形状**决定(头像 1:1、题图 16:9),
|
// 比例由**这个位置显示成什么形状**决定(头像 1:1、题图 16:9),
|
||||||
// 由服务端通过 data-crop-ratio 告诉这里 —— 前端不再自己定一个数字。
|
// 由服务端通过 data-crop-ratio 告诉这里 —— 前端不再自己定一个数字。
|
||||||
//
|
//
|
||||||
|
// 缩放(用户明确要求「裁剪图片的时候,需要可以进行缩放」):
|
||||||
|
// 舞台是个**固定大小的视口**(baseW×baseH),图片按 zoom 放大到 baseW*zoom,
|
||||||
|
// 放不下就由舞台自己滚动。裁剪框的坐标一律相对**显示出来的图片**算,
|
||||||
|
// 最后除以 dispW/dispH 换成 0..1 —— 所以放大多少倍都不影响落库的那个矩形。
|
||||||
|
// 框的最小边长固定 32 显示像素,于是放大之后能选中原图上更小的区域,
|
||||||
|
// 这正是"缩放"要解决的问题(合影里挑出某一个人的脸)。
|
||||||
|
//
|
||||||
// ⚠️ 这里只是「让用户挑区域」,真正裁切在服务端做(见 app/Upload.php):
|
// ⚠️ 这里只是「让用户挑区域」,真正裁切在服务端做(见 app/Upload.php):
|
||||||
// 前端算出来的框只是**建议**,绕过前端也不会存进一张比例不对的图。
|
// 前端算出来的框只是**建议**,绕过前端也不会存进一张比例不对的图。
|
||||||
// 所以确认时写回的是**归一化矩形**(相对原图的 0..1),不是像素。
|
// 所以确认时写回的是**归一化矩形**(相对原图的 0..1),不是像素。
|
||||||
@@ -67,9 +74,15 @@
|
|||||||
'<div class="crop-panel" role="dialog" aria-modal="true" aria-label="裁剪图片">' +
|
'<div class="crop-panel" role="dialog" aria-modal="true" aria-label="裁剪图片">' +
|
||||||
'<div class="crop-head"><b>裁剪图片</b><span class="crop-shape"></span></div>' +
|
'<div class="crop-head"><b>裁剪图片</b><span class="crop-shape"></span></div>' +
|
||||||
'<div class="crop-stage">' +
|
'<div class="crop-stage">' +
|
||||||
'<img class="crop-img" alt="">' +
|
'<img class="crop-img" alt="" draggable="false">' +
|
||||||
'<div class="crop-box"><i class="crop-handle" aria-hidden="true"></i></div>' +
|
'<div class="crop-box"><i class="crop-handle" aria-hidden="true"></i></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
'<div class="crop-zoom-row">' +
|
||||||
|
'<label for="cropZoom">缩放</label>' +
|
||||||
|
'<input type="range" class="crop-zoom" id="cropZoom" min="1" max="4" step="0.05" value="1">' +
|
||||||
|
'<span class="crop-zoom-val">1.0×</span>' +
|
||||||
|
'<button type="button" class="btn btn-ghost btn-sm" data-act="zoom-reset">还原</button>' +
|
||||||
|
'</div>' +
|
||||||
'<p class="crop-tip">拖动方框挪位置,拖右下角改大小。框里就是最终会保存的样子。</p>' +
|
'<p class="crop-tip">拖动方框挪位置,拖右下角改大小。框里就是最终会保存的样子。</p>' +
|
||||||
'<div class="crop-actions">' +
|
'<div class="crop-actions">' +
|
||||||
'<button type="button" class="btn btn-ghost" data-act="cancel">取消</button>' +
|
'<button type="button" class="btn btn-ghost" data-act="cancel">取消</button>' +
|
||||||
@@ -86,10 +99,24 @@
|
|||||||
var act = e.target.getAttribute && e.target.getAttribute('data-act');
|
var act = e.target.getAttribute && e.target.getAttribute('data-act');
|
||||||
if (act === 'cancel') { close(false); }
|
if (act === 'cancel') { close(false); }
|
||||||
if (act === 'ok') { close(true); }
|
if (act === 'ok') { close(true); }
|
||||||
|
if (act === 'zoom-reset') { setZoom(1); }
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (overlay && overlay.parentNode && e.key === 'Escape') { close(false); }
|
if (overlay && overlay.parentNode && e.key === 'Escape') { close(false); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var zoomEl = overlay.querySelector('.crop-zoom');
|
||||||
|
zoomEl.addEventListener('input', function () {
|
||||||
|
var z = parseFloat(zoomEl.value);
|
||||||
|
if (isFinite(z) && z > 0) { setZoom(z); }
|
||||||
|
});
|
||||||
|
// Ctrl/⌘ + 滚轮 缩放;**普通滚轮不拦**,留给舞台自己滚动(放大之后要能平移)
|
||||||
|
stage.addEventListener('wheel', function (e) {
|
||||||
|
if (!st || !(e.ctrlKey || e.metaKey)) { return; }
|
||||||
|
e.preventDefault();
|
||||||
|
setZoom(st.zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
bindDrag(overlay.querySelector('.crop-handle'), 'resize');
|
bindDrag(overlay.querySelector('.crop-handle'), 'resize');
|
||||||
bindDrag(boxEl, 'move');
|
bindDrag(boxEl, 'move');
|
||||||
}
|
}
|
||||||
@@ -125,6 +152,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var MIN = 32; // 裁剪框最小边长(显示像素),太小了没法拖
|
var MIN = 32; // 裁剪框最小边长(显示像素),太小了没法拖
|
||||||
|
var MAX_ZOOM = 4;
|
||||||
|
|
||||||
/** 把裁剪框放到指定位置,并夹在图片范围内 */
|
/** 把裁剪框放到指定位置,并夹在图片范围内 */
|
||||||
function place(x, y, w, h) {
|
function place(x, y, w, h) {
|
||||||
@@ -140,6 +168,49 @@
|
|||||||
boxEl.style.height = h + 'px';
|
boxEl.style.height = h + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 当前框的归一化矩形(相对**显示出来的图片**,与倍率无关) */
|
||||||
|
function normOf() {
|
||||||
|
if (!st || !st.dispW || !st.dispH) { return { x: 0, y: 0, w: 1, h: 1 }; }
|
||||||
|
return {
|
||||||
|
x: boxEl.offsetLeft / st.dispW,
|
||||||
|
y: boxEl.offsetTop / st.dispH,
|
||||||
|
w: boxEl.offsetWidth / st.dispW,
|
||||||
|
h: boxEl.offsetHeight / st.dispH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改缩放。
|
||||||
|
*
|
||||||
|
* 关键:**框住的归一化区域保持不变**,只是把像素坐标整体重算 ——
|
||||||
|
* 于是"放大"只让你看得更清楚、能框得更细,绝不会把已经选好的区域改掉。
|
||||||
|
* 放大后把框滚回视野中间,不然用户会以为框不见了(框在滚动区外面)。
|
||||||
|
*/
|
||||||
|
function setZoom(z) {
|
||||||
|
if (!st) { return; }
|
||||||
|
z = Math.max(1, Math.min(MAX_ZOOM, z));
|
||||||
|
var before = normOf();
|
||||||
|
st.zoom = z;
|
||||||
|
st.dispW = Math.max(1, Math.round(st.baseW * z));
|
||||||
|
st.dispH = Math.max(1, Math.round(st.baseH * z));
|
||||||
|
|
||||||
|
imgEl.style.width = st.dispW + 'px';
|
||||||
|
imgEl.style.height = st.dispH + 'px';
|
||||||
|
// 舞台是**固定大小的视口**(不跟着图片变大),放不下就滚动
|
||||||
|
stage.style.width = st.baseW + 'px';
|
||||||
|
stage.style.height = st.baseH + 'px';
|
||||||
|
|
||||||
|
var zoomEl = overlay.querySelector('.crop-zoom');
|
||||||
|
if (zoomEl) { zoomEl.value = String(z); }
|
||||||
|
var valEl = overlay.querySelector('.crop-zoom-val');
|
||||||
|
if (valEl) { valEl.textContent = z.toFixed(1) + '×'; }
|
||||||
|
|
||||||
|
place(before.x * st.dispW, before.y * st.dispH,
|
||||||
|
before.w * st.dispW, before.h * st.dispH);
|
||||||
|
stage.scrollLeft = Math.max(0, boxEl.offsetLeft - (st.baseW - boxEl.offsetWidth) / 2);
|
||||||
|
stage.scrollTop = Math.max(0, boxEl.offsetTop - (st.baseH - boxEl.offsetHeight) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
function open(input, file) {
|
function open(input, file) {
|
||||||
if (!overlay) { build(); }
|
if (!overlay) { build(); }
|
||||||
var ratio = parseRatioAttr(input.getAttribute('data-crop-ratio'));
|
var ratio = parseRatioAttr(input.getAttribute('data-crop-ratio'));
|
||||||
@@ -151,28 +222,41 @@
|
|||||||
var availW = Math.min(window.innerWidth * 0.86, 620);
|
var availW = Math.min(window.innerWidth * 0.86, 620);
|
||||||
var availH = Math.min(window.innerHeight * 0.52, 420);
|
var availH = Math.min(window.innerHeight * 0.52, 420);
|
||||||
var scale = Math.min(availW / probe.naturalWidth, availH / probe.naturalHeight, 1);
|
var scale = Math.min(availW / probe.naturalWidth, availH / probe.naturalHeight, 1);
|
||||||
var dispW = Math.max(1, Math.round(probe.naturalWidth * scale));
|
var baseW = Math.max(1, Math.round(probe.naturalWidth * scale));
|
||||||
var dispH = Math.max(1, Math.round(probe.naturalHeight * scale));
|
var baseH = Math.max(1, Math.round(probe.naturalHeight * scale));
|
||||||
|
|
||||||
st = { input: input, ratio: ratio, dispW: dispW, dispH: dispH,
|
st = { input: input, ratio: ratio,
|
||||||
|
baseW: baseW, baseH: baseH, zoom: 1,
|
||||||
|
dispW: baseW, dispH: baseH,
|
||||||
naturalW: probe.naturalWidth, naturalH: probe.naturalHeight,
|
naturalW: probe.naturalWidth, naturalH: probe.naturalHeight,
|
||||||
previewSrc: null, outW: parseInt(input.getAttribute('data-crop-w'), 10) || dispW,
|
previewSrc: null, outW: parseInt(input.getAttribute('data-crop-w'), 10) || baseW,
|
||||||
outH: parseInt(input.getAttribute('data-crop-h'), 10) || dispH };
|
outH: parseInt(input.getAttribute('data-crop-h'), 10) || baseH };
|
||||||
|
|
||||||
imgEl.src = url;
|
imgEl.src = url;
|
||||||
stage.style.width = dispW + 'px';
|
imgEl.style.width = baseW + 'px';
|
||||||
stage.style.height = dispH + 'px';
|
imgEl.style.height = baseH + 'px';
|
||||||
|
stage.style.width = baseW + 'px';
|
||||||
|
stage.style.height = baseH + 'px';
|
||||||
|
stage.scrollLeft = 0;
|
||||||
|
stage.scrollTop = 0;
|
||||||
|
// ⚠️ 倍率每次打开都从 1× 开始:上一张图缩到 3× 不该带到这一张上,
|
||||||
|
// 否则新图的框会莫名其妙是上一张的"放大版"。
|
||||||
|
var zoomEl = overlay.querySelector('.crop-zoom');
|
||||||
|
if (zoomEl) { zoomEl.value = '1'; }
|
||||||
|
var valEl = overlay.querySelector('.crop-zoom-val');
|
||||||
|
if (valEl) { valEl.textContent = '1.0×'; }
|
||||||
|
|
||||||
var shape = overlay.querySelector('.crop-shape');
|
var shape = overlay.querySelector('.crop-shape');
|
||||||
shape.textContent = input.getAttribute('data-crop-label') || '';
|
shape.textContent = input.getAttribute('data-crop-label') || '';
|
||||||
overlay.querySelector('.crop-tip').textContent =
|
overlay.querySelector('.crop-tip').textContent =
|
||||||
input.getAttribute('data-crop-hint') ||
|
input.getAttribute('data-crop-hint') ||
|
||||||
'拖动方框挪位置,拖右下角改大小。框里就是最终会保存的样子。';
|
'拖动方框挪位置,拖右下角改大小;放不下就用「缩放」放大再看细节。框里就是最终会保存的样子。';
|
||||||
|
|
||||||
// 默认框 = 图里最大的、符合目标比例的那块(居中)—— 什么都不动直接确定,
|
// 默认框 = 图里最大的、符合目标比例的那块(居中)—— 什么都不动直接确定,
|
||||||
// 效果就等于老的「居中裁切」,所以默认值是安全的。
|
// 效果就等于老的「居中裁切」,所以默认值是安全的。
|
||||||
var w = dispW, h = w / ratio;
|
var w = baseW, h = w / ratio;
|
||||||
if (h > dispH) { h = dispH; w = h * ratio; }
|
if (h > baseH) { h = baseH; w = h * ratio; }
|
||||||
place((dispW - w) / 2, (dispH - h) / 2, w, h);
|
place((baseW - w) / 2, (baseH - h) / 2, w, h);
|
||||||
|
|
||||||
// 记住预览图原来的 src:取消时恢复,否则预览会停在"没裁过的图"上
|
// 记住预览图原来的 src:取消时恢复,否则预览会停在"没裁过的图"上
|
||||||
var prev = preview(input);
|
var prev = preview(input);
|
||||||
@@ -332,4 +416,154 @@
|
|||||||
idc.addEventListener('input', calc);
|
idc.addEventListener('input', calc);
|
||||||
calc();
|
calc();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ===== 名册:拖动卡片改顺序 / 拖到别的一栏改分组 =====
|
||||||
|
//
|
||||||
|
// 落库的是**整版布局**(每栏的有序 id 列表),一次提交写完 ——
|
||||||
|
// 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。
|
||||||
|
//
|
||||||
|
// 没有这段 JS 也完全能用:卡片上的「⋯ → 调整分组」表单还在,
|
||||||
|
// 而且手机上 HTML5 拖放本来就不生效,那套表单就是窄屏的正路。
|
||||||
|
(function () {
|
||||||
|
var roster = document.querySelector('.roster.is-droppable');
|
||||||
|
if (!roster || !window.FormData || !window.fetch) { return; }
|
||||||
|
var url = roster.getAttribute('data-reorder');
|
||||||
|
var csrf = roster.getAttribute('data-csrf') || '';
|
||||||
|
var dragging = null;
|
||||||
|
var noteEl = null;
|
||||||
|
|
||||||
|
function each(list, fn) { Array.prototype.forEach.call(list, fn); }
|
||||||
|
|
||||||
|
/** 从事件目标往上找那张可拖的卡片(点在卡片里的链接/表单上时也算这张卡) */
|
||||||
|
function cardFrom(e) {
|
||||||
|
var t = e.target;
|
||||||
|
while (t && t !== roster) {
|
||||||
|
if (t.classList && t.classList.contains('person')
|
||||||
|
&& t.getAttribute('draggable') === 'true') {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
t = t.parentNode;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blocks() {
|
||||||
|
return roster.querySelectorAll('.group-block');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每栏人数与"空栏"标记跟着 DOM 走 —— 不然拖完之后标题上的「N 人」还是旧的 */
|
||||||
|
function refreshCounts() {
|
||||||
|
each(blocks(), function (b) {
|
||||||
|
var n = b.querySelectorAll('.people-grid .person').length;
|
||||||
|
var nEl = b.querySelector('.group-n .n');
|
||||||
|
if (nEl) { nEl.textContent = String(n); }
|
||||||
|
b.classList.toggle('is-empty', n === 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 右下角那个小提示(成功/失败都只活两秒,不打断操作) */
|
||||||
|
function note(text, ok) {
|
||||||
|
if (!noteEl) {
|
||||||
|
noteEl = document.createElement('div');
|
||||||
|
noteEl.className = 'roster-note';
|
||||||
|
document.body.appendChild(noteEl);
|
||||||
|
}
|
||||||
|
noteEl.textContent = text;
|
||||||
|
noteEl.className = 'roster-note is-show' + (ok ? '' : ' is-err');
|
||||||
|
window.clearTimeout(note._t);
|
||||||
|
note._t = window.setTimeout(function () {
|
||||||
|
noteEl.className = 'roster-note';
|
||||||
|
}, 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
var cols = [];
|
||||||
|
each(blocks(), function (b) {
|
||||||
|
var ids = [];
|
||||||
|
each(b.querySelectorAll('.people-grid .person'), function (c) {
|
||||||
|
var pid = parseInt(c.getAttribute('data-person'), 10);
|
||||||
|
if (pid > 0) { ids.push(pid); }
|
||||||
|
});
|
||||||
|
cols.push({ g: parseInt(b.getAttribute('data-group'), 10) || 0, ids: ids });
|
||||||
|
});
|
||||||
|
|
||||||
|
var body = new URLSearchParams();
|
||||||
|
body.append('_csrf', csrf);
|
||||||
|
body.append('layout', JSON.stringify(cols));
|
||||||
|
|
||||||
|
window.fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
// 让服务端知道这是 AJAX:它就回 JSON 而不是 302(不然 flash 会攒到下次刷新才冒出来)
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
body: body,
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
}).then(function (j) {
|
||||||
|
if (j && j.ok) {
|
||||||
|
note('已保存', true);
|
||||||
|
} else {
|
||||||
|
note('没保存上,刷新页面再试一次', false);
|
||||||
|
}
|
||||||
|
}).catch(function () {
|
||||||
|
note('没保存上,刷新页面再试一次', false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
roster.addEventListener('dragstart', function (e) {
|
||||||
|
var card = cardFrom(e);
|
||||||
|
if (!card) { return; }
|
||||||
|
// 「⋯」菜单里的下拉与按钮不参与拖动 —— 在那儿按下多半是想选项,不是想拖卡
|
||||||
|
var t = e.target;
|
||||||
|
while (t && t !== roster) {
|
||||||
|
if (t.getAttribute && t.getAttribute('data-no-drag') !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
t = t.parentNode;
|
||||||
|
}
|
||||||
|
dragging = card;
|
||||||
|
roster.classList.add('is-dragging');
|
||||||
|
card.classList.add('is-dragged');
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
try { e.dataTransfer.setData('text/plain', card.getAttribute('data-person')); } catch (err) { /* 老浏览器忽略 */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
roster.addEventListener('dragover', function (e) {
|
||||||
|
if (!dragging) { return; }
|
||||||
|
var block = e.target && e.target.closest ? e.target.closest('.group-block') : null;
|
||||||
|
if (!block) { return; }
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer) { e.dataTransfer.dropEffect = 'move'; }
|
||||||
|
var grid = block.querySelector('.people-grid');
|
||||||
|
// 落点 = 指针在谁的上半部分,就插到谁前面(和列表拖拽的直觉一致)
|
||||||
|
var after = null;
|
||||||
|
each(grid.querySelectorAll('.person'), function (c) {
|
||||||
|
if (after || c === dragging) { return; }
|
||||||
|
var r = c.getBoundingClientRect();
|
||||||
|
if (e.clientY < r.top + r.height / 2) { after = c; }
|
||||||
|
});
|
||||||
|
if (after) {
|
||||||
|
grid.insertBefore(dragging, after);
|
||||||
|
} else {
|
||||||
|
grid.appendChild(dragging);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
roster.addEventListener('drop', function (e) {
|
||||||
|
// 不 preventDefault 的话浏览器会按"跳到某个地址"处理,整页就跳走了
|
||||||
|
if (dragging) { e.preventDefault(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
roster.addEventListener('dragend', function () {
|
||||||
|
if (!dragging) { return; }
|
||||||
|
dragging.classList.remove('is-dragged');
|
||||||
|
roster.classList.remove('is-dragging');
|
||||||
|
dragging = null;
|
||||||
|
refreshCounts();
|
||||||
|
save();
|
||||||
|
});
|
||||||
|
})();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use App\Kinship;
|
|||||||
use App\Nickname;
|
use App\Nickname;
|
||||||
use App\Person;
|
use App\Person;
|
||||||
use App\PersonGroup;
|
use App\PersonGroup;
|
||||||
|
use App\PersonRoster;
|
||||||
use App\Relations;
|
use App\Relations;
|
||||||
use App\Schema;
|
use App\Schema;
|
||||||
use App\Setting;
|
use App\Setting;
|
||||||
@@ -56,7 +57,13 @@ if (Database::hasTables()) {
|
|||||||
Schema::ensure();
|
Schema::ensure();
|
||||||
Setting::set($familyIdForSchema, 'schema_version', FB_SCHEMA_VERSION);
|
Setting::set($familyIdForSchema, 'schema_version', FB_SCHEMA_VERSION);
|
||||||
} catch (\Throwable $ex) {
|
} 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'])) {
|
if (!empty($GLOBALS['config']['debug'])) {
|
||||||
throw $ex;
|
throw $ex;
|
||||||
}
|
}
|
||||||
@@ -104,12 +111,14 @@ function read_birthday_input(array $existing = []): array
|
|||||||
/**
|
/**
|
||||||
* 渲染「个人设置」页(settings/me)。
|
* 渲染「个人设置」页(settings/me)。
|
||||||
*
|
*
|
||||||
* 抽成函数是因为它有三处调用者:正常打开、以及资料表单校验失败时的两处
|
* 从 v5 起这一页只剩两件事:**我怎么称呼家人**、**我的分组**。
|
||||||
* ("我的资料"和"改密码"各一次)—— 那三处的数据装配必须完全一致,
|
* 「我的资料」表单不在这里 —— 它和「成员资料」是同一张表单
|
||||||
* 否则校验失败重画的那次会缺东西(比如分组顺序整块消失),且不会有任何报错。
|
* (`templates/people/form.php`,两条入口都指过去),页面顶部给一个按钮过去。
|
||||||
|
* 这是用户明确要求的:「编辑成员」和「修改自己的资料」本来就是一件事,
|
||||||
|
* 两套表单必然填出两种口径。
|
||||||
*
|
*
|
||||||
* @param array $person 当前登录者自己的名册记录
|
* @param array $person 当前登录者自己的名册记录
|
||||||
* @param array $errors 字段名 => 错误文案
|
* @param array $errors 字段名 => 错误文案(保留给"校验失败就地重画"用)
|
||||||
*/
|
*/
|
||||||
function render_me_settings(array $person, array $errors = []): void
|
function render_me_settings(array $person, array $errors = []): void
|
||||||
{
|
{
|
||||||
@@ -119,14 +128,14 @@ function render_me_settings(array $person, array $errors = []): void
|
|||||||
'title' => '个人设置',
|
'title' => '个人设置',
|
||||||
'person' => $person,
|
'person' => $person,
|
||||||
'errors' => $errors,
|
'errors' => $errors,
|
||||||
'familyMotto' => (string)(Family::find($familyId)['motto'] ?? ''),
|
|
||||||
// 我怎么称呼别人(视角属性,只有我自己能定)
|
// 我怎么称呼别人(视角属性,只有我自己能定)
|
||||||
'nicknames' => Nickname::mineWithNames($familyId, $me),
|
'nicknames' => Nickname::mineWithNames($familyId, $me),
|
||||||
'options' => Person::options($familyId, $me, $me),
|
'options' => Person::options($familyId, $me, $me),
|
||||||
'birthdays' => Birthday::describe($person),
|
'birthdays' => Birthday::describe($person),
|
||||||
// 名册分组:顺序是「我的」,分类是「全家的」
|
// 我的分组 = 公共的 + 我自己加的,顺序是我的(user_settings.group_order)
|
||||||
'groups' => PersonGroup::orderFor($familyId, $me),
|
'groups' => PersonGroup::orderFor($familyId, $me),
|
||||||
'groupCounts' => PersonGroup::counts($familyId, 'member'),
|
'groupCounts' => PersonGroup::counts($familyId, $me),
|
||||||
|
'rosterCount' => PersonRoster::countFor($familyId, $me),
|
||||||
], 'settings');
|
], 'settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +275,9 @@ switch ($r) {
|
|||||||
$q = trim((string)($_GET['q'] ?? ''));
|
$q = trim((string)($_GET['q'] ?? ''));
|
||||||
$familyId = (int)Auth::familyId();
|
$familyId = (int)Auth::familyId();
|
||||||
$me = (int)Auth::personId();
|
$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);
|
$people = Person::map($familyId);
|
||||||
$graph = Kinship::graph($familyId);
|
$graph = Kinship::graph($familyId);
|
||||||
// 名册里也标出「我该怎么称呼他」
|
// 名册里也标出「我该怎么称呼他」
|
||||||
@@ -290,18 +301,24 @@ switch ($r) {
|
|||||||
'groups' => PersonGroup::orderFor($familyId, $me),
|
'groups' => PersonGroup::orderFor($familyId, $me),
|
||||||
'q' => $q,
|
'q' => $q,
|
||||||
'meId' => $me,
|
'meId' => $me,
|
||||||
|
// 家里已经有人、但还不在我名册里 → 给一个「直接挑」的入口,
|
||||||
|
// 免得为了同一个人又录一遍(录两遍会变成两条记录,账号与亲属关系各存一份)
|
||||||
|
'pickable' => $q === '' ? count(PersonRoster::pickable($familyId, $me)) : 0,
|
||||||
], 'people');
|
], 'people');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 名册卡片上的「调整分组」。
|
* 名册卡片上的「调整分组」。
|
||||||
*
|
*
|
||||||
* 单开一个路由而不是让人去「编辑成员」里改,是因为那个表单很长
|
* 从 v5 起改的是**我自己那份名册**里的归类(person_roster.group_id),
|
||||||
|
* 不再动 persons —— 所以「我把张伟归到同事」不会让妈妈那边也变成同事。
|
||||||
|
* 单开一个路由而不是让人去「编辑成员」里改,是因为那张表很长
|
||||||
* (三个生日、身份证号、住址…),为了挪一个分组去翻半页是折磨。
|
* (三个生日、身份证号、住址…),为了挪一个分组去翻半页是折磨。
|
||||||
* 只改 group_id 一列,所以走 Person::setGroup(),不会碰到别的字段。
|
* 而且分组**本来就只能在名册里改**(它是"我和他的关系")。
|
||||||
*/
|
*/
|
||||||
case 'people/group':
|
case 'people/group':
|
||||||
$familyId = (int)Auth::familyId();
|
$familyId = (int)Auth::familyId();
|
||||||
|
$me = (int)Auth::personId();
|
||||||
if (!Auth::canWrite()) {
|
if (!Auth::canWrite()) {
|
||||||
flash('err', '你的身份是访客,不能改名册里的分组。');
|
flash('err', '你的身份是访客,不能改名册里的分组。');
|
||||||
redirect('people');
|
redirect('people');
|
||||||
@@ -314,19 +331,152 @@ switch ($r) {
|
|||||||
flash('err', '找不到这个人。');
|
flash('err', '找不到这个人。');
|
||||||
redirect('people');
|
redirect('people');
|
||||||
}
|
}
|
||||||
// 分组必须也是这个家庭的 —— 否则可以把人挂到别人家的分组上,
|
if (!PersonRoster::assignGroup($familyId, $me, $pid, $gid > 0 ? $gid : null)) {
|
||||||
// 名册按 group_id 分栏时就永远看不见他了
|
flash('err', '这个人不在你的名册里,或者那个分组你用不了。');
|
||||||
if ($gid > 0 && !isset(PersonGroup::byId($familyId)[$gid])) {
|
|
||||||
flash('err', '找不到那个分组。');
|
|
||||||
redirect('people');
|
redirect('people');
|
||||||
}
|
}
|
||||||
Person::setGroup($pid, $gid > 0 ? $gid : null);
|
|
||||||
flash('ok', $gid > 0
|
flash('ok', $gid > 0
|
||||||
? '已把「' . $p['name'] . '」归到「' . PersonGroup::name($familyId, $gid) . '」。'
|
? '已把「' . $p['name'] . '」归到「' . PersonGroup::name($familyId, $gid) . '」。'
|
||||||
: '已把「' . $p['name'] . '」改成未分类。');
|
: '已把「' . $p['name'] . '」改成未分类。');
|
||||||
redirect('people');
|
redirect('people');
|
||||||
break;
|
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 就连名字都写不了。
|
// 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。
|
||||||
@@ -347,56 +497,21 @@ switch ($r) {
|
|||||||
render_me_settings($p);
|
render_me_settings($p);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'settings/me_save': // 表单现在提交到这里
|
/**
|
||||||
case 'people/me_save': // 兼容旧链接/旧标签页里还没刷新的表单
|
* 旧的「保存我的资料」入口。
|
||||||
Auth::requireLogin();
|
*
|
||||||
csrf_check();
|
* v5 起资料表单只剩一张(`templates/people/form.php`,提交到 people/update),
|
||||||
$me = (int)Auth::personId();
|
* 所以这两个地址不再处理表单 —— 只把还开着旧标签页的人送回个人设置,
|
||||||
$p = Person::find($me);
|
* 免得一次提交撞上 404 让人以为"保存坏了"。
|
||||||
if (!$p) {
|
*/
|
||||||
flash('err', '找不到你自己的名册记录。');
|
case 'settings/me_save':
|
||||||
redirect('home');
|
case 'people/me_save':
|
||||||
}
|
|
||||||
$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', '已保存。');
|
|
||||||
redirect('settings/me');
|
redirect('settings/me');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ==================== 分组顺序(只影响我自己) ====================
|
// ==================== 分组的顺序 ====================
|
||||||
|
// 两套:「我自己的顺序」(只影响我看到的)与「公共分组的默认顺序」(全家兜底)。
|
||||||
|
// 靠表单里的 scope 字段区分 —— 同一个动作,落到两个地方。
|
||||||
case 'settings/groups/move':
|
case 'settings/groups/move':
|
||||||
Auth::requireLogin();
|
Auth::requireLogin();
|
||||||
csrf_check();
|
csrf_check();
|
||||||
@@ -404,74 +519,106 @@ switch ($r) {
|
|||||||
$me = (int)Auth::personId();
|
$me = (int)Auth::personId();
|
||||||
$id = (int)($_POST['id'] ?? 0);
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
$dir = ($_POST['dir'] ?? '') === 'up' ? 'up' : 'down';
|
$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);
|
$ids = array_map(fn($g) => (int)$g['id'], $list);
|
||||||
$at = array_search($id, $ids, true);
|
$at = array_search($id, $ids, true);
|
||||||
|
|
||||||
if ($at === false) {
|
if ($at === false) {
|
||||||
flash('err', '找不到这个分组。');
|
flash('err', '找不到这个分组。');
|
||||||
redirect('settings/me');
|
redirect($scope === 'public' ? 'settings' : 'settings/me');
|
||||||
}
|
}
|
||||||
// 挪到边界就什么也不做(按钮在两端本来就是 disabled 的,
|
// 挪到边界就什么也不做(按钮在两端本来就是 disabled 的,
|
||||||
// 但地址栏里手敲一次也能到这儿,别让它把顺序搞乱)
|
// 但地址栏里手敲一次也能到这儿,别让它把顺序搞乱)
|
||||||
$to = $dir === 'up' ? $at - 1 : $at + 1;
|
$to = $dir === 'up' ? $at - 1 : $at + 1;
|
||||||
if ($to >= 0 && $to < count($ids)) {
|
if ($to >= 0 && $to < count($ids)) {
|
||||||
[$ids[$at], $ids[$to]] = [$ids[$to], $ids[$at]];
|
[$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;
|
break;
|
||||||
|
|
||||||
// ==================== 关系分类的增删改名(全家共用) ====================
|
// ==================== 分组的增删改名 ====================
|
||||||
// 分类写在共享的表里,所以是"能写"的人才能动;访客只读,给个明确的说法再回去,
|
// 两套:公共的(管理员在系统设置里维护,全家共用)与我自己的(个人设置里,
|
||||||
|
// 只有自己看得见)。访客只读,给个明确的说法再回去,
|
||||||
// 而不是甩一张 403 页面(他明明是点了自己设置里的按钮)。
|
// 而不是甩一张 403 页面(他明明是点了自己设置里的按钮)。
|
||||||
case 'settings/groups/add':
|
case 'settings/groups/add':
|
||||||
Auth::requireLogin();
|
Auth::requireLogin();
|
||||||
csrf_check();
|
csrf_check();
|
||||||
if (!Auth::canWrite()) {
|
if (!Auth::canWrite()) {
|
||||||
flash('err', '你的身份是访客,不能改关系分类。');
|
flash('err', '你的身份是访客,不能加分组。');
|
||||||
redirect('settings/me');
|
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']
|
flash($res['ok'] ? 'ok' : 'err', $res['ok']
|
||||||
? '已加上分类「' . trim((string)($_POST['name'] ?? '')) . '」。'
|
? '已加上分组「' . trim((string)($_POST['name'] ?? '')) . '」。'
|
||||||
: (string)($res['error'] ?? '加不上。'));
|
: (string)($res['error'] ?? '加不上。'));
|
||||||
redirect('settings/me');
|
redirect($scope === 'public' ? 'settings' : 'settings/me');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'settings/groups/rename':
|
case 'settings/groups/rename':
|
||||||
Auth::requireLogin();
|
Auth::requireLogin();
|
||||||
csrf_check();
|
csrf_check();
|
||||||
if (!Auth::canWrite()) {
|
if (!Auth::canWrite()) {
|
||||||
flash('err', '你的身份是访客,不能改关系分类。');
|
flash('err', '你的身份是访客,不能改分组。');
|
||||||
redirect('settings/me');
|
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),
|
$res = PersonGroup::rename((int)Auth::familyId(), (int)($_POST['id'] ?? 0),
|
||||||
(string)($_POST['name'] ?? ''));
|
(string)($_POST['name'] ?? ''), $owner);
|
||||||
flash($res['ok'] ? 'ok' : 'err', $res['ok']
|
flash($res['ok'] ? 'ok' : 'err', $res['ok']
|
||||||
? '分类名已改。'
|
? '分组名已改。'
|
||||||
: (string)($res['error'] ?? '改不了。'));
|
: (string)($res['error'] ?? '改不了。'));
|
||||||
redirect('settings/me');
|
redirect($scope === 'public' ? 'settings' : 'settings/me');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'settings/groups/delete':
|
case 'settings/groups/delete':
|
||||||
Auth::requireLogin();
|
Auth::requireLogin();
|
||||||
csrf_check();
|
csrf_check();
|
||||||
if (!Auth::canWrite()) {
|
if (!Auth::canWrite()) {
|
||||||
flash('err', '你的身份是访客,不能改关系分类。');
|
flash('err', '你的身份是访客,不能删分组。');
|
||||||
redirect('settings/me');
|
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']) {
|
if ($res['ok']) {
|
||||||
$n = (int)($res['moved'] ?? 0);
|
$n = (int)($res['moved'] ?? 0);
|
||||||
flash('ok', $n > 0
|
flash('ok', $n > 0
|
||||||
? '分类已删掉,那 ' . $n . ' 个人现在显示在「未分类」里,去名册重新归类即可。'
|
? '分组已删掉,那 ' . $n . ' 个人现在显示在「未分类」里,去名册重新归类即可。'
|
||||||
: '分类已删掉(那一组本来也没人)。');
|
: '分组已删掉(那一组本来也没人)。');
|
||||||
} else {
|
} else {
|
||||||
flash('err', (string)($res['error'] ?? '删不了。'));
|
flash('err', (string)($res['error'] ?? '删不了。'));
|
||||||
}
|
}
|
||||||
redirect('settings/me');
|
redirect($scope === 'public' ? 'settings' : 'settings/me');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ==================== 别人怎么称呼(只有我自己能定) ====================
|
// ==================== 别人怎么称呼(只有我自己能定) ====================
|
||||||
@@ -503,16 +650,18 @@ switch ($r) {
|
|||||||
Auth::requireWrite();
|
Auth::requireWrite();
|
||||||
clear_old();
|
clear_old();
|
||||||
View::render('people/form', [
|
View::render('people/form', [
|
||||||
'title' => '添加成员',
|
'title' => '添加成员',
|
||||||
'person' => null,
|
'person' => null,
|
||||||
'errors' => [],
|
'errors' => [],
|
||||||
'groups' => PersonGroup::byId((int)Auth::familyId()),
|
'canEditAll' => true,
|
||||||
|
'isSelf' => false,
|
||||||
], 'people');
|
], 'people');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'people/create':
|
case 'people/create':
|
||||||
Auth::requireWrite();
|
Auth::requireWrite();
|
||||||
csrf_check();
|
csrf_check();
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
$d = [
|
$d = [
|
||||||
'name' => trim((string)($_POST['name'] ?? '')),
|
'name' => trim((string)($_POST['name'] ?? '')),
|
||||||
'gender' => (string)($_POST['gender'] ?? ''),
|
'gender' => (string)($_POST['gender'] ?? ''),
|
||||||
@@ -522,19 +671,16 @@ switch ($r) {
|
|||||||
'address' => trim((string)($_POST['address'] ?? '')),
|
'address' => trim((string)($_POST['address'] ?? '')),
|
||||||
'note' => trim((string)($_POST['note'] ?? '')),
|
'note' => trim((string)($_POST['note'] ?? '')),
|
||||||
'slogan' => trim((string)($_POST['slogan'] ?? '')),
|
'slogan' => trim((string)($_POST['slogan'] ?? '')),
|
||||||
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
|
|
||||||
'avatar' => '',
|
'avatar' => '',
|
||||||
'kind' => 'member',
|
'kind' => 'member',
|
||||||
// ⚠️ 这两项必须显式从表单取回:表单里有,但漏了这一步就会被静默丢掉,
|
// ⚠️ 这两项必须显式从表单取回:表单里有,但漏了这一步就会被静默丢掉,
|
||||||
// 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。
|
// 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。
|
||||||
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
|
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
|
||||||
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
|
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
|
||||||
// 关系分组:下拉里的「未分类」是空 value,于是这里落成 null
|
|
||||||
'group_id' => !empty($_POST['group_id']) ? (int)$_POST['group_id'] : null,
|
|
||||||
] + read_birthday_input();
|
] + read_birthday_input();
|
||||||
$errors = Person::validate($d);
|
$errors = Person::validate($d);
|
||||||
|
|
||||||
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? [],
|
$up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
|
||||||
(string)($_POST['avatar_crop'] ?? ''));
|
(string)($_POST['avatar_crop'] ?? ''));
|
||||||
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
||||||
$errors['avatar'] = $up['error'];
|
$errors['avatar'] = $up['error'];
|
||||||
@@ -547,7 +693,8 @@ switch ($r) {
|
|||||||
'errors' => $errors,
|
'errors' => $errors,
|
||||||
'phoneValue' => $d['phone'],
|
'phoneValue' => $d['phone'],
|
||||||
'idCardValue'=> $d['id_card'],
|
'idCardValue'=> $d['id_card'],
|
||||||
'groups' => PersonGroup::byId((int)Auth::familyId()),
|
'canEditAll' => true,
|
||||||
|
'isSelf' => false,
|
||||||
], 'people');
|
], 'people');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -555,7 +702,7 @@ switch ($r) {
|
|||||||
$d['avatar'] = $up['file'];
|
$d['avatar'] = $up['file'];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$id = Person::create((int)Auth::familyId(), $d);
|
$id = Person::create($familyId, $d);
|
||||||
} catch (\PDOException $ex) {
|
} catch (\PDOException $ex) {
|
||||||
set_old($_POST);
|
set_old($_POST);
|
||||||
View::render('people/form', [
|
View::render('people/form', [
|
||||||
@@ -564,59 +711,82 @@ switch ($r) {
|
|||||||
'errors' => ['phone' => '这个手机号已经在名册里了'],
|
'errors' => ['phone' => '这个手机号已经在名册里了'],
|
||||||
'phoneValue' => $d['phone'],
|
'phoneValue' => $d['phone'],
|
||||||
'idCardValue'=> $d['id_card'],
|
'idCardValue'=> $d['id_card'],
|
||||||
'groups' => PersonGroup::byId((int)Auth::familyId()),
|
'canEditAll' => true,
|
||||||
|
'isSelf' => false,
|
||||||
], 'people');
|
], 'people');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
// 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。
|
||||||
|
// 分组先留空 —— 用户可以在名册里拖到对应那一栏,也可以在那张卡片的「调整分组」里选。
|
||||||
|
PersonRoster::ensure($familyId, (int)Auth::personId(), $id, null, 'self');
|
||||||
clear_old();
|
clear_old();
|
||||||
flash('ok', '已把「' . $d['name'] . '」加进名册。');
|
flash('ok', '已把「' . $d['name'] . '」加进名册。');
|
||||||
redirect('people/show', ['id' => $id]);
|
redirect('people/show', ['id' => $id]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成员资料表单 —— **只有这一张**(需求:「编辑成员」与「改自己的资料」本来就是一件事)。
|
||||||
|
*
|
||||||
|
* 两个入口都指到这里:名册卡片/详情页的「编辑资料」、以及个人设置顶部的
|
||||||
|
* 「编辑我的资料」。区别只有一个:编辑**自己**时不要求能写(访客也能改自己的名字),
|
||||||
|
* 而编辑别人必须是能写的人。
|
||||||
|
*/
|
||||||
case 'people/edit':
|
case 'people/edit':
|
||||||
Auth::requireWrite();
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
$p = Person::find((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()) {
|
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
||||||
flash('err', '找不到这个人。');
|
flash('err', '找不到这个人。');
|
||||||
redirect('people');
|
redirect('people');
|
||||||
}
|
}
|
||||||
clear_old();
|
clear_old();
|
||||||
View::render('people/form', [
|
View::render('people/form', [
|
||||||
'title' => '编辑成员',
|
'title' => $isSelf ? '我的资料' : '编辑成员',
|
||||||
'person' => $p,
|
'person' => $p,
|
||||||
'errors' => [],
|
'errors' => [],
|
||||||
|
'canEditAll' => Auth::canWrite(),
|
||||||
|
'isSelf' => $isSelf,
|
||||||
'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']),
|
'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']),
|
||||||
'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']),
|
'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']),
|
||||||
'groups' => PersonGroup::byId((int)Auth::familyId()),
|
|
||||||
], 'people');
|
], 'people');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'people/update':
|
case 'people/update':
|
||||||
Auth::requireWrite();
|
|
||||||
csrf_check();
|
csrf_check();
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
$id = (int)($_POST['id'] ?? 0);
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
$p = Person::find($id);
|
$p = Person::find($id);
|
||||||
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
if (!$p || (int)$p['family_id'] !== $familyId) {
|
||||||
flash('err', '找不到这个人。');
|
flash('err', '找不到这个人。');
|
||||||
redirect('people');
|
redirect('people');
|
||||||
}
|
}
|
||||||
|
$isSelf = $id === (int)Auth::personId();
|
||||||
|
if (!$isSelf) {
|
||||||
|
Auth::requireWrite();
|
||||||
|
}
|
||||||
|
// 改别人(= 能写的人编名册):字段是完整的一套。
|
||||||
|
// 改自己(可能是访客):只允许动属于自己的那些 —— 与 Person::updateSelf 一一对应。
|
||||||
|
$canEditAll = Auth::canWrite();
|
||||||
$d = [
|
$d = [
|
||||||
'name' => trim((string)($_POST['name'] ?? '')),
|
'name' => trim((string)($_POST['name'] ?? '')),
|
||||||
'gender' => (string)($_POST['gender'] ?? ''),
|
'gender' => (string)($_POST['gender'] ?? ''),
|
||||||
'phone' => trim((string)($_POST['phone'] ?? '')),
|
'phone' => $canEditAll ? trim((string)($_POST['phone'] ?? '')) : Crypto::decrypt((string)$p['phone_encrypted']),
|
||||||
'id_card' => trim((string)($_POST['id_card'] ?? '')),
|
'id_card' => $canEditAll ? trim((string)($_POST['id_card'] ?? '')) : Crypto::decrypt((string)$p['id_card_encrypted']),
|
||||||
'email' => trim((string)($_POST['email'] ?? '')),
|
'email' => trim((string)($_POST['email'] ?? '')),
|
||||||
'address' => trim((string)($_POST['address'] ?? '')),
|
'address' => trim((string)($_POST['address'] ?? '')),
|
||||||
'note' => trim((string)($_POST['note'] ?? '')),
|
'note' => trim((string)($_POST['note'] ?? '')),
|
||||||
'slogan' => trim((string)($_POST['slogan'] ?? '')),
|
'slogan' => trim((string)($_POST['slogan'] ?? '')),
|
||||||
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
|
|
||||||
'avatar' => (string)$p['avatar'],
|
'avatar' => (string)$p['avatar'],
|
||||||
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
|
'birth_rank' => $canEditAll ? trim((string)($_POST['birth_rank'] ?? '')) : ($p['birth_rank'] ?? ''),
|
||||||
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
|
'is_placeholder' => $canEditAll
|
||||||
'group_id' => !empty($_POST['group_id']) ? (int)$_POST['group_id'] : null,
|
? (!empty($_POST['is_placeholder']) ? 1 : 0)
|
||||||
|
: (int)($p['is_placeholder'] ?? 0),
|
||||||
] + read_birthday_input($p);
|
] + read_birthday_input($p);
|
||||||
$errors = Person::validate($d);
|
$errors = Person::validate($d);
|
||||||
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? [],
|
$up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
|
||||||
(string)($_POST['avatar_crop'] ?? ''));
|
(string)($_POST['avatar_crop'] ?? ''));
|
||||||
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
||||||
$errors['avatar'] = $up['error'];
|
$errors['avatar'] = $up['error'];
|
||||||
@@ -624,35 +794,46 @@ switch ($r) {
|
|||||||
if ($errors) {
|
if ($errors) {
|
||||||
set_old($_POST);
|
set_old($_POST);
|
||||||
View::render('people/form', [
|
View::render('people/form', [
|
||||||
'title' => '编辑成员',
|
'title' => $isSelf ? '我的资料' : '编辑成员',
|
||||||
'person' => $p,
|
'person' => $p,
|
||||||
'errors' => $errors,
|
'errors' => $errors,
|
||||||
|
'canEditAll' => $canEditAll,
|
||||||
|
'isSelf' => $isSelf,
|
||||||
'phoneValue' => $d['phone'],
|
'phoneValue' => $d['phone'],
|
||||||
'idCardValue' => $d['id_card'],
|
'idCardValue' => $d['id_card'],
|
||||||
'groups' => PersonGroup::byId((int)Auth::familyId()),
|
|
||||||
], 'people');
|
], 'people');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
if ($up['ok']) {
|
if ($up['ok']) {
|
||||||
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
|
Upload::remove($familyId, (string)$p['avatar']);
|
||||||
$d['avatar'] = $up['file'];
|
$d['avatar'] = $up['file'];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Person::update($id, $d);
|
// 改自己且没有编名册的权限 → 只能动属于自己的字段(Person::updateSelf)。
|
||||||
|
// 这一条不是"防贼",是为了让访客也能改自己的名字,同时不会顺手把
|
||||||
|
// 手机号(= 登录凭据)或身份证号改掉。
|
||||||
|
if ($isSelf && !$canEditAll) {
|
||||||
|
Person::updateSelf($id, $d);
|
||||||
|
} else {
|
||||||
|
Person::update($id, $d);
|
||||||
|
}
|
||||||
} catch (\PDOException $ex) {
|
} catch (\PDOException $ex) {
|
||||||
set_old($_POST);
|
set_old($_POST);
|
||||||
View::render('people/form', [
|
View::render('people/form', [
|
||||||
'title' => '编辑成员',
|
'title' => $isSelf ? '我的资料' : '编辑成员',
|
||||||
'person' => $p,
|
'person' => $p,
|
||||||
'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'],
|
'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'],
|
||||||
|
'canEditAll' => $canEditAll,
|
||||||
|
'isSelf' => $isSelf,
|
||||||
'phoneValue' => $d['phone'],
|
'phoneValue' => $d['phone'],
|
||||||
'idCardValue' => $d['id_card'],
|
'idCardValue' => $d['id_card'],
|
||||||
'groups' => PersonGroup::byId((int)Auth::familyId()),
|
|
||||||
], 'people');
|
], 'people');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
if ((int)$p['id'] === (int)Auth::personId()) {
|
if ((int)$p['id'] === (int)Auth::personId()) {
|
||||||
Auth::refreshAvatar((string)$d['avatar']);
|
Auth::refreshAvatar((string)$d['avatar']);
|
||||||
|
// 姓名也可能改了 —— 不同步会话的话顶栏还是旧名字
|
||||||
|
$_SESSION['name'] = (string)$d['name'];
|
||||||
}
|
}
|
||||||
clear_old();
|
clear_old();
|
||||||
flash('ok', '已保存。');
|
flash('ok', '已保存。');
|
||||||
@@ -692,6 +873,8 @@ switch ($r) {
|
|||||||
'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']),
|
'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']),
|
||||||
'birthdays' => Birthday::describe($p),
|
'birthdays' => Birthday::describe($p),
|
||||||
'birthdayNext'=> Birthday::nextOccurrences($p, date('Y-m-d'), 400),
|
'birthdayNext'=> Birthday::nextOccurrences($p, date('Y-m-d'), 400),
|
||||||
|
// 名册(每人一份):他是不是在我的名册里、可以把他的名片分享给谁
|
||||||
|
'inMyRoster' => in_array($id, PersonRoster::visibleIds($familyId, $me), true),
|
||||||
], 'people');
|
], 'people');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -756,8 +939,12 @@ switch ($r) {
|
|||||||
$p = Person::find($personId);
|
$p = Person::find($personId);
|
||||||
$acc = Account::findByPerson($personId);
|
$acc = Account::findByPerson($personId);
|
||||||
if ($p && $acc) {
|
if ($p && $acc) {
|
||||||
if ($role === 'member' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
|
// ⚠️ 判的是 `$role !== 'admin'` 而不是 `$role === 'member'`:
|
||||||
flash('err', '至少要留一位管理员。');
|
// 早先只拦"降成家庭成员",于是把**最后一位管理员**降成访客能一路通过 ——
|
||||||
|
// 降完家里一个管理员都不剩,谁也进不了系统设置了。
|
||||||
|
// 允许改自己的权限(包括给自己降级),只在"会一个管理员都不剩"时拦。
|
||||||
|
if ($role !== 'admin' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
|
||||||
|
flash('err', '至少要留一位管理员 —— 把最后一位管理员降级,全家就没人能管理系统了。');
|
||||||
redirect('people/show', ['id' => $personId]);
|
redirect('people/show', ['id' => $personId]);
|
||||||
}
|
}
|
||||||
Account::setRole((int)$acc['id'], $role);
|
Account::setRole((int)$acc['id'], $role);
|
||||||
@@ -873,9 +1060,12 @@ switch ($r) {
|
|||||||
$pid = Person::create($familyId, [
|
$pid = Person::create($familyId, [
|
||||||
'name' => $name, 'gender' => '', 'avatar' => '', 'phone' => $phone,
|
'name' => $name, 'gender' => '', 'avatar' => '', 'phone' => $phone,
|
||||||
'id_card' => '', 'email' => '', 'address' => '', 'note' => $note,
|
'id_card' => '', 'email' => '', 'address' => '', 'note' => $note,
|
||||||
'slogan' => '', 'sort_order' => 2000, 'kind' => 'contact',
|
'slogan' => '', 'kind' => 'contact',
|
||||||
]);
|
]);
|
||||||
Account::create($pid, $pass, 'guest');
|
Account::create($pid, $pass, 'guest');
|
||||||
|
// 我邀的访客也进**我的**名册(名册是每人一份的;访客自己那份由
|
||||||
|
// Account::create 给他补上他自己)
|
||||||
|
PersonRoster::ensure($familyId, (int)Auth::personId(), $pid, null, 'self');
|
||||||
clear_old();
|
clear_old();
|
||||||
flash('ok', '访客「' . $name . '」已开通,把手机号和密码告诉他就能登录。');
|
flash('ok', '访客「' . $name . '」已开通,把手机号和密码告诉他就能登录。');
|
||||||
redirect('accounts/invite');
|
redirect('accounts/invite');
|
||||||
@@ -906,6 +1096,8 @@ switch ($r) {
|
|||||||
// 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET)
|
// 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET)
|
||||||
$diag = $_SESSION['_oss_diag'] ?? null;
|
$diag = $_SESSION['_oss_diag'] ?? null;
|
||||||
unset($_SESSION['_oss_diag']);
|
unset($_SESSION['_oss_diag']);
|
||||||
|
// 公共分组(owner_id = 0)—— 管理员在这里维护全家共用的那一套。
|
||||||
|
// 人数按"全家名册"统计(这里要看的是"这一组里全家有多少人",不是我个人那份)。
|
||||||
View::render('settings', [
|
View::render('settings', [
|
||||||
'title' => '设置',
|
'title' => '设置',
|
||||||
'family' => Family::find((int)Auth::familyId()),
|
'family' => Family::find((int)Auth::familyId()),
|
||||||
@@ -913,6 +1105,9 @@ switch ($r) {
|
|||||||
'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0,
|
'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0,
|
||||||
'roleCounts' => $acc,
|
'roleCounts' => $acc,
|
||||||
'relCount' => Relations::count((int)Auth::familyId()),
|
'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()),
|
'storageDriver' => \App\Storage\Storage::driver((int)Auth::familyId()),
|
||||||
'storageOss' => \App\Storage\Storage::config((int)Auth::familyId()),
|
'storageOss' => \App\Storage\Storage::config((int)Auth::familyId()),
|
||||||
'storageOk' => \App\Storage\Storage::configComplete((int)Auth::familyId()),
|
'storageOk' => \App\Storage\Storage::configComplete((int)Auth::familyId()),
|
||||||
|
|||||||
+5
-5
@@ -70,15 +70,15 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
|||||||
'address' => '',
|
'address' => '',
|
||||||
'note' => '',
|
'note' => '',
|
||||||
'slogan' => '',
|
'slogan' => '',
|
||||||
'sort_order'=> 1,
|
|
||||||
'kind' => 'member',
|
'kind' => 'member',
|
||||||
]);
|
]);
|
||||||
Account::create($personId, $password, 'admin');
|
Account::create($personId, $password, 'admin');
|
||||||
|
|
||||||
// 创始人(=第一个管理员)默认归到「家人」。
|
// 名册是「每人一份」(v5 起)。创始人先把自己放进自己的名册 ——
|
||||||
// 用 backfillMissing 而不是自己算组 id:规则「member → 家人」只写在一处,
|
// 之后他每加一个人,那个人就自动进他的名册(见 index.php 的 people/create)。
|
||||||
// 安装与老库升级才不会有第二种说法。
|
// ⚠️ 这里**不能**把全家成员都塞给他:新装时除他之外还没有别人,
|
||||||
\App\PersonGroup::backfillMissing(Database::get());
|
// 而"别人"要靠他自己加或复用,这正是"每人一份"的意思。
|
||||||
|
\App\PersonRoster::ensure((int)$familyId, (int)$personId, (int)$personId, null, 'self');
|
||||||
|
|
||||||
// 装完就删掉自己 —— 避免把安装入口长期暴露在公网
|
// 装完就删掉自己 —— 避免把安装入口长期暴露在公网
|
||||||
@unlink(__FILE__);
|
@unlink(__FILE__);
|
||||||
|
|||||||
@@ -52,9 +52,6 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '
|
|||||||
<?php if ($mottoIsMine): ?>
|
<?php if ($mottoIsMine): ?>
|
||||||
<span class="motto-from">我的一句话</span>
|
<span class="motto-from">我的一句话</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($me): ?>
|
|
||||||
<a class="motto-edit" href="<?= e(url('settings/me')) ?>#me-profile"><?= $mottoIsMine ? '改' : '写一句我自己的' ?></a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-stats">
|
<div class="hero-stats">
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ $genWord = function (int $level): string {
|
|||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>我的家谱</h1>
|
<h1>我的家谱</h1>
|
||||||
<span class="sub">以 <?= e(\App\Auth::name()) ?> 为中心 · 上下各三代</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (!$nodes || count($nodes) <= 1): ?>
|
<?php if (!$nodes || count($nodes) <= 1): ?>
|
||||||
@@ -42,7 +41,6 @@ $genWord = function (int $level): string {
|
|||||||
<div class="tree-gen">
|
<div class="tree-gen">
|
||||||
<div class="tree-gen-label">
|
<div class="tree-gen-label">
|
||||||
<span class="gname"><?= e($genWord((int)$level)) ?></span>
|
<span class="gname"><?= e($genWord((int)$level)) ?></span>
|
||||||
<span class="gtag"><?= (int)$level === 0 ? '中心' : ((int)$level < 0 ? '上 ' . abs((int)$level) . ' 代' : '下 ' . (int)$level . ' 代') ?></span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tree-row">
|
<div class="tree-row">
|
||||||
<?php foreach ($ids as $id): $n = $nodes[$id]; ?>
|
<?php foreach ($ids as $id): $n = $nodes[$id]; ?>
|
||||||
@@ -63,19 +61,6 @@ $genWord = function (int $level): string {
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-pad" style="margin-top:24px">
|
|
||||||
<h2 class="section-title">这张图是怎么来的 <span class="hint">只存两代,其余推导</span></h2>
|
|
||||||
<p class="small muted" style="margin:0 0 10px">
|
|
||||||
系统里只记录**相邻两代之间**的关系:谁是谁的父母、谁和谁是配偶。
|
|
||||||
至于「奶奶是谁」,是顺着「我的父亲 → 我父亲的母亲」两级边推出来的。
|
|
||||||
这样每条关系只需维护一次,同一个人在每个人眼里还会自动变成不同的称呼。
|
|
||||||
</p>
|
|
||||||
<p class="small muted" style="margin:0">
|
|
||||||
兄弟姐妹也不单独存档 —— 只要你们有同一对父母,系统就会把你们认成一家人,
|
|
||||||
并按排行显示成哥哥/姐姐/弟弟/妹妹、长子/次女。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<a class="btn btn-ghost" href="<?= e(url('people/show', ['id' => $meId])) ?>">补充我的亲属关系</a>
|
<a class="btn btn-ghost" href="<?= e(url('people/show', ['id' => $meId])) ?>">补充我的亲属关系</a>
|
||||||
<a class="btn btn-ghost" href="<?= e(url('people')) ?>">看整个名册</a>
|
<a class="btn btn-ghost" href="<?= e(url('people')) ?>">看整个名册</a>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
// ⚠️ 「系统设置」**不在**顶栏里(用户明确要求):它已经收进右上角头像下拉菜单里了,
|
||||||
|
// 顶栏只留「首页 / 家谱 / 名册」三个日常入口,别再把管理入口顶出来。
|
||||||
$navItems = [
|
$navItems = [
|
||||||
'home' => ['首页', 'home'],
|
'home' => ['首页', 'home'],
|
||||||
'kin' => ['家谱', 'kin'],
|
'kin' => ['家谱', 'kin'],
|
||||||
'people' => ['名册', 'people'],
|
'people' => ['名册', 'people'],
|
||||||
];
|
];
|
||||||
if (\App\Auth::isAdmin()) {
|
|
||||||
$navItems['settings'] = ['设置', 'settings'];
|
|
||||||
}
|
|
||||||
// ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的,
|
// ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的,
|
||||||
// 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。
|
// 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。
|
||||||
// home.php 用 $me 画「补充亲属关系」链接,被覆盖成 true 之后
|
// home.php 用 $me 画「补充亲属关系」链接,被覆盖成 true 之后
|
||||||
|
|||||||
+32
-26
@@ -13,6 +13,11 @@
|
|||||||
*/
|
*/
|
||||||
$isEdit = !empty($person['id']);
|
$isEdit = !empty($person['id']);
|
||||||
$avatarUrl = avatar_url($person['avatar'] ?? null);
|
$avatarUrl = avatar_url($person['avatar'] ?? null);
|
||||||
|
// 这张表单有**两个入口**(名册/详情页的「编辑资料」、个人设置里的「编辑我的资料」),
|
||||||
|
// 但只有一套字段。改自己、且不是能写的人(访客)时,只给属于他自己的那些 ——
|
||||||
|
// 见 index.php 的 people/update(那条分支调 Person::updateSelf)。
|
||||||
|
$canEditAll = $canEditAll ?? \App\Auth::canWrite();
|
||||||
|
$isSelf = $isSelf ?? false;
|
||||||
|
|
||||||
$bdSource = old('birthday_source', 'solar');
|
$bdSource = old('birthday_source', 'solar');
|
||||||
// 库里这列叫 birthday、表单叫 birthday_solar,一律走 solarOf() 取,别直接下标
|
// 库里这列叫 birthday、表单叫 birthday_solar,一律走 solarOf() 取,别直接下标
|
||||||
@@ -27,7 +32,7 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1><?= $isEdit ? '编辑成员' : '添加成员' ?></h1>
|
<h1><?= $isEdit ? ($isSelf ? '我的资料' : '编辑成员') : '添加成员' ?></h1>
|
||||||
<span class="sub"><?= $isEdit ? e($person['name']) : '填姓名就行,其他都可以以后再补' ?></span>
|
<span class="sub"><?= $isEdit ? e($person['name']) : '填姓名就行,其他都可以以后再补' ?></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,6 +62,7 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if ($canEditAll): ?>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="birth_rank">家中排行</label>
|
<label for="birth_rank">家中排行</label>
|
||||||
<input class="input" id="birth_rank" name="birth_rank" type="number" min="1" max="99"
|
<input class="input" id="birth_rank" name="birth_rank" type="number" min="1" max="99"
|
||||||
@@ -64,31 +70,15 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
|
|||||||
<span class="tip">用来认兄弟姐妹的长幼:长子、次女、三弟…</span>
|
<span class="tip">用来认兄弟姐妹的长幼:长子、次女、三弟…</span>
|
||||||
<?php if (!empty($errors['birth_rank'])): ?><span class="err"><?= e($errors['birth_rank']) ?></span><?php endif; ?>
|
<?php if (!empty($errors['birth_rank'])): ?><span class="err"><?= e($errors['birth_rank']) ?></span><?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="field">
|
<?php /* ⚠️ 这里**没有**「排序号」,也**没有**「关系分组」:
|
||||||
<label for="sort_order">排序号</label>
|
① 排序号已经彻底取消(用户原话:「排序号,不要让用户填写」),
|
||||||
<input class="input" id="sort_order" name="sort_order" type="number"
|
名册里的先后靠**拖动卡片**调,存在我自己的 person_roster.sort_order 上;
|
||||||
value="<?= e(old('sort_order', $person['sort_order'] ?? 1000)) ?>">
|
② 分组是「我和他的关系」,写在我自己的名册条目上 ——
|
||||||
<span class="tip">小的排前面。</span>
|
只能在名册里拖到那一栏,或在那张卡片的「调整分组」里改。
|
||||||
</div>
|
写进这张(全家人共用的)资料表单里是错的:我把他归成"同事"
|
||||||
|
不该把妈妈那边的归类也改掉。 */ ?>
|
||||||
<?php /* 一个人只属于一个关系分组;分组是全家共用的一套(名册按它分栏显示),
|
|
||||||
但「哪一组排前面」是各人自己的习惯,在「个人设置」里调。 */ ?>
|
|
||||||
<div class="field">
|
|
||||||
<label for="group_id">关系分组</label>
|
|
||||||
<select class="select" id="group_id" name="group_id">
|
|
||||||
<option value="">未分类</option>
|
|
||||||
<?php foreach (($groups ?? []) as $g): $gid = (int)$g['id']; ?>
|
|
||||||
<option value="<?= $gid ?>"
|
|
||||||
<?= (int)old('group_id', $person['group_id'] ?? 0) === $gid ? 'selected' : '' ?>>
|
|
||||||
<?= e((string)$g['name']) ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
<span class="tip">
|
|
||||||
名册会按这个分组分栏显示。分组不够用就自己在「<a href="<?= e(url('settings/me')) ?>">个人设置</a>」里加。
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ==================== 三个生日 ==================== -->
|
<!-- ==================== 三个生日 ==================== -->
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
@@ -153,6 +143,7 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
|
|||||||
<?php if (!empty($errors['slogan'])): ?><span class="err"><?= e($errors['slogan']) ?></span><?php endif; ?>
|
<?php if (!empty($errors['slogan'])): ?><span class="err"><?= e($errors['slogan']) ?></span><?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if ($canEditAll): ?>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="phone">手机号</label>
|
<label for="phone">手机号</label>
|
||||||
<input class="input" id="phone" name="phone" inputmode="numeric"
|
<input class="input" id="phone" name="phone" inputmode="numeric"
|
||||||
@@ -160,6 +151,7 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
|
|||||||
<span class="tip">开通登录后用它登录;加密存储,列表里只显示部分。</span>
|
<span class="tip">开通登录后用它登录;加密存储,列表里只显示部分。</span>
|
||||||
<?php if (!empty($errors['phone'])): ?><span class="err"><?= e($errors['phone']) ?></span><?php endif; ?>
|
<?php if (!empty($errors['phone'])): ?><span class="err"><?= e($errors['phone']) ?></span><?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="email">邮箱</label>
|
<label for="email">邮箱</label>
|
||||||
@@ -168,6 +160,7 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
|
|||||||
<?php if (!empty($errors['email'])): ?><span class="err"><?= e($errors['email']) ?></span><?php endif; ?>
|
<?php if (!empty($errors['email'])): ?><span class="err"><?= e($errors['email']) ?></span><?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if ($canEditAll): ?>
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label for="id_card">身份证号</label>
|
<label for="id_card">身份证号</label>
|
||||||
<input class="input mono" id="id_card" name="id_card"
|
<input class="input mono" id="id_card" name="id_card"
|
||||||
@@ -179,6 +172,7 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
|
|||||||
</span>
|
</span>
|
||||||
<?php if (!empty($errors['id_card'])): ?><span class="err"><?= e($errors['id_card']) ?></span><?php endif; ?>
|
<?php if (!empty($errors['id_card'])): ?><span class="err"><?= e($errors['id_card']) ?></span><?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label for="address">住址</label>
|
<label for="address">住址</label>
|
||||||
@@ -215,6 +209,7 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
|
|||||||
style="width:88px;height:88px;border-radius:12px;object-fit:cover;border:1px solid var(--line)">
|
style="width:88px;height:88px;border-radius:12px;object-fit:cover;border:1px solid var(--line)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if ($canEditAll): ?>
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label class="check-item">
|
<label class="check-item">
|
||||||
<input type="checkbox" name="is_placeholder" value="1"
|
<input type="checkbox" name="is_placeholder" value="1"
|
||||||
@@ -228,12 +223,23 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn" type="submit"><?= $isEdit ? '保存' : '加入名册' ?></button>
|
<button class="btn" type="submit"><?= $isEdit ? '保存' : '加入名册' ?></button>
|
||||||
<a class="btn btn-ghost" href="<?= e($isEdit ? url('people/show', ['id' => (int)$person['id']]) : url('people')) ?>">取消</a>
|
<?php
|
||||||
|
// 取消:改自己的话回「个人设置」更顺手(那是他来的地方),
|
||||||
|
// 改别人 / 新增则回他的详情页 / 名册。
|
||||||
|
$cancelUrl = $isEdit
|
||||||
|
? ($isSelf ? url('settings/me') : url('people/show', ['id' => (int)$person['id']]))
|
||||||
|
: url('people');
|
||||||
|
?>
|
||||||
|
<a class="btn btn-ghost" href="<?= e($cancelUrl) ?>">取消</a>
|
||||||
|
<?php if ($isSelf): ?>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('people/show', ['id' => (int)$person['id']])) ?>">看我的详情页</a>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+107
-52
@@ -1,20 +1,24 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 家庭名册 —— 按「关系分组」分栏显示。
|
* 家庭名册 —— 按「关系分组」分栏显示,卡片可以拖动。
|
||||||
*
|
*
|
||||||
* 两个刻意的设计:
|
* 三处刻意的设计:
|
||||||
* ① **分栏顺序由看的人自己定**($groups 已经是"按我的习惯排好"的顺序,
|
* ① **名册是每人一份的**(v5 起,见 App\PersonRoster):这里只显示"我加的人 +
|
||||||
* 见 App\PersonGroup::orderFor)—— 所以我看到的「家人」在第一栏,
|
* 别人分享给我的",归到哪一组、排第几也都只影响我自己。所以"改分组"不再是
|
||||||
* 妈妈可以把「亲戚」挪到第一栏,两个人相安无事。
|
* 改全家的信息,而是改我自己那份 —— 于是顺手就能做成拖动。
|
||||||
* ② 分类是全家共用的一套,所以**改一个人的分组是全家的信息**,
|
* ② **拖动改分组、拖动改顺序**(需求原话:「拖动一个人的卡片进行排序…通过拖动
|
||||||
* 这里只有能写的人(管理员 / 家庭成员)才看得到那一行「调整分组」。
|
* 改变分组」):拖动本身是 JS,但**每个分组栏也是空的时候照样存在**,
|
||||||
|
* 拖动时统一露出来当落点,否则"想拖到一个现在没人的分组里"就没地方放。
|
||||||
|
* ③ **没有 JS 也完全能用**:卡片上的 `<details>调整分组</details>` 还在,
|
||||||
|
* 而且手机上的 HTML5 拖放本来就不生效,那套表单就是窄屏的正路。
|
||||||
*
|
*
|
||||||
* ⚠️ 卡片结构是 div > a + details,**不能写成 a > form**:
|
* ⚠️ 卡片结构是 div > a + details,**不能写成 a > form**:
|
||||||
* 表单套在链接里是非法 HTML,浏览器会把 DOM 拆开,表单里那个「移动」按钮
|
* 表单套在链接里是非法 HTML,浏览器会把 DOM 拆开,表单里那个「移动」按钮
|
||||||
* 可能连提交机会都没有(或者把整张卡片点成跳转)。
|
* 可能连提交机会都没有(或者把整张卡片点成跳转)。
|
||||||
*/
|
*/
|
||||||
$canMove = \App\Auth::canWrite();
|
$canMove = \App\Auth::canWrite();
|
||||||
$total = 0;
|
$pickable = $pickable ?? 0;
|
||||||
|
$total = 0;
|
||||||
foreach ($grouped as $list) {
|
foreach ($grouped as $list) {
|
||||||
$total += count($list);
|
$total += count($list);
|
||||||
}
|
}
|
||||||
@@ -22,11 +26,13 @@ foreach ($grouped as $list) {
|
|||||||
/**
|
/**
|
||||||
* 画一栏人。用闭包是因为"分好组的各栏"和"未分类"两处要画同一套卡片,
|
* 画一栏人。用闭包是因为"分好组的各栏"和"未分类"两处要画同一套卡片,
|
||||||
* 复制一遍迟早会改漏一处。
|
* 复制一遍迟早会改漏一处。
|
||||||
|
*
|
||||||
|
* ⚠️ `$groups` 必须写进 `use (...)`:卡片里的「调整分组」下拉要列出我能用的分组。
|
||||||
|
* 漏掉它的后果**不会报错**(PHP 8 只 warning 一句"未定义变量",
|
||||||
|
* 而 foreach 拿到 null 就是零次循环)—— 于是每张卡片的那个下拉里
|
||||||
|
* 只剩「未分类」一项,看着像"分组功能没做",其实是变量没传进来。
|
||||||
*/
|
*/
|
||||||
$renderGrid = function (array $list) use ($groups, $meId, $canMove): void {
|
$renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
|
||||||
if (!$list) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
?>
|
?>
|
||||||
<div class="people-grid">
|
<div class="people-grid">
|
||||||
<?php foreach ($list as $p): ?>
|
<?php foreach ($list as $p): ?>
|
||||||
@@ -40,9 +46,14 @@ $renderGrid = function (array $list) use ($groups, $meId, $canMove): void {
|
|||||||
$pNick = (string)($p['my_nickname'] ?? '');
|
$pNick = (string)($p['my_nickname'] ?? '');
|
||||||
$pid = (int)$p['id'];
|
$pid = (int)$p['id'];
|
||||||
?>
|
?>
|
||||||
<div class="person">
|
<div class="person" data-person="<?= $pid ?>" <?= $canMove ? 'draggable="true"' : '' ?>>
|
||||||
<a class="person-main" href="<?= e(url('people/show', ['id' => $pid])) ?>">
|
<?php if ($canMove): ?>
|
||||||
<div class="ph"><img src="<?= e(avatar_url($p['avatar'])) ?>" alt=""></div>
|
<span class="drag-handle" title="拖到我想要的位置 / 那一栏" aria-hidden="true">⠿</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php /* draggable="false" 很重要:链接与图片浏览器默认就是可拖的,
|
||||||
|
不关掉的话拖起来会变成"拖一个网址",父卡片反而拿不到这次拖动。 */ ?>
|
||||||
|
<a class="person-main" href="<?= e(url('people/show', ['id' => $pid])) ?>" draggable="false">
|
||||||
|
<div class="ph"><img src="<?= e(avatar_url($p['avatar'])) ?>" alt="" draggable="false"></div>
|
||||||
<div class="pb">
|
<div class="pb">
|
||||||
<div class="pname">
|
<div class="pname">
|
||||||
<?= e($pshow) ?>
|
<?= e($pshow) ?>
|
||||||
@@ -71,13 +82,13 @@ $renderGrid = function (array $list) use ($groups, $meId, $canMove): void {
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<?php if ($canMove): ?>
|
<?php if ($canMove): ?>
|
||||||
<details class="person-more">
|
<details class="person-more" data-no-drag>
|
||||||
<summary>调整分组</summary>
|
<summary aria-label="调整分组">⋯</summary>
|
||||||
<form method="post" action="<?= e(url('people/group')) ?>" class="person-move">
|
<form method="post" action="<?= e(url('people/group')) ?>" class="person-move">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="person_id" value="<?= $pid ?>">
|
<input type="hidden" name="person_id" value="<?= $pid ?>">
|
||||||
<label class="sr-only" for="pg<?= $pid ?>">分组</label>
|
<label class="sr-only" for="pg<?= $pid ?>">分组</label>
|
||||||
<select class="select" id="pg<?= $pid ?>" name="group_id">
|
<select class="select" id="pg<?= $pid ?>" name="group_id" data-no-drag>
|
||||||
<option value="">未分类</option>
|
<option value="">未分类</option>
|
||||||
<?php foreach ($groups as $g): $gid = (int)$g['id']; ?>
|
<?php foreach ($groups as $g): $gid = (int)$g['id']; ?>
|
||||||
<option value="<?= $gid ?>"
|
<option value="<?= $gid ?>"
|
||||||
@@ -86,7 +97,12 @@ $renderGrid = function (array $list) use ($groups, $meId, $canMove): void {
|
|||||||
</option>
|
</option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-ghost btn-sm" type="submit">移动</button>
|
<button class="btn btn-ghost btn-sm" type="submit" data-no-drag>移动</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="<?= e(url('people/forget')) ?>" class="person-move">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="person_id" value="<?= $pid ?>">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit" data-no-drag>从我的名册移走</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -95,18 +111,46 @@ $renderGrid = function (array $list) use ($groups, $meId, $canMove): void {
|
|||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 一栏(含空栏)。空栏平时收起来,拖动时由 JS 加 is-dragging 露出来当落点。 */
|
||||||
|
$renderBlock = function (int $gid, string $name, array $list) use ($renderGrid): void {
|
||||||
|
?>
|
||||||
|
<section class="group-block<?= $list ? '' : ' is-empty' ?>" data-group="<?= $gid ?>">
|
||||||
|
<h2 class="group-head">
|
||||||
|
<span class="group-title"><?= e($name) ?></span>
|
||||||
|
<span class="group-n"><span class="n"><?= count($list) ?></span> 人</span>
|
||||||
|
<span class="group-drop-hint">放到这里</span>
|
||||||
|
</h2>
|
||||||
|
<?php $renderGrid($list); ?>
|
||||||
|
</section>
|
||||||
|
<?php
|
||||||
|
};
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>家庭名册</h1>
|
<h1>家庭名册</h1>
|
||||||
<span class="sub">共 <?= $total ?> 人<?= $groups && $q === '' ? ',分 ' . count($groups) . ' 个关系分组' : '' ?></span>
|
<span class="sub">
|
||||||
|
共 <?= $total ?> 人<?= $groups && $q === '' ? ',分 ' . count($groups) . ' 个关系分组' : '' ?>
|
||||||
|
</span>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<?php if ($canMove && $pickable > 0): ?>
|
||||||
|
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/pick')) ?>">
|
||||||
|
从家里已有的人里挑(<?= $pickable ?>)
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
<?php if (\App\Auth::isAdmin()): ?>
|
<?php if (\App\Auth::isAdmin()): ?>
|
||||||
<a class="btn btn-sm" href="<?= e(url('people/new')) ?>">添加成员</a>
|
<a class="btn btn-sm" href="<?= e(url('people/new')) ?>">添加成员</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if ($canMove && $q === '' && $total > 0): ?>
|
||||||
|
<p class="small muted roster-tip" style="margin:0 0 12px">
|
||||||
|
拖动卡片(左边那个 <span class="drag-handle-inline">⠿</span>)就能改顺序,
|
||||||
|
拖到别的一栏里就是改分组。改的是<b>你自己那份名册</b>,不影响家里其他人。
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<form class="search" method="get" action="index.php">
|
<form class="search" method="get" action="index.php">
|
||||||
<input type="hidden" name="r" value="people">
|
<input type="hidden" name="r" value="people">
|
||||||
<input class="input" name="q" value="<?= e($q) ?>" placeholder="按姓名、称呼或邮箱搜索">
|
<input class="input" name="q" value="<?= e($q) ?>" placeholder="按姓名、称呼或邮箱搜索">
|
||||||
@@ -118,8 +162,29 @@ $renderGrid = function (array $list) use ($groups, $meId, $canMove): void {
|
|||||||
|
|
||||||
<?php if (!$people): ?>
|
<?php if (!$people): ?>
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<b><?= $q !== '' ? '没有匹配的人' : '名册还是空的' ?></b>
|
<?php if ($q !== ''): ?>
|
||||||
<?= $q !== '' ? '换个词试试。' : '把家里人加进来,之后观影、旅行的同行人就可以直接从名册里选。' ?>
|
<b>没有匹配的人</b>
|
||||||
|
换个词试试。
|
||||||
|
<?php elseif ($pickable > 0): ?>
|
||||||
|
<b>你的名册还是空的</b>
|
||||||
|
家里已经有 <?= (int)$pickable ?> 个人了 —— 直接挑过来就行,不用重新录一遍。
|
||||||
|
<div class="btn-row" style="justify-content:center;margin-top:14px">
|
||||||
|
<a class="btn" href="<?= e(url('people/pick')) ?>">从家里已有的人里挑</a>
|
||||||
|
<?php if (\App\Auth::isAdmin()): ?>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('people/new')) ?>">添加一个新人</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<b>名册还是空的</b>
|
||||||
|
<?php if (\App\Auth::isAdmin()): ?>
|
||||||
|
把家里人加进来,之后观影、旅行的同行人就可以直接从名册里选。
|
||||||
|
<div class="btn-row" style="justify-content:center;margin-top:14px">
|
||||||
|
<a class="btn" href="<?= e(url('people/new')) ?>">添加成员</a>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
等家里人把你加进来,或者让管理员给你开个账号。
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php elseif ($q !== ''): ?>
|
<?php elseif ($q !== ''): ?>
|
||||||
<?php /* 搜索时按"命中的人"平铺 —— 分栏会把一次搜索拆成一堆只有一两个人的碎块,反而更难看 */ ?>
|
<?php /* 搜索时按"命中的人"平铺 —— 分栏会把一次搜索拆成一堆只有一两个人的碎块,反而更难看 */ ?>
|
||||||
@@ -128,35 +193,25 @@ $renderGrid = function (array $list) use ($groups, $meId, $canMove): void {
|
|||||||
</p>
|
</p>
|
||||||
<?php $renderGrid($people); ?>
|
<?php $renderGrid($people); ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach ($groups as $g): ?>
|
<div class="roster<?= $canMove ? ' is-droppable' : '' ?>"
|
||||||
<?php
|
data-reorder="<?= e(url('people/reorder')) ?>"
|
||||||
$gid = (int)$g['id'];
|
data-csrf="<?= e(csrf_token()) ?>">
|
||||||
$list = $grouped[$gid] ?? [];
|
<?php foreach ($groups as $g): ?>
|
||||||
// 空分组不画 —— 七个预置分类里家里可能只用到三个,
|
<?php $gid = (int)$g['id']; ?>
|
||||||
// 把空栏也摊出来会让名册看上去破破烂烂的
|
<?php $renderBlock($gid, (string)$g['name'], $grouped[$gid] ?? []); ?>
|
||||||
if (!$list) {
|
<?php endforeach; ?>
|
||||||
continue;
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<section class="group-block">
|
|
||||||
<h2 class="group-head">
|
|
||||||
<span class="group-title"><?= e((string)$g['name']) ?></span>
|
|
||||||
<span class="group-n"><?= count($list) ?> 人</span>
|
|
||||||
</h2>
|
|
||||||
<?php $renderGrid($list); ?>
|
|
||||||
</section>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
|
|
||||||
<?php if (!empty($grouped[0])): ?>
|
<?php
|
||||||
<section class="group-block">
|
// 「未分类」栏永远画出来(哪怕是空的)—— 它是把卡片从某个分组里拖出来的落点。
|
||||||
<h2 class="group-head">
|
// 提示语按需求删掉了(用户原话:「这些人还没归到任何分组里…这些提示,都去掉」)。
|
||||||
<span class="group-title">未分类</span>
|
$renderBlock(0, '未分类', $grouped[0] ?? []);
|
||||||
<span class="group-n"><?= count($grouped[0]) ?> 人</span>
|
?>
|
||||||
</h2>
|
|
||||||
<p class="small muted" style="margin:-4px 0 12px">
|
<?php if (!$groups): ?>
|
||||||
这些人还没归到任何分组里。管理员可以在卡片下面直接选一个分组(或去「编辑成员」里改)。
|
<p class="small muted" style="margin:0">
|
||||||
|
还没有任何分组。管理员可以在「系统设置 → 公共分组」里建一套全家共用的,
|
||||||
|
你也可以在「个人设置 → 我的分组」里加只给自己看的。
|
||||||
</p>
|
</p>
|
||||||
<?php $renderGrid($grouped[0]); ?>
|
<?php endif; ?>
|
||||||
</section>
|
</div>
|
||||||
<?php endif; ?>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 「从家里已有的人里挑」—— 添加成员的第二个入口。
|
||||||
|
*
|
||||||
|
* 为什么要有这一页:同一个人不该被录两遍。
|
||||||
|
* 如果张伟已经被妈妈录进名册了,我再走「添加成员」录一遍,
|
||||||
|
* 系统里就会出现两条「张伟」—— 账号、亲属关系、生日各存一份,之后永远对不上。
|
||||||
|
* 所以这里把「这个家庭里已经有的人」列出来(带账号徽标,好认出是谁),
|
||||||
|
* 勾一个就把他**复用**进我的名册,一个字段都不用重填。
|
||||||
|
*
|
||||||
|
* 需求原话:「当一个用户添加一个名册的时候…如果这个人员信息在系统当中已经存在,
|
||||||
|
* 那么他可以直接复用这个人的信息,就不需要再重新录入。」
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>从家里已有的人里挑</h1>
|
||||||
|
<span class="sub">选中的会进你的名册,资料共用一份,不会重复录入</span>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-ghost btn-sm" href="<?= e(url('people')) ?>">回名册</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!$people): ?>
|
||||||
|
<div class="empty">
|
||||||
|
<b>没有可挑的人了</b>
|
||||||
|
家里已经有的人,都在你的名册里了。要加新人就点「添加成员」。
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="post" action="<?= e(url('people/pick')) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<p class="small muted" style="margin:0 0 14px">
|
||||||
|
这些人都已经在这个家庭里(可能是别人先录的)。勾上就会出现在你的名册里 ——
|
||||||
|
<b>资料是共用的那一份</b>,你以后改的也是同一份。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="field" style="max-width:280px;margin-bottom:16px">
|
||||||
|
<label for="pickGroup">加进哪一组</label>
|
||||||
|
<select class="select" id="pickGroup" name="group_id">
|
||||||
|
<option value="">先不分组(之后拖到那一栏也行)</option>
|
||||||
|
<?php foreach ($groups as $g): $gid = (int)$g['id']; ?>
|
||||||
|
<option value="<?= $gid ?>"><?= e((string)$g['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="pick-list">
|
||||||
|
<?php foreach ($people as $p): $pid = (int)$p['id']; ?>
|
||||||
|
<?php
|
||||||
|
// 显示名优先用「我怎么称呼他」:别人录的名字未必是我对他的叫法
|
||||||
|
$shown = (string)($p['my_nickname'] ?? '');
|
||||||
|
if ($shown === '') {
|
||||||
|
$shown = (string)$p['name'];
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<li class="pick-row">
|
||||||
|
<label class="pick-item">
|
||||||
|
<input type="checkbox" name="ids[]" value="<?= $pid ?>">
|
||||||
|
<img class="pick-ph" src="<?= e(avatar_url($p['avatar'])) ?>" alt="">
|
||||||
|
<span class="pick-name">
|
||||||
|
<?= e($shown) ?>
|
||||||
|
<?php if ($shown !== (string)$p['name']): ?>
|
||||||
|
<span class="muted small">· 本名 <?= e((string)$p['name']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">待确认</span><?php endif; ?>
|
||||||
|
<?php
|
||||||
|
$r = (string)($p['account_role'] ?? '');
|
||||||
|
if ($r === 'admin' && ($p['account_status'] ?? '') === 'active') {
|
||||||
|
echo '<span class="badge badge-admin">管理员</span>';
|
||||||
|
} elseif ($r === 'guest') {
|
||||||
|
echo '<span class="badge">访客</span>';
|
||||||
|
} elseif ($r !== '') {
|
||||||
|
echo '<span class="badge badge-open">已开通</span>';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn" type="submit">加进我的名册</button>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('people')) ?>">取消</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
+71
-20
@@ -40,9 +40,7 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' =
|
|||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1><?= e($pShown) ?></h1>
|
<h1><?= e($pShown) ?></h1>
|
||||||
<span class="sub">
|
<span class="sub">
|
||||||
<?php if ($isSelf): ?>
|
<?php if (!$isSelf): ?>
|
||||||
这是你自己
|
|
||||||
<?php else: ?>
|
|
||||||
<span class="rel-label"><?= e($labelToMe) ?></span>
|
<span class="rel-label"><?= e($labelToMe) ?></span>
|
||||||
<?php if ($isMyNick): ?>
|
<?php if ($isMyNick): ?>
|
||||||
· 本名 <?= e((string)$p['name']) ?>
|
· 本名 <?= e((string)$p['name']) ?>
|
||||||
@@ -51,11 +49,10 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' =
|
|||||||
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">虚拟人物</span><?php endif; ?>
|
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">虚拟人物</span><?php endif; ?>
|
||||||
</span>
|
</span>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<?php if ($isSelf): ?>
|
<?php if ($isSelf || $canW): ?>
|
||||||
<a class="btn btn-sm" href="<?= e(url('settings/me')) ?>#me-profile">改我自己的资料</a>
|
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/edit', ['id' => $pid])) ?>">
|
||||||
<?php endif; ?>
|
<?= $isSelf ? '编辑我的资料' : '编辑资料' ?>
|
||||||
<?php if ($canW): ?>
|
</a>
|
||||||
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/edit', ['id' => $pid])) ?>">编辑资料</a>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($admin): ?>
|
<?php if ($admin): ?>
|
||||||
<form method="post" action="<?= e(url('people/destroy')) ?>" style="display:inline"
|
<form method="post" action="<?= e(url('people/destroy')) ?>" style="display:inline"
|
||||||
@@ -362,6 +359,57 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' =
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- ============ 名册归属 / 分享名片 ============ -->
|
||||||
|
<?php if (!$isSelf): ?>
|
||||||
|
<div class="card card-pad" style="margin-top:16px">
|
||||||
|
<h2 class="section-title">名册 <span class="hint">名册是每人一份的</span></h2>
|
||||||
|
<p class="small muted" style="margin:0 0 14px">
|
||||||
|
<?php if (!empty($inMyRoster)): ?>
|
||||||
|
「<?= e($pShown) ?>」在你的名册里。归到哪一组、排第几,只影响你自己看到的名册 ——
|
||||||
|
家里其他人各有一份,互不打搅。
|
||||||
|
<?php else: ?>
|
||||||
|
「<?= e($pShown) ?>」还不在你的名册里。你仍然能在家谱与首页看到他,
|
||||||
|
但名册只列<b>你自己加的人</b>和<b>别人分享给你的名片</b>。
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
<div class="btn-row" style="margin-top:0;flex-wrap:wrap;align-items:center">
|
||||||
|
<?php if (empty($inMyRoster) && $canW): ?>
|
||||||
|
<form method="post" action="<?= e(url('people/pick')) ?>" style="display:inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="ids[]" value="<?= $pid ?>">
|
||||||
|
<button class="btn btn-sm" type="submit">加进我的名册</button>
|
||||||
|
</form>
|
||||||
|
<?php elseif (!empty($inMyRoster) && $canW): ?>
|
||||||
|
<form method="post" action="<?= e(url('people/forget')) ?>" style="display:inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="person_id" value="<?= $pid ?>">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">从我的名册里移走</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($canW && !empty($options)): ?>
|
||||||
|
<?php /* 需求原话:「我可以把一个人的名片,分享给一个用户,
|
||||||
|
则这个名片,自动出现在这个用户的名册中」。 */ ?>
|
||||||
|
<details class="share-wrap">
|
||||||
|
<summary class="btn btn-ghost btn-sm">分享他的名片给…</summary>
|
||||||
|
<form method="post" action="<?= e(url('people/share')) ?>" class="inline-form">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="person_id" value="<?= $pid ?>">
|
||||||
|
<input type="hidden" name="back" value="<?= $pid ?>">
|
||||||
|
<label class="sr-only" for="shareTo">分享给谁</label>
|
||||||
|
<select class="select" id="shareTo" name="to_id">
|
||||||
|
<?php foreach ($options as $o): $oid = (int)$o['id']; ?>
|
||||||
|
<option value="<?= $oid ?>"><?= e((string)$o['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm" type="submit">分享</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($admin): ?>
|
<?php if ($admin): ?>
|
||||||
<div class="card card-pad" style="margin-top:16px">
|
<div class="card card-pad" style="margin-top:16px">
|
||||||
<h2 class="section-title">登录账号</h2>
|
<h2 class="section-title">登录账号</h2>
|
||||||
@@ -396,17 +444,22 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' =
|
|||||||
<?= $role === 'guest' ? '—— 只能查看,不能新增或修改任何内容。' : '' ?>
|
<?= $role === 'guest' ? '—— 只能查看,不能新增或修改任何内容。' : '' ?>
|
||||||
</p>
|
</p>
|
||||||
<div class="btn-row" style="margin-top:0;flex-wrap:wrap">
|
<div class="btn-row" style="margin-top:0;flex-wrap:wrap">
|
||||||
|
<form method="post" action="<?= e(url('accounts/role')) ?>" style="display:inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="person_id" value="<?= $pid ?>">
|
||||||
|
<select class="select" name="role" style="width:auto;display:inline-block">
|
||||||
|
<option value="member" <?= $role === 'member' ? 'selected' : '' ?>>家庭成员</option>
|
||||||
|
<option value="admin" <?= $role === 'admin' ? 'selected' : '' ?>>管理员</option>
|
||||||
|
<option value="guest" <?= $role === 'guest' ? 'selected' : '' ?>>访客</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">改角色</button>
|
||||||
|
<?php if ($isSelf): ?>
|
||||||
|
<?php /* 自己也能改自己的权限(哪怕给自己降级)—— 系统的家可能就一个人用,
|
||||||
|
难道他还不能调自己的权限?唯一拦的是"降完一个管理员都不剩"。 */ ?>
|
||||||
|
<span class="small muted">可以改自己的。只有「把最后一位管理员降级」会被拦下。</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
<?php if (!$isSelf): ?>
|
<?php if (!$isSelf): ?>
|
||||||
<form method="post" action="<?= e(url('accounts/role')) ?>" style="display:inline">
|
|
||||||
<?= csrf_field() ?>
|
|
||||||
<input type="hidden" name="person_id" value="<?= $pid ?>">
|
|
||||||
<select class="select" name="role" style="width:auto;display:inline-block">
|
|
||||||
<option value="member" <?= $role === 'member' ? 'selected' : '' ?>>家庭成员</option>
|
|
||||||
<option value="admin" <?= $role === 'admin' ? 'selected' : '' ?>>管理员</option>
|
|
||||||
<option value="guest" <?= $role === 'guest' ? 'selected' : '' ?>>访客</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-ghost btn-sm" type="submit">改角色</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="<?= e(url('accounts/status')) ?>" style="display:inline">
|
<form method="post" action="<?= e(url('accounts/status')) ?>" style="display:inline">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="person_id" value="<?= $pid ?>">
|
<input type="hidden" name="person_id" value="<?= $pid ?>">
|
||||||
@@ -415,8 +468,6 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' =
|
|||||||
<?= $p['account_status'] === 'active' ? '停用账号' : '启用账号' ?>
|
<?= $p['account_status'] === 'active' ? '停用账号' : '启用账号' ?>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<?php else: ?>
|
|
||||||
<span class="small muted">这是你自己 —— 要改自己的权限,请让另一位管理员来操作。</span>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -31,6 +31,98 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<?php if (($upgradeWarn ?? '') !== ''): ?>
|
||||||
|
<div class="flash" style="margin-top:16px">
|
||||||
|
<div class="flash-item err">
|
||||||
|
<b>上次结构升级有步骤失败了</b>(其余步骤已经跑完,站点可用):<br>
|
||||||
|
<?= e((string)$upgradeWarn) ?><br>
|
||||||
|
<span class="muted small">修好之后可以清掉这条提示。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- ==================== 公共分组 ==================== -->
|
||||||
|
<?php
|
||||||
|
$pubGroups = $pubGroups ?? [];
|
||||||
|
$pubCounts = $pubCounts ?? [];
|
||||||
|
$pubTotal = count($pubGroups);
|
||||||
|
?>
|
||||||
|
<div class="card card-pad" style="margin-top:20px" id="public-groups">
|
||||||
|
<h2 class="section-title">
|
||||||
|
公共分组
|
||||||
|
<span class="hint">全家人共用的一套</span>
|
||||||
|
</h2>
|
||||||
|
<p class="small muted" style="margin:0 0 16px">
|
||||||
|
名册按「关系分组」分栏显示。这里维护的是<b>公共的那一套</b> ——
|
||||||
|
建一次,全家每个人都能拿来用。
|
||||||
|
<br>
|
||||||
|
每个人还可以在「个人设置 → 我的分组」里<b>再加只给自己看的分组</b>,
|
||||||
|
那部分不在这儿、也不会影响别人。挪顺序用 ▲ ▼(▲▼ 调的是"全家的默认顺序",
|
||||||
|
各人仍可在个人设置里改成自己习惯的顺序)。
|
||||||
|
<br>
|
||||||
|
删掉一个分组<b>不会删掉人</b>:名册里归到那一组的人会变成「未分类」,
|
||||||
|
各自重新归类即可。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php if ($pubGroups): ?>
|
||||||
|
<ol class="order-list">
|
||||||
|
<?php foreach ($pubGroups as $i => $g): $gid = (int)$g['id']; ?>
|
||||||
|
<li class="order-row">
|
||||||
|
<span class="order-no"><?= $i + 1 ?></span>
|
||||||
|
<span class="order-name"><?= e((string)$g['name']) ?></span>
|
||||||
|
<span class="order-count"><?= (int)($pubCounts[$gid] ?? 0) ?> 人</span>
|
||||||
|
<form method="post" action="<?= e(url('settings/groups/move')) ?>" class="order-move">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= $gid ?>">
|
||||||
|
<input type="hidden" name="scope" value="public">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="up"
|
||||||
|
title="上移一组" <?= $i === 0 ? 'disabled' : '' ?>>▲</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="down"
|
||||||
|
title="下移一组" <?= $i === $pubTotal - 1 ? 'disabled' : '' ?>>▼</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ol>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" action="<?= e(url('settings/groups/add')) ?>" class="inline-form" style="margin-top:16px">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="scope" value="public">
|
||||||
|
<label class="sr-only" for="newPubGroup">新分组名</label>
|
||||||
|
<input class="input" id="newPubGroup" name="name" maxlength="20" placeholder="例如:邻居、亲家、老同事">
|
||||||
|
<button class="btn btn-sm" type="submit">加一个公共分组</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if ($pubGroups): ?>
|
||||||
|
<ul class="group-manage" style="margin-top:14px">
|
||||||
|
<?php foreach ($pubGroups as $g): $gid = (int)$g['id']; ?>
|
||||||
|
<li class="group-manage-row">
|
||||||
|
<form method="post" action="<?= e(url('settings/groups/rename')) ?>" class="group-rename">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= $gid ?>">
|
||||||
|
<input type="hidden" name="scope" value="public">
|
||||||
|
<label class="sr-only" for="pgname<?= $gid ?>">分组名</label>
|
||||||
|
<input class="input" id="pgname<?= $gid ?>" name="name" maxlength="20"
|
||||||
|
value="<?= e((string)$g['name']) ?>">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">改名</button>
|
||||||
|
</form>
|
||||||
|
<span class="group-count muted small"><?= (int)($pubCounts[$gid] ?? 0) ?> 人</span>
|
||||||
|
<details class="del-wrap">
|
||||||
|
<summary class="btn btn-ghost btn-sm">删除</summary>
|
||||||
|
<form method="post" action="<?= e(url('settings/groups/delete')) ?>" class="del-confirm">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= $gid ?>">
|
||||||
|
<input type="hidden" name="scope" value="public">
|
||||||
|
<span class="small muted">这组的人会变成「未分类」,确定?</span>
|
||||||
|
<button class="btn btn-ghost btn-sm danger" type="submit">确认删除</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ==================== 附件存储 ==================== -->
|
<!-- ==================== 附件存储 ==================== -->
|
||||||
<form method="post" action="<?= e(url('settings/storage')) ?>" class="card card-pad" style="margin-top:20px">
|
<form method="post" action="<?= e(url('settings/storage')) ?>" class="card card-pad" style="margin-top:20px">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* 「我的资料」表单(个人设置的第一块)。
|
|
||||||
*
|
|
||||||
* 只放**属于我自己的**东西:
|
|
||||||
* · 我的姓名、性别、头像、联系方式、备注
|
|
||||||
* · 我的三个生日(身份证生日只读,由证件推导)
|
|
||||||
* · 我的 slogan(没填就用家庭统一的那句)
|
|
||||||
* 排行、排序号、虚拟人物标记、以及会牵动身份证生日的身份证号,
|
|
||||||
* 都属于"管理员编名册",不在这里(这也是为了不让一次自助提交悄悄改掉这些)。
|
|
||||||
*
|
|
||||||
* 作为独立片段是因为「个人设置」页还要接上称呼、分组顺序、分类管理三块,
|
|
||||||
* 全塞一个文件里会变成没人愿意读的六百行。
|
|
||||||
*/
|
|
||||||
$p = $person;
|
|
||||||
$avatarUrl = avatar_url($p['avatar'] ?? null);
|
|
||||||
$bdSource = old('birthday_source', 'solar');
|
|
||||||
$idBd = trim((string)($p['birthday_id_card'] ?? ''));
|
|
||||||
// 裁剪比例由「这个位置显示成什么形状」决定,前后端共用同一份配置(见 App\ImageSlot)
|
|
||||||
$avSlot = \App\ImageSlot::get('avatar');
|
|
||||||
?>
|
|
||||||
|
|
||||||
<form method="post" enctype="multipart/form-data" action="<?= e(url('settings/me_save')) ?>">
|
|
||||||
<?= csrf_field() ?>
|
|
||||||
<div class="card card-pad">
|
|
||||||
<div class="form-grid">
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="name">姓名 *</label>
|
|
||||||
<input class="input" id="name" name="name" value="<?= e(old('name', $p['name'])) ?>" required>
|
|
||||||
<?php if (!empty($errors['name'])): ?><span class="err"><?= e($errors['name']) ?></span><?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="gender">性别</label>
|
|
||||||
<select class="select" id="gender" name="gender">
|
|
||||||
<?php $g = old('gender', $p['gender'] ?? ''); ?>
|
|
||||||
<option value="" <?= $g === '' ? 'selected' : '' ?>>不填</option>
|
|
||||||
<option value="m" <?= $g === 'm' ? 'selected' : '' ?>>男</option>
|
|
||||||
<option value="f" <?= $g === 'f' ? 'selected' : '' ?>>女</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field full">
|
|
||||||
<label for="slogan">我的一句话</label>
|
|
||||||
<input class="input" id="slogan" name="slogan" maxlength="200"
|
|
||||||
value="<?= e(old('slogan', $p['slogan'] ?? '')) ?>"
|
|
||||||
placeholder="留空就用家庭统一的那句:<?= e($familyMotto !== '' ? $familyMotto : '(还没设)') ?>">
|
|
||||||
<span class="tip">显示在你自己的首页标题下面。</span>
|
|
||||||
<?php if (!empty($errors['slogan'])): ?><span class="err"><?= e($errors['slogan']) ?></span><?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ==================== 我的三个生日 ==================== -->
|
|
||||||
<div class="field full">
|
|
||||||
<label>我的生日</label>
|
|
||||||
<p class="small muted" style="margin:0 0 12px">
|
|
||||||
身份证上的那个由证件推导,<b>系统永远不会改它</b>;
|
|
||||||
下面两个是"实际过的阳历生日"和"长辈记的农历生日"。
|
|
||||||
改其中任意一个,另一个会自动跟着变。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="field" style="margin-bottom:12px">
|
|
||||||
<label>身份证生日(只读)</label>
|
|
||||||
<span class="bd-readonly"><?= $idBd !== '' ? e($idBd) : '名册里还没填身份证号' ?></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bd-source">
|
|
||||||
<label for="bdSource">我这次改的是</label>
|
|
||||||
<select class="select" id="bdSource" name="birthday_source" style="width:auto">
|
|
||||||
<option value="solar" <?= $bdSource === 'solar' ? 'selected' : '' ?>>阳历(公历)</option>
|
|
||||||
<option value="lunar" <?= $bdSource === 'lunar' ? 'selected' : '' ?>>农历(阴历)</option>
|
|
||||||
</select>
|
|
||||||
<span class="small muted">选中的那一边为准,另一边自动算</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bd-pane" id="bdSolarWrap">
|
|
||||||
<p class="bd-pane-k">阳历生日</p>
|
|
||||||
<?php
|
|
||||||
$dtName = 'birthday_solar';
|
|
||||||
$dtValue = old('birthday_solar', \App\Birthday::pretty(\App\Birthday::solarOf($p)));
|
|
||||||
$dtId = 'birthday_solar_date';
|
|
||||||
$dtRequired = false;
|
|
||||||
require __DIR__ . '/../_datetime.php';
|
|
||||||
?>
|
|
||||||
<?php if (!empty($errors['birthday_solar'])): ?><span class="err"><?= e($errors['birthday_solar']) ?></span><?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bd-pane" id="bdLunarWrap">
|
|
||||||
<p class="bd-pane-k">农历生日</p>
|
|
||||||
<?php
|
|
||||||
$dtName = 'birthday_lunar';
|
|
||||||
$dtValue = old('birthday_lunar', (string)($p['birthday_lunar'] ?? ''));
|
|
||||||
$dtId = 'birthday_lunar_date';
|
|
||||||
$dtRequired = false;
|
|
||||||
require __DIR__ . '/../_datetime.php';
|
|
||||||
?>
|
|
||||||
<label class="check-item" style="margin-top:8px">
|
|
||||||
<input type="checkbox" name="birthday_lunar_leap" value="1"
|
|
||||||
<?= old('birthday_lunar_leap', $p['birthday_lunar_leap'] ?? 0) ? 'checked' : '' ?>>
|
|
||||||
<span>这个月是<b>闰月</b>(如「闰六月」)</span>
|
|
||||||
</label>
|
|
||||||
<?php if (!empty($errors['birthday_lunar'])): ?><span class="err"><?= e($errors['birthday_lunar']) ?></span><?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="email">邮箱</label>
|
|
||||||
<input class="input" id="email" name="email" type="email"
|
|
||||||
value="<?= e(old('email', $p['email'] ?? '')) ?>">
|
|
||||||
<?php if (!empty($errors['email'])): ?><span class="err"><?= e($errors['email']) ?></span><?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="address">住址</label>
|
|
||||||
<input class="input" id="address" name="address"
|
|
||||||
value="<?= e(old('address', $p['address'] ?? '')) ?>">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field full">
|
|
||||||
<label for="note">备注</label>
|
|
||||||
<textarea class="input" id="note" name="note"><?= e(old('note', $p['note'] ?? '')) ?></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="avatar">换头像</label>
|
|
||||||
<input class="input" id="avatar" name="avatar" type="file" accept="image/*"
|
|
||||||
data-crop-ratio="<?= e((string)$avSlot['ratio']) ?>"
|
|
||||||
data-crop-w="<?= (int)$avSlot['w'] ?>"
|
|
||||||
data-crop-h="<?= (int)$avSlot['h'] ?>"
|
|
||||||
data-crop-label="<?= e((string)$avSlot['label']) ?>"
|
|
||||||
data-crop-hint="<?= e((string)$avSlot['hint']) ?>"
|
|
||||||
data-crop-preview="#avatar-preview">
|
|
||||||
<?php /* 用户挑的裁剪框(归一化 0..1,相对原图)。不裁/没 JS 就留空 = 居中裁切 */ ?>
|
|
||||||
<input type="hidden" name="avatar_crop" value="">
|
|
||||||
<span class="tip">
|
|
||||||
选好图片后会弹出裁剪框,把要留的部分框住。<b>比例按它在页面上的形状定</b>
|
|
||||||
(头像是 1:1 正方形)。上限 5MB。
|
|
||||||
</span>
|
|
||||||
<?php if (!empty($errors['avatar'])): ?><span class="err"><?= e($errors['avatar']) ?></span><?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>当前头像</label>
|
|
||||||
<img id="avatar-preview" src="<?= e($avatarUrl) ?>" alt=""
|
|
||||||
style="width:88px;height:88px;border-radius:12px;object-fit:cover;border:1px solid var(--line)">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-row">
|
|
||||||
<button class="btn" type="submit">保存我的资料</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
+63
-43
@@ -3,23 +3,23 @@
|
|||||||
* 个人设置 —— 「我自己的」设置。
|
* 个人设置 —— 「我自己的」设置。
|
||||||
*
|
*
|
||||||
* 与「系统设置」(?r=settings,仅管理员)的分工:
|
* 与「系统设置」(?r=settings,仅管理员)的分工:
|
||||||
* · 系统设置改一次**全家都变**:家庭 slogan、附件存储、运行环境;
|
* · 系统设置改一次**全家都变**:slogan 兜底、附件存储、运行环境、**公共分组**;
|
||||||
* · 个人设置只影响**我自己看到的样子**:我的资料、我怎么称呼别人、
|
* · 个人设置只影响**我自己看到的样子**:我怎么称呼家人、我自己的分组。
|
||||||
* 名册里分组的先后顺序、以及关系分类的增删改名。
|
|
||||||
*
|
*
|
||||||
* ⚠️ 关于「顺序」与「分类」这两个都不在系统设置里,理由是:
|
* ⚠️ 「我的资料」表单**不在这页**(用户明确要求合并):
|
||||||
* 分类是全家共用的一套(老杨的同事,对全家来说都是同事),所以它写在共享的表里;
|
* 「编辑成员」和「改自己的资料」本来就是一件事,所以只剩
|
||||||
* 但**哪一组排前面纯属个人习惯** —— 爸爸把「家人」排第一、妈妈把「亲戚」排第一,
|
* `templates/people/form.php` 那一张表单,两个入口都指过去 ——
|
||||||
* 这种偏好存在 user_settings 里,跟 family 级的 settings 严格分开,
|
* 这页顶部那个按钮,和名册卡片上的「编辑资料」,落到的完全是同一个页面。
|
||||||
* 免得"改一个人的偏好"误伤到全家。
|
|
||||||
*/
|
*/
|
||||||
$groupCounts = $groupCounts ?? [];
|
$groupCounts = $groupCounts ?? [];
|
||||||
$groupTotal = count($groups);
|
$groupTotal = count($groups);
|
||||||
|
$rosterCount = $rosterCount ?? 0;
|
||||||
?>
|
?>
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>个人设置</h1>
|
<h1>个人设置</h1>
|
||||||
<span class="sub">只影响你自己看到的样子</span>
|
<span class="sub">只影响你自己看到的样子</span>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<a class="btn btn-sm" href="<?= e(url('people/edit', ['id' => (int)$person['id']])) ?>">编辑我的资料</a>
|
||||||
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/show', ['id' => (int)$person['id']])) ?>">看我的详情页</a>
|
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/show', ['id' => (int)$person['id']])) ?>">看我的详情页</a>
|
||||||
<?php if (\App\Auth::isAdmin()): ?>
|
<?php if (\App\Auth::isAdmin()): ?>
|
||||||
<a class="btn btn-ghost btn-sm" href="<?= e(url('settings')) ?>">系统设置</a>
|
<a class="btn btn-ghost btn-sm" href="<?= e(url('settings')) ?>">系统设置</a>
|
||||||
@@ -27,36 +27,58 @@ $groupTotal = count($groups);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="section-title" id="me-profile">我的资料 <span class="hint">姓名、头像、生日、联系方式</span></h2>
|
<div class="card card-pad">
|
||||||
<?php require __DIR__ . '/_profile.php'; ?>
|
<h2 class="section-title">我的资料 <span class="hint">姓名、头像、生日、联系方式</span></h2>
|
||||||
|
<p class="small muted" style="margin:0 0 14px">
|
||||||
|
<?= e((string)$person['name']) ?>
|
||||||
|
<?php if (trim((string)($person['slogan'] ?? '')) !== ''): ?>
|
||||||
|
· 「<?= e((string)$person['slogan']) ?>」
|
||||||
|
<?php endif; ?>
|
||||||
|
<br>
|
||||||
|
资料的编辑表单只有一张,和「编辑成员」是同一个页面 —— 从这里进去改的就是你自己。
|
||||||
|
</p>
|
||||||
|
<div class="btn-row" style="margin-top:0">
|
||||||
|
<a class="btn" href="<?= e(url('people/edit', ['id' => (int)$person['id']])) ?>">编辑我的资料</a>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('auth/password')) ?>">改登录密码</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="stack-gap"></div>
|
<div class="stack-gap"></div>
|
||||||
<?php require __DIR__ . '/_nicknames.php'; ?>
|
<?php require __DIR__ . '/_nicknames.php'; ?>
|
||||||
|
|
||||||
<!-- ==================== 名册分组的先后顺序 ==================== -->
|
<!-- ==================== 我的分组 ==================== -->
|
||||||
<div class="card card-pad" style="margin-top:20px">
|
<div class="card card-pad" style="margin-top:20px">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
名册分组顺序
|
我的分组
|
||||||
<span class="hint">只影响你自己看到的顺序</span>
|
<span class="hint">公共的 + 我自己加的</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="small muted" style="margin:0 0 16px">
|
<p class="small muted" style="margin:0 0 16px">
|
||||||
名册是按「关系分组」分栏显示的。分组本身是全家共用的一套,
|
名册是按「关系分组」分栏显示的。这里有两层:
|
||||||
但<b>哪一组排前面是你自己的习惯</b> —— 你在这里调,别人看到的顺序不变。
|
<b>公共分组</b>由管理员在「系统设置」里维护(全家都能用);
|
||||||
<?php if ($groupTotal > 1): ?><br>用 ▲ ▼ 把它挪到你要的位置,挪一下就存一次。<?php endif; ?>
|
你要是觉得不够,就在下面<b>加只给自己看的分组</b>,别人看不到、也不受影响。
|
||||||
|
<br>
|
||||||
|
列表里 <b>{{ 公共 }}</b> 标的是大家共用的那几个。
|
||||||
|
哪一组排前面也是你自己的习惯,用 ▲ ▼ 调 —— 别人看到的顺序不变。
|
||||||
|
<br>
|
||||||
|
你的名册现在有 <b><?= (int)$rosterCount ?></b> 人。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<?php if (!$groups): ?>
|
<?php if (!$groups): ?>
|
||||||
<p class="small muted" style="margin:0">还没有分组。</p>
|
<p class="small muted" style="margin:0 0 14px">还没有分组。加一个吧。</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<ol class="order-list">
|
<ol class="order-list">
|
||||||
<?php foreach ($groups as $i => $g): $gid = (int)$g['id']; ?>
|
<?php foreach ($groups as $i => $g): $gid = (int)$g['id']; $own = (int)$g['owner_id']; ?>
|
||||||
<li class="order-row">
|
<li class="order-row">
|
||||||
<span class="order-no"><?= $i + 1 ?></span>
|
<span class="order-no"><?= $i + 1 ?></span>
|
||||||
<span class="order-name"><?= e((string)$g['name']) ?></span>
|
<span class="order-name">
|
||||||
|
<?= e((string)$g['name']) ?>
|
||||||
|
<?php if ($own === 0): ?><span class="badge">公共</span><?php else: ?><span class="badge badge-open">我的</span><?php endif; ?>
|
||||||
|
</span>
|
||||||
<span class="order-count"><?= (int)($groupCounts[$gid] ?? 0) ?> 人</span>
|
<span class="order-count"><?= (int)($groupCounts[$gid] ?? 0) ?> 人</span>
|
||||||
<form method="post" action="<?= e(url('settings/groups/move')) ?>" class="order-move">
|
<form method="post" action="<?= e(url('settings/groups/move')) ?>" class="order-move">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="<?= $gid ?>">
|
<input type="hidden" name="id" value="<?= $gid ?>">
|
||||||
|
<input type="hidden" name="scope" value="me">
|
||||||
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="up"
|
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="up"
|
||||||
title="上移一组" <?= $i === 0 ? 'disabled' : '' ?>>▲</button>
|
title="上移一组" <?= $i === 0 ? 'disabled' : '' ?>>▲</button>
|
||||||
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="down"
|
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="down"
|
||||||
@@ -66,38 +88,29 @@ $groupTotal = count($groups);
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ol>
|
</ol>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ==================== 关系分类的增删改名 ==================== -->
|
<form method="post" action="<?= e(url('settings/groups/add')) ?>" class="inline-form" style="margin-top:16px">
|
||||||
<div class="card card-pad" style="margin-top:20px">
|
|
||||||
<h2 class="section-title">
|
|
||||||
关系分类管理
|
|
||||||
<span class="hint">分类是全家共用的</span>
|
|
||||||
</h2>
|
|
||||||
<p class="small muted" style="margin:0 0 16px">
|
|
||||||
预置了「家人 / 亲戚 / 同事 / 朋友 / 合作伙伴 / 认识 / 不熟」七个。
|
|
||||||
<b>不够用就自己加</b>,叫法不合适就改名。
|
|
||||||
分类是全家共用的一套(你加的,家里人都会看到)——
|
|
||||||
因为「老杨的同事」对全家来说都是同事,各人各定一套会让名册没法看。
|
|
||||||
<br>
|
|
||||||
删掉一个分类<b>不会删掉人</b>:那一组人会变成「未分类」,在名册里提醒你重新归类。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form method="post" action="<?= e(url('settings/groups/add')) ?>" class="inline-form">
|
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<label class="sr-only" for="newGroup">新分类名</label>
|
<input type="hidden" name="scope" value="me">
|
||||||
<input class="input" id="newGroup" name="name" maxlength="20" placeholder="例如:邻居、球友、老同学">
|
<label class="sr-only" for="newMyGroup">新分组名</label>
|
||||||
<button class="btn btn-sm" type="submit">加一个分类</button>
|
<input class="input" id="newMyGroup" name="name" maxlength="20" placeholder="例如:球友、老同学">
|
||||||
|
<button class="btn btn-sm" type="submit">加一个我自己的分组</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<?php if ($groups): ?>
|
<?php
|
||||||
<ul class="group-manage">
|
// 只有我自己建的分组才给改名 / 删除 —— 公共分组要在系统设置里改(那是管理员的事),
|
||||||
<?php foreach ($groups as $g): $gid = (int)$g['id']; ?>
|
// 免得有人在自己设置里把"家人"改成别的、影响全家。
|
||||||
|
$mineGroups = array_values(array_filter($groups, fn($g) => (int)$g['owner_id'] > 0));
|
||||||
|
?>
|
||||||
|
<?php if ($mineGroups): ?>
|
||||||
|
<ul class="group-manage" style="margin-top:14px">
|
||||||
|
<?php foreach ($mineGroups as $g): $gid = (int)$g['id']; ?>
|
||||||
<li class="group-manage-row">
|
<li class="group-manage-row">
|
||||||
<form method="post" action="<?= e(url('settings/groups/rename')) ?>" class="group-rename">
|
<form method="post" action="<?= e(url('settings/groups/rename')) ?>" class="group-rename">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="<?= $gid ?>">
|
<input type="hidden" name="id" value="<?= $gid ?>">
|
||||||
<label class="sr-only" for="gname<?= $gid ?>">分类名</label>
|
<input type="hidden" name="scope" value="me">
|
||||||
|
<label class="sr-only" for="gname<?= $gid ?>">分组名</label>
|
||||||
<input class="input" id="gname<?= $gid ?>" name="name" maxlength="20"
|
<input class="input" id="gname<?= $gid ?>" name="name" maxlength="20"
|
||||||
value="<?= e((string)$g['name']) ?>">
|
value="<?= e((string)$g['name']) ?>">
|
||||||
<button class="btn btn-ghost btn-sm" type="submit">改名</button>
|
<button class="btn btn-ghost btn-sm" type="submit">改名</button>
|
||||||
@@ -109,6 +122,7 @@ $groupTotal = count($groups);
|
|||||||
<form method="post" action="<?= e(url('settings/groups/delete')) ?>" class="del-confirm">
|
<form method="post" action="<?= e(url('settings/groups/delete')) ?>" class="del-confirm">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="<?= $gid ?>">
|
<input type="hidden" name="id" value="<?= $gid ?>">
|
||||||
|
<input type="hidden" name="scope" value="me">
|
||||||
<span class="small muted">这组的人会变成「未分类」,确定?</span>
|
<span class="small muted">这组的人会变成「未分类」,确定?</span>
|
||||||
<button class="btn btn-ghost btn-sm danger" type="submit">确认删除</button>
|
<button class="btn btn-ghost btn-sm danger" type="submit">确认删除</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -117,4 +131,10 @@ $groupTotal = count($groups);
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (\App\Auth::isAdmin()): ?>
|
||||||
|
<p class="small muted" style="margin:16px 0 0">
|
||||||
|
公共分组在「<a href="<?= e(url('settings')) ?>#public-groups">系统设置</a>」里维护。
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user