登录居中 · 个人 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:
@@ -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', '已保存。');
|
||||
|
||||
Reference in New Issue
Block a user