登录居中 · 个人 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:
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 列不存在才加 */
|
/** 列不存在才加 */
|
||||||
|
|||||||
@@ -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}",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
})();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
); ?>
|
||||||
+68
-13
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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; ?>
|
||||||
|
|||||||
+100
-16
@@ -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>
|
||||||
|
|||||||
+31
-6
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user