登录居中 · 个人 slogan · 三种生日 · 称呼视角化 · 附件统一走代理

按九点反馈一次性改造:

1. 登录页「图标 + 用手机号登录你的家书」包进 .auth-head 并真正居中
   (以前只加了个 class 名,没有样式,看着像做了其实没做)。

2. slogan 改成两段式:系统级 slogan 作兜底,每个人可在自己的资料里写一个,
   填了就显示自己的、留空就回落到系统那句。settings 里「家庭名称」字段去掉
   (只在安装时归档,不参与展示)。

3. 三个生日:身份证生日(从证件号截取,永不改动)/真实阳历/农历。
   同一人填了其一即自动推出另一个(靠 birthday_source 指明填的是哪一侧,
   不依赖 JS)。三者落在同一天时合并成一条提醒,否则分别提醒。
   农历表不是照抄流传的那份 lunarInfo(它在 5 个年份上是错的)——
   用五个独立实现按 7.3 万天多数表决重新推导,再要求 100% 重现。

4. 称呼改成「视角属性」:nicknames(family_id, viewer_id, target_id, nickname)。
   「小明」不是某个人的属性——同一个人被不同人叫法不同,所以是「谁看」的属性。
   旧的 persons.nickname 由 Nickname::migrateLegacy() 搬到新表,只搬一次。

5. 点自己的头像/名字直接进「我的资料」,不必再绕
   「名册 → 找到自己 → 编辑资料」三步。

6. 附件(头像与其它上传)一律走 media.php 代理,不再直连 uploads/ 或云端地址:
   可读性由登录态决定而不是「知道文件名」,Content-Type/缓存/下载头由服务端统一给。
   uploads/ 整个目录 deny(含历史遗留备份文件),并有真实探针验证。

7. OSS 设置页加「连通性测试」(本地 / 云端两个按钮),逐步给出结论与耗时。

8. 生日输入改用「原生日期框 + 24 小时制时:分:秒 三个下拉」:
   datetime-local 的格式由浏览器 UI 语言决定(中文环境渲染成「下午 03:30」,
   没有秒、还容易把上下午填反)。点输入框任意一处都能弹出选择器(showPicker)。

9. 结构版本号收敛成 Schema::VERSION 一处,安装时即盖章,避免首次请求白跑升级。

验证(全部为「先注入 bug 证伪、再跑通过」):
  php -l 42 个文件;e2e 149 条;老库升级专测 37 条;反向验证 23 个注入全被抓;
  桌面几何 108 条(768/1280/1920/2560);移动端真机几何 212 条(360/375/390/414);
  交互检查 49 条(生日整框可点、登录居中)。
This commit is contained in:
2026-09-30 09:44:55 +08:00
parent 1669ab48d0
commit 7e9b4520c0
26 changed files with 2561 additions and 204 deletions
+205 -17
View File
@@ -12,10 +12,12 @@ require __DIR__ . '/app/bootstrap.php';
use App\Account;
use App\Auth;
use App\Birthday;
use App\Crypto;
use App\Database;
use App\Family;
use App\Kinship;
use App\Nickname;
use App\Person;
use App\Relations;
use App\Schema;
@@ -35,7 +37,7 @@ if (!Database::hasTables()) {
* 新的 guest 角色。靠人去手动 ALTER 迟早会漏,所以在每次请求的开头比一下版本号,
* 落后了就补一次 —— 平时只是一次字符串比较,几乎零成本。
*/
const FB_SCHEMA_VERSION = '2';
const FB_SCHEMA_VERSION = Schema::VERSION;
if (Database::hasTables()) {
$familyIdForSchema = isset($_SESSION['family_id']) ? (int)$_SESSION['family_id'] : 0;
// 登录前还没有 family_id,用首个家庭兜底(本系统是单家庭部署)
@@ -71,6 +73,33 @@ if (!Auth::check() && !in_array($r, $publicRoutes, true)) {
redirect('auth/login');
}
/**
* 从表单里读「三个生日」。
*
* 为什么要有 `birthday_source` 这个显式字段:
* 阳历生日与农历生日说的是**同一个人的同一天**,不能各填各的(会自相矛盾)。
* 用户手填时必然是在其中一边操作,所以让他明确"我这次填的是哪一个",
* 服务端据此**反推另一个** —— 不依赖 JS,也就不会有"JS 没跑到就静默存错"。
* 兼容:source=solar 但阳历留空、农历有值 → 按农历算(用户只填了一边)。
*/
function read_birthday_input(array $existing = []): array
{
$source = (($_POST['birthday_source'] ?? 'solar') === 'lunar') ? 'lunar' : 'solar';
$solar = (string)(read_dt_input('birthday_solar') ?? '');
$lunar = (string)(read_dt_input('birthday_lunar') ?? '');
if ($source === 'solar' && $solar === '' && $lunar !== '') {
$source = 'lunar';
}
return [
'birthday_source' => $source,
'birthday_solar' => $solar,
'birthday_lunar' => $lunar,
'birthday_lunar_leap' => !empty($_POST['birthday_lunar_leap']) ? 1 : 0,
// 库里已有的身份证生日要**带回来**:它是"只进不出"的,不能被一次提交清掉
'birthday_id_card' => (string)($existing['birthday_id_card'] ?? ''),
];
}
switch ($r) {
// ==================== 登录 / 退出 ====================
@@ -164,6 +193,8 @@ switch ($r) {
foreach ($birthdays as &$b) {
$b['label'] = isset($people[(int)$b['id']])
? Kinship::label($graph, $me, (int)$b['id'], $people) : '';
// 名字优先用"我叫他什么" —— 同一个孩子,爸爸看到"儿子"、奶奶看到"小宝"
$b['shown'] = Nickname::display($me, $people[(int)$b['id']] ?? null) ?: $b['name'];
}
unset($b);
@@ -176,6 +207,9 @@ switch ($r) {
'allCount' => count($people),
'relCount' => Relations::count($familyId),
'birthdays' => $birthdays,
// 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页)
'mySlogan' => trim((string)($people[$me]['slogan'] ?? '')),
'myNicknames'=> Nickname::mapFor($me),
], 'home');
break;
@@ -201,12 +235,13 @@ switch ($r) {
$q = trim((string)($_GET['q'] ?? ''));
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$rows = Person::all($familyId, $q);
$rows = Person::all($familyId, $q, 'member', $me);
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
// 名册里也标出「我该怎么称呼他」
foreach ($rows as &$row) {
$row['label'] = Kinship::label($graph, $me, (int)$row['id'], $people);
$row['shown'] = Nickname::display($me, $row) ?: (string)$row['name'];
}
unset($row);
@@ -218,6 +253,107 @@ switch ($r) {
], 'people');
break;
// ==================== 我自己的资料 ====================
// 点顶栏的头像/名字直接落到这里。访客也能改自己的资料 ——
// 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。
case 'people/me':
Auth::requireLogin();
$me = (int)Auth::personId();
$p = Person::find($me);
if (!$p) {
flash('err', '找不到你自己的名册记录。');
redirect('home');
}
clear_old();
View::render('people/me', [
'title' => '我的资料',
'person' => $p,
'errors' => [],
'familyMotto'=> (string)(Family::find((int)Auth::familyId())['motto'] ?? ''),
'nicknames' => Nickname::mineWithNames((int)Auth::familyId(), $me),
'options' => Person::options((int)Auth::familyId(), $me, $me),
'birthdays' => Birthday::describe($p),
], 'people');
break;
case 'people/me_save':
Auth::requireLogin();
csrf_check();
$me = (int)Auth::personId();
$p = Person::find($me);
if (!$p) {
flash('err', '找不到你自己的名册记录。');
redirect('home');
}
$d = [
'name' => trim((string)($_POST['name'] ?? '')),
'gender' => (string)($_POST['gender'] ?? ''),
'email' => trim((string)($_POST['email'] ?? '')),
'address' => trim((string)($_POST['address'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')),
'slogan' => trim((string)($_POST['slogan'] ?? '')),
'avatar' => (string)$p['avatar'],
] + read_birthday_input($p);
$errors = Person::validate($d);
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []);
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
$errors['avatar'] = $up['error'];
}
if ($errors) {
set_old($_POST);
View::render('people/me', [
'title' => '我的资料',
'person' => $p,
'errors' => $errors,
'familyMotto'=> (string)(Family::find((int)Auth::familyId())['motto'] ?? ''),
'nicknames' => Nickname::mineWithNames((int)Auth::familyId(), $me),
'options' => Person::options((int)Auth::familyId(), $me, $me),
'birthdays' => Birthday::describe($p),
], 'people');
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('people/me');
break;
// ==================== 别人怎么称呼(只有我自己能定) ====================
case 'nickname/save':
Auth::requireLogin();
csrf_check();
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$target = (int)($_POST['target_id'] ?? 0);
$nick = (string)($_POST['nickname'] ?? '');
$t = Person::find($target);
$back = (string)($_POST['back'] ?? 'people/show');
if (!$t || (int)$t['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
Nickname::set($familyId, $me, $target, $nick);
flash('ok', trim($nick) === ''
? '已恢复成用姓名显示。'
: '记下了:你叫他「' . trim($nick) . '」。这只影响你自己看到的称呼。');
if ($back === 'people/me') {
redirect('people/me');
}
redirect('people/show', ['id' => $target]);
break;
case 'people/new':
Auth::requireWrite();
clear_old();
@@ -233,14 +369,13 @@ switch ($r) {
csrf_check();
$d = [
'name' => trim((string)($_POST['name'] ?? '')),
'nickname' => trim((string)($_POST['nickname'] ?? '')),
'gender' => (string)($_POST['gender'] ?? ''),
'birthday' => trim((string)($_POST['birthday'] ?? '')),
'phone' => trim((string)($_POST['phone'] ?? '')),
'id_card' => trim((string)($_POST['id_card'] ?? '')),
'email' => trim((string)($_POST['email'] ?? '')),
'address' => trim((string)($_POST['address'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')),
'slogan' => trim((string)($_POST['slogan'] ?? '')),
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
'avatar' => '',
'kind' => 'member',
@@ -248,7 +383,7 @@ switch ($r) {
// 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
];
] + read_birthday_input();
$errors = Person::validate($d);
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []);
@@ -315,19 +450,18 @@ switch ($r) {
}
$d = [
'name' => trim((string)($_POST['name'] ?? '')),
'nickname' => trim((string)($_POST['nickname'] ?? '')),
'gender' => (string)($_POST['gender'] ?? ''),
'birthday' => trim((string)($_POST['birthday'] ?? '')),
'phone' => trim((string)($_POST['phone'] ?? '')),
'id_card' => trim((string)($_POST['id_card'] ?? '')),
'email' => trim((string)($_POST['email'] ?? '')),
'address' => trim((string)($_POST['address'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')),
'slogan' => trim((string)($_POST['slogan'] ?? '')),
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
'avatar' => (string)$p['avatar'],
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
];
] + read_birthday_input($p);
$errors = Person::validate($d);
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []);
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
@@ -373,7 +507,7 @@ switch ($r) {
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$id = (int)($_GET['id'] ?? 0);
$p = Person::findWithAccount($id);
$p = Person::findWithAccount($id, $me);
if (!$p || (int)$p['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
@@ -395,8 +529,13 @@ switch ($r) {
// 以这个人为中心的两代亲属,供详情页展示
'theirTree' => $tree,
'theirRels' => Relations::forPerson($familyId, $id),
'options' => Person::options($familyId, $id),
'options' => Person::options($familyId, $id, $me),
'kinReady' => Kinship::graph($familyId) !== [],
// 「我叫他什么」—— 只有我自己能改,改了也只影响我看到的
'myNickname' => $id === $me ? '' : Nickname::get($me, $id),
'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']),
'birthdays' => Birthday::describe($p),
'birthdayNext'=> Birthday::nextOccurrences($p, date('Y-m-d'), 400),
], 'people');
break;
@@ -543,9 +682,11 @@ switch ($r) {
case 'accounts/invite':
Auth::requireAdmin();
clear_old();
// ⚠️ 访客建出来是 kind='contact'(不进家谱),而 Person::all() 默认只取 'member' ——
// 早先这里没传 kind,于是"已有访客"永远列不出人来(页面看着像是从没开通过)。
View::render('people/invite', [
'title' => '邀请访客',
'people' => Person::all((int)Auth::familyId()),
'people' => Person::all((int)Auth::familyId(), '', 'contact', (int)Auth::personId()),
'options' => Person::options((int)Auth::familyId()),
], 'people');
break;
@@ -574,9 +715,9 @@ switch ($r) {
try {
// 访客也进名册(kind=contact,与家庭成员区分开),但不参与家谱
$pid = Person::create($familyId, [
'name' => $name, 'nickname' => '', 'gender' => '', 'birthday' => '',
'avatar' => '', 'phone' => $phone, 'id_card' => '', 'email' => '',
'address' => '', 'note' => $note, 'sort_order' => 2000, 'kind' => 'contact',
'name' => $name, 'gender' => '', 'avatar' => '', 'phone' => $phone,
'id_card' => '', 'email' => '', 'address' => '', 'note' => $note,
'slogan' => '', 'sort_order' => 2000, 'kind' => 'contact',
]);
Account::create($pid, $pass, 'guest');
clear_old();
@@ -589,7 +730,7 @@ switch ($r) {
set_old($_POST);
View::render('people/invite', [
'title' => '邀请访客',
'people' => Person::all($familyId),
'people' => Person::all($familyId, '', 'contact', (int)Auth::personId()),
'options' => Person::options($familyId),
'errors' => $errors,
], 'people');
@@ -606,6 +747,9 @@ switch ($r) {
}
$rawKey = (string)fb_config('app_key', '');
$acc = Account::roleCounts();
// 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET)
$diag = $_SESSION['_oss_diag'] ?? null;
unset($_SESSION['_oss_diag']);
View::render('settings', [
'title' => '设置',
'family' => Family::find((int)Auth::familyId()),
@@ -617,9 +761,48 @@ switch ($r) {
'storageOss' => \App\Storage\Storage::config((int)Auth::familyId()),
'storageOk' => \App\Storage\Storage::configComplete((int)Auth::familyId()),
'storageEffective' => \App\Storage\Storage::effectiveDriver((int)Auth::familyId()),
'ossDiag' => $diag,
], 'settings');
break;
/**
* 阿里云 OSS 连通性测试。
*
* 与 settings/test_storage 的分工:
* · test_storage 测的是**当前生效的驱动**(可能是本地),判"能不能存东西";
* · 这一个测的是**已填的 OSS 配置本身**,即使当前跑在本地也能测 ——
* 这样就能"先把 OSS 测通、再切过去",而不是"先切、发现不行、再切回来"。
* 它会把每一步的结论摊开(配置/凭据/地域/公网/内网/读写),
* 让"连不上"这件事有具体原因,而不是一句"写入失败"。
*/
case 'settings/test_oss':
Auth::requireAdmin();
csrf_check();
$familyId = (int)Auth::familyId();
$disk = \App\Storage\Storage::ossDiskForDiagnosis($familyId);
if ($disk === null) {
$_SESSION['_oss_diag'] = [
'ok' => false,
'steps' => [[
'name' => '配置完整性', 'ok' => false, 'ms' => 0,
'detail' => 'OSS 参数还没填全(至少要 Bucket、Endpoint 和一种凭据)。先在下面填好并保存,再测。',
]],
'at' => time(),
];
redirect('settings');
}
$steps = $disk->diagnose();
$ok = true;
foreach ($steps as $s) {
if (empty($s['ok'])) {
$ok = false;
break;
}
}
$_SESSION['_oss_diag'] = ['ok' => $ok, 'steps' => $steps, 'at' => time()];
redirect('settings');
break;
case 'settings/storage':
Auth::requireAdmin();
csrf_check();
@@ -679,9 +862,14 @@ switch ($r) {
case 'settings/update':
Auth::requireAdmin();
csrf_check();
$fid = (int)Auth::familyId();
// ⚠️ 「家庭名称」已从设置里撤掉(见 settings 模板的说明):
// 首页标题现在是「某某某的家书」,一个家庭级名字既没人看、也容易和它打架。
// 这里沿用已有值,不再接受表单输入 —— 免得"某次提交把名字清空"。
$cur = Family::find($fid);
Family::update(
(int)Auth::familyId(),
trim((string)($_POST['name'] ?? '家书')),
$fid,
(string)($cur['name'] ?? '家书'),
trim((string)($_POST['motto'] ?? ''))
);
flash('ok', '已保存。');