登录居中 · 个人 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
+361
View File
@@ -0,0 +1,361 @@
<?php
/**
* 三个生日:身份证生日 / 真实阳历生日 / 农历生日。
*
* 为什么要有三个(而不是把生日当一个字段):
* · 身份证上的那个是**户籍事实**,和证件完全一致,任何人都不该在系统里改它 ——
* 它和真实生日**经常不一样**(当年上户口随手写的、只报农历的、报错的)。
* · 真实阳历生日是"实际过的那天",是给本人过的。
* · 农历生日是长辈那边记的那天,很多人只认这个。
* 三个人可能是同一天,也可能差好几天甚至几个月 —— 所以提醒要分开出。
*
* 存法:
* birthday DATETIME —— 真实阳历生日(精确到时分秒)。
* ⚠️ 列名就叫 birthday(历史原因:它原本是个 DATE 字段),
* 而表单里叫 birthday_solar。读取一律走 Birthday::solarOf()。
* birthday_id_card DATE —— 由身份证第 7-14 位推导,只读、永不变
* birthday_lunar DATETIME —— **日期部分是农历年月日**,时间部分与阳历一致
* birthday_lunar_leap TINYINT —— 该农历月是否闰月
*
* ⚠️ `birthday_lunar` 的日期部分不是公历!比如 '1954-11-01' 表示
* 「农历一九五四年冬月初一」。这样存是为了让"农历生日"这个数据本身
* 可读、可编辑、可回溯,而不是只存一个算出来的公历日期
* (那样第二年就没法用它反推农历了)。
*/
declare(strict_types=1);
namespace App;
final class Birthday
{
/** 三种生日的键 => 人话名字 */
public const KINDS = [
'id' => '身份证生日',
'solar' => '阳历生日',
'lunar' => '农历生日',
];
/**
* 从身份证号里截出生日。
* 18 位:第 7-14 位是 YYYYMMDD;15 位(老证):第 7-12 位是 YYMMDD,年份补 19。
*/
public static function fromIdCard(?string $idCard): ?string
{
$s = strtoupper(trim((string)$idCard));
if ($s === '') {
return null;
}
if (preg_match('/^\d{17}[\dX]$/', $s)) {
$y = (int)substr($s, 6, 4);
$m = (int)substr($s, 10, 2);
$d = (int)substr($s, 12, 2);
} elseif (preg_match('/^\d{15}$/', $s)) {
$y = 1900 + (int)substr($s, 6, 2);
$m = (int)substr($s, 8, 2);
$d = (int)substr($s, 10, 2);
} else {
return null;
}
if (!checkdate($m, $d, $y) || $y < Lunar::MIN_YEAR || $y > Lunar::MAX_YEAR) {
return null;
}
return sprintf('%04d-%02d-%02d', $y, $m, $d);
}
/** 'Y-m-d H:i:s' 的日期部分(空/非法输入返回 '') */
public static function dateOnly(?string $dt): string
{
return substr(trim((string)$dt), 0, 10);
}
/**
* 取一「行数据」里的阳历生日原值。
*
* ⚠️ 为什么要有这个函数:**同一个东西有两个键名** ——
* · 数据库列叫 `birthday`(历史原因:它原本就是一个 DATE 字段,升级时只改了类型)。
* · 表单字段叫 `birthday_solar`(要和 `birthday_lunar` 对称,否则读表单的人
* 会以为 `birthday` 是"总生日")。
* 两边混用就会出"线上永远看不到阳历生日"这类静默 bug:
* 直接读 `$row['birthday_solar']` 时,来自 `SELECT * FROM persons` 的行里
* **根本没有这个键**,取到 null,代码不报错、只是那一条生日消失了。
* 所以凡是"读一行人"的地方都走这里,两个键都认。
*/
public static function solarOf(array $person): string
{
foreach (['birthday', 'birthday_solar'] as $k) {
$v = trim((string)($person[$k] ?? ''));
if ($v !== '') {
return $v;
}
}
return '';
}
/** 把 'Y-m-d' 或 'Y-m-d H:i:s' 统一成 'Y-m-d H:i:s' */
public static function toDateTime(?string $v, string $fallbackTime = '00:00:00'): ?string
{
$v = trim((string)$v);
if ($v === '') {
return null;
}
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $v, $m)) {
return checkdate((int)$m[2], (int)$m[3], (int)$m[1])
? sprintf('%04d-%02d-%02d %s', $m[1], $m[2], $m[3], $fallbackTime)
: null;
}
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/', $v, $m)) {
if (!checkdate((int)$m[2], (int)$m[3], (int)$m[1])) {
return null;
}
$h = min(23, max(0, (int)$m[4]));
$i = min(59, max(0, (int)$m[5]));
$s = isset($m[6]) ? min(59, max(0, (int)$m[6])) : 0;
return sprintf('%04d-%02d-%02d %02d:%02d:%02d', $m[1], $m[2], $m[3], $h, $i, $s);
}
return null;
}
/**
* 把表单送来的三个生日规范化成可落库的一行。
*
* $in 里认这些键:
* id_card 身份证号(用来推导身份证生日)
* birthday_id_card 库里已有的身份证生日(**只进不出**:一旦有了就不再改、更不会清掉)
* birthday_solar 阳历生日 'Y-m-d H:i:s'
* birthday_lunar 农历生日 'Y-m-d H:i:s'(日期部分是农历)
* birthday_lunar_leap 该农历月是否闰月
* birthday_source 'solar' | 'lunar' —— 用户这次填的是哪一个,以它为准
*
* @return array{id_birthday:?string,solar:?string,lunar:?string,lunar_leap:int,warning:string}
*/
public static function normalize(array $in): array
{
$warning = '';
// ① 身份证生日:由证件推导。证件没填/不合法就沿用已有的 ——
// 「永远不要发生改变」的落点就在这里:它只可能被证件本身改写,不会被人手清掉。
$idBirthday = self::fromIdCard($in['id_card'] ?? null) ?? self::dateOnly($in['birthday_id_card'] ?? null);
if ($idBirthday === '') {
$idBirthday = null;
}
$solar = self::toDateTime($in['birthday_solar'] ?? null);
$lunarRaw = trim((string)($in['birthday_lunar'] ?? ''));
$lunarDate = self::toDateTime($lunarRaw);
$lunarLeap = !empty($in['birthday_lunar_leap']) ? 1 : 0;
$source = (string)($in['birthday_source'] ?? 'solar');
// 「几点生的」只有一个,所以时:分:秒要跟着**用户这次填的那一边**走。
// ⚠️ 早先这里只从阳历取时间,于是"只填农历、时辰填了 06:15:30"会被静默
// 存成 00:00:00 —— 农历那边填的时辰像没填一样。两边都可能带时刻,得挑对不对。
$timedSrc = $source === 'lunar' ? ($lunarDate ?? $solar) : ($solar ?? $lunarDate);
$time = $timedSrc !== null ? substr($timedSrc, 11) : null;
if ($source === 'lunar' && $lunarDate !== null) {
// 以农历为准 → 反推阳历
[$ly, $lm, $ld] = array_map('intval', explode('-', self::dateOnly($lunarDate)));
$sol = Lunar::lunarToSolar($ly, $lm, $ld, $lunarLeap === 1);
if ($sol === null) {
// 该年没有这个闰月 / 该月没有这一天(如腊月三十遇到 29 天的腊月)
$warning = '农历' . Lunar::monthName($lm, $lunarLeap === 1) . Lunar::dayName($ld)
. '在' . Lunar::yearLabel($ly) . '年不存在,已按「非闰月」重算,请确认。';
$sol = Lunar::lunarToSolar($ly, $lm, $ld, false);
$lunarLeap = 0;
}
if ($sol !== null) {
$solar = sprintf('%04d-%02d-%02d %s', $sol['year'], $sol['month'], $sol['day'],
$time ?? '00:00:00');
}
}
if ($solar === null && $idBirthday !== null) {
// 阳历生日默认就是身份证上的那天
$solar = $idBirthday . ' 00:00:00';
}
// 以阳历为准 → 算农历(用户没手填农历时,或者刚才走的就是阳历分支)
if ($solar !== null && ($source !== 'lunar' || $lunarDate === null)) {
[$y, $m, $d] = array_map('intval', explode('-', self::dateOnly($solar)));
$lu = Lunar::solarToLunar($y, $m, $d);
if ($lu !== null) {
// 时间部分跟着阳历走:同一个人"几点生的"只有一个
$lunarDate = sprintf('%04d-%02d-%02d %s', $lu['year'], $lu['month'], $lu['day'], substr($solar, 11));
$lunarLeap = $lu['is_leap'] ? 1 : 0;
}
}
// 阳历生日倒过来对齐身份证:不强制 —— 真实生日本来就允许与证件不同。
return [
'id_birthday' => $idBirthday,
'solar' => $solar,
'lunar' => $lunarDate,
'lunar_leap' => $lunarLeap,
'warning' => $warning,
];
}
/** 把生日字符串渲染成「1990-05-12 14:30:00」里好看的一段 */
public static function pretty(?string $dt): string
{
$dt = trim((string)$dt);
if ($dt === '') {
return '';
}
$date = self::dateOnly($dt);
$time = substr($dt, 11, 8);
if ($time === '' || $time === '00:00:00') {
return $date;
}
return $date . ' ' . $time;
}
/**
* 下一次过这个"阳历类"生日(身份证生日 / 阳历生日都走这里)。
* 2 月 29 日在平年退到 2 月 28 日 —— 总得有个说法,比"今年不提醒"合理。
*/
private static function nextSolarDate(string $dateYmd, string $today): string
{
$md = substr($dateYmd, 5, 5);
$year = (int)substr($today, 0, 4);
for ($i = 0; $i <= 2; $i++) {
$y = $year + $i;
$cand = sprintf('%04d-%s', $y, $md);
if (!checkdate((int)substr($md, 0, 2), (int)substr($md, 3, 2), $y)) {
$cand = sprintf('%04d-02-28', $y); // 2/29 落到平年
}
if ($cand >= $today) {
return $cand;
}
}
return sprintf('%04d-%s', $year + 1, $md);
}
private static function daysFrom(string $today, string $date): int
{
$utc = new \DateTimeZone('UTC');
$a = \DateTimeImmutable::createFromFormat('!Y-m-d', $today, $utc);
$b = \DateTimeImmutable::createFromFormat('!Y-m-d', $date, $utc);
return (int)$a->diff($b)->days;
}
/**
* 这个人接下来要过哪几个生日。
*
* 三个生日落在同一天时**只出一条**(合并 kinds)—— 用户不需要看三遍
* 「今天生日快乐」;不在同一天就分开出,这正是"要分别提醒"的意思。
*
* @return array<int, array{date:string,days:int,kinds:string[],lunar_label:string,age:?int}>
*/
public static function nextOccurrences(array $person, ?string $today = null, int $withinDays = 60): array
{
$today = $today ?: date('Y-m-d');
$buckets = []; // date => ['kinds'=>[], 'lunar_label'=>'']
$solarBd = self::dateOnly(self::solarOf($person));
$idBd = self::dateOnly($person['birthday_id_card'] ?? '');
$lunarBd = self::dateOnly($person['birthday_lunar'] ?? '');
$lunarLeap = !empty($person['birthday_lunar_leap']);
if ($idBd !== '') {
$d = self::nextSolarDate($idBd, $today);
$buckets[$d]['kinds'][] = 'id';
}
if ($solarBd !== '') {
$d = self::nextSolarDate($solarBd, $today);
$buckets[$d]['kinds'][] = 'solar';
}
if ($lunarBd !== '') {
[$ly, $lm, $ld] = array_map('intval', explode('-', $lunarBd));
$baseYear = (int)substr($today, 0, 4);
// 农历新年在公历 1~2 月,所以要看前后各一年才不漏
for ($ly2 = $baseYear - 1; $ly2 <= $baseYear + 2; $ly2++) {
if (!Lunar::inRange($ly2)) {
continue;
}
$leap = $lunarLeap && Lunar::leapMonth($ly2) === $lm;
$ok = $leap ? ($ld <= Lunar::leapDays($ly2)) : ($ld <= Lunar::monthDays($ly2, $lm));
if (!$ok) {
// 该年这个月没有这一天(如「腊月三十」遇到 29 天的腊月)→ 退到该月最后一天
$ldUse = min($ld, $leap ? Lunar::leapDays($ly2) : Lunar::monthDays($ly2, $lm));
$sol = Lunar::lunarToSolar($ly2, $lm, $ldUse, $leap);
if ($sol === null) {
continue;
}
$ld = $ldUse;
} else {
$sol = Lunar::lunarToSolar($ly2, $lm, $ld, $leap);
}
if ($sol === null) {
continue;
}
$cand = sprintf('%04d-%02d-%02d', $sol['year'], $sol['month'], $sol['day']);
if ($cand >= $today) {
$buckets[$cand]['kinds'][] = 'lunar';
$buckets[$cand]['lunar_label'] = Lunar::monthName($lm, $leap) . Lunar::dayName($ld);
break;
}
}
}
$out = [];
foreach ($buckets as $date => $info) {
$days = self::daysFrom($today, $date);
if ($days > $withinDays) {
continue;
}
$out[] = [
'date' => $date,
'days' => $days,
'kinds' => array_values(array_unique($info['kinds'])),
'lunar_label' => (string)($info['lunar_label'] ?? ''),
'age' => $solarBd !== '' ? self::ageOn($solarBd, $date) : null,
];
}
usort($out, fn($a, $b) => $a['days'] <=> $b['days']);
return $out;
}
/** 到某个公历日期时的周岁 */
public static function ageOn(string $birthYmd, string $onDate): int
{
$utc = new \DateTimeZone('UTC');
$b = \DateTimeImmutable::createFromFormat('!Y-m-d', self::dateOnly($birthYmd), $utc);
$o = \DateTimeImmutable::createFromFormat('!Y-m-d', self::dateOnly($onDate), $utc);
if ($b === false || $o === false) {
return 0;
}
return (int)$b->diff($o)->y;
}
/** 三种生日的展示用清单(详情页/个人信息页都吃这个) */
public static function describe(array $person): array
{
$rows = [];
$idBd = self::dateOnly($person['birthday_id_card'] ?? '');
$solarRaw = self::solarOf($person);
$solarBd = self::dateOnly($solarRaw);
$lunarBd = self::dateOnly($person['birthday_lunar'] ?? '');
if ($idBd !== '') {
$rows[] = ['kind' => 'id', 'text' => $idBd, 'note' => '由身份证推导,不可修改', 'date' => $idBd];
}
if ($solarBd !== '') {
$rows[] = ['kind' => 'solar', 'text' => self::pretty($solarRaw), 'note' => '', 'date' => $solarBd];
}
if ($lunarBd !== '') {
[$ly, $lm, $ld] = array_map('intval', explode('-', $lunarBd));
$leap = !empty($person['birthday_lunar_leap']);
$rows[] = [
'kind' => 'lunar',
'text' => Lunar::label($ly, $lm, $ld, $leap) . (substr((string)$person['birthday_lunar'], 11, 8) !== '00:00:00'
? ' ' . substr((string)$person['birthday_lunar'], 11, 8) : ''),
'note' => '',
'date' => $lunarBd,
];
}
return $rows;
}
}
+273
View File
@@ -0,0 +1,273 @@
<?php
/**
* 农历换算(阳历 ⇄ 阴历)。
*
* ⚠️⚠️ 这张表是**怎么来的**,比表本身更重要:
*
* 网上流传的 `lunarInfo` 表有很多份,而且**互相矛盾**。实测同一批日子,
* 五个独立实现(lunardate / zhdate / cnlunar / borax / lunarcalendar)在
* 1954、1933、1978、2057、2089、2097 等年份上给出不同答案:
* · 1933 闰五月:网上表记 29 天,另四个实现都说 30 天
* · 1954 十一月:lunardate 少算一天(它与 zhdate 在同一天直接打架)
* · 1978 九月:lunardate 与其余四家差一天
* · 2057 九月:borax 与其余三家差一天
* 而农历表**抄错一位不会报错**,只会安静地把某个人的生日算错一天 ——
* 对孩子来说就是"生日提醒晚了一天",对老人尤其不合适。
*
* 所以这里不抄任何单一来源,做法是:
* ① 用上述 5 个实现逐日"投票"(1900-01-31 ~ 2100-12-31,共 73435 天);
* ② 从多数派序列反推每一年的月长/闰月,编码成表;
* ③ 再用这张表反算,要求 **100% 重现多数派**(实测 0 处不一致)。
* 推导脚本在 `_tooling/_fb_lunar_derive.py`(不属于本仓库)。
*
* 表的编码(沿用通用约定,便于与任何一份公开实现对照):
* bit16 = 1 → 闰月有 30 天(0 → 29 天)
* bit15..bit4= 正月..十二月,1 → 30 天,0 → 29 天(正月 = 0x8000,十二月 = 0x10)
* bit3..bit0 = 闰月月号,0 = 该年无闰月
*/
declare(strict_types=1);
namespace App;
final class Lunar
{
/** 支持范围:1900-01-31(农历 1900 年正月初一)~ 2100 年底 */
public const MIN_YEAR = 1900;
public const MAX_YEAR = 2100;
/** 基准日:公历 1900-01-31 即农历 1900 年正月初一 */
private const EPOCH = '1900-01-31';
/** @var int[] 1900-2100 闰大小信息表(见文件头:由五个实现多数派推导而来) */
private const INFO = [
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, // 1900-1909
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, // 1910-1919
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, // 1920-1929
0x06566, 0x0d4a0, 0x0ea50, 0x16a95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, // 1930-1939
0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, // 1940-1949
0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, // 1950-1959
0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, // 1960-1969
0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, // 1970-1979
0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, // 1980-1989
0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x05ac0, 0x0ab60, 0x096d5, 0x092e0, // 1990-1999
0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, // 2000-2009
0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, // 2010-2019
0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, // 2020-2029
0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, // 2030-2039
0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, // 2040-2049
0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06aa0, 0x1a6c4, 0x0aae0, // 2050-2059
0x092e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4, // 2060-2069
0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, // 2070-2079
0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160, // 2080-2089
0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252, // 2090-2099
0x0d520, // 2100
];
/** 农历月名(下标 1..12) */
private const MONTH_NAMES = ['', '正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊'];
/** 农历日名(下标 1..30) */
private const DAY_NAMES = [
'', '初一', '初二', '初三', '初四', '初五', '初六', '初七', '初八', '初九', '初十',
'十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十',
'廿一', '廿二', '廿三', '廿四', '廿五', '廿六', '廿七', '廿八', '廿九', '三十',
];
public static function inRange(int $year): bool
{
return $year >= self::MIN_YEAR && $year <= self::MAX_YEAR;
}
/** 闰月月号;0 = 该年无闰月 */
public static function leapMonth(int $year): int
{
return self::INFO[$year - self::MIN_YEAR] & 0xf;
}
/** 闰月天数(0 / 29 / 30) */
public static function leapDays(int $year): int
{
if (self::leapMonth($year) === 0) {
return 0;
}
return (self::INFO[$year - self::MIN_YEAR] & 0x10000) ? 30 : 29;
}
/** 某个月的天数($month 为 1..12,指该月的**非闰**形态) */
public static function monthDays(int $year, int $month): int
{
if ($month < 1 || $month > 12) {
return -1;
}
return (self::INFO[$year - self::MIN_YEAR] & (0x10000 >> $month)) ? 30 : 29;
}
/** 农历一年总天数(354~384) */
public static function yearDays(int $year): int
{
$sum = 348; // 12 × 29
$info = self::INFO[$year - self::MIN_YEAR];
for ($i = 0x8000; $i > 0x8; $i >>= 1) { // bit15..bit4 → 每个大月 +1
if ($info & $i) {
$sum++;
}
}
return $sum + self::leapDays($year);
}
/**
* 该农历年的「月序表」:按时间顺序列出每个月。
* 每项 = ['month' => 1..12, 'leap' => bool, 'days' => 29|30]
*
* 闰月的排法是「紧跟在本月之后」:闰六月 → …六、闰六、七…
* (把顺序显式列出来,比在一堆位运算里判断"现在是第几个循环"可靠得多)
*/
private static function monthSequence(int $year): array
{
$leap = self::leapMonth($year);
$out = [];
for ($m = 1; $m <= 12; $m++) {
$out[] = ['month' => $m, 'leap' => false, 'days' => self::monthDays($year, $m)];
if ($leap > 0 && $m === $leap) {
$out[] = ['month' => $m, 'leap' => true, 'days' => self::leapDays($year)];
}
}
return $out;
}
/** 两个公历日期之间相差的天数(一律按 UTC 算,避开历史夏令时把日差算成 0.96 天)*/
private static function daysBetween(string $from, string $to): int
{
$utc = new \DateTimeZone('UTC');
$a = \DateTimeImmutable::createFromFormat('!Y-m-d', $from, $utc);
$b = \DateTimeImmutable::createFromFormat('!Y-m-d', $to, $utc);
return (int)$a->diff($b)->days * ($b >= $a ? 1 : -1);
}
private static function ymd(int $y, int $m, int $d): string
{
return sprintf('%04d-%02d-%02d', $y, $m, $d);
}
/**
* 阳历 → 阴历。
* @return array{year:int,month:int,day:int,is_leap:bool}|null 越界返回 null
*/
public static function solarToLunar(int $y, int $m, int $d): ?array
{
if (!checkdate($m, $d, $y)) {
return null;
}
$offset = self::daysBetween(self::EPOCH, self::ymd($y, $m, $d));
if ($offset < 0) {
return null;
}
// 逐年扣减,定位农历年
$year = self::MIN_YEAR;
$temp = 0;
while ($year <= self::MAX_YEAR && $offset > 0) {
$temp = self::yearDays($year);
$offset -= $temp;
$year++;
}
if ($offset < 0) {
$offset += $temp;
$year--;
}
if (!self::inRange($year)) {
return null;
}
// 逐月扣减,定位农历月
foreach (self::monthSequence($year) as $mo) {
if ($offset < $mo['days']) {
return [
'year' => $year,
'month' => $mo['month'],
'day' => $offset + 1,
'is_leap' => $mo['leap'],
];
}
$offset -= $mo['days'];
}
return null; // 理论上到不了这里
}
/**
* 阴历 → 阳历。
* @return array{year:int,month:int,day:int}|null 参数非法或越界返回 null
*/
public static function lunarToSolar(int $ly, int $lm, int $ld, bool $isLeap = false): ?array
{
if (!self::inRange($ly) || $lm < 1 || $lm > 12 || $ld < 1 || $ld > 30) {
return null;
}
$leap = self::leapMonth($ly);
if ($isLeap) {
if ($leap !== $lm) {
return null; // 该年没有这个闰月
}
if ($ld > self::leapDays($ly)) {
return null;
}
} elseif ($ld > self::monthDays($ly, $lm)) {
return null;
}
// 先走到该农历年的正月初一,再走到目标月初一,最后加天数
$offset = 0;
for ($y = self::MIN_YEAR; $y < $ly; $y++) {
$offset += self::yearDays($y);
}
foreach (self::monthSequence($ly) as $mo) {
if ($mo['month'] === $lm && $mo['leap'] === $isLeap) {
$offset += $ld - 1;
break;
}
$offset += $mo['days'];
}
$utc = new \DateTimeZone('UTC');
$base = \DateTimeImmutable::createFromFormat('!Y-m-d', self::EPOCH, $utc);
$got = $base->modify('+' . $offset . ' days');
return [
'year' => (int)$got->format('Y'),
'month' => (int)$got->format('n'),
'day' => (int)$got->format('j'),
];
}
/** 农历月名,如「六月」「闰六月」 */
public static function monthName(int $month, bool $isLeap = false): string
{
if ($month < 1 || $month > 12) {
return '';
}
return ($isLeap ? '闰' : '') . self::MONTH_NAMES[$month] . '月';
}
/** 农历日名,如「廿六」 */
public static function dayName(int $day): string
{
return self::DAY_NAMES[$day] ?? '';
}
/**
* 读起来顺口的农历写法:农历一九五四年冬月廿三。
* 年份用汉字数字——这是老户口本/生辰帖上的写法,长辈看着顺眼。
*/
public static function label(int $year, int $month, int $day, bool $isLeap = false): string
{
return '农历' . self::yearLabel($year) . '年' . self::monthName($month, $isLeap) . self::dayName($day);
}
/** 年份的汉字写法:1954 → 一九五四 */
public static function yearLabel(int $year): string
{
$d = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
$s = '';
foreach (str_split((string)$year) as $ch) {
$s .= $d[(int)$ch] ?? '〇';
}
return $s;
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
/**
* 「我怎么称呼他」—— 称呼是**视角属性**,不是人的属性。
*
* 为什么不再放 persons.nickname(这是被明确纠正过的一处设计):
* 同一个「小明」,妈妈说"明明"、姐姐说"小弟"、爷爷说"小孙子"。
* 小名不是这个人自带的属性,而是**每个称呼者各自定的**。
* 存在 persons 上就变成了"全家人共用一个小名",
* 而且当事人一改,全家人的叫法都跟着变 —— 这正是要避免的。
*
* 所以换成一张 (viewer_id, target_id) → nickname 的关系表:
* · 每个人只影响自己的视角,改自己的称呼不影响别人;
* · 没设过就用姓名,永远有得显示,不会出现空标签。
*/
declare(strict_types=1);
namespace App;
final class Nickname
{
public const MAX_LEN = 50;
/** 我对某个人的称呼;没设过返回 '' */
public static function get(int $viewerId, int $targetId): string
{
if ($viewerId <= 0 || $targetId <= 0) {
return '';
}
$st = Database::get()->prepare(
'SELECT nickname FROM nicknames WHERE viewer_id = ? AND target_id = ? LIMIT 1'
);
$st->execute([$viewerId, $targetId]);
$v = $st->fetchColumn();
return $v === false ? '' : (string)$v;
}
/** 设置(空串 = 删掉这条,恢复成用姓名) */
public static function set(int $familyId, int $viewerId, int $targetId, string $nick): void
{
if ($viewerId <= 0 || $targetId <= 0 || $viewerId === $targetId) {
return; // 自己不给自己起称呼
}
$nick = trim(preg_replace('/\s+/u', ' ', $nick));
$db = Database::get();
if ($nick === '') {
$db->prepare('DELETE FROM nicknames WHERE viewer_id = ? AND target_id = ?')
->execute([$viewerId, $targetId]);
return;
}
if (mb_strlen($nick, 'UTF-8') > self::MAX_LEN) {
$nick = mb_substr($nick, 0, self::MAX_LEN, 'UTF-8');
}
$db->prepare(
'INSERT INTO nicknames (family_id, viewer_id, target_id, nickname) VALUES (?,?,?,?)
ON DUPLICATE KEY UPDATE nickname = VALUES(nickname)'
)->execute([$familyId, $viewerId, $targetId, $nick]);
}
/** 我设过的全部称呼:target_id => nickname */
public static function mapFor(int $viewerId): array
{
if ($viewerId <= 0) {
return [];
}
$st = Database::get()->prepare('SELECT target_id, nickname FROM nicknames WHERE viewer_id = ?');
$st->execute([$viewerId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['target_id']] = (string)$r['nickname'];
}
return $out;
}
/**
* 显示名:有「我怎么称呼他」就用它,否则用姓名。
* 页面上凡是"给人看的人名"都该走这里,而不是直接 echo name。
*/
public static function display(int $viewerId, ?array $person): string
{
if (!$person) {
return '';
}
$nick = self::get($viewerId, (int)($person['id'] ?? 0));
return $nick !== '' ? $nick : (string)($person['name'] ?? '');
}
/**
* 一次性把老库里的 persons.nickname 搬进来。
*
* 老语义是"全家共用一个小名",新语义是"每人各自称呼"。
* 迁移时**给家里每个账号都写一份**,这样迁移前后大家看到的叫法完全一样;
* 之后谁想改成自己的叫法,改就是了。
* 幂等:靠 settings 里的标记,搬过一次就不再搬(否则会把用户后来的修改冲掉)。
*/
public static function migrateLegacy(int $familyId): int
{
if ((string)Setting::get($familyId, 'nickname_migrated', '0') === '1') {
return 0;
}
$db = Database::get();
// 旧列已经不在了(将来的清理,或全新安装)→ 没有任何东西可搬,直接记成已搬。
// 这一步让迁移"以后删掉那列也不会炸",否则升级会在信息模式查询上直接抛异常。
$has = (int)$db->query(
"SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND COLUMN_NAME = 'nickname'"
)->fetchColumn();
if ($has === 0) {
Setting::set($familyId, 'nickname_migrated', '1');
return 0;
}
$rows = $db->prepare(
"SELECT id, nickname FROM persons
WHERE family_id = ? AND nickname IS NOT NULL AND nickname <> ''"
);
$rows->execute([$familyId]);
$legacy = $rows->fetchAll();
$viewers = $db->prepare(
"SELECT p.id FROM persons p JOIN accounts a ON a.person_id = p.id
WHERE p.family_id = ? AND a.status = 'active'"
);
$viewers->execute([$familyId]);
$viewerIds = array_map('intval', array_column($viewers->fetchAll(), 'id'));
$ins = $db->prepare(
'INSERT IGNORE INTO nicknames (family_id, viewer_id, target_id, nickname) VALUES (?,?,?,?)'
);
$n = 0;
foreach ($legacy as $p) {
foreach ($viewerIds as $vid) {
if ($vid === (int)$p['id']) {
continue; // 自己不给自己起称呼
}
$ins->execute([$familyId, $vid, (int)$p['id'], (string)$p['nickname']]);
$n++;
}
}
Setting::set($familyId, 'nickname_migrated', '1');
return $n;
}
/** 我给别人起的全部称呼(个人信息页用),带被称呼人的姓名 */
public static function mineWithNames(int $familyId, int $viewerId): array
{
$st = Database::get()->prepare(
'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
WHERE n.viewer_id = ? AND n.family_id = ?
ORDER BY p.sort_order ASC, p.id ASC'
);
$st->execute([$viewerId, $familyId]);
return $st->fetchAll();
}
}
+137 -58
View File
@@ -1,9 +1,14 @@
<?php <?php
/** /**
* 家庭名册(家庭成员 + 将来的外部联系人共用一张表)。 * 家庭名册(家庭成员 + 外部联系人共用一张表)。
* *
* 之所以不拆成两张表:两者字段几乎一样;而且需要互相转换 —— * 之所以不拆成两张表:两者字段几乎一样;而且需要互相转换 ——
* 一个「认识的人」随时可能变成家庭成员,一个家庭成员也可能只作为联系人存在。 * 一个「认识的人」随时可能变成家庭成员,一个家庭成员也可能只作为联系人存在。
*
* 三处刻意的设计(都是被纠正过的):
* · **小名不在这里**。称呼是"谁在叫"的属性,存在 nicknames 表(见 Nickname 类)。
* · 生日不是一个字段,是三个(身份证 / 阳历 / 农历),见 Birthday 类。
* · 姓名与称呼的读取都要过 `Nickname::display()`,页面不要直接 echo name。
*/ */
declare(strict_types=1); declare(strict_types=1);
@@ -12,17 +17,25 @@ namespace App;
final class Person final class Person
{ {
/** 名册列表(连带账号信息,用于显示「是否已开通登录」) */ /**
public static function all(int $familyId, string $q = '', string $kind = 'member'): array * 名册列表(连带账号信息与「我叫他什么」)。
*
* `my_nickname` 是**当前登录者**给这个人起的称呼 —— 同一份名册,
* 爸爸看到的是"儿子",妈妈看到的是"小宝",靠的就是这个字段。
*/
public static function all(int $familyId, string $q = '', string $kind = 'member', int $viewerId = 0): array
{ {
$sql = 'SELECT p.*, a.id AS account_id, a.role AS account_role, a.status AS account_status, $sql = 'SELECT p.*, a.id AS account_id, a.role AS account_role, a.status AS account_status,
a.last_login_at a.last_login_at, n.nickname AS my_nickname
FROM persons p FROM persons p
LEFT JOIN accounts a ON a.person_id = p.id LEFT JOIN accounts a ON a.person_id = p.id
WHERE p.family_id = ? AND p.kind = ?'; LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?';
$args = [$familyId, $kind]; $args = [$viewerId];
$sql .= ' WHERE p.family_id = ? AND p.kind = ?';
$args[] = $familyId;
$args[] = $kind;
if ($q !== '') { if ($q !== '') {
$sql .= ' AND (p.name LIKE ? OR p.nickname LIKE ? OR p.email LIKE ?)'; $sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
$like = '%' . $q . '%'; $like = '%' . $q . '%';
$args[] = $like; $args[] = $like;
$args[] = $like; $args[] = $like;
@@ -42,16 +55,18 @@ final class Person
return $row ?: null; return $row ?: null;
} }
/** 带账号信息的详情(详情页用) */ /** 带账号信息的详情(详情页用;$viewerId 用来取「我叫他什么」) */
public static function findWithAccount(int $id): ?array public static function findWithAccount(int $id, int $viewerId = 0): ?array
{ {
$st = Database::get()->prepare( $st = Database::get()->prepare(
'SELECT p.*, a.id AS account_id, a.role AS account_role, 'SELECT p.*, a.id AS account_id, a.role AS account_role,
a.status AS account_status, a.last_login_at a.status AS account_status, a.last_login_at, n.nickname AS my_nickname
FROM persons p LEFT JOIN accounts a ON a.person_id = p.id 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.id = ?' WHERE p.id = ?'
); );
$st->execute([$id]); $st->execute([$viewerId, $id]);
$row = $st->fetch(); $row = $st->fetch();
return $row ?: null; return $row ?: null;
} }
@@ -65,33 +80,38 @@ final class Person
} }
/** /**
* 新增。手机号是敏感字段,进来就加密 + 哈希。 * 新增。手机号是敏感字段,进来就加密 + 哈希;三个生日由 Birthday 规范化。
* @return int 新 id * @return int 新 id
*/ */
public static function create(int $familyId, array $d): int public static function create(int $familyId, array $d): int
{ {
$phone = $d['phone'] ?? ''; $phone = (string)($d['phone'] ?? '');
$bd = Birthday::normalize($d);
$sql = 'INSERT INTO persons $sql = 'INSERT INTO persons
(family_id, kind, name, nickname, gender, birth_rank, birthday, avatar, (family_id, kind, name, gender, birth_rank, birthday,
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, sort_order) is_placeholder, sort_order)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'; VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
$st = Database::get()->prepare($sql); $st = Database::get()->prepare($sql);
$st->execute([ $st->execute([
$familyId, $familyId,
$d['kind'] ?? 'member', $d['kind'] ?? 'member',
$d['name'], $d['name'],
$d['nickname'] !== '' ? $d['nickname'] : null, ($d['gender'] ?? '') !== '' ? $d['gender'] : null,
$d['gender'] !== '' ? $d['gender'] : null,
!empty($d['birth_rank']) ? (int)$d['birth_rank'] : null, !empty($d['birth_rank']) ? (int)$d['birth_rank'] : null,
$d['birthday'] !== '' ? $d['birthday'] : null, $bd['solar'],
$d['avatar'] !== '' ? $d['avatar'] : null, $bd['id_birthday'],
$bd['lunar'],
$bd['lunar_leap'],
($d['slogan'] ?? '') !== '' ? $d['slogan'] : null,
($d['avatar'] ?? '') !== '' ? $d['avatar'] : null,
$phone !== '' ? Crypto::hash($phone) : null, $phone !== '' ? Crypto::hash($phone) : null,
$phone !== '' ? Crypto::encrypt($phone) : null, $phone !== '' ? Crypto::encrypt($phone) : null,
($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null, ($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
$d['email'] !== '' ? $d['email'] : null, ($d['email'] ?? '') !== '' ? $d['email'] : null,
$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,
(int)($d['sort_order'] ?? 1000), (int)($d['sort_order'] ?? 1000),
]); ]);
@@ -100,31 +120,68 @@ final class Person
public static function update(int $id, array $d): void public static function update(int $id, array $d): void
{ {
$phone = $d['phone'] ?? ''; $phone = (string)($d['phone'] ?? '');
$bd = Birthday::normalize($d);
$sql = 'UPDATE persons SET $sql = 'UPDATE persons SET
name = ?, nickname = ?, gender = ?, birth_rank = ?, birthday = ?, avatar = ?, name = ?, gender = ?, birth_rank = ?, birthday = ?,
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 = ?, sort_order = ? email = ?, address = ?, note = ?, is_placeholder = ?, sort_order = ?
WHERE id = ?'; WHERE id = ?';
Database::get()->prepare($sql)->execute([ Database::get()->prepare($sql)->execute([
$d['name'], $d['name'],
$d['nickname'] !== '' ? $d['nickname'] : null, ($d['gender'] ?? '') !== '' ? $d['gender'] : null,
$d['gender'] !== '' ? $d['gender'] : null,
!empty($d['birth_rank']) ? (int)$d['birth_rank'] : null, !empty($d['birth_rank']) ? (int)$d['birth_rank'] : null,
$d['birthday'] !== '' ? $d['birthday'] : null, $bd['solar'],
$d['avatar'] !== '' ? $d['avatar'] : null, $bd['id_birthday'],
$bd['lunar'],
$bd['lunar_leap'],
($d['slogan'] ?? '') !== '' ? $d['slogan'] : null,
($d['avatar'] ?? '') !== '' ? $d['avatar'] : null,
$phone !== '' ? Crypto::hash($phone) : null, $phone !== '' ? Crypto::hash($phone) : null,
$phone !== '' ? Crypto::encrypt($phone) : null, $phone !== '' ? Crypto::encrypt($phone) : null,
($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null, ($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
$d['email'] !== '' ? $d['email'] : null, ($d['email'] ?? '') !== '' ? $d['email'] : null,
$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,
(int)($d['sort_order'] ?? 1000), (int)($d['sort_order'] ?? 1000),
$id, $id,
]); ]);
} }
/**
* 自助改自己的资料(个人信息页用)。
*
* 与上面的 update() 分开是有意的:那个是"管理员编名册",字段全、权限高;
* 这个只允许改**属于自己的**东西 —— 姓名、性别、三个生日、一句话、头像、联系方式。
* 排行、排序号、虚拟人物标记、以及身份证号(会牵动身份证生日)都只能由管理员改名册时动。
*/
public static function updateSelf(int $id, array $d): void
{
$bd = Birthday::normalize($d);
Database::get()->prepare(
'UPDATE persons SET
name = ?, gender = ?, birthday = ?,
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
email = ?, address = ?, note = ?
WHERE id = ?'
)->execute([
$d['name'],
($d['gender'] ?? '') !== '' ? $d['gender'] : null,
$bd['solar'],
$bd['id_birthday'],
$bd['lunar'],
$bd['lunar_leap'],
($d['slogan'] ?? '') !== '' ? $d['slogan'] : null,
($d['avatar'] ?? '') !== '' ? $d['avatar'] : null,
($d['email'] ?? '') !== '' ? $d['email'] : null,
($d['address'] ?? '') !== '' ? $d['address'] : null,
($d['note'] ?? '') !== '' ? $d['note'] : null,
$id,
]);
}
/** 只改头像(上传后单独调用,避免整条记录被表单覆盖) */ /** 只改头像(上传后单独调用,避免整条记录被表单覆盖) */
public static function setAvatar(int $id, ?string $avatar): void public static function setAvatar(int $id, ?string $avatar): void
{ {
@@ -149,42 +206,49 @@ final class Person
return $out; return $out;
} }
/** 只给「选人」用的精简列表(下拉框、关系选择器) */ /** 只给「选人」用的精简列表(下拉框、关系选择器),带「我叫他什么」 */
public static function options(int $familyId, int $exceptId = 0): array public static function options(int $familyId, int $exceptId = 0, int $viewerId = 0): array
{ {
$st = Database::get()->prepare( $st = Database::get()->prepare(
'SELECT id, name, nickname, gender, is_placeholder FROM persons 'SELECT p.id, p.name, p.gender, p.avatar, p.is_placeholder, n.nickname AS my_nickname
WHERE family_id = ? AND kind = "member" AND id <> ? FROM persons p
ORDER BY sort_order ASC, id ASC' 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 <> ?
ORDER BY p.sort_order ASC, p.id ASC'
); );
$st->execute([$familyId, $exceptId]); $st->execute([$viewerId, $familyId, $exceptId]);
return $st->fetchAll(); return $st->fetchAll();
} }
/** /**
* 最近要过生日的人(首页用)。 * 最近要过生日的人(首页用)。
* 直接算「今天是今年的第几天」与生日的差值,跨年自动处理。 *
* ⚠️ 这里返回的是「**一个生日事件**」而不是「一个人」:
* 一个人可能同时有身份证生日与农历生日两条(不重合时),
* 也可能它们正好落在同一天(那就是 1 条,kinds 里有两个)。
* 早先按"人"来算,于是农历生日完全不会被提醒 —— 而家里老人过的正是农历。
*/ */
public static function upcomingBirthdays(int $familyId, int $withinDays = 60): array public static function upcomingBirthdays(int $familyId, int $withinDays = 60): array
{ {
$st = Database::get()->prepare( $today = date('Y-m-d');
"SELECT id, name, avatar, birthday FROM persons
WHERE family_id = ? AND kind = 'member' AND birthday IS NOT NULL
ORDER BY DAYOFYEAR(birthday)"
);
$st->execute([$familyId]);
$rows = $st->fetchAll();
$out = []; $out = [];
foreach ($rows as $r) { foreach (self::map($familyId) as $p) {
$days = days_until_birthday($r['birthday']); foreach (Birthday::nextOccurrences($p, $today, $withinDays) as $occ) {
if ($days !== null && $days <= $withinDays) { $out[] = [
$r['days_left'] = $days; 'id' => (int)$p['id'],
$out[] = $r; 'name' => (string)$p['name'],
'avatar' => $p['avatar'],
'date' => $occ['date'],
'days_left' => $occ['days'],
'kinds' => $occ['kinds'],
'kind_words' => array_map(fn($k) => Birthday::KINDS[$k] ?? $k, $occ['kinds']),
'lunar_label' => $occ['lunar_label'],
'age' => $occ['age'],
];
} }
} }
usort($out, function ($a, $b) { usort($out, function ($a, $b) {
return $a['days_left'] <=> $b['days_left']; return $a['days_left'] <=> $b['days_left'] ?: strcmp($a['date'], $b['date']);
}); });
return $out; return $out;
} }
@@ -204,19 +268,34 @@ final class Person
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$err['email'] = '邮箱格式看起来不对'; $err['email'] = '邮箱格式看起来不对';
} }
$b = trim((string)($d['birthday'] ?? '')); // 生日现在是「日期 + 时分秒」,但要宽松:只填日期也必须收下
if ($b !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $b)) { foreach (['birthday_solar' => '阳历生日', 'birthday_lunar' => '农历生日'] as $key => $word) {
$err['birthday'] = '生日请按 年-月-日 填写'; $v = trim((string)($d[$key] ?? ''));
if ($v === '') {
continue;
}
if (!preg_match('/^\d{4}-\d{2}-\d{2}([ ]\d{2}:\d{2}(:\d{2})?)?$/', $v)) {
$err[$key] = $word . '格式不对';
continue;
}
[$yy, $mm, $dd] = array_map('intval', explode('-', substr($v, 0, 10)));
if (!checkdate($mm, $dd, $yy) || $yy < Lunar::MIN_YEAR || $yy > Lunar::MAX_YEAR) {
$err[$key] = $word . '看起来不是一个真实的日子';
}
} }
// 身份证号只做很松的校验:家庭场景里要不要存它,本来就值得三思 // 身份证号只做很松的校验:家庭场景里要不要存它,本来就值得三思
$idc = trim((string)($d['id_card'] ?? '')); $idc = trim((string)($d['id_card'] ?? ''));
if ($idc !== '' && !preg_match('/^[0-9Xx]{15,18}$/', $idc)) { if ($idc !== '' && !preg_match('/^\d{17}[\dXx]$|^\d{15}$/', $idc)) {
$err['id_card'] = '身份证号应为 15 或 18 位'; $err['id_card'] = '身份证号应为 18 位(或 15 位老证)';
} }
$rank = $d['birth_rank'] ?? ''; $rank = $d['birth_rank'] ?? '';
if ($rank !== '' && $rank !== null && (!ctype_digit((string)$rank) || (int)$rank < 1 || (int)$rank > 99)) { if ($rank !== '' && $rank !== null && (!ctype_digit((string)$rank) || (int)$rank < 1 || (int)$rank > 99)) {
$err['birth_rank'] = '排行请填 1-99 的整数(老大填 1)'; $err['birth_rank'] = '排行请填 1-99 的整数(老大填 1)';
} }
$slogan = trim((string)($d['slogan'] ?? ''));
if (mb_strlen($slogan, 'UTF-8') > 200) {
$err['slogan'] = '一句话请控制在 200 字以内';
}
return $err; return $err;
} }
+110 -1
View File
@@ -16,6 +16,17 @@ namespace App;
final class Schema final class Schema
{ {
/**
* 当前结构版本。**唯一来源** —— index.php 的 FB_SCHEMA_VERSION 与
* install.php 的盖章都取自这里,别在两处各写一个字符串(迟早会漂移)。
*
* 版本史:
* 1 → 初始(families/persons/accounts/settings/relations)
* 2 → birth_rank、is_placeholder、accounts.role 加 guest
* 3 → 三个生日(birthday 升 DATETIME + id_card/lunar/leap)、slogan、nicknames 表
*/
public const VERSION = '3';
/** @return string[] */ /** @return string[] */
public static function statements(): array public static function statements(): array
{ {
@@ -35,7 +46,7 @@ CREATE TABLE IF NOT EXISTS `persons` (
`family_id` INT UNSIGNED NOT NULL, `family_id` INT UNSIGNED NOT NULL,
`kind` ENUM('member','contact') NOT NULL DEFAULT 'member' COMMENT '家庭成员 / 外部联系人', `kind` ENUM('member','contact') NOT NULL DEFAULT 'member' COMMENT '家庭成员 / 外部联系人',
`name` VARCHAR(50) NOT NULL COMMENT '姓名', `name` VARCHAR(50) NOT NULL COMMENT '姓名',
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '小名', `nickname` VARCHAR(50) DEFAULT NULL COMMENT '【旧语义,已废弃】全家共用的小名;升级时搬进 nicknames 表,新代码不再读写它',
`gender` ENUM('m','f','x') DEFAULT NULL COMMENT '男 / 女 / 未填写', `gender` ENUM('m','f','x') DEFAULT NULL COMMENT '男 / 女 / 未填写',
`birth_rank` INT DEFAULT NULL COMMENT '家中排行(同一对父母的子女里,1=老大),用来算长子/次女', `birth_rank` INT DEFAULT NULL COMMENT '家中排行(同一对父母的子女里,1=老大),用来算长子/次女',
`birthday` DATE DEFAULT NULL COMMENT '生日(自动生成日历上的生日日程用)', `birthday` DATE DEFAULT NULL COMMENT '生日(自动生成日历上的生日日程用)',
@@ -97,6 +108,21 @@ CREATE TABLE IF NOT EXISTS `settings` (
PRIMARY KEY (`family_id`, `k`), PRIMARY KEY (`family_id`, `k`),
CONSTRAINT `fk_settings_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE CONSTRAINT `fk_settings_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 `nicknames` (
`family_id` INT UNSIGNED NOT NULL,
`viewer_id` INT UNSIGNED NOT NULL COMMENT '谁在称呼别人(当前登录的人)',
`target_id` INT UNSIGNED NOT NULL COMMENT '被称呼的人',
`nickname` VARCHAR(50) NOT NULL COMMENT '我叫他/她什么',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`viewer_id`, `target_id`),
KEY `idx_nick_family` (`family_id`),
KEY `idx_nick_target` (`target_id`),
CONSTRAINT `fk_nick_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_nick_viewer` FOREIGN KEY (`viewer_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_nick_target` FOREIGN KEY (`target_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='称呼是视角属性:每个人各自决定怎么称呼别人,不挂在被称呼者身上'
SQL, SQL,
]; ];
} }
@@ -119,6 +145,89 @@ SQL,
self::addColumn($db, 'persons', 'is_placeholder', self::addColumn($db, 'persons', 'is_placeholder',
"ADD COLUMN `is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)' AFTER `note`"); "ADD COLUMN `is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)' AFTER `note`");
self::widenRoleEnum($db); self::widenRoleEnum($db);
// ---- 三个生日(版本 3)----
// birthday 原本是 DATE,现在要精确到时分秒 → 升成 DATETIME。
// MODIFY 是幂等的(已经是 DATETIME 时再 MODIFY 也不报错、不丢数据)。
$db->exec("ALTER TABLE `persons` MODIFY COLUMN `birthday`
DATETIME DEFAULT NULL COMMENT '真实阳历生日(精确到时分秒)'");
self::addColumn($db, 'persons', 'birthday_id_card',
"ADD COLUMN `birthday_id_card` DATE DEFAULT NULL COMMENT '身份证上的生日:由身份证第 7-14 位推导,只读、永不变' AFTER `birthday`");
self::addColumn($db, 'persons', 'birthday_lunar',
"ADD COLUMN `birthday_lunar` DATETIME DEFAULT NULL COMMENT '农历生日:**日期部分是农历年月日**,时间部分与阳历一致' AFTER `birthday_id_card`");
self::addColumn($db, 'persons', 'birthday_lunar_leap',
"ADD COLUMN `birthday_lunar_leap` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'birthday_lunar 的月份是否为闰月' AFTER `birthday_lunar`");
self::addColumn($db, 'persons', 'slogan',
"ADD COLUMN `slogan` VARCHAR(200) DEFAULT NULL COMMENT '个人的一句话;没填就用家庭统一的那句' AFTER `note`");
// 老数据里 birthday 若是 DATE,升成 DATETIME 后时间部分自动是 00:00:00,无需回填。
// 把老数据补出农历生日:只在还没算过时算一次(幂等)。
self::backfillLunarBirthday($db);
self::backfillIdCardBirthday($db);
// ---- 称呼从「人的属性」改成「视角属性」----
// 老语义:persons.nickname 是全家共用的小名。
// 新语义:nicknames 表,(viewer, target) → 我叫他什么。
// ⚠️ 必须在这里搬:不然升级之后界面上再没有人读 persons.nickname,
// 用户会觉得"以前填的小名一夜之间全没了"。搬的时候给每个账号都写一份,
// 迁移前后大家看到的叫法完全一致(`Nickname::migrateLegacy` 里说明了原因)。
// 放在结构升级里、而不是"某次请求里顺手做",是为了升级完就已经搬好。
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\Nickname::migrateLegacy((int)$fam['id']);
}
}
/** 老库已有阳历生日但没有农历生日 → 补算一次。用「为空」作条件,天然幂等。 */
private static function backfillLunarBirthday(\PDO $db): void
{
$st = $db->query(
"SELECT id, birthday FROM persons
WHERE birthday IS NOT NULL AND (birthday_lunar IS NULL OR birthday_lunar = '')"
);
$rows = $st->fetchAll();
if (!$rows) {
return;
}
$up = $db->prepare('UPDATE persons SET birthday_lunar = ?, birthday_lunar_leap = ? WHERE id = ?');
foreach ($rows as $r) {
$bd = substr((string)$r['birthday'], 0, 10);
[$y, $m, $d] = array_map('intval', explode('-', $bd));
$lu = \App\Lunar::solarToLunar($y, $m, $d);
if ($lu === null) {
continue;
}
$up->execute([
sprintf('%04d-%02d-%02d %s', $lu['year'], $lu['month'], $lu['day'], substr((string)$r['birthday'], 11, 8) ?: '00:00:00'),
$lu['is_leap'] ? 1 : 0,
(int)$r['id'],
]);
}
}
/** 老库已有身份证号但没有「身份证生日」→ 推导一次。同样以「为空」为条件。 */
private static function backfillIdCardBirthday(\PDO $db): void
{
$st = $db->query(
"SELECT id, id_card_encrypted FROM persons
WHERE id_card_encrypted IS NOT NULL AND id_card_encrypted <> ''
AND (birthday_id_card IS NULL)"
);
$rows = $st->fetchAll();
if (!$rows) {
return;
}
$up = $db->prepare('UPDATE persons SET birthday_id_card = ? WHERE id = ?');
foreach ($rows as $r) {
try {
$id = \App\Crypto::decrypt((string)$r['id_card_encrypted']);
} catch (\Throwable $ex) {
continue; // 密钥换过 / 解不开就跳过,绝不能因此让整次升级失败
}
$bd = \App\Birthday::fromIdCard($id);
if ($bd !== null) {
$up->execute([$bd, (int)$r['id']]);
}
}
} }
/** 列不存在才加 */ /** 列不存在才加 */
+4 -1
View File
@@ -64,15 +64,17 @@ final class Http
curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
} }
$t0 = microtime(true);
$out = curl_exec($ch); $out = curl_exec($ch);
$err = curl_error($ch); $err = curl_error($ch);
$errno = curl_errno($ch); $errno = curl_errno($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch); curl_close($ch);
$ms = (int)round((microtime(true) - $t0) * 1000);
if ($out === false) { if ($out === false) {
return [ return [
'ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'ms' => $ms,
'error' => $errno === CURLE_OPERATION_TIMEDOUT 'error' => $errno === CURLE_OPERATION_TIMEDOUT
? "请求超时({$timeout}s)" ? "请求超时({$timeout}s)"
: "网络错误({$errno}): {$err}", : "网络错误({$errno}): {$err}",
@@ -83,6 +85,7 @@ final class Http
'status' => $status, 'status' => $status,
'headers' => $respHeaders, 'headers' => $respHeaders,
'body' => $out, 'body' => $out,
'ms' => $ms,
'error' => $status >= 200 && $status < 300 ? '' : "HTTP {$status}", 'error' => $status >= 200 && $status < 300 ? '' : "HTTP {$status}",
]; ];
} }
+140
View File
@@ -335,4 +335,144 @@ final class OssDriver implements StorageDriver
} }
return $this->objectUrl($key); return $this->objectUrl($key);
} }
// ---------------- 连通性诊断 ----------------
/**
* 一步一步把「连不上 OSS」的原因定位到具体的哪一环。
*
* 为什么不能只用一个"测试存储是否可用"的按钮:
* 失败时它只会说"写入失败" —— 而失败可能是 6 种完全不同的原因
* (没填 bucket / 凭据取不到 / 桶地域与本机不同 / 内网域名解析不了 /
* AK 没这个桶的权限 / 桶 ACL 不是私有…),用户根本无从下手。
* 这里把每一步的**结论与耗时**都摊开,一眼能看出卡在哪。
*
* @return array<int, array{name:string,ok:bool,detail:string,ms:int}>
*/
public function diagnose(): array
{
$steps = [];
// ① 配置完整性(纯本地判断,最快,先挡掉一半问题)
$err = $this->validateConfig();
$steps[] = [
'name' => '配置完整性',
'ok' => $err === '',
'detail' => $err !== '' ? $err : sprintf('bucket=%s endpoint=%s 前缀=%s 凭据方式=%s',
$this->cfg['bucket'] ?: '(空)',
$this->endpoint() ?: '(空)',
trim((string)($this->cfg['prefix'] ?? '')) !== '' ? trim((string)$this->cfg['prefix']) : '(无)',
($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role' ? '服务器绑定角色' : 'AccessKey'),
'ms' => 0,
];
if ($err !== '') {
return $steps; // 配置都没填全,后面每一步都会以同样的原因失败,不必刷屏
}
// ② 凭据
$t0 = microtime(true);
$cred = $this->credentials();
$ms = (int)round((microtime(true) - $t0) * 1000);
$steps[] = [
'name' => '取凭据',
'ok' => !empty($cred['ok']),
'detail' => !empty($cred['ok'])
? (($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role'
? '已从实例 RAM 角色取到临时凭据' : '使用填写的 AccessKey')
: (string)($cred['error'] ?? '取不到凭据'),
'ms' => $ms,
];
if (empty($cred['ok'])) {
return $steps;
}
// ③ 地域比对 —— 跨地域时内网域名能解析但永远连不上,必须先说出来
$bucketRegion = $this->bucketRegion();
$ecsRegion = EcsMeta::region($this->familyId);
$sameRegion = ($bucketRegion === '' || $ecsRegion === '' || $bucketRegion === $ecsRegion);
$steps[] = [
'name' => '地域',
'ok' => $sameRegion,
'detail' => $bucketRegion === '' || $ecsRegion === ''
? sprintf('本机地域=%s 桶地域=%s(信息不全,跳过内网加速)',
$ecsRegion ?: '未知', $bucketRegion ?: '未知')
: ($sameRegion
? "本机与桶都在 {$bucketRegion},内网可达"
: "本机在 {$ecsRegion}、桶在 {$bucketRegion} —— **不是同一地域**,内网走不通,只能走公网"),
'ms' => 0,
];
// ④ 公网 endpoint 可达性(HEAD 桶,即使 403 也证明网络通了)
$url = $this->scheme() . '://' . $this->host() . '/';
$res = Http::send('HEAD', $url, $this->readHeaders('/', $cred, [], 'HEAD'), null, 10, 5);
$steps[] = [
'name' => '公网 endpoint 可达',
'ok' => (int)$res['status'] > 0,
'detail' => (int)$res['status'] > 0
? sprintf('%s → HTTP %d%s', $url, (int)$res['status'],
(int)$res['status'] === 403 ? '(可达;403 只是没有 ListBucket 权限,正常)' : '')
: (string)($res['error'] ?? '连不上'),
'ms' => (int)($res['ms'] ?? 0),
];
if ((int)$res['status'] === 0) {
return $steps; // 公网都不通,后面没意义
}
// ⑤ 内网 endpoint(只在同地域且配了内网时才测;跨地域测它是白等超时)
$internal = $this->internalEndpoint();
if ($internal !== '') {
if (!$sameRegion) {
$steps[] = ['name' => '内网 endpoint', 'ok' => true,
'detail' => '跳过:跨地域时内网域名能解析但连不上,测了只会白等一次超时', 'ms' => 0];
} else {
$iurl = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $internal . '/';
$ires = Http::send('HEAD', $iurl, $this->readHeaders('/', $cred, [], 'HEAD'), null, 5, 2);
$steps[] = [
'name' => '内网 endpoint(服务端读取更快且免流量费)',
'ok' => (int)$ires['status'] > 0,
'detail' => (int)$ires['status'] > 0
? sprintf('%s → HTTP %d', $iurl, (int)$ires['status'])
: sprintf('%s → %s(会自动回退公网,功能不受影响)', $iurl, (string)($ires['error'] ?? '连不上')),
'ms' => (int)($ires['ms'] ?? 0),
];
}
}
// ⑥ 真写一个对象再读回来再删掉 —— 唯一能证明"有权限"的一步
$probeKey = 'avatars/.diag-' . bin2hex(random_bytes(6)) . '.txt';
$payload = 'familybook-diag-' . time();
$t0 = microtime(true);
$put = $this->putContents($payload, $probeKey, 'text/plain');
$putMs = (int)round((microtime(true) - $t0) * 1000);
if (empty($put['ok'])) {
$steps[] = ['name' => '写入(PUT)', 'ok' => false,
'detail' => (string)($put['error'] ?? '写入失败')
. ' —— 常见原因:这个 AK 对这个桶没有 PutObject 权限,或桶 ACL 不是私有/公共读写。',
'ms' => $putMs];
return $steps;
}
$steps[] = ['name' => '写入(PUT)', 'ok' => true, 'detail' => '对象已写入 ' . $probeKey, 'ms' => $putMs];
$get = $this->get($probeKey);
$same = !empty($get['ok']) && trim((string)$get['body']) === $payload;
$steps[] = [
'name' => '读回(GET)',
'ok' => $same,
'detail' => $same
? '读回内容与写入一致'
: sprintf('写入成功但读回来对不上(HTTP %d)—— 多半是内网/外网 endpoint 指错,或桶有版本/加密设置',
(int)($get['status'] ?? 0)),
'ms' => (int)($get['ms'] ?? 0),
];
$del = $this->delete($probeKey);
$steps[] = [
'name' => '删除(DELETE)',
'ok' => !empty($del['ok']),
'detail' => !empty($del['ok']) ? '探针对象已清理' : (string)($del['error'] ?? '删不掉(会留下垃圾文件)'),
'ms' => 0,
];
return $steps;
}
} }
+35 -15
View File
@@ -146,9 +146,20 @@ final class Storage
} }
} }
/** 读取内容(导出、代理等场景) */ /**
* 读取内容(代理、导出等场景)。
*
* 读取顺序与 exists() 一致,刻意设计成「本地优先」:
* 从本地搬到云端(或反过来)时,历史文件可能只在一边,
* 本地优先能保证迁移期的老文件照样打得开,也让回滚随时可行。
*/
public static function read(int $familyId, string $key): array public static function read(int $familyId, string $key): array
{ {
$local = FB_UPLOADS . '/' . ltrim($key, '/');
if ($key !== '' && is_file($local)) {
$body = (string)@file_get_contents($local);
return ['ok' => true, 'status' => 200, 'headers' => [], 'body' => $body, 'error' => '', 'from' => 'local'];
}
return self::disk($familyId)->get($key); return self::disk($familyId)->get($key);
} }
@@ -166,10 +177,14 @@ final class Storage
/** /**
* **唯一出口**:把相对键变成可访问地址。 * **唯一出口**:把相对键变成可访问地址。
* *
* 读取顺序刻意设计成「本地优先」: * ⚠️ 现在一律指向 `media.php` 代理,不再直接给 uploads/ 或 OSS 的地址:
* ① 已是绝对地址 → 原样返回 * · 统一:本地与云端的差别被代理吃掉,前端只认一个地址形态;
* ② 本地有文件 → 走本地(迁移期老文件仍可访问,随时可回滚) * · 安全:桶可以设成私有、uploads/ 可以整个封死,能否看到附件由**登录态**决定,
* ③ 交给当前驱动 * 而不是"知道文件名就能看";
* · 标准:Content-Type / 缓存 / 下载头由服务端统一给。
*
* 为什么保留这个方法:`media.php` 自己不调它(它用 read() 取字节),
* 所以不存在自递归;留在这里是为了让"谁想把键变成地址"永远只有一条路。
*/ */
public static function url(int $familyId, ?string $key): string public static function url(int $familyId, ?string $key): string
{ {
@@ -180,15 +195,20 @@ final class Storage
if (preg_match('#^https?://#i', $key)) { if (preg_match('#^https?://#i', $key)) {
return $key; return $key;
} }
$key = ltrim($key, '/'); return \media_url($key);
$local = FB_UPLOADS . '/' . $key; }
if (is_file($local)) {
return asset('/uploads/' . $key); /**
} * 直接拿「已保存的 OSS 配置」建一个 OSS 驱动来做连通性诊断。
$disk = self::disk($familyId); *
if ($disk->id() === 'local') { * 与 disk() 的区别很重要:disk() 会按 effectiveDriver() 决定用哪个驱动,
return asset('/uploads/' . $key); // 本地也没有 → 让默认头像逻辑兜住 * 当前选的是"本地"时就只会给你 LocalDriver —— 于是**用户没法在切到 OSS 之前
} * 先把 OSS 测通**,只能"先切换、发现不行、再切回来"。这里绕开那个判断。
return $disk->url($key); */
public static function ossDiskForDiagnosis(int $familyId): ?OssDriver
{
$cfg = self::config($familyId);
$driver = new OssDriver($familyId, $cfg);
return $driver->validateConfig() === '' ? $driver : null;
} }
} }
+84 -12
View File
@@ -23,19 +23,82 @@ function asset(string $rel): string
/** /**
* 附件(头像等)地址 —— 全站唯一出口。 * 附件(头像等)地址 —— 全站唯一出口。
* 走这里是纪律:数据库只存相对键,云端/本地的差异由 Storage 自己吸收。 *
* ⚠️ 现在**一律指向 media.php 代理**,不再直接把 uploads/ 或 OSS 的地址吐给浏览器:
* · 统一:本地/云端的形态差别被代理吃掉,前端只认一个 media.php?k=…
* · 安全:桶不必开公共读;uploads/ 可以直接封死不让 Web 访问,
* 附件是否可读由**登录态**决定,而不是"知道文件名就能看"。
* · 标准:Content-Type / 缓存 / 下载头由服务端统一给,而不是靠存储桶配得对不对。
*/ */
function storage_url(?string $key): string function storage_url(?string $key): string
{ {
$fid = \App\Auth::familyId(); return media_url($key);
return \App\Storage\Storage::url((int)$fid, $key);
} }
/** 头像地址(无头像自动回退内置剪影) */ /** 头像地址(无头像自动回退内置剪影) */
function avatar_url(?string $key): string function avatar_url(?string $key): string
{ {
$fid = (int)\App\Auth::familyId(); $key = trim((string)$key);
return \App\Upload::avatarUrl($fid, $key); if ($key === '') {
return asset('/assets/avatar-default.svg');
}
return media_url($key);
}
/**
* 附件代理地址。
* @param array $extra 额外参数,如 ['dl' => 1] 表示按下附件下载
*/
function media_url(?string $key, array $extra = []): string
{
$key = trim((string)$key);
if ($key === '') {
return asset('/assets/avatar-default.svg');
}
// 已经是绝对地址(历史数据/外部链接)→ 不代理,原样用
if (preg_match('#^https?://#i', $key)) {
return $key;
}
return 'media.php?' . http_build_query(array_merge(['k' => ltrim($key, '/')], $extra));
}
/**
* 读「日期 + 24 小时制时:分:秒」组合控件(templates/_datetime.php 产出)。
*
* 为什么不用 <input type="datetime-local">:它的显示格式由**浏览器 UI 语言 /
* 系统时间格式**决定,<html lang> 压不过它 —— 中文环境会渲染成「下午 03:30」,
* 和"精确到时分秒"的要求对不上,用户还会把上午下午填反。
* 拆成「原生日期选择器 + 时/分/秒三个下拉」后,24 小时制由控件本身保证。
*
* 只选了时间没选日期 → 视为没填(与 TripLog 的行为一致)。
*/
function read_dt_input(string $name): ?string
{
$date = trim((string)($_POST[$name . '_date'] ?? ''));
$hRaw = trim((string)($_POST[$name . '_hour'] ?? ''));
$mRaw = trim((string)($_POST[$name . '_minute'] ?? ''));
$sRaw = trim((string)($_POST[$name . '_second'] ?? ''));
// 兼容一次性传整串的老表单(如 CSV 导入、内部调用)
if ($date === '' && $hRaw === '' && $mRaw === '') {
return \App\Birthday::toDateTime((string)($_POST[$name] ?? ''));
}
if ($date === '') {
return null;
}
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $mt)) {
return null;
}
if (!checkdate((int)$mt[2], (int)$mt[3], (int)$mt[1])) {
return null;
}
$h = $hRaw === '' ? 0 : (int)$hRaw;
$i = $mRaw === '' ? 0 : (int)$mRaw;
$s = $sRaw === '' ? 0 : (int)$sRaw;
if ($h < 0 || $h > 23 || $i < 0 || $i > 59 || $s < 0 || $s > 59) {
return null;
}
return sprintf('%s %02d:%02d:%02d', $date, $h, $i, $s);
} }
/** 站内链接(统一走 query-string 路由,不依赖 Apache 重写) */ /** 站内链接(统一走 query-string 路由,不依赖 Apache 重写) */
@@ -133,27 +196,36 @@ function clear_old(): void
unset($_SESSION['_old']); unset($_SESSION['_old']);
} }
/** 生日相关:返回年龄与距下次生日天数 */ /**
function age_from(string $birthday): ?int * 生日相关:年龄与距下次生日天数。
*
* ⚠️ 输入可能是 DATETIME('1990-05-12 14:30:00')—— 一律先截到日期再算,
* 否则 'm-d' 之类会带上时间,闰年/跨年边界容易算歪。
* 真正的"下一次生日"请用 App\Birthday::nextOccurrences():
* 它会分别处理身份证/阳历/农历三个生日,这里只是给页面用的简版。
*/
function age_from(?string $birthday): ?int
{ {
if ($birthday === '' || $birthday === null) { $d = \App\Birthday::dateOnly($birthday);
if ($d === '') {
return null; return null;
} }
try { try {
$b = new DateTimeImmutable($birthday); $b = new DateTimeImmutable($d);
} catch (Exception $ex) { } catch (Exception $ex) {
return null; return null;
} }
return (int)$b->diff(new DateTimeImmutable('today'))->y; return (int)$b->diff(new DateTimeImmutable('today'))->y;
} }
function days_until_birthday(string $birthday): ?int function days_until_birthday(?string $birthday): ?int
{ {
if (!$birthday) { $d = \App\Birthday::dateOnly($birthday);
if ($d === '') {
return null; return null;
} }
try { try {
$b = new DateTimeImmutable($birthday); $b = new DateTimeImmutable($d);
$now = new DateTimeImmutable('today'); $now = new DateTimeImmutable('today');
$next = new DateTimeImmutable($now->format('Y') . '-' . $b->format('m-d')); $next = new DateTimeImmutable($now->format('Y') . '-' . $b->format('m-d'));
if ($next < $now) { if ($next < $now) {
+136 -5
View File
@@ -64,7 +64,12 @@ a:hover { text-decoration: underline; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; } .topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.who { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 14px; } .who { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 14px; }
a.who:hover { text-decoration: none; }
/* 头像/名字是个「编辑我自己」的入口 —— hover 时给点反馈,让人知道它可点 */
a.who:hover .who-name { color: var(--brand); text-decoration: underline; }
a.who:hover img { box-shadow: 0 0 0 2px var(--brand-2); }
.who img { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; } .who img { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; }
.who img { transition: box-shadow .15s ease; }
.link-quiet { color: var(--muted); font-size: 14px; } .link-quiet { color: var(--muted); font-size: 14px; }
.link-quiet:hover { color: var(--brand); } .link-quiet:hover { color: var(--brand); }
@@ -104,8 +109,22 @@ a:hover { text-decoration: underline; }
display: flex; align-items: center; gap: 20px; flex-wrap: wrap; display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
} }
.hero-logo { width: 64px; height: 64px; border-radius: 16px; flex: 0 0 auto; } .hero-logo { width: 64px; height: 64px; border-radius: 16px; flex: 0 0 auto; }
.hero-main { flex: 1 1 240px; min-width: 0; }
.hero h1 { font-size: 24px; font-weight: 600; margin: 0 0 4px; } .hero h1 { font-size: 24px; font-weight: 600; margin: 0 0 4px; }
.hero .motto { color: var(--muted); margin: 0; font-size: 14px; } .hero .motto { color: var(--muted); margin: 0; font-size: 14px; }
/* 那句小字到底是谁的:自己写的那句要标一下,否则用户会以为这是系统统一发的 */
.motto-from {
display: inline-block; margin-left: 6px; padding: 0 7px;
border-radius: 999px; background: var(--line-2);
font-size: 11px; color: var(--faint);
}
.motto-edit { margin-left: 8px; font-size: 12px; color: var(--brand-2); white-space: nowrap; }
/* 详情页顶部引用本人的一句话 */
.motto-line {
margin: -8px 0 16px; padding-left: 12px;
border-left: 3px solid var(--brand-2);
color: var(--muted); font-size: 14px;
}
.hero-stats { margin-left: auto; display: flex; gap: 28px; } .hero-stats { margin-left: auto; display: flex; gap: 28px; }
.stat { text-align: center; } .stat { text-align: center; }
.stat b { display: block; font-size: 24px; font-weight: 600; line-height: 1.2; } .stat b { display: block; font-size: 24px; font-weight: 600; line-height: 1.2; }
@@ -139,17 +158,39 @@ a:hover { text-decoration: underline; }
} }
.badge-admin { background: #F6E3D7; color: var(--brand-ink); } .badge-admin { background: #F6E3D7; color: var(--brand-ink); }
.badge-open { background: #E6F0DC; color: #4A7226; } .badge-open { background: #E6F0DC; color: #4A7226; }
.badge-me { background: var(--brand); color: #fff; }
/* 生日条 */ /* 生日条 —— 一个人可能同时出两条(农历与阳历不在同一天),
落在同一天时并成一条、kinds 里挂多个标签,正好对应"要分别提醒"。 */
.bdays { display: flex; gap: 12px; flex-wrap: wrap; } .bdays { display: flex; gap: 12px; flex-wrap: wrap; }
.bday { .bday {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 12px;
background: var(--surface); border: 1px solid var(--line); background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: 6px 16px 6px 6px; box-shadow: var(--shadow); border-radius: var(--radius); padding: 10px 18px 10px 10px;
box-shadow: var(--shadow);
color: var(--ink); text-decoration: none;
transition: transform .15s ease, box-shadow .15s ease;
} }
.bday img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; } .bday:hover { transform: translateY(-2px); box-shadow: var(--shadow-l); text-decoration: none; }
.bday img { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; }
.bday b { font-weight: 500; } .bday b { font-weight: 500; }
.bday .d { color: var(--brand); font-size: 13px; } .bday .d { color: var(--brand); font-size: 13px; }
.bday-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 3px; }
.bday-date { font-size: 12px; color: var(--faint); font-variant-numeric: tabular-nums; }
/* 「身份证生日 / 阳历生日 / 农历生日」的标签,首页与详情页共用 */
.bd-kind {
display: inline-block; padding: 0 7px; border-radius: 999px;
font-size: 11px; line-height: 1.8;
background: var(--line-2); color: var(--muted);
}
.bd-kind-id { background: #E6F0DC; color: #4A7226; }
.bd-kind-solar { background: #F6E3D7; color: var(--brand-ink); }
.bd-kind-lunar { background: #E8E4F2; color: #55477E; }
/* 详情页的三个生日 */
.bd-list { list-style: none; margin: 0; padding: 0; }
.bd-list li { display: flex; align-items: baseline; gap: 8px; margin-bottom: 3px; flex-wrap: wrap; }
.bd-list .bd-val { font-variant-numeric: tabular-nums; }
/* ---------------- 表单 ---------------- */ /* ---------------- 表单 ---------------- */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@@ -233,7 +274,14 @@ textarea.input { min-height: 76px; resize: vertical; }
border: 1px solid var(--line); border-radius: 20px; box-shadow: var(--shadow-l); border: 1px solid var(--line); border-radius: 20px; box-shadow: var(--shadow-l);
padding: 32px; padding: 32px;
} }
.auth-card .logo { width: 56px; height: 56px; border-radius: 14px; margin-bottom: 14px; } /* 图标 + 「用手机号登录你的家书」+ 家庭名,整块水平居中。
⚠️ 只有这一块居中:下面的表单仍然左对齐(label 左对齐才好读),
「忘记密码」那行本来就是居中,所以视觉上不会歪。 */
.auth-head { text-align: center; }
.auth-card .logo {
display: block; margin: 0 auto 14px;
width: 56px; height: 56px; border-radius: 14px;
}
.auth-card h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; } .auth-card h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
.auth-card .lead { color: var(--muted); font-size: 14px; margin: 0 0 24px; } .auth-card .lead { color: var(--muted); font-size: 14px; margin: 0 0 24px; }
.auth-card .btn { width: 100%; margin-top: 20px; } .auth-card .btn { width: 100%; margin-top: 20px; }
@@ -246,6 +294,40 @@ textarea.input { min-height: 76px; resize: vertical; }
.search { display: flex; gap: 8px; margin-bottom: 16px; } .search { display: flex; gap: 8px; margin-bottom: 16px; }
.search .input { max-width: 260px; } .search .input { max-width: 260px; }
/* ---------------- 日期 + 时:分:秒 组合控件(templates/_datetime.php) ----------------
原生 datetime-local 的显示格式由浏览器 UI 语言/系统时间格式决定,
中文环境渲染成「下午 03:30」且没有秒,所以自己拼一个。
日期部分仍是原生 date 控件 → app.js 的 showPicker 让它整框可点。 */
.dt-input { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.dt-input .dt-date {
flex: 1 1 150px; min-width: 0;
padding: 9px 11px; border: 1px solid var(--line); border-radius: var(--radius-s);
background: var(--surface); color: var(--ink); font: inherit;
accent-color: var(--brand);
}
.dt-input .dt-date { cursor: pointer; } /* 整框可点,光标也要说明这一点 */
.dt-input .dt-date::-webkit-calendar-picker-indicator { cursor: pointer; opacity: .65; }
.dt-time { display: flex; align-items: center; gap: 3px; }
.dt-input .dt-hour,
.dt-input .dt-minute,
.dt-input .dt-second {
padding: 9px 6px; border: 1px solid var(--line); border-radius: var(--radius-s);
background: var(--surface); color: var(--ink); font: inherit;
font-variant-numeric: tabular-nums; cursor: pointer;
}
.dt-input .dt-colon { color: var(--muted); font-weight: 600; }
/* 「以哪个生日为准」的两块:被选中的那块描一圈,一眼看出填的是哪边 */
.bd-source { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
.bd-pane { border: 1px solid transparent; border-radius: var(--radius); padding: 10px 12px 2px; }
.bd-pane.is-primary { border-color: var(--brand-2); background: #FDF6F1; }
.bd-pane .bd-pane-k { font-size: 13px; font-weight: 600; margin: 0 0 8px; }
.bd-readonly {
display: inline-block; padding: 6px 10px; border-radius: var(--radius-s);
background: var(--line-2); color: var(--muted); font-size: 14px;
font-variant-numeric: tabular-nums;
}
/* ---------------- 称谓标签 ---------------- */ /* ---------------- 称谓标签 ---------------- */
.rel-label { .rel-label {
display: inline-block; padding: 1px 9px; border-radius: 999px; display: inline-block; padding: 1px 9px; border-radius: 999px;
@@ -387,3 +469,52 @@ textarea.input { min-height: 76px; resize: vertical; }
.search { flex-wrap: wrap; } .search { flex-wrap: wrap; }
.search .input { max-width: none; flex: 1 1 auto; } .search .input { max-width: none; flex: 1 1 auto; }
} }
/* ---------------- 「我怎么称呼家人」(视角属性,见 app/Nickname.php) ---------------- */
.nick-list { display: flex; flex-direction: column; gap: 8px; }
.nick-row {
display: grid; grid-template-columns: minmax(120px, auto) 1fr auto;
gap: 10px; align-items: center;
padding: 8px 10px; border-radius: var(--radius-s);
background: #FAF7F1; border: 1px solid var(--line-2);
}
.nick-who { display: flex; align-items: center; gap: 8px; font-size: 14px; min-width: 0; }
.nick-who img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; }
.nick-who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 详情页上的单个"我叫他什么"输入行 */
.nick-inline { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.nick-inline .input { flex: 1 1 220px; max-width: 340px; }
@media (max-width: 720px) {
.nick-row { grid-template-columns: 1fr; }
.nick-inline .input { max-width: none; }
}
/* ---------------- 连通性测试与诊断(设置页) ---------------- */
.test-actions { display: flex; flex-direction: column; gap: 10px; }
.test-actions form { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.oss-diag {
margin-top: 16px; border-radius: var(--radius-s); overflow: hidden;
border: 1px solid var(--line);
}
.oss-diag-head {
display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap;
padding: 10px 14px; font-size: 13px;
background: var(--line-2);
}
.oss-diag.is-ok .oss-diag-head { background: #E6F0DC; color: #3F6021; }
.oss-diag.is-bad .oss-diag-head { background: #F6E3D7; color: var(--brand-ink); }
.oss-diag-table { width: 100%; border-collapse: collapse; font-size: 13px; background: var(--surface); }
.oss-diag-table th,
.oss-diag-table td { padding: 8px 12px; text-align: left; vertical-align: top; border-top: 1px solid var(--line-2); }
.oss-diag-table th { font-weight: 500; color: var(--muted); font-size: 12px; }
.oss-diag-table td:nth-child(1) { white-space: nowrap; }
.oss-diag-table td:nth-child(3) { white-space: nowrap; }
.oss-diag-table tr.row-ok td:nth-child(2) { color: #4A7226; }
.oss-diag-table tr.row-bad td:nth-child(2) { color: var(--red); }
@media (max-width: 720px) {
.oss-diag-table th:nth-child(3),
.oss-diag-table td:nth-child(3) { display: none; }
}
+67
View File
@@ -2,6 +2,30 @@
(function () { (function () {
'use strict'; 'use strict';
// ===== 时间框:点输入框任意一处都弹出选择器 =====
// 原生行为只在点右侧那个小日历图标时才弹(而且各浏览器还不一致),
// 用户会以为框只能读、或者找不到那个图标在哪。
// showPicker() 是标准做法:在 click 里调它,等于把整块输入区都当成了那个图标。
//
// 为什么能跟浏览器自带的那次弹出共存:若本次点击已经弹过,再调一次是空操作
// (规范里「show the picker」对已经显示的选择器直接返回),不会闪两下。
// 不支持的浏览器(老 Safari 等)静默跳过 —— 退化成原生行为,不会比现在更差。
(function () {
var pickers = document.querySelectorAll('input[type="date"], input[type="datetime-local"]');
function hook(el) {
el.addEventListener('click', function () {
if (typeof el.showPicker !== 'function') { return; }
try {
el.showPicker();
} catch (err) {
// NotAllowedError:不是用户手势触发的(或被跨域 iframe 挡住)。
// 忽略即可 —— 原生那套点击行为还在,用户仍有办法选时间。
}
});
}
for (var i = 0; i < pickers.length; i++) { hook(pickers[i]); }
})();
// 头像选好后即时预览,别等上传完才知道选错了 // 头像选好后即时预览,别等上传完才知道选错了
var fileInput = document.getElementById('avatar'); var fileInput = document.getElementById('avatar');
if (fileInput && window.FileReader) { if (fileInput && window.FileReader) {
@@ -24,4 +48,47 @@
img.src = '/assets/avatar-default.svg'; img.src = '/assets/avatar-default.svg';
}); });
}); });
// ===== 三个生日:选择"以哪个为准" =====
// 阳历与农历其实是同一个人的同一天,不能各填各的(那样就自相矛盾了)。
// 表单上让用户明确指定"我这次填的是哪一个",服务端据此反推另一个。
(function () {
var sel = document.getElementById('bdSource');
if (!sel) { return; }
var solarBox = document.getElementById('bdSolarWrap');
var lunarBox = document.getElementById('bdLunarWrap');
function sync() {
var isLunar = sel.value === 'lunar';
if (solarBox) { solarBox.classList.toggle('is-primary', !isLunar); }
if (lunarBox) { lunarBox.classList.toggle('is-primary', isLunar); }
}
sel.addEventListener('change', sync);
sync();
})();
// ===== 身份证号 → 即时算出「身份证生日」 =====
// 让用户当场看到"系统从证件里读出来的是哪一天",
// 而不是存下去之后才发现跟自己的生日差了一个月。
(function () {
var idc = document.getElementById('id_card');
var out = document.getElementById('idCardBirthday');
if (!idc || !out) { return; }
function pad(n) { return (n < 10 ? '0' : '') + n; }
function calc() {
var s = (idc.value || '').toUpperCase().replace(/\s/g, '');
var y, m, d;
if (/^\d{17}[\dX]$/.test(s)) {
y = +s.substr(6, 4); m = +s.substr(10, 2); d = +s.substr(12, 2);
} else if (/^\d{15}$/.test(s)) {
y = 1900 + +s.substr(6, 2); m = +s.substr(8, 2); d = +s.substr(10, 2);
} else {
out.textContent = '';
return;
}
if (m < 1 || m > 12 || d < 1 || d > 31) { out.textContent = ''; return; }
out.textContent = y + '-' + pad(m) + '-' + pad(d);
}
idc.addEventListener('input', calc);
calc();
})();
})(); })();
+205 -17
View File
@@ -12,10 +12,12 @@ require __DIR__ . '/app/bootstrap.php';
use App\Account; use App\Account;
use App\Auth; use App\Auth;
use App\Birthday;
use App\Crypto; use App\Crypto;
use App\Database; use App\Database;
use App\Family; use App\Family;
use App\Kinship; use App\Kinship;
use App\Nickname;
use App\Person; use App\Person;
use App\Relations; use App\Relations;
use App\Schema; use App\Schema;
@@ -35,7 +37,7 @@ if (!Database::hasTables()) {
* 新的 guest 角色。靠人去手动 ALTER 迟早会漏,所以在每次请求的开头比一下版本号, * 新的 guest 角色。靠人去手动 ALTER 迟早会漏,所以在每次请求的开头比一下版本号,
* 落后了就补一次 —— 平时只是一次字符串比较,几乎零成本。 * 落后了就补一次 —— 平时只是一次字符串比较,几乎零成本。
*/ */
const FB_SCHEMA_VERSION = '2'; const FB_SCHEMA_VERSION = Schema::VERSION;
if (Database::hasTables()) { if (Database::hasTables()) {
$familyIdForSchema = isset($_SESSION['family_id']) ? (int)$_SESSION['family_id'] : 0; $familyIdForSchema = isset($_SESSION['family_id']) ? (int)$_SESSION['family_id'] : 0;
// 登录前还没有 family_id,用首个家庭兜底(本系统是单家庭部署) // 登录前还没有 family_id,用首个家庭兜底(本系统是单家庭部署)
@@ -71,6 +73,33 @@ if (!Auth::check() && !in_array($r, $publicRoutes, true)) {
redirect('auth/login'); 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) { switch ($r) {
// ==================== 登录 / 退出 ==================== // ==================== 登录 / 退出 ====================
@@ -164,6 +193,8 @@ switch ($r) {
foreach ($birthdays as &$b) { foreach ($birthdays as &$b) {
$b['label'] = isset($people[(int)$b['id']]) $b['label'] = isset($people[(int)$b['id']])
? Kinship::label($graph, $me, (int)$b['id'], $people) : ''; ? Kinship::label($graph, $me, (int)$b['id'], $people) : '';
// 名字优先用"我叫他什么" —— 同一个孩子,爸爸看到"儿子"、奶奶看到"小宝"
$b['shown'] = Nickname::display($me, $people[(int)$b['id']] ?? null) ?: $b['name'];
} }
unset($b); unset($b);
@@ -176,6 +207,9 @@ switch ($r) {
'allCount' => count($people), 'allCount' => count($people),
'relCount' => Relations::count($familyId), 'relCount' => Relations::count($familyId),
'birthdays' => $birthdays, 'birthdays' => $birthdays,
// 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页)
'mySlogan' => trim((string)($people[$me]['slogan'] ?? '')),
'myNicknames'=> Nickname::mapFor($me),
], 'home'); ], 'home');
break; break;
@@ -201,12 +235,13 @@ 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); $rows = Person::all($familyId, $q, 'member', $me);
$people = Person::map($familyId); $people = Person::map($familyId);
$graph = Kinship::graph($familyId); $graph = Kinship::graph($familyId);
// 名册里也标出「我该怎么称呼他」 // 名册里也标出「我该怎么称呼他」
foreach ($rows as &$row) { foreach ($rows as &$row) {
$row['label'] = Kinship::label($graph, $me, (int)$row['id'], $people); $row['label'] = Kinship::label($graph, $me, (int)$row['id'], $people);
$row['shown'] = Nickname::display($me, $row) ?: (string)$row['name'];
} }
unset($row); unset($row);
@@ -218,6 +253,107 @@ switch ($r) {
], 'people'); ], 'people');
break; 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': case 'people/new':
Auth::requireWrite(); Auth::requireWrite();
clear_old(); clear_old();
@@ -233,14 +369,13 @@ switch ($r) {
csrf_check(); csrf_check();
$d = [ $d = [
'name' => trim((string)($_POST['name'] ?? '')), 'name' => trim((string)($_POST['name'] ?? '')),
'nickname' => trim((string)($_POST['nickname'] ?? '')),
'gender' => (string)($_POST['gender'] ?? ''), 'gender' => (string)($_POST['gender'] ?? ''),
'birthday' => trim((string)($_POST['birthday'] ?? '')),
'phone' => trim((string)($_POST['phone'] ?? '')), 'phone' => trim((string)($_POST['phone'] ?? '')),
'id_card' => trim((string)($_POST['id_card'] ?? '')), 'id_card' => trim((string)($_POST['id_card'] ?? '')),
'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'] ?? '')),
'sort_order'=> (int)($_POST['sort_order'] ?? 1000), 'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
'avatar' => '', 'avatar' => '',
'kind' => 'member', 'kind' => 'member',
@@ -248,7 +383,7 @@ switch ($r) {
// 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。 // 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。
'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,
]; ] + read_birthday_input();
$errors = Person::validate($d); $errors = Person::validate($d);
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []); $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []);
@@ -315,19 +450,18 @@ switch ($r) {
} }
$d = [ $d = [
'name' => trim((string)($_POST['name'] ?? '')), 'name' => trim((string)($_POST['name'] ?? '')),
'nickname' => trim((string)($_POST['nickname'] ?? '')),
'gender' => (string)($_POST['gender'] ?? ''), 'gender' => (string)($_POST['gender'] ?? ''),
'birthday' => trim((string)($_POST['birthday'] ?? '')),
'phone' => trim((string)($_POST['phone'] ?? '')), 'phone' => trim((string)($_POST['phone'] ?? '')),
'id_card' => trim((string)($_POST['id_card'] ?? '')), 'id_card' => trim((string)($_POST['id_card'] ?? '')),
'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'] ?? '')),
'sort_order'=> (int)($_POST['sort_order'] ?? 1000), 'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
'avatar' => (string)$p['avatar'], 'avatar' => (string)$p['avatar'],
'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,
]; ] + read_birthday_input($p);
$errors = Person::validate($d); $errors = Person::validate($d);
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []); $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []);
if (!$up['ok'] && ($up['error'] ?? '') !== '') { if (!$up['ok'] && ($up['error'] ?? '') !== '') {
@@ -373,7 +507,7 @@ switch ($r) {
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
$me = (int)Auth::personId(); $me = (int)Auth::personId();
$id = (int)($_GET['id'] ?? 0); $id = (int)($_GET['id'] ?? 0);
$p = Person::findWithAccount($id); $p = Person::findWithAccount($id, $me);
if (!$p || (int)$p['family_id'] !== $familyId) { if (!$p || (int)$p['family_id'] !== $familyId) {
flash('err', '找不到这个人。'); flash('err', '找不到这个人。');
redirect('people'); redirect('people');
@@ -395,8 +529,13 @@ switch ($r) {
// 以这个人为中心的两代亲属,供详情页展示 // 以这个人为中心的两代亲属,供详情页展示
'theirTree' => $tree, 'theirTree' => $tree,
'theirRels' => Relations::forPerson($familyId, $id), 'theirRels' => Relations::forPerson($familyId, $id),
'options' => Person::options($familyId, $id), 'options' => Person::options($familyId, $id, $me),
'kinReady' => Kinship::graph($familyId) !== [], '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'); ], 'people');
break; break;
@@ -543,9 +682,11 @@ switch ($r) {
case 'accounts/invite': case 'accounts/invite':
Auth::requireAdmin(); Auth::requireAdmin();
clear_old(); clear_old();
// ⚠️ 访客建出来是 kind='contact'(不进家谱),而 Person::all() 默认只取 'member' ——
// 早先这里没传 kind,于是"已有访客"永远列不出人来(页面看着像是从没开通过)。
View::render('people/invite', [ View::render('people/invite', [
'title' => '邀请访客', 'title' => '邀请访客',
'people' => Person::all((int)Auth::familyId()), 'people' => Person::all((int)Auth::familyId(), '', 'contact', (int)Auth::personId()),
'options' => Person::options((int)Auth::familyId()), 'options' => Person::options((int)Auth::familyId()),
], 'people'); ], 'people');
break; break;
@@ -574,9 +715,9 @@ switch ($r) {
try { try {
// 访客也进名册(kind=contact,与家庭成员区分开),但不参与家谱 // 访客也进名册(kind=contact,与家庭成员区分开),但不参与家谱
$pid = Person::create($familyId, [ $pid = Person::create($familyId, [
'name' => $name, 'nickname' => '', 'gender' => '', 'birthday' => '', 'name' => $name, 'gender' => '', 'avatar' => '', 'phone' => $phone,
'avatar' => '', 'phone' => $phone, 'id_card' => '', 'email' => '', 'id_card' => '', 'email' => '', 'address' => '', 'note' => $note,
'address' => '', 'note' => $note, 'sort_order' => 2000, 'kind' => 'contact', 'slogan' => '', 'sort_order' => 2000, 'kind' => 'contact',
]); ]);
Account::create($pid, $pass, 'guest'); Account::create($pid, $pass, 'guest');
clear_old(); clear_old();
@@ -589,7 +730,7 @@ switch ($r) {
set_old($_POST); set_old($_POST);
View::render('people/invite', [ View::render('people/invite', [
'title' => '邀请访客', 'title' => '邀请访客',
'people' => Person::all($familyId), 'people' => Person::all($familyId, '', 'contact', (int)Auth::personId()),
'options' => Person::options($familyId), 'options' => Person::options($familyId),
'errors' => $errors, 'errors' => $errors,
], 'people'); ], 'people');
@@ -606,6 +747,9 @@ switch ($r) {
} }
$rawKey = (string)fb_config('app_key', ''); $rawKey = (string)fb_config('app_key', '');
$acc = Account::roleCounts(); $acc = Account::roleCounts();
// 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET)
$diag = $_SESSION['_oss_diag'] ?? null;
unset($_SESSION['_oss_diag']);
View::render('settings', [ View::render('settings', [
'title' => '设置', 'title' => '设置',
'family' => Family::find((int)Auth::familyId()), 'family' => Family::find((int)Auth::familyId()),
@@ -617,9 +761,48 @@ switch ($r) {
'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()),
'storageEffective' => \App\Storage\Storage::effectiveDriver((int)Auth::familyId()), 'storageEffective' => \App\Storage\Storage::effectiveDriver((int)Auth::familyId()),
'ossDiag' => $diag,
], 'settings'); ], 'settings');
break; 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': case 'settings/storage':
Auth::requireAdmin(); Auth::requireAdmin();
csrf_check(); csrf_check();
@@ -679,9 +862,14 @@ switch ($r) {
case 'settings/update': case 'settings/update':
Auth::requireAdmin(); Auth::requireAdmin();
csrf_check(); csrf_check();
$fid = (int)Auth::familyId();
// ⚠️ 「家庭名称」已从设置里撤掉(见 settings 模板的说明):
// 首页标题现在是「某某某的家书」,一个家庭级名字既没人看、也容易和它打架。
// 这里沿用已有值,不再接受表单输入 —— 免得"某次提交把名字清空"。
$cur = Family::find($fid);
Family::update( Family::update(
(int)Auth::familyId(), $fid,
trim((string)($_POST['name'] ?? '家书')), (string)($cur['name'] ?? '家书'),
trim((string)($_POST['motto'] ?? '')) trim((string)($_POST['motto'] ?? ''))
); );
flash('ok', '已保存。'); flash('ok', '已保存。');
+4 -2
View File
@@ -54,17 +54,19 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
// 所以老库重新跑一次就能自动升级,不必手工改表。 // 所以老库重新跑一次就能自动升级,不必手工改表。
Schema::ensure(); Schema::ensure();
$familyId = Family::create($familyName, $motto); $familyId = Family::create($familyName, $motto);
// 装完就把结构版本盖上:不盖的话,第一次进首页发现"版本对不上",
// 会再跑一次 ensure()(幂等,不出错,但白跑一遍 ALTER 查询)。
\App\Setting::set($familyId, 'schema_version', Schema::VERSION);
$personId = Person::create($familyId, [ $personId = Person::create($familyId, [
'name' => $name, 'name' => $name,
'nickname' => '',
'gender' => '', 'gender' => '',
'birthday' => '',
'avatar' => '', 'avatar' => '',
'phone' => $phone, 'phone' => $phone,
'id_card' => '', 'id_card' => '',
'email' => '', 'email' => '',
'address' => '', 'address' => '',
'note' => '', 'note' => '',
'slogan' => '',
'sort_order'=> 1, 'sort_order'=> 1,
'kind' => 'member', 'kind' => 'member',
]); ]);
+161
View File
@@ -0,0 +1,161 @@
<?php
/**
* 家书 FamilyBook · 附件代理
*
* 所有的头像/图片/附件都从这里出去,`?k=<相对键>`。
*
* ⚠️ 为什么要一个代理,而不是直接把地址给浏览器:
* ① **统一**:本地存储的 `/uploads/xxx`、OSS 的 `https://bucket.oss-...`、
* 挂了 CDN 的自定义域名 —— 三种形态完全不同,前端与数据库都得跟着变。
* 走代理后,谁都只看到一个 `media.php?k=…`,地址形态恒定。
* ② **安全**:直连意味着"知道文件名就能看到",也意味着桶必须开公共读。
* 走代理后,能否取到附件由**登录态**决定;桶可以设成私有;
* `uploads/` 可以整个禁止 Web 访问(见 uploads/.htaccess),
* 连目录探测、历史遗留文件的意外暴露一并消掉。
* ③ **标准**:Content-Type / 缓存 / 下载头由这里统一给,
* 不再依赖"桶的元数据配得对不对"。
*
* 安全约束(都集中在这一处,便于审阅):
* · 必须登录;未登录一律 401,不吐任何字节
* · 键必须是**规范化的相对路径**:拒绝 `..`、绝对路径、反斜杠、空字节、点文件
* · 键的字符集白名单(字母数字与 . _ - / ),杜绝编码绕过
* · MIME 一律由**内容嗅探**决定,绝不信扩展名、也用云端给的 Content-Type
* · 能被浏览器当 HTML/JS 执行的一律降级为下载;SVG 额外加 CSP sandbox
*/
declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Auth;
use App\Storage\Storage;
/** 统一的失败出口 */
function media_fail(int $code, string $message): void
{
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo $message;
exit;
}
$key = (string)($_GET['k'] ?? '');
// ---- 1. 鉴权 ----
if (!Auth::check()) {
// 401 而不是 302:这是给 <img src> 用的端点,跳登录页只会返回一坨 HTML 当成图片
media_fail(401, '需要登录');
}
$familyId = (int)Auth::familyId();
// ---- 2. 键净化 ----
$key = str_replace('\\', '/', $key);
$key = ltrim($key, '/');
if ($key === '' || strpos($key, "\0") !== false || strpos($key, '..') !== false) {
media_fail(404, '没有这个文件');
}
if (!preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $key)) {
media_fail(404, '没有这个文件');
}
foreach (explode('/', $key) as $seg) {
// 点文件(.htaccess 等)永远不给出去;另外挡掉空段
if ($seg === '' || $seg === '.' || $seg[0] === '.') {
media_fail(404, '没有这个文件');
}
}
// ---- 3. 取内容(本地优先,再由当前驱动兜底)----
$res = Storage::read($familyId, $key);
$status = (int)($res['status'] ?? 0);
$body = (string)($res['body'] ?? '');
if (empty($res['ok']) || $status < 200 || $status >= 300) {
if ($status === 404 || $status === 0) {
media_fail(404, '没有这个文件');
}
// 云端出错(403 / 签名失败等)不能假装成 404 ——
// 那会让「桶权限配错了」看起来像「这个文件没了」,排查方向整个跑偏。
media_fail(502, '附件存储暂时取不到:' . (string)($res['error'] ?? '未知错误'));
}
// ---- 4. MIME:一律嗅探内容,不用扩展名、不用云端声明 ----
$extMap = [
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml',
'pdf' => 'application/pdf', 'txt' => 'text/plain; charset=utf-8',
'mp4' => 'video/mp4', 'mov' => 'video/quicktime', 'mp3' => 'audio/mpeg',
];
$ext = strtolower(pathinfo($key, PATHINFO_EXTENSION));
$mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($body) ?: '';
if ($mime === '' || $mime === 'application/octet-stream') {
$mime = $extMap[$ext] ?? 'application/octet-stream';
}
if ($mime === 'image/svg+xml') {
// 有些环境下 finfo 认不出 SVG(会给成 text/plain / text/xml),按扩展名补回
$mime = 'image/svg+xml';
}
/** 能被浏览器当页面/脚本执行的一律降级成下载,避免"上传 SVG / HTML 拿到 XSS" */
$dangerous = (strpos($mime, 'text/html') !== false)
|| (strpos($mime, 'xhtml') !== false)
|| (strpos($mime, 'javascript') !== false)
|| (strpos($mime, 'x-shockwave') !== false);
if ($dangerous) {
$mime = 'application/octet-stream';
}
$size = strlen($body);
$etag = '"' . md5($key . '|' . $size . '|' . $mime) . '"';
// ---- 5. 条件请求 ----
$inm = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''));
if ($inm !== '' && $inm === $etag) {
http_response_code(304);
header('ETag: ' . $etag);
header('Cache-Control: private, max-age=604800');
exit;
}
// ---- 6. Range(附件将来可能是音视频,播放器会拖进度条)----
$start = 0;
$end = $size - 1;
$isRange = false;
if (($range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''))) !== ''
&& preg_match('/^bytes=(\d*)-(\d*)$/', $range, $m)
&& !($m[1] === '' && $m[2] === '')) {
if ($m[1] === '') { // bytes=-N → 最后 N 字节
$start = max(0, $size - (int)$m[2]);
} else {
$start = (int)$m[1];
$end = $m[2] === '' ? $size - 1 : min((int)$m[2], $size - 1);
}
if ($start > $end || $start >= $size) {
http_response_code(416);
header('Content-Range: bytes */' . $size);
exit;
}
$isRange = true;
}
// ---- 7. 输出 ----
http_response_code($isRange ? 206 : 200);
header('Content-Type: ' . $mime);
header('Content-Length: ' . (($end - $start) + 1));
header('Cache-Control: private, max-age=604800'); // 登录态相关 → 绝不进共享缓存
header('ETag: ' . $etag);
header('X-Content-Type-Options: nosniff');
if ($mime === 'image/svg+xml') {
// SVG 里能带脚本 → 即便要显示,也禁止它执行脚本、加载外部资源
header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox");
}
if ($isRange) {
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
}
header('Accept-Ranges: bytes');
$filename = basename($key);
$disp = !empty($_GET['dl']) ? 'attachment' : 'inline';
header('Content-Disposition: ' . $disp . '; filename="' . rawurlencode($filename) . '"');
echo substr($body, $start, ($end - $start) + 1);
exit;
+63
View File
@@ -0,0 +1,63 @@
<?php
/**
* 「日期 + 24 小时制时:分:秒」组合输入控件(从 TripLog 的 _datetime.php 移植)。
*
* ⚠️ 为什么不用 <input type="datetime-local">:
* 它的显示格式由**浏览器 UI 语言 / 系统时间格式**决定,<html lang> 与元素 lang
* 都压不过它。中文环境实测会渲染成「下午 03:30」,用户很容易把上下午填反;
* 而且它只有时:分,**没有秒**。拆成「原生日期选择器 + 三个下拉」后,
* 24 小时制与"精确到秒"都由控件本身保证,浏览器改不掉。
*
* 日期部分仍是原生 `<input type="date">`(app.js 里的 showPicker 脚本对它同样生效,
* 点输入框任意位置都能弹出日历 —— 这正是"整个输入框点击都可以弹出"的落点)。
*
* 用法(include 前 set 好这些变量;模板里只读它们):
* $dtName 字段基名,如 'birthday_solar' → 产出 *_date / _hour / _minute / _second
* $dtValue 可空;'Y-m-d H:i:s' / 'Y-m-d H:i' / 'Y-m-d'。服务端用 read_dt_input($dtName) 取值
* $dtRequired 日期是否必填(默认 false)
* $dtId 可选;日期控件的 id(供 <label for>)
* $dtSeconds 是否要"秒"这一档(默认 true;生日要精确到时分秒)
*/
$dtName = (string)($dtName ?? 'time');
$dtValue = $dtValue ?? null;
$dtRequired = (bool)($dtRequired ?? false);
$dtId = (string)($dtId ?? '');
$dtSeconds = (bool)($dtSeconds ?? true);
$dtTs = (!empty($dtValue) && ($t = strtotime(trim((string)$dtValue)))) ? $t : false;
$dtDate = $dtTs ? date('Y-m-d', $dtTs) : '';
$dtHour = $dtTs ? (int)date('G', $dtTs) : 0; // G = 0..23,天然 24 小时制
$dtMin = $dtTs ? (int)date('i', $dtTs) : 0;
$dtSec = $dtTs ? (int)date('s', $dtTs) : 0;
?>
<div class="dt-input">
<input type="date" class="dt-date" name="<?= e($dtName) ?>_date"
<?= $dtId !== '' ? 'id="' . e($dtId) . '"' : '' ?>
value="<?= e($dtDate) ?>"<?= $dtRequired ? ' required' : '' ?>
aria-label="日期">
<span class="dt-time">
<select class="dt-hour" name="<?= e($dtName) ?>_hour" aria-label="时(24 小时制)">
<?php for ($h = 0; $h < 24; $h++): $hv = sprintf('%02d', $h); ?>
<option value="<?= $hv ?>"<?= $h === $dtHour ? ' selected' : '' ?>><?= $hv ?></option>
<?php endfor; ?>
</select>
<span class="dt-colon" aria-hidden="true">:</span>
<select class="dt-minute" name="<?= e($dtName) ?>_minute" aria-label="分">
<?php for ($m = 0; $m < 60; $m++): $mv = sprintf('%02d', $m); ?>
<option value="<?= $mv ?>"<?= $m === $dtMin ? ' selected' : '' ?>><?= $mv ?></option>
<?php endfor; ?>
</select>
<?php if ($dtSeconds): ?>
<span class="dt-colon" aria-hidden="true">:</span>
<select class="dt-second" name="<?= e($dtName) ?>_second" aria-label="秒">
<?php for ($s = 0; $s < 60; $s++): $sv = sprintf('%02d', $s); ?>
<option value="<?= $sv ?>"<?= $s === $dtSec ? ' selected' : '' ?>><?= $sv ?></option>
<?php endfor; ?>
</select>
<?php endif; ?>
</span>
</div>
<?php /* 用完即清,避免残留变量影响下一次 include */ unset(
$dtName, $dtValue, $dtRequired, $dtId, $dtSeconds,
$dtTs, $dtDate, $dtHour, $dtMin, $dtSec
); ?>
+69 -14
View File
@@ -4,27 +4,56 @@
* *
* 同一份数据,爸爸登进来看到的是「我的父亲/我的儿子」, * 同一份数据,爸爸登进来看到的是「我的父亲/我的儿子」,
* 爱人登进来看到的是同一批人,称呼却变成「公公/婆婆」。 * 爱人登进来看到的是同一批人,称呼却变成「公公/婆婆」。
* 称呼全部由 Kinship 现算,库里不存任何一次性称谓。 * 称呼全部由 Kinship 现算、由 Nickname 决定"我叫他什么",库里不存任何一次性称谓。
*
* 标题下面那一行小字的取法(这处被明确纠正过):
* 先看**我自己**有没有写 slogan → 没写才回落到家庭统一的那句。
* 所以同一页不同人登进来看到的是不同的话,而不是全家共用一个口号。
* 家庭名称本身不再展示 —— 标题已经是「某某某的家书」,再挂一个家庭名只会打架。
*/ */
$kinCount = count($kin); $kinCount = count($kin);
$hasRel = $relCount > 0; $hasRel = $relCount > 0;
// 显示名一律走「我怎么称呼他」:同一个孩子,爸爸看到"儿子"、妈妈看到"小宝"。
// ⚠️ 用**已经取好的那张表**(target_id => 我叫他什么)做本地查表,
// 不要再对每个人调一次 Nickname::display() —— 那是每人一条 SQL。
// 没有称呼的人回落到姓名,所以永远不会出现空白名字。
$shownOf = function (array $row) use ($myNicknames): string {
$nk = (string)($myNicknames[(int)($row['id'] ?? 0)] ?? '');
return $nk !== '' ? $nk : (string)($row['name'] ?? '');
};
// 有亲属关系 → 只显示家人;还没建 → 退回显示全名册,别让首页空着 // 有亲属关系 → 只显示家人;还没建 → 退回显示全名册,别让首页空着
$list = $hasRel ? $kin : []; $list = $hasRel ? $kin : [];
if (!$list) { if (!$list) {
foreach (\App\Person::all((int)\App\Auth::familyId()) as $p) { foreach (\App\Person::all((int)\App\Auth::familyId(), '', 'member', (int)$meId) as $p) {
$list[(int)$p['id']] = $p + ['label' => '']; $p['label'] = '';
$list[(int)$p['id']] = $p;
} }
} }
// 标题下的一行小字:我的一句话 → 家庭统一那句 → 兜底
$motto = trim((string)$mySlogan);
$mottoIsMine = $motto !== '';
if (!$mottoIsMine) {
$motto = trim((string)($family['motto'] ?? ''));
}
if ($motto === '') {
$motto = '一本用岁月写成的家谱';
}
// 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出
$shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '农历生日'];
?> ?>
<section class="hero"> <section class="hero">
<img class="hero-logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt=""> <img class="hero-logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
<div> <div class="hero-main">
<h1><?= e(\App\Auth::homeTitle()) ?></h1> <h1><?= e(\App\Auth::homeTitle()) ?></h1>
<p class="motto"> <p class="motto">
<?= e($motto) ?>
<?php if ($mottoIsMine): ?>
<span class="motto-from">我的一句话</span>
<?php endif; ?>
<?php if ($me): ?> <?php if ($me): ?>
<?= e($family['name']) ?> · 这里的称呼都是按你的视角算的 <a class="motto-edit" href="<?= e(url('people/me')) ?>"><?= $mottoIsMine ? '改' : '写一句我自己的' ?></a>
<?php else: ?>
<?= e($family['motto'] ?: '一本用岁月写成的家谱') ?>
<?php endif; ?> <?php endif; ?>
</p> </p>
</div> </div>
@@ -40,22 +69,41 @@ if (!$list) {
<h2 class="section-title">接下来的日子 <span class="hint">两个月内</span></h2> <h2 class="section-title">接下来的日子 <span class="hint">两个月内</span></h2>
<div class="bdays"> <div class="bdays">
<?php foreach ($birthdays as $b): ?> <?php foreach ($birthdays as $b): ?>
<div class="bday"> <?php
// 同一个人可能出两条(农历与阳历不在同一天);落在同一天时后端已经并成一条,
// kinds 里会有多项 —— 这里照着列出来,用户才知道"今天过的是哪个生日"。
$bkinds = (array)($b['kinds'] ?? []);
$bwords = [];
foreach ($bkinds as $k) {
$bwords[] = $shortKind[$k] ?? (string)$k;
}
$bshown = (string)($b['shown'] ?? $b['name'] ?? '');
?>
<a class="bday" href="<?= e(url('people/show', ['id' => (int)$b['id']])) ?>">
<img src="<?= e(avatar_url($b['avatar'])) ?>" alt=""> <img src="<?= e(avatar_url($b['avatar'])) ?>" alt="">
<div> <div>
<b><?= e($b['name']) ?></b> <b><?= e($bshown) ?></b>
<?php if (!empty($b['label']) && $b['label'] !== '我'): ?> <?php if (!empty($b['label']) && $b['label'] !== '我'): ?>
<span class="badge"><?= e($b['label']) ?></span> <span class="badge"><?= e($b['label']) ?></span>
<?php endif; ?> <?php endif; ?>
<div class="d"> <div class="d">
<?php if ($b['days_left'] === 0): ?> <?php if ((int)$b['days_left'] === 0): ?>
就是今天,生日快乐 就是今天,生日快乐
<?php else: ?> <?php else: ?>
还有 <?= (int)$b['days_left'] ?> 天过生日 还有 <?= (int)$b['days_left'] ?> 天过生日
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="bday-meta">
<span class="bday-date"><?= e((string)$b['date']) ?></span>
<?php foreach ($bwords as $w): ?>
<span class="bd-kind"><?= e($w) ?></span>
<?php endforeach; ?>
<?php if (!empty($b['lunar_label'])): ?>
<span class="muted small">农历<?= e((string)$b['lunar_label']) ?></span>
<?php endif; ?>
</div>
</div> </div>
</div> </a>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</section> </section>
@@ -97,11 +145,18 @@ if (!$list) {
<?php else: ?> <?php else: ?>
<div class="people-grid"> <div class="people-grid">
<?php foreach ($list as $p): ?> <?php foreach ($list as $p): ?>
<?php
// ⚠️ 这里必须用「我给他起的称呼」,不能直接用姓名:
// 同一个「杨父」,儿媳妇看到的是"公公大人"、儿子看到的是"爸"。
$pshow = $shownOf($p);
$pIsMe = (int)$p['id'] === (int)$meId;
?>
<a class="person" href="<?= e(url('people/show', ['id' => (int)$p['id']])) ?>"> <a class="person" href="<?= e(url('people/show', ['id' => (int)$p['id']])) ?>">
<div class="ph"><img src="<?= e(avatar_url($p['avatar'] ?? '')) ?>" alt=""></div> <div class="ph"><img src="<?= e(avatar_url($p['avatar'] ?? '')) ?>" alt=""></div>
<div class="pb"> <div class="pb">
<div class="pname"> <div class="pname">
<?= e($p['name']) ?> <?= e($pshow) ?>
<?php if ($pIsMe): ?><span class="badge badge-me">我</span><?php endif; ?>
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">待确认</span><?php endif; ?> <?php if (!empty($p['is_placeholder'])): ?><span class="badge">待确认</span><?php endif; ?>
</div> </div>
<div class="pmeta"> <div class="pmeta">
@@ -117,7 +172,7 @@ if (!$list) {
} elseif (!empty($p['account_id'])) { } elseif (!empty($p['account_id'])) {
echo '<span class="badge badge-open">已开通</span>'; echo '<span class="badge badge-open">已开通</span>';
} }
$age = age_from($p['birthday'] ?? ''); $age = age_from(\App\Birthday::solarOf($p));
if ($age !== null) { echo ' · ' . (int)$age . ' 岁'; } if ($age !== null) { echo ' · ' . (int)$age . ' 岁'; }
?> ?>
</div> </div>
@@ -126,4 +181,4 @@ if (!$list) {
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</section> </section>
+3 -1
View File
@@ -15,11 +15,13 @@
<label for="family_name">家庭名称</label> <label for="family_name">家庭名称</label>
<input class="input" id="family_name" name="family_name" <input class="input" id="family_name" name="family_name"
value="<?= e(old('family_name', '我们家')) ?>" placeholder="比如:我们家" required> value="<?= e(old('family_name', '我们家')) ?>" placeholder="比如:我们家" required>
<span class="tip">只存档用 —— 页面上显示的是「某某某的家书」,不会再展示这个名称。</span>
</div> </div>
<div class="field full"> <div class="field full">
<label for="motto">一句话</label> <label for="motto">系统 slogan</label>
<input class="input" id="motto" name="motto" value="<?= e(old('motto')) ?>" <input class="input" id="motto" name="motto" value="<?= e(old('motto')) ?>"
placeholder="写在首页的一行小字,可留空"> placeholder="写在首页的一行小字,可留空">
<span class="tip">每个人都可以写自己的一句;只有没写的人才会看到这句。</span>
</div> </div>
<div class="field"> <div class="field">
<label for="name">你的姓名</label> <label for="name">你的姓名</label>
+6 -2
View File
@@ -41,11 +41,15 @@ $isLoggedIn = \App\Auth::check();
<?php endforeach; ?> <?php endforeach; ?>
</nav> </nav>
<div class="topbar-right"> <div class="topbar-right">
<span class="who"> <?php /* 点自己的头像/名字 = 直接编辑自己的资料。
⚠️ 以前这里是个不可点的 <span>:想改自己的信息得绕
「名册 → 找到自己 → 编辑资料」三步,而最该一眼可达的
恰恰是「改我自己」这件事。 */ ?>
<a class="who" href="<?= e(url('people/me')) ?>" title="改我自己的资料">
<img src="<?= e(avatar_url($_SESSION['avatar'] ?? null)) ?>" alt=""> <img src="<?= e(avatar_url($_SESSION['avatar'] ?? null)) ?>" alt="">
<span class="who-name"><?= e(\App\Auth::name()) ?></span> <span class="who-name"><?= e(\App\Auth::name()) ?></span>
<?php if (\App\Auth::isGuest()): ?><span class="badge">访客</span><?php endif; ?> <?php if (\App\Auth::isGuest()): ?><span class="badge">访客</span><?php endif; ?>
</span> </a>
<a class="link-quiet who-pwd" href="<?= e(url('auth/password')) ?>">改密码</a> <a class="link-quiet who-pwd" href="<?= e(url('auth/password')) ?>">改密码</a>
<a class="link-quiet" href="<?= e(url('auth/logout', ['_csrf' => csrf_token()])) ?>">退出</a> <a class="link-quiet" href="<?= e(url('auth/logout', ['_csrf' => csrf_token()])) ?>">退出</a>
</div> </div>
+5 -3
View File
@@ -1,8 +1,10 @@
<div class="auth-wrap"> <div class="auth-wrap">
<div class="auth-card"> <div class="auth-card">
<img class="logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt=""> <div class="auth-head">
<h1><?= e($familyName) ?></h1> <img class="logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
<p class="lead">用手机号登录你的家书。</p> <h1><?= e($familyName) ?></h1>
<p class="lead">用手机号登录你的家书</p>
</div>
<?php if (!empty($error)): ?> <?php if (!empty($error)): ?>
<div class="flash"><div class="flash-item err"><?= e($error) ?></div></div> <div class="flash"><div class="flash-item err"><?= e($error) ?></div></div>
+95 -22
View File
@@ -1,6 +1,29 @@
<?php <?php
/**
* 成员表单(新增 / 编辑)。
*
* 三处与早期版本不同的地方:
* · 「小名」没了 —— 称呼是**视角属性**(妈妈叫"明明"、姐姐叫"小弟"),
* 在「成员详情」页里各自设置自己怎么叫,不写在这个人的档案里。
* · 生日变成**三个**:身份证生日(由证件推导、只读)、阳历生日、农历生日。
* 阳历与农历是同一个人的同一天,所以用「以哪个为准」明确这次填的是哪一个,
* 服务端据此反推另一个 —— 不靠 JS,避免"JS 没跑到就静默存错"。
* · 生日精确到**时分秒**:原生 datetime-local 只有时分、且中文环境会显示成
* 「下午 03:30」,所以用 _datetime.php 的「日期 + 24 小时制时:分:秒」组合控件。
*/
$isEdit = !empty($person['id']); $isEdit = !empty($person['id']);
$avatarUrl = avatar_url($person['avatar'] ?? null); $avatarUrl = avatar_url($person['avatar'] ?? null);
$bdSource = old('birthday_source', 'solar');
// 库里这列叫 birthday、表单叫 birthday_solar,一律走 solarOf() 取,别直接下标
$solVal = old('birthday_solar', \App\Birthday::pretty(\App\Birthday::solarOf($person ?? [])));
$lunVal = old('birthday_lunar', '');
if ($lunVal === '') {
// 农历控件的值 = 库里存的农历年月日 + 时间
$lv = (string)($person['birthday_lunar'] ?? '');
$lunVal = $lv;
}
$idBd = trim((string)($person['birthday_id_card'] ?? ''));
?> ?>
<div class="page-head"> <div class="page-head">
@@ -24,12 +47,6 @@ $avatarUrl = avatar_url($person['avatar'] ?? null);
<?php if (!empty($errors['name'])): ?><span class="err"><?= e($errors['name']) ?></span><?php endif; ?> <?php if (!empty($errors['name'])): ?><span class="err"><?= e($errors['name']) ?></span><?php endif; ?>
</div> </div>
<div class="field">
<label for="nickname">小名</label>
<input class="input" id="nickname" name="nickname"
value="<?= e(old('nickname', $person['nickname'] ?? '')) ?>" placeholder="家里怎么叫他/她">
</div>
<div class="field"> <div class="field">
<label for="gender">性别</label> <label for="gender">性别</label>
<select class="select" id="gender" name="gender"> <select class="select" id="gender" name="gender">
@@ -40,14 +57,6 @@ $avatarUrl = avatar_url($person['avatar'] ?? null);
</select> </select>
</div> </div>
<div class="field">
<label for="birthday">生日</label>
<input class="input" id="birthday" name="birthday" type="date"
value="<?= e(old('birthday', $person['birthday'] ?? '')) ?>">
<span class="tip">填了之后,首页会提醒还有几天过生日。</span>
<?php if (!empty($errors['birthday'])): ?><span class="err"><?= e($errors['birthday']) ?></span><?php endif; ?>
</div>
<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"
@@ -56,6 +65,76 @@ $avatarUrl = avatar_url($person['avatar'] ?? null);
<?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>
<div class="field">
<label for="sort_order">排序号</label>
<input class="input" id="sort_order" name="sort_order" type="number"
value="<?= e(old('sort_order', $person['sort_order'] ?? 1000)) ?>">
<span class="tip">小的排前面。</span>
</div>
<!-- ==================== 三个生日 ==================== -->
<div class="field full">
<label>生日</label>
<p class="small muted" style="margin:0 0 12px">
一个人有三个生日:<b>身份证上的</b>(由证件推导,系统永远不会改它)、
<b>真实阳历生日</b>、<b>农历生日</b>。填了身份证号会自动读出第一个;
阳历与农历只要填一个,另一个自动算出来。三个不在同一天时,首页会分别提醒。
</p>
<div class="field" style="margin-bottom:12px">
<label for="birthday_id_card_display">身份证生日(只读)</label>
<span class="bd-readonly" id="birthday_id_card_display"><?= $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 = $solVal;
$dtId = 'birthday_solar_date';
$dtRequired = false;
require __DIR__ . '/../_datetime.php';
?>
<span class="tip">精确到时分秒。不填时分秒就按 00:00:00 记。</span>
<?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 = $lunVal;
$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', $person['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 full">
<label for="slogan">这个人的一句话</label>
<input class="input" id="slogan" name="slogan" maxlength="200"
value="<?= e(old('slogan', $person['slogan'] ?? '')) ?>"
placeholder="可留空;留空时用家庭统一的那句">
<span class="tip">显示在这个人自己的首页标题下面。本人也可以在自己的资料页里改。</span>
<?php if (!empty($errors['slogan'])): ?><span class="err"><?= e($errors['slogan']) ?></span><?php endif; ?>
</div>
<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"
@@ -77,7 +156,8 @@ $avatarUrl = avatar_url($person['avatar'] ?? null);
value="<?= e(old('id_card', $idCardValue ?? '')) ?>" placeholder="可留空"> value="<?= e(old('id_card', $idCardValue ?? '')) ?>" placeholder="可留空">
<span class="tip"> <span class="tip">
加密存储,页面上一律只显示前 6 后 4 位。 加密存储,页面上一律只显示前 6 后 4 位。
家庭场景里真正需要它的场合很少 —— 能不填就别填。 <b>填了它,上面的「身份证生日」会自动读出来</b>(当前:<span id="idCardBirthday"><?= $idBd !== '' ? e($idBd) : '' ?></span>),
这个日期系统永远不会改动。家庭场景里真正需要它的场合很少 —— 能不填就别填。
</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>
@@ -104,13 +184,6 @@ $avatarUrl = avatar_url($person['avatar'] ?? null);
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>
<div class="field">
<label for="sort_order">排序号</label>
<input class="input" id="sort_order" name="sort_order" type="number"
value="<?= e(old('sort_order', $person['sort_order'] ?? 1000)) ?>">
<span class="tip">小的排前面。</span>
</div>
<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"
+16 -3
View File
@@ -10,7 +10,7 @@
<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="按姓名、称呼或邮箱搜索">
<button class="btn btn-ghost btn-sm" type="submit">搜索</button> <button class="btn btn-ghost btn-sm" type="submit">搜索</button>
<?php if ($q !== ''): ?> <?php if ($q !== ''): ?>
<a class="link-quiet" href="<?= e(url('people')) ?>">清除</a> <a class="link-quiet" href="<?= e(url('people')) ?>">清除</a>
@@ -25,11 +25,21 @@
<?php else: ?> <?php else: ?>
<div class="people-grid"> <div class="people-grid">
<?php foreach ($people as $p): ?> <?php foreach ($people as $p): ?>
<?php
// 显示名走「我怎么称呼他」:同一个孩子,爸爸看到"儿子"、妈妈看到"小宝"。
// ⚠️ 不要再读 $p['nickname'] —— 那是已废弃的旧列(全家共用一个小名)。
$pshow = (string)($p['shown'] ?? '');
if ($pshow === '') {
$pshow = (string)$p['name'];
}
$pNick = (string)($p['my_nickname'] ?? '');
?>
<a class="person" href="<?= e(url('people/show', ['id' => (int)$p['id']])) ?>"> <a class="person" href="<?= e(url('people/show', ['id' => (int)$p['id']])) ?>">
<div class="ph"><img src="<?= e(avatar_url($p['avatar'])) ?>" alt=""></div> <div class="ph"><img src="<?= e(avatar_url($p['avatar'])) ?>" alt=""></div>
<div class="pb"> <div class="pb">
<div class="pname"> <div class="pname">
<?= e($p['name']) ?> <?= e($pshow) ?>
<?php if ((int)$p['id'] === (int)$meId): ?><span class="badge badge-me">我</span><?php endif; ?>
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">待确认</span><?php endif; ?> <?php if (!empty($p['is_placeholder'])): ?><span class="badge">待确认</span><?php endif; ?>
</div> </div>
<div class="pmeta"> <div class="pmeta">
@@ -45,8 +55,11 @@
} elseif (!empty($p['account_id'])) { } elseif (!empty($p['account_id'])) {
echo '<span class="badge badge-open">已开通</span>'; echo '<span class="badge badge-open">已开通</span>';
} }
// 我叫他别的名字时,把本名再用小字附上,免得自己都认不出这人是谁
if ($pNick !== '' && $pNick !== (string)$p['name']) {
echo ' · <span class="muted">' . e((string)$p['name']) . '</span>';
}
?> ?>
<?php if (!empty($p['nickname'])): ?> · <?= e($p['nickname']) ?><?php endif; ?>
</div> </div>
</div> </div>
</a> </a>
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="role-cards"> <div class="role-cards">
<div class="role-card"> <div class="role-card">
<div class="rc-name">管理员</div> <div class="rc-name">管理员</div>
<p class="rc-desc">一个家庭通常只有一位。改家庭名称、附件存哪儿、谁能登录,都在他手里。</p> <p class="rc-desc">一个家庭通常只有一位。系统级的 slogan、附件存哪儿、谁能登录,都在他手里。</p>
</div> </div>
<div class="role-card"> <div class="role-card">
<div class="rc-name">家庭成员</div> <div class="rc-name">家庭成员</div>
+189
View File
@@ -0,0 +1,189 @@
<?php
/**
* 「我的资料」—— 每个人自己的页面。
*
* 从顶栏的头像/名字一点就到,不用再绕「名册 → 找到自己 → 编辑资料」三步。
*
* 这里只放**属于我自己的**东西:
* · 我的姓名、性别、头像、联系方式、备注
* · 我的三个生日(身份证生日只读,由证件推导)
* · 我的 slogan(没填就用家庭统一的那句)
* · **我怎么称呼别人** —— 这是视角属性,只有我自己能定,改了也只影响我自己看到的
* 排行、排序号、虚拟人物标记、以及会牵动身份证生日的身份证号,
* 都属于"管理员编名册",不在这里(这也是为了不让一次自助提交悄悄改掉这些)。
*/
$p = $person;
$isSelf = true;
$avatarUrl = avatar_url($p['avatar'] ?? null);
$bdSource = old('birthday_source', 'solar');
$idBd = trim((string)($p['birthday_id_card'] ?? ''));
// 我已设过的称呼:target_id => nickname
$myNicks = [];
foreach ($nicknames as $n) {
$myNicks[(int)$n['target_id']] = (string)$n['nickname'];
}
?>
<div class="page-head">
<h1>我的资料</h1>
<span class="sub">你自己看到的样子 · 别人看到你的名字不变</span>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('auth/password')) ?>">改密码</a>
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/show', ['id' => (int)$p['id']])) ?>">看我的详情页</a>
</div>
</div>
<form method="post" enctype="multipart/form-data" action="<?= e(url('people/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/*">
<span class="tip">会自动裁成正方形,上限 5MB。</span>
</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>
<!-- ==================== 我怎么称呼别人 ==================== -->
<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">
同一个「小明」,妈妈叫"明明"、姐姐叫"小弟" —— 称呼是**每个人各自定的**,
不是这个人自带的属性。所以这里改的只影响你自己的页面,别人看到的还是他们自己的叫法。
留空 = 恢复成用姓名显示。
</p>
<?php if (!$options): ?>
<p class="small muted" style="margin:0">名册里还没有别人。</p>
<?php else: ?>
<div class="nick-list">
<?php foreach ($options as $o): $oid = (int)$o['id']; ?>
<form method="post" action="<?= e(url('nickname/save')) ?>" class="nick-row">
<?= csrf_field() ?>
<input type="hidden" name="target_id" value="<?= $oid ?>">
<input type="hidden" name="back" value="people/me">
<span class="nick-who">
<img src="<?= e(avatar_url($o['avatar'] ?? '')) ?>" alt="">
<span><?= e((string)$o['name']) ?></span>
</span>
<input class="input" name="nickname" maxlength="50"
value="<?= e($myNicks[$oid] ?? '') ?>" placeholder="我叫他/她…">
<button class="btn btn-ghost btn-sm" type="submit">记下</button>
</form>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
+108 -24
View File
@@ -5,7 +5,7 @@
* 页面上每个人名旁边都带着「我该怎么称呼他」,同一个页面 * 页面上每个人名旁边都带着「我该怎么称呼他」,同一个页面
* 爸爸看到「我的儿子」,爱人看到「我的丈夫」,全靠 Kinship 现算。 * 爸爸看到「我的儿子」,爱人看到「我的丈夫」,全靠 Kinship 现算。
*/ */
$age = age_from($person['birthday'] ?? ''); $age = age_from(\App\Birthday::solarOf($person));
$admin = \App\Auth::isAdmin(); $admin = \App\Auth::isAdmin();
$canW = \App\Auth::canWrite(); $canW = \App\Auth::canWrite();
$pid = (int)$person['id']; $pid = (int)$person['id'];
@@ -13,28 +13,47 @@ $p = $person;
$isSelf = (int)$p['id'] === (int)\App\Auth::personId(); $isSelf = (int)$p['id'] === (int)\App\Auth::personId();
$role = (string)($p['account_role'] ?? ''); $role = (string)($p['account_role'] ?? '');
$nameOf = function (int $id) use ($people): string { // ⚠️ 显示名一律走「我怎么称呼他」,不要直接 echo 姓名:
return (string)($people[$id]['name'] ?? '(已移除)'); // 同一个「小明」,妈妈看到的是"明明"、姐姐看到的是"小弟"。
// 这里一次性把「我设过的称呼」取出来做本地查表,避免每个节点一次 SQL。
$nickMap = $isSelf ? [] : \App\Nickname::mapFor((int)$meId);
$nameOf = function (int $id) use ($people, $pid, $nickMap): string {
$real = (string)($people[$id]['name'] ?? '(已移除)');
if ($id === $pid) {
return $real; // 我自己看到自己,永远用本名
}
$nk = (string)($nickMap[$id] ?? '');
return $nk !== '' ? $nk : $real;
}; };
// 当前这个人在我眼里叫什么(页面上所有"给他看的人名"都用它)
$pShown = $isSelf ? (string)$p['name'] : (string)($shown !== '' ? $shown : $p['name']);
$isMyNick = !$isSelf && $pShown !== (string)$p['name'];
// 当前这个人的父母/配偶/子女(用于在录入表单里显示已填的内容) // 当前这个人的父母/配偶/子女(用于在录入表单里显示已填的内容)
$theirParents = \App\Kinship::parents($graph, $pid); $theirParents = \App\Kinship::parents($graph, $pid);
$theirSpouses = \App\Kinship::spouseIds($graph, $pid); $theirSpouses = \App\Kinship::spouseIds($graph, $pid);
$theirKids = \App\Kinship::childIds($graph, $pid); $theirKids = \App\Kinship::childIds($graph, $pid);
$theirSibs = \App\Kinship::siblingIds($graph, $pid); $theirSibs = \App\Kinship::siblingIds($graph, $pid);
$shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '农历生日'];
?> ?>
<div class="page-head"> <div class="page-head">
<h1><?= e($p['name']) ?></h1> <h1><?= e($pShown) ?></h1>
<span class="sub"> <span class="sub">
<?php if ($labelToMe !== '我'): ?> <?php if ($isSelf): ?>
<span class="rel-label"><?= e($labelToMe) ?></span>
<?php else: ?>
这是你自己 这是你自己
<?php else: ?>
<span class="rel-label"><?= e($labelToMe) ?></span>
<?php if ($isMyNick): ?>
· 本名 <?= e((string)$p['name']) ?>
<?php endif; ?>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($p['nickname'])): ?> · 小名 <?= e($p['nickname']) ?><?php endif; ?>
<?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): ?>
<a class="btn btn-sm" href="<?= e(url('people/me')) ?>">改我自己的资料</a>
<?php endif; ?>
<?php if ($canW): ?> <?php if ($canW): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/edit', ['id' => $pid])) ?>">编辑资料</a> <a class="btn btn-ghost btn-sm" href="<?= e(url('people/edit', ['id' => $pid])) ?>">编辑资料</a>
<?php endif; ?> <?php endif; ?>
@@ -49,10 +68,15 @@ $theirSibs = \App\Kinship::siblingIds($graph, $pid);
</div> </div>
</div> </div>
<?php if ($isSelf && !empty($p['slogan'])): ?>
<p class="motto-line">“<?= e((string)$p['slogan']) ?>”</p>
<?php endif; ?>
<div class="detail"> <div class="detail">
<aside class="detail-photo"> <aside class="detail-photo">
<img src="<?= e(avatar_url($p['avatar'])) ?>" alt=""> <img src="<?= e(avatar_url($p['avatar'])) ?>" alt="">
<div class="name"><?= e($p['name']) ?></div> <div class="name"><?= e($pShown) ?></div>
<?php if ($isMyNick): ?><div class="sub">本名 <?= e((string)$p['name']) ?></div><?php endif; ?>
<div class="sub"> <div class="sub">
<?php if ($role === 'admin' && $p['account_status'] === 'active'): ?> <?php if ($role === 'admin' && $p['account_status'] === 'active'): ?>
家庭管理员 家庭管理员
@@ -69,10 +93,7 @@ $theirSibs = \App\Kinship::siblingIds($graph, $pid);
<div> <div>
<div class="card card-pad"> <div class="card card-pad">
<dl class="kv"> <dl class="kv">
<div class="kv-row"><dt>姓名</dt><dd><?= e($p['name']) ?></dd></div> <div class="kv-row"><dt>姓名</dt><dd><?= e((string)$p['name']) ?></dd></div>
<?php if (!empty($p['nickname'])): ?>
<div class="kv-row"><dt>小名</dt><dd><?= e($p['nickname']) ?></dd></div>
<?php endif; ?>
<div class="kv-row"> <div class="kv-row">
<dt>性别</dt> <dt>性别</dt>
<dd><?= e(\App\Person::genderWord((string)($p['gender'] ?? '')) ?: '未填写') ?></dd> <dd><?= e(\App\Person::genderWord((string)($p['gender'] ?? '')) ?: '未填写') ?></dd>
@@ -80,20 +101,61 @@ $theirSibs = \App\Kinship::siblingIds($graph, $pid);
<?php if (!empty($p['birth_rank'])): ?> <?php if (!empty($p['birth_rank'])): ?>
<div class="kv-row"><dt>家中排行</dt><dd>第 <?= (int)$p['birth_rank'] ?> 个孩子</dd></div> <div class="kv-row"><dt>家中排行</dt><dd>第 <?= (int)$p['birth_rank'] ?> 个孩子</dd></div>
<?php endif; ?> <?php endif; ?>
<!-- ============ 三个生日 ============ -->
<div class="kv-row"> <div class="kv-row">
<dt>生日</dt> <dt>生日</dt>
<dd> <dd>
<?php if (!empty($p['birthday'])): ?> <?php if (!$birthdays): ?>
<?= e($p['birthday']) ?>
<?php if ($age !== null): ?> · <?= (int)$age ?> 岁<?php endif; ?>
<?php $d = days_until_birthday($p['birthday']); ?>
<?php if ($d === 0): ?> · <span style="color:var(--brand)">今天生日</span>
<?php elseif ($d !== null): ?> · 还有 <?= (int)$d ?> 天<?php endif; ?>
<?php else: ?>
<span class="muted">未填写</span> <span class="muted">未填写</span>
<?php else: ?>
<ul class="bd-list">
<?php foreach ($birthdays as $b): ?>
<li>
<span class="bd-kind bd-kind-<?= e((string)$b['kind']) ?>">
<?= e($shortKind[$b['kind']] ?? (string)$b['kind']) ?>
</span>
<span class="bd-val"><?= e((string)$b['text']) ?></span>
<?php if ((string)$b['note'] !== ''): ?>
<span class="muted small">(<?= e((string)$b['note']) ?>)</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php if ($age !== null): ?>
<div class="muted small" style="margin-top:4px">今年 <?= (int)$age ?> 岁</div>
<?php endif; ?>
<?php endif; ?> <?php endif; ?>
</dd> </dd>
</div> </div>
<?php if ($birthdayNext): ?>
<div class="kv-row">
<dt>接下来</dt>
<dd>
<?php foreach ($birthdayNext as $n): ?>
<?php
$words = [];
foreach ((array)$n['kinds'] as $k) {
$words[] = $shortKind[$k] ?? (string)$k;
}
?>
<div>
<?= e((string)$n['date']) ?> ·
<?php if ((int)$n['days'] === 0): ?>
<b style="color:var(--brand)">今天</b>
<?php else: ?>
还有 <b><?= (int)$n['days'] ?></b> 天
<?php endif; ?>
<span class="muted small">
(<?= e(implode('、', $words)) ?><?= (string)$n['lunar_label'] !== '' ? ',农历' . e((string)$n['lunar_label']) : '' ?>)
</span>
</div>
<?php endforeach; ?>
</dd>
</div>
<?php endif; ?>
<div class="kv-row"> <div class="kv-row">
<dt>手机号</dt> <dt>手机号</dt>
<dd class="mono"><?= e(\App\Crypto::maskPhone((string)($p['phone_encrypted'] ?? ''))) ?></dd> <dd class="mono"><?= e(\App\Crypto::maskPhone((string)($p['phone_encrypted'] ?? ''))) ?></dd>
@@ -118,10 +180,32 @@ $theirSibs = \App\Kinship::siblingIds($graph, $pid);
</dl> </dl>
</div> </div>
<!-- ============ 我叫他/她什么(视角属性,只有我自己能改) ============ -->
<?php if (!$isSelf): ?>
<div class="card card-pad" style="margin-top:16px">
<h2 class="section-title">
我怎么称呼 <?= e((string)$p['name']) ?>
<span class="hint">只影响我自己看到的</span>
</h2>
<form method="post" action="<?= e(url('nickname/save')) ?>" class="nick-inline">
<?= csrf_field() ?>
<input type="hidden" name="target_id" value="<?= $pid ?>">
<input type="hidden" name="back" value="people/show">
<input class="input" name="nickname" maxlength="50"
value="<?= e($myNickname) ?>" placeholder="留空 = 恢复成用姓名显示">
<button class="btn btn-sm" type="submit">记下</button>
</form>
<p class="small muted" style="margin:10px 0 0">
同一个「小明」,妈妈叫"明明"、姐姐叫"小弟" —— 称呼是<b>每个人各自定的</b>,
不是 TA 自带的属性。所以这里改的只影响你自己的页面,别人看到的还是他们自己的叫法。
</p>
</div>
<?php endif; ?>
<!-- ============ 亲属关系 ============ --> <!-- ============ 亲属关系 ============ -->
<div class="card card-pad" style="margin-top:16px"> <div class="card card-pad" style="margin-top:16px">
<h2 class="section-title"> <h2 class="section-title">
<?= e($p['name']) ?> 的亲属 <?= e($pShown) ?> 的亲属
<span class="hint">关系是双向的,填一次两边都生效</span> <span class="hint">关系是双向的,填一次两边都生效</span>
</h2> </h2>
@@ -211,7 +295,7 @@ $theirSibs = \App\Kinship::siblingIds($graph, $pid);
<option value="">— 选择一个人 —</option> <option value="">— 选择一个人 —</option>
<?php foreach ($options as $o): ?> <?php foreach ($options as $o): ?>
<option value="<?= (int)$o['id'] ?>"> <option value="<?= (int)$o['id'] ?>">
<?= e($o['name']) ?><?= !empty($o['nickname']) ? '(' . e($o['nickname']) . ')' : '' ?> <?= e((string)$o['name']) ?><?= !empty($o['my_nickname']) ? '(' . e((string)$o['my_nickname']) . ')' : '' ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
@@ -260,7 +344,7 @@ $theirSibs = \App\Kinship::siblingIds($graph, $pid);
<!-- ============ 以这个人看出去的家谱 ============ --> <!-- ============ 以这个人看出去的家谱 ============ -->
<?php if (!empty($theirTree['nodes']) && count($theirTree['nodes']) > 1): ?> <?php if (!empty($theirTree['nodes']) && count($theirTree['nodes']) > 1): ?>
<div class="card card-pad" style="margin-top:16px"> <div class="card card-pad" style="margin-top:16px">
<h2 class="section-title">从 <?= e($p['name']) ?> 的角度看 <span class="hint">上下各两代</span></h2> <h2 class="section-title">从 <?= e($pShown) ?> 的角度看 <span class="hint">上下各两代</span></h2>
<div class="mini-tree"> <div class="mini-tree">
<?php foreach ($theirTree['generations'] as $level => $ids): ?> <?php foreach ($theirTree['generations'] as $level => $ids): ?>
<div class="mini-gen"> <div class="mini-gen">
@@ -268,7 +352,7 @@ $theirSibs = \App\Kinship::siblingIds($graph, $pid);
<a class="mini-node <?= (int)$id === $pid ? 'is-me' : '' ?>" <a class="mini-node <?= (int)$id === $pid ? 'is-me' : '' ?>"
href="<?= e(url('people/show', ['id' => (int)$id])) ?>"> href="<?= e(url('people/show', ['id' => (int)$id])) ?>">
<img src="<?= e(avatar_url($n['avatar'])) ?>" alt=""> <img src="<?= e(avatar_url($n['avatar'])) ?>" alt="">
<span class="mn-name"><?= e($n['name']) ?></span> <span class="mn-name"><?= e($nameOf((int)$id)) ?></span>
<span class="mn-label"><?= e($n['label']) ?></span> <span class="mn-label"><?= e($n['label']) ?></span>
</a> </a>
<?php endforeach; ?> <?php endforeach; ?>
+101 -17
View File
@@ -1,15 +1,29 @@
<?php
/**
* 设置(仅管理员)。
*
* 与早期版本的两处不同,都是被明确要求改的:
* ① **「家庭名称」撤了**。首页标题已经是「某某某的家书」,一个家庭级名字
* 既没人看、又容易和它打架;后端 settings/update 也不再接收这个字段,
* 免得"某次提交把名字清空"。库里那一列留着不动(历史数据无害)。
* ② **slogan 变成"系统级默认"**。真正的 slogan 是每个人自己写的那句
* (在「我的资料」里),这里是**没人写自己的时候拿出来的兜底**。
* 另加:阿里云 OSS 的**连通性测试**——逐步摊开结论,
* 让"连不上"有具体原因,而不是一句"写入失败"。
*/
?>
<form method="post" action="<?= e(url('settings/update')) ?>" class="card card-pad"> <form method="post" action="<?= e(url('settings/update')) ?>" class="card card-pad">
<?= csrf_field() ?> <?= csrf_field() ?>
<h2 class="section-title">家庭</h2> <h2 class="section-title">家庭 <span class="hint">首页上那一行小字</span></h2>
<div class="form-grid"> <div class="form-grid">
<div class="field full"> <div class="field full">
<label for="name">家庭名称</label> <label for="motto">系统 slogan</label>
<input class="input" id="name" name="name" value="<?= e($family['name']) ?>" required>
</div>
<div class="field full">
<label for="motto">一句话</label>
<input class="input" id="motto" name="motto" value="<?= e($family['motto'] ?? '') ?>" <input class="input" id="motto" name="motto" value="<?= e($family['motto'] ?? '') ?>"
placeholder="展示在首页的一行小字"> placeholder="例如:一本用岁月写成的家谱">
<span class="tip">
每个人都可以在「我的资料」里写**自己的一句**,有的话就显示他自己的;
<b>没写的人才用这里的这句</b>。所以这是一句"兜底",不是全家的统一口号。
</span>
</div> </div>
</div> </div>
<div class="btn-row"> <div class="btn-row">
@@ -28,6 +42,9 @@
<p class="small muted" style="margin:0 0 16px"> <p class="small muted" style="margin:0 0 16px">
数据库里只存「相对路径」,所以随时可以在本地与云端之间切换 —— 数据库里只存「相对路径」,所以随时可以在本地与云端之间切换 ——
已上传的文件会按「本地优先」的顺序去找,切换后老文件照样能打开,出问题也能随时切回来。 已上传的文件会按「本地优先」的顺序去找,切换后老文件照样能打开,出问题也能随时切回来。
<br>
<b>不管选哪个,页面上的图片与附件都统一走 <code>media.php</code> 代理</b>:
桶不必开公共读、<code>uploads/</code> 也可以封死,能不能看由登录态决定。
</p> </p>
<div class="field" style="margin-bottom:16px"> <div class="field" style="margin-bottom:16px">
@@ -39,14 +56,14 @@
</label> </label>
<label class="radio-item"> <label class="radio-item">
<input type="radio" name="driver" value="oss" <?= $storageDriver === 'oss' ? 'checked' : '' ?>> <input type="radio" name="driver" value="oss" <?= $storageDriver === 'oss' ? 'checked' : '' ?>>
<span><b>阿里云 OSS</b><br><span class="muted small">文件独立存放,换服务器不丢;也可以挂 CDN 加速。</span></span> <span><b>阿里云 OSS</b><br><span class="muted small">文件独立存放,换服务器不丢。</span></span>
</label> </label>
</div> </div>
</div> </div>
<?php if ($storageDriver === 'oss' && !$storageOk): ?> <?php if ($storageDriver === 'oss' && !$storageOk): ?>
<div class="flash"><div class="flash-item err"> <div class="flash"><div class="flash-item err">
云端配置还不完整,系统现在**临时用本地存储**在跑 —— 填全下面的信息再保存。 云端配置还不完整,系统现在<b>临时用本地存储</b>在跑 —— 填全下面的信息再保存。
</div></div> </div></div>
<?php elseif ($storageDriver === 'oss'): ?> <?php elseif ($storageDriver === 'oss'): ?>
<div class="flash"><div class="flash-item"> <div class="flash"><div class="flash-item">
@@ -122,13 +139,76 @@
</div> </div>
</form> </form>
<form method="post" action="<?= e(url('settings/test_storage')) ?>" style="margin-top:12px"> <!-- ==================== 两种测试 ==================== -->
<?= csrf_field() ?> <div class="card card-pad" style="margin-top:16px">
<button class="btn btn-ghost" type="submit">测试存储是否可用</button> <h2 class="section-title">
<span class="small muted" style="margin-left:10px"> 连通性测试
会真的写一个临时文件、读回来、再删掉 —— 比只连一下可靠。 <span class="hint">两种测试的分工不一样</span>
</span> </h2>
</form> <p class="small muted" style="margin:0 0 14px">
「当前存储」测的是<b>现在生效的那一个</b>(可能是本地),只回答"能不能存东西";
「OSS 连通性」测的是<b>下面填的那套 OSS 参数本身</b> —— 即使现在跑在本地也能测,
这样就可以<b>先把 OSS 测通、再切过去</b>,而不是"先切、发现不行、再切回来"。
</p>
<div class="test-actions">
<form method="post" action="<?= e(url('settings/test_storage')) ?>">
<?= csrf_field() ?>
<button class="btn btn-ghost" type="submit">测试当前存储</button>
<span class="small muted">写一个临时文件 → 读回来 → 删掉</span>
</form>
<form method="post" action="<?= e(url('settings/test_oss')) ?>">
<?= csrf_field() ?>
<button class="btn btn-ghost" type="submit">测试 OSS 连通性</button>
<span class="small muted">逐步:配置 → 凭据 → 地域 → 公网 → 内网 → 读写</span>
</form>
</div>
<?php if (!empty($ossDiag['steps'])): ?>
<?php
$dSteps = (array)$ossDiag['steps'];
$dOk = 0;
foreach ($dSteps as $s) {
if (!empty($s['ok'])) {
$dOk++;
}
}
$dAll = $dOk === count($dSteps);
?>
<div class="oss-diag <?= $dAll ? 'is-ok' : 'is-bad' ?>">
<div class="oss-diag-head">
<b><?= $dAll ? '全部通过' : '有问题' ?></b>
<span>共 <?= count($dSteps) ?> 步,通过 <?= $dOk ?> 步</span>
<?php if (!empty($ossDiag['at'])): ?>
<span class="muted small">· <?= e(date('Y-m-d H:i:s', (int)$ossDiag['at'])) ?></span>
<?php endif; ?>
</div>
<table class="oss-diag-table">
<thead>
<tr><th>步骤</th><th>结论</th><th>耗时</th><th>详情</th></tr>
</thead>
<tbody>
<?php foreach ($dSteps as $s): ?>
<tr class="<?= !empty($s['ok']) ? 'row-ok' : 'row-bad' ?>">
<td><?= !empty($s['ok']) ? '✓' : '✕' ?> <?= e((string)($s['name'] ?? '')) ?></td>
<td><?= !empty($s['ok']) ? '通过' : '失败' ?></td>
<td class="mono"><?= (int)($s['ms'] ?? 0) ?> ms</td>
<td><?= nl2br(e((string)($s['detail'] ?? ''))) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="small muted" style="margin:12px 0 0">
探针对象用后即删,不会在桶里留垃圾;跑完这一遍之后再切驱动最稳妥。
</p>
</div>
<?php else: ?>
<p class="small muted" style="margin:14px 0 0">
还没测过。建议<b>先在下面填好 OSS 参数并保存,再点「测试 OSS 连通性」</b>,
通过之后再回到上面把「存在哪里」切到阿里云 OSS。
</p>
<?php endif; ?>
</div>
<!-- ==================== 运行环境 ==================== --> <!-- ==================== 运行环境 ==================== -->
<div class="card card-pad" style="margin-top:20px"> <div class="card card-pad" style="margin-top:20px">
@@ -145,6 +225,10 @@
<?php endif; ?> <?php endif; ?>
</dd> </dd>
</div> </div>
<div class="kv-row">
<dt>附件出口</dt>
<dd>统一走 media.php 代理 <span class="muted small">(本地与云端同一形态,需登录才能读)</span></dd>
</div>
<div class="kv-row"> <div class="kv-row">
<dt>亲属连线</dt> <dt>亲属连线</dt>
<dd><?= (int)$relCount ?> 条</dd> <dd><?= (int)$relCount ?> 条</dd>
@@ -165,4 +249,4 @@
<p class="small muted" style="margin:10px 0 0"> <p class="small muted" style="margin:10px 0 0">
访客账号在「<a href="<?= e(url('accounts/invite')) ?>">邀请访客</a>」里开通。 访客账号在「<a href="<?= e(url('accounts/invite')) ?>">邀请访客</a>」里开通。
</p> </p>
</div> </div>
+31 -6
View File
@@ -1,6 +1,34 @@
# 上传目录里禁止执行任何脚本 —— 没有这条,上传的 .php 就能被直接访问执行。 # uploads/ —— 整个目录**禁止 Web 直接访问**。
# 同时兼容 Apache 2.4(Require)与 2.2(Order)。 #
<FilesMatch "\.(?i:php|php3|php4|php5|php7|phtml|pht|phar|cgi|pl|asp|aspx|jsp|sh|py)$"> # 为什么从"只禁脚本"升级成"全禁":
# 原本只挡了 .php 之类的可执行扩展名,其余文件(头像、附件、以及
# 历史遗留的 *.{sql,txt,log,zip} 之类)都能被**任何人**按地址直接下载 ——
# 只要文件名泄出去(分享过链接、日志里出现过、被爬过),就等于公开。
# 排查这类事故时还会遇到"备份文件其实是原样吐出的源码"那种更糟的情况。
#
# 现在所有附件一律经 media.php 代理出去:
# · 是否可见由**登录态**决定,而不是"知道文件名";
# · 云端桶也可设成私有,不必为了显示图片开公共读。
#
# ⚠️ 这条规则不是"多一道保险"而是"唯一的门"—— media.php 读文件走的是
# 文件系统(PHP 进程),不受 .htaccess 影响,所以功能不会因此受损。
# 若哪天发现图片不显示,先看 media.php 有没有报错,别急着重开直连。
# Apache 2.4
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
# Apache 2.2
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
# 关掉目录列表
Options -Indexes
# 顺带把可能的备份/临时文件也点名拒掉(防"规则文件被自己的副本绕过")
<FilesMatch "(^\.|~$|\.(bak|bak\.[0-9]+|old|orig|save|swp|tmp|log|sql|zip|tar|gz)$)">
<IfModule mod_authz_core.c> <IfModule mod_authz_core.c>
Require all denied Require all denied
</IfModule> </IfModule>
@@ -9,6 +37,3 @@
Deny from all Deny from all
</IfModule> </IfModule>
</FilesMatch> </FilesMatch>
# 关掉目录列表
Options -Indexes