家谱卡片重做 / 首页控制台 / 名册改列表 / 重复日程与旅程公开

用户 2026-10-03 提的四条:

① 家谱卡片重做
   · 去掉「⇄ 姻亲」按钮,改成**每个人**卡片右下角都有「以 TA 为中心」
     (点了以他重画一张家谱,涵盖原来的姻亲场景)。
   · 头像外框改**长方形**(96×72,宽度贴合头像),object-fit: contain 不裁切,
     姓名/称呼在头像下方。
   · 修「点右下角按钮误进成员页」:卡片不再整张是 <a>,
     只有姓名是链接,右下角是独立的 <button>(物理上分开,不抢点击)。

② 家谱文案与按钮清理
   · 去掉副标题与下方图例说明;整块去掉「看全部 / 回到我 / 回到我的家谱」
     (顺带消掉"出现两个回到我的家谱")。镜头由库自动 fit,拖拽/滚轮即可。

③ 首页控制台
   · 标题下显示个人 slogan;去掉右侧三个统计;「我的资料/我的家谱」换成图标按钮;
     去掉与导航条重复的「名册」按钮。
   · 去掉「接下来的日子」(生日归到"未来七天"里,不再重复)。
   · 新增统计信息块:按时段随机问候 + 今日天气(Open-Meteo,免 key,可留空不显示)
     + 近七天日程/待办/逾期数。
   · 新增各模块小结卡(任务/日程/纪念日/观影/旅程/财务/名册/家谱/资源)。
   · 个人设置新增「首页上的模块」:可勾选与拖动排序(每人一份,存 user_settings)。
   · 保留「往年的今天」。

④ 名册改列表
   · 卡片网格 → **竖向列表**:左头像、中间姓名+与我的关系+手机号+生日。
   · 修拖动「只能往后不能往前」:落点改为显式记录"插到谁前面/后面",
     并排除被拖元素自身。
   · 去掉那句拖动提示(能力保留)。
   · 搜索新增**姓名首字母**检索(y / yy / yyq 都命中「杨永全」):
     新 app/Pinyin.php,intl 优先 + 内置常用字表兜底(本地无 intl 也能测);
     纯字母/数字查询词不走 SQL LIKE(拼音与加密手机号在库里都比不了)。

附带上两轮的收尾:
   · 菜单点外部自动关闭(全局事件委托,覆盖所有 details.menu)。
   · 旅程 is_public:列表与日历只显示公开的;详情页不拦(不丢数据)。
   · 重复日程:新 app/Repeat.php(RRULE 解析/生成/展开,含"工作日"),
     新表 calendar_event_skips(RFC 的 EXDATE);日历按规则展开;
     编辑支持三档「全部 / 这一次及以后 / 仅此一次」。
   · 结束时间可添加/去掉,并随开始时间联动。

验证:端到端 772 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 26、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
This commit is contained in:
2026-10-03 11:30:48 +08:00
parent 8f80f3e835
commit 02f5d072a4
21 changed files with 2235 additions and 213 deletions
+129 -21
View File
@@ -317,6 +317,75 @@ final class Calendar
return (string)$st->fetchColumn() === 'write'; return (string)$st->fetchColumn() === 'write';
} }
// ---------------- 重复日程的「某一次」例外(用户 2026-10-03 第 5 条)----------------
/** 把某一天标记为"跳过"(取消这一次 / 这一次被挪走) */
public static function skipOnce(int $eventId, string $dateYmd): void
{
$d = substr(trim($dateYmd), 0, 10);
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
return;
}
Database::get()->prepare(
'INSERT INTO calendar_event_skips (event_id, skip_date) VALUES (?,?)
ON DUPLICATE KEY UPDATE skip_date = VALUES(skip_date)'
)->execute([$eventId, $d]);
}
/** 取消"跳过"标记(撤销一次取消) */
public static function unskipOnce(int $eventId, string $dateYmd): void
{
Database::get()->prepare('DELETE FROM calendar_event_skips WHERE event_id = ? AND skip_date = ?')
->execute([$eventId, substr(trim($dateYmd), 0, 10)]);
}
/** 这条日程被跳过了哪些日期 */
public static function skipsOf(int $eventId): array
{
$st = Database::get()->prepare(
'SELECT skip_date FROM calendar_event_skips WHERE event_id = ? ORDER BY skip_date'
);
$st->execute([$eventId]);
return array_map(static fn($r) => (string)$r['skip_date'], $st->fetchAll());
}
/**
* 把"重复日程里被改掉的某一次"落成一条**独立的单次日程**。
*
* 为什么要独立一条而不是改原日程:原日程是**整个系列**的定义,
* 「仅此一次」的改动不能碰它。所以:
* ① 在原系列里把这一天标记为跳过(不再出现原样那次);
* ② 新建一条只发生这一次的日程,内容 = 用户改后的。
* 这样"改过的那一天"与"其余各次"各自独立,之后怎么改都不会互相干扰。
*
* @return int 新建的那条日程 id
*/
public static function detachOnce(array $ev, string $date, array $d, int $byId = 0): int
{
$date = substr(trim($date), 0, 10);
self::skipOnce((int)$ev['id'], $date);
// 时刻:沿用用户改后的;他改的是"那一天的"日程,日期必须锁在 $date
$starts = (string)($d['starts_at'] ?? '');
$ends = (string)($d['ends_at'] ?? '');
$startDay = substr($starts, 0, 10);
if ($startDay !== $date) {
// 用户没改日期(或者改到别的天了)→ 把这一天对齐到 $date,
// 只取他改的时刻部分;否则"仅此一次"会莫名其妙跳走一天。
$starts = $date . ' ' . (substr($starts, 11, 8) !== '' ? substr($starts, 11, 8) : '00:00:00');
}
$ends = $ends !== '' ? $date . ' ' . (substr($ends, 11, 8) ?: '00:00:00') : null;
$new = $d;
$new['starts_at'] = $starts;
$new['ends_at'] = $ends;
$new['rrule'] = ''; // 单次日程不带重复规则
// ⚠️ addEvent 内部会自己 newUid(),别在这里另设 $new['uid'](会被忽略,
// 只是让人误以为"UID 是我指定的")。created_by 走 $d['created_by']。
$new['created_by'] = $byId ?: ($d['created_by'] ?? null);
return self::addEvent((int)$ev['calendar_id'], $new);
}
/** 一条日程的 UID(CalDAV 那边按 UID 取) */ /** 一条日程的 UID(CalDAV 那边按 UID 取) */
public static function newUid(): string public static function newUid(): string
{ {
@@ -468,39 +537,71 @@ final class Calendar
} }
if ($ids) { if ($ids) {
$in = implode(',', array_fill(0, count($ids), '?')); $in = implode(',', array_fill(0, count($ids), '?'));
// ⚠️ 取数范围要**比 [from,to] 放宽**:重复日程的种子(starts_at)可能远早于
// 本月("从去年起每周三"),按 starts_at 直接卡范围会把整个系列漏掉。
// 这里取到 `to` 为止、往前不设限,展开阶段再按 [from,to] 裁。
$st = Database::get()->prepare( $st = Database::get()->prepare(
"SELECT * FROM calendar_events "SELECT * FROM calendar_events
WHERE calendar_id IN ($in) WHERE calendar_id IN ($in)
AND DATE(starts_at) <= ? AND DATE(COALESCE(ends_at, starts_at)) >= ? AND DATE(starts_at) <= ?
ORDER BY starts_at" ORDER BY starts_at"
); );
$st->execute(array_merge($ids, [$to, $from])); $st->execute(array_merge($ids, [$to]));
$calById = []; $calById = [];
foreach ($calendars as $c) { foreach ($calendars as $c) {
$calById[(int)$c['id']] = $c; $calById[(int)$c['id']] = $c;
} }
foreach ($st->fetchAll() as $e) { $rows = $st->fetchAll();
// 一次把这批日程的「例外日期」取出来(N+1 会在重复日程多时明显变慢)
$skips = [];
if ($rows) {
$eids = array_map('intval', array_column($rows, 'id'));
$in2 = implode(',', array_fill(0, count($eids), '?'));
$sk = Database::get()->prepare(
"SELECT event_id, skip_date FROM calendar_event_skips WHERE event_id IN ($in2)"
);
$sk->execute($eids);
foreach ($sk->fetchAll() as $r) {
$skips[(int)$r['event_id']][(string)$r['skip_date']] = true;
}
}
foreach ($rows as $e) {
$c = $calById[(int)$e['calendar_id']] ?? null; $c = $calById[(int)$e['calendar_id']] ?? null;
$sd = substr((string)$e['starts_at'], 0, 10); $sd = substr((string)$e['starts_at'], 0, 10);
$ed = substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10); $ed = substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10);
// 跨天日程:在范围内**每天**都出一条(群晖日历就是这么画的) $tm = substr((string)$e['starts_at'], 11, 5);
$first = true; $rr = (string)($e['rrule'] ?? '');
foreach (self::eachDay($sd, $ed, $from, $to) as $day) { // 用户 2026-10-03 第 5/6 条:重复日程按规则展开成若干次发生;
$tm = substr((string)$e['starts_at'], 11, 5); // 不重复的走同一条路(occurrences 会返回它自己那一天)。
$out[] = [ $days = Repeat::occurrences(
'cal' => (int)$e['calendar_id'], (string)$e['starts_at'], $rr, $from, $to,
'color' => (string)($c['color'] ?? '#4A7C82'), $skips[(int)$e['id']] ?? []
'calname' => (string)($c['name'] ?? ''), );
'title' => (string)$e['title'], foreach ($days as $day) {
'date' => $day, // 跨天日程:从这次发生的当天起,按事件本身的长度铺开
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻 $span = (int)floor((strtotime($ed) - strtotime($sd)) / 86400);
'time' => ($first && empty($e['all_day'])) ? $tm : '', $first = true;
'all_day' => !empty($e['all_day']), foreach (self::eachDay($day, date('Y-m-d', strtotime($day . ' +' . $span . ' day')), $from, $to) as $d2) {
'kind' => 'custom', $out[] = [
'ref_id' => (int)$e['id'], 'cal' => (int)$e['calendar_id'],
'href' => url('calendar/event', ['id' => (int)$e['id']]), 'color' => (string)($c['color'] ?? '#4A7C82'),
]; 'calname' => (string)($c['name'] ?? ''),
$first = false; 'title' => (string)$e['title'],
'date' => $d2,
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
'time' => ($first && empty($e['all_day'])) ? $tm : '',
'all_day' => !empty($e['all_day']),
'kind' => 'custom',
'ref_id' => (int)$e['id'],
// 带上这一天的日期,重复日程点进去才能定位到"改哪一次"
'occ' => $day,
'repeat' => $rr,
'href' => url('calendar/event', ['id' => (int)$e['id'], 'd' => $day]),
];
$first = false;
}
} }
} }
} }
@@ -540,14 +641,21 @@ final class Calendar
// 现在按 trip_nodes.starts_at 出条目,跨天的节点同样逐天铺开。 // 现在按 trip_nodes.starts_at 出条目,跨天的节点同样逐天铺开。
if (isset($byKind['trip'])) { if (isset($byKind['trip'])) {
$c = $byKind['trip'][0]; $c = $byKind['trip'][0];
// 用户 2026-10-03 第 4 条:不公开的旅程,节点也不上日历。
// COALESCE 兜底:缺 trip_details 行时按"公开"处理(老数据不会凭空消失)。
// ⚠️ 别在 SQL 字符串里写 `--` 行内注释:PHP 里那段是普通字符串,
// 但 `--` 之后的内容仍是字符串的一部分,SQL 侧被注释掉,
// 而 PHP 侧语法检查看不出问题 —— 会变成"语句少半截"的静默错误。
$st = Database::get()->prepare( $st = Database::get()->prepare(
"SELECT n.id AS node_id, n.title AS node_title, n.starts_at, n.ends_at, "SELECT n.id AS node_id, n.title AS node_title, n.starts_at, n.ends_at,
n.node_type, n.status AS node_status, n.node_type, n.status AS node_status,
e.id AS event_id, e.title AS trip_title e.id AS event_id, e.title AS trip_title
FROM trip_nodes n FROM trip_nodes n
JOIN events e ON e.id = n.event_id AND e.kind = 'trip' JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
LEFT JOIN trip_details d ON d.event_id = e.id
WHERE n.family_id = ? AND n.starts_at IS NOT NULL WHERE n.family_id = ? AND n.starts_at IS NOT NULL
AND n.status <> 'cancelled' AND n.status <> 'cancelled'
AND COALESCE(d.is_public, 1) = 1
AND DATE(n.starts_at) <= ? AND COALESCE(DATE(n.ends_at), DATE(n.starts_at)) >= ? AND DATE(n.starts_at) <= ? AND COALESCE(DATE(n.ends_at), DATE(n.starts_at)) >= ?
ORDER BY n.starts_at" ORDER BY n.starts_at"
); );
+393
View File
@@ -0,0 +1,393 @@
<?php
/**
* 首页 dashboard 的数据装配(用户 2026-10-03 第 3 条)。
*
* 这一版首页要回答的是「今天是个什么日子、家里最近怎么样」:
* · 一句随机问候(按当前时段 + 今日待办/日程给一句人话)
* · 今天的天气
* · 近七天的日程数 / 待办数 / 逾期数
* · 各模块的一张小结(用户可开关与排序)
*
* ⚠️ 天气是**外部依赖**,所以:
* ① 只在配置里给了 key 时才去请求(没配就整块不显示,不报错、不占位);
* ② 结果**缓存 30 分钟**(天气不需要每分钟刷;也避免把外部接口打爆);
* ③ 请求失败一律静默回落成"没有天气"——首页不能因为第三方挂了就打不开。
*/
declare(strict_types=1);
namespace App;
final class Home
{
/** 天气缓存有效期(秒) */
private const WEATHER_TTL = 1800;
/**
* 随机问候语。
*
* 用户原话:「随机给出一段问候语,例如,上午好,xxx,最近心情怎么样之类的」。
* 所以是**按时段 + 带名字 + 有点变化**,而不是固定的"你好"。
* ⚠️ 用 md5(日期+人名) 做种子而不是 random_int:
* 同一天刷新多次看到的应该是**同一句**,否则每次刷新都变、像抽奖。
*
* @param string $name 我怎么称呼自己(家书里"我"就是他)
*/
public static function greeting(string $name, string $today, int $events = 0, int $overdue = 0): string
{
$h = (int)date('G');
if ($h < 5) {
$part = '夜深了';
} elseif ($h < 11) {
$part = '早上好';
} elseif ($h < 13) {
$part = '中午好';
} elseif ($h < 18) {
$part = '下午好';
} else {
$part = '晚上好';
}
$who = trim($name) !== '' ? ',' . trim($name) : '';
// 有逾期先说逾期(那是最该被看见的),其次今天的安排,最后才是寒暄
if ($overdue > 0) {
$tail = '有 ' . $overdue . ' 件事已经过期了,要不要先处理一下?';
} elseif ($events > 0) {
$tail = '今天有 ' . $events . ' 件事,慢慢来。';
} else {
$tails = [
'最近心情怎么样?',
'今天想做点什么?',
'日子慢慢过,不着急。',
'记得给家里打个电话。',
'今天也是好日子。',
'有空翻翻往年的今天。',
];
$seed = crc32($today . '|' . $name);
$tail = $tails[$seed % count($tails)];
}
return $part . $who . ',' . $tail;
}
/** 今天星期几(中文) */
public static function weekday(string $date): string
{
$w = ['日', '一', '二', '三', '四', '五', '六'][(int)date('w', strtotime($date))];
return '星期' . $w;
}
/**
* 今天的天气。没配 key / 请求失败 / 超时 → null(首页整块不显示)。
*
* 用 Open-Meteo:**不需要申请 key**、允许非商业使用、有中文描述。
* 之所以不选和风/心知:都要申请 key,而"配好 key 才能看见天气"这件事
* 会让用户以为功能没做(配置项藏在哪他未必找得到)。
*
* ⚠️ 只按经纬度请求,不传城市名 —— 经纬度由管理员在系统设置里填。
* 没填经纬度就当"没配天气",不猜(猜错城市比不显示更糟)。
*
* @return array{temp:string,text:string,icon:string,place:string}|null
*/
public static function weather(int $familyId, string $lat, string $lon, string $place): ?array
{
$lat = trim($lat);
$lon = trim($lon);
if ($lat === '' || $lon === '' || !is_numeric($lat) || !is_numeric($lon)) {
return null;
}
// ⚠️ 缓存存在 settings 表里,而那张表对 family_id 有**外键**:
// 传 0 会插入失败(外键不存在),所以必须带家庭 id。
// 同一个家庭共用一份天气缓存 —— 本来也就是同一个地方。
$cacheKey = 'weather_' . md5($lat . ',' . $lon);
$cached = Setting::get($familyId, $cacheKey, '');
if ($cached !== '') {
$dec = json_decode((string)$cached, true);
if (is_array($dec) && (int)($dec['at'] ?? 0) + self::WEATHER_TTL > time()) {
return self::weatherOut((array)($dec['data'] ?? []), $place);
}
}
$url = 'https://api.open-meteo.com/v1/forecast'
. '?latitude=' . urlencode($lat) . '&longitude=' . urlencode($lon)
. '&current=temperature_2m,weather_code&timezone=Asia%2FShanghai';
$body = self::httpGet($url);
if ($body === null) {
return null;
}
$j = json_decode($body, true);
if (!is_array($j) || !isset($j['current'])) {
return null;
}
$data = [
'temp' => (string)round((float)($j['current']['temperature_2m'] ?? 0)),
'code' => (int)($j['current']['weather_code'] ?? -1),
];
// 缓存下来(存在 settings,key 里带 family 0 = 全站级)
Setting::set($familyId, $cacheKey, json_encode(['at' => time(), 'data' => $data], JSON_UNESCAPED_UNICODE));
return self::weatherOut($data, $place);
}
/** 内部结构 → 页面用的天气结构 */
private static function weatherOut(array $data, string $place): ?array
{
if (!isset($data['temp'])) {
return null;
}
[$text, $icon] = self::wmo((int)($data['code'] ?? -1));
return [
'temp' => (string)$data['temp'] . '°C',
'text' => $text,
'icon' => $icon,
'place' => trim($place),
];
}
/**
* WMO 天气代码 → [中文描述, 一个符号]。
* 符号用**普通字符**(不是 emoji):emoji 在部分旧系统上会变豆腐块,
* 而这是每天都要看的一块,宁可用朴素符号也不要出现方框。
*/
private static function wmo(int $c): array
{
if ($c === 0) {
return ['晴', '☀'];
}
if (in_array($c, [1, 2], true)) {
return ['多云', '⛅'];
}
if ($c === 3) {
return ['阴', '☁'];
}
if (in_array($c, [45, 48], true)) {
return ['有雾', '≋'];
}
if ($c >= 51 && $c <= 57) {
return ['毛毛雨', '☂'];
}
if ($c >= 61 && $c <= 67) {
return ['下雨', '☂'];
}
if ($c >= 71 && $c <= 77) {
return ['下雪', '❄'];
}
if ($c >= 80 && $c <= 82) {
return ['阵雨', '☂'];
}
if ($c >= 85 && $c <= 86) {
return ['阵雪', '❄'];
}
if ($c >= 95) {
return ['雷雨', '⚡'];
}
return ['天气', '·'];
}
/** 极简 GET(3 秒超时;失败返回 null,绝不抛) */
private static function httpGet(string $url): ?string
{
if (!function_exists('curl_init')) {
$ctx = stream_context_create(['http' => ['timeout' => 3, 'ignore_errors' => true]]);
$r = @file_get_contents($url, false, $ctx);
return $r === false ? null : (string)$r;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 3,
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'FamilyBook/1.0',
]);
$r = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($r === false || $code < 200 || $code >= 300) {
return null;
}
return (string)$r;
}
/**
* 各模块的一张小结。返回 key => ['name','icon','color','href','big','lines']。
*
* 「big」是一个最该被看见的数字/短句,「lines」是几行补充。
* 每张卡都**点得进去**(href 指向该模块最能回答这张卡的那一页)。
*/
public static function moduleCards(int $familyId, int $me, array $soon, array $tasks): array
{
$cards = [];
// ---- 名册 ----
$people = Person::map($familyId);
$roster = Person::roster($familyId, $me, 'member', '');
$cards['people'] = [
'name' => '名册',
'icon' => self::icon('people'),
'color' => Module::color('people', $familyId),
'href' => url('people'),
'big' => (string)count($roster) . ' 人',
'lines' => [count($people) . ' 人在这个家里'],
];
// ---- 家谱 ----
$graph = Kinship::graph($familyId);
$cards['kin'] = [
'name' => '家谱',
'icon' => self::icon('kin'),
'color' => Module::color('kin', $familyId),
'href' => url('kin'),
'big' => (string)Relations::count($familyId) . ' 条连线',
'lines' => ['父母 / 配偶 / 子女填上就能长出来'],
];
// ---- 观影 ----
$st = Database::get()->prepare(
"SELECT COUNT(*) FROM events WHERE family_id = ? AND kind = 'movie'"
);
$st->execute([$familyId]);
$movieN = (int)$st->fetchColumn();
$st = Database::get()->prepare(
"SELECT title FROM events WHERE family_id = ? AND kind = 'movie'
ORDER BY starts_at DESC, id DESC LIMIT 1"
);
$st->execute([$familyId]);
$lastMovie = (string)($st->fetchColumn() ?: '');
$cards['movies'] = [
'name' => '观影',
'icon' => self::icon('movies'),
'color' => Module::color('movies', $familyId),
'href' => url('movies'),
'big' => $movieN . ' 部',
'lines' => $lastMovie !== '' ? ['最近看了《' . $lastMovie . '》'] : ['还没有观影记录'],
];
// ---- 旅程 ----
$st = Database::get()->prepare(
"SELECT COUNT(*) FROM events e
LEFT JOIN trip_details d ON d.event_id = e.id
WHERE e.family_id = ? AND e.kind = 'trip' AND COALESCE(d.is_public,1) = 1"
);
$st->execute([$familyId]);
$tripN = (int)$st->fetchColumn();
$st = Database::get()->prepare(
"SELECT e.title FROM events e
LEFT JOIN trip_details d ON d.event_id = e.id
WHERE e.family_id = ? AND e.kind = 'trip' AND COALESCE(d.is_public,1) = 1
ORDER BY e.starts_at DESC, e.id DESC LIMIT 1"
);
$st->execute([$familyId]);
$lastTrip = (string)($st->fetchColumn() ?: '');
$cards['trips'] = [
'name' => '旅程',
'icon' => self::icon('trips'),
'color' => Module::color('trips', $familyId),
'href' => url('trips'),
'big' => $tripN . ' 趟',
'lines' => $lastTrip !== '' ? ['最近去了' . $lastTrip] : ['还没有旅程记录'],
];
// ---- 日程 ----
$cards['calendar'] = [
'name' => '日程',
'icon' => self::icon('calendar'),
'color' => Module::color('calendar', $familyId),
'href' => url('calendar'),
'big' => count($soon) . ' 条',
'lines' => ['未来七天'],
];
// ---- 资源 ----
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM resources r
LEFT JOIN resource_shares s ON s.resource_id = r.id AND s.person_id = ?
WHERE r.family_id = ? AND (r.owner_id = ? OR s.person_id IS NOT NULL)'
);
$st->execute([$me, $familyId, $me]);
$resN = (int)$st->fetchColumn();
$cards['resources'] = [
'name' => '资源',
'icon' => self::icon('resources'),
'color' => Module::color('resources', $familyId),
'href' => url('resources'),
'big' => $resN . ' 项',
'lines' => ['证件 / 资料 / 附件'],
];
// ---- 任务 ----
$board = Task::board($familyId, $me);
$doing = count($board['doing']);
$todo = count($board['todo']);
$late = 0;
foreach ([$board['todo'], $board['doing']] as $col) {
foreach ($col as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) {
$late++;
}
}
}
$cards['tasks'] = [
'name' => '任务',
'icon' => self::icon('tasks'),
'color' => Module::color('tasks', $familyId),
'href' => url('tasks'),
'big' => $doing . ' 件在做',
'lines' => [$todo . ' 件待办' . ($late > 0 ? ' · ' . $late . ' 件逾期' : '')],
];
// ---- 纪念日 ----
$up = Anniversary::upcoming($familyId, $me, 30);
$next = $up[0] ?? null;
$cards['days'] = [
'name' => '纪念日',
'icon' => self::icon('days'),
'color' => Module::color('days', $familyId),
'href' => url('days'),
'big' => $next ? ((int)$next['days'] === 0 ? '今天' : (int)$next['days'] . ' 天后') : '—',
'lines' => [$next ? (string)$next['title'] : '三十天内没有纪念日'],
];
// ---- 财务 ----
$books = Ledger::mine($familyId, $me);
$net = 0;
$hasBook = false;
foreach ($books as $b) {
$hasBook = true;
$net += Ledger::summary(Ledger::entries((int)$b['id']))['net'];
}
$cards['ledger'] = [
'name' => '财务',
'icon' => self::icon('ledger'),
'color' => Module::color('ledger', $familyId),
'href' => url('ledger'),
'big' => $hasBook ? Ledger::money($net) : '还没记账',
'lines' => [$hasBook ? count($books) . ' 本账(净资产)' : '先建一本家庭账'],
];
return $cards;
}
/** 模块图标(行内 SVG;与观影设置下拉同一套做法,不引外部字体/图片) */
public static function icon(string $key): string
{
$paths = [
'people' => '<circle cx="7" cy="6" r="2.6"/><path d="M2.6 14c0-2.4 2-3.9 4.4-3.9s4.4 1.5 4.4 3.9"/><circle cx="13" cy="7.2" r="2"/><path d="M11.6 14c0-1.9 1.2-3 2.8-3s2.6 1.1 2.6 3"/>',
'kin' => '<rect x="6.4" y="1.8" width="5.2" height="4" rx="1"/><rect x="1.8" y="12.2" width="5.2" height="4" rx="1"/><rect x="11" y="12.2" width="5.2" height="4" rx="1"/><path d="M9 5.8v3M4.4 12.2v-1.6h9.2v1.6M4.4 10.6h9.2"/>',
'movies' => '<rect x="1.8" y="3.4" width="14.4" height="11.2" rx="2"/><path d="M1.8 7.2h14.4M5.4 3.4v3.8M10.6 3.4v3.8"/>',
'trips' => '<path d="M9 1.8c-2.9 0-5.2 2.3-5.2 5.2 0 3.9 5.2 9.2 5.2 9.2s5.2-5.3 5.2-9.2c0-2.9-2.3-5.2-5.2-5.2z"/><circle cx="9" cy="7" r="1.9"/>',
'calendar' => '<rect x="2" y="3.6" width="14" height="12.4" rx="2"/><path d="M2 7.6h14M5.6 1.8v3.6M12.4 1.8v3.6"/>',
'resources' => '<path d="M2.4 4.4A1.6 1.6 0 0 1 4 2.8h3.2l1.6 2h5.2A1.6 1.6 0 0 1 15.6 6.4v7.2a1.6 1.6 0 0 1-1.6 1.6H4a1.6 1.6 0 0 1-1.6-1.6z"/>',
'tasks' => '<rect x="2.4" y="2.4" width="13.2" height="13.2" rx="2"/><path d="M5.6 9.2l2.4 2.4 4.4-4.8"/>',
'days' => '<path d="M9 2.2l2 4.2 4.6.6-3.4 3.2.8 4.6L9 12.6l-4 2.2.8-4.6L2.4 7l4.6-.6z"/>',
'ledger' => '<rect x="2.4" y="3" width="13.2" height="12" rx="2"/><path d="M5.6 6.6h6.8M5.6 9.4h6.8M5.6 12.2h4"/>',
];
$d = $paths[$key] ?? '';
if ($d === '') {
return '';
}
return '<svg viewBox="0 0 18 18" width="18" height="18" aria-hidden="true" fill="none"'
. ' stroke="currentColor" stroke-width="1.5" stroke-linecap="round"'
. ' stroke-linejoin="round">' . $d . '</svg>';
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
/**
* 首页上显示哪些模块、什么顺序(用户 2026-10-03 第 3 条)。
*
* 原话:「在个人设置那里,要增加一个设置,用户可以选择,在首页上显示哪些模块,
* 并且调整它们显示的顺序。」
*
* 与导航条那套(Module::navFor + nav_known)是**同一套思路**,所以这里刻意
* 复用同样的三条纪律(踩过的坑不再踩第二遍):
*
* ① 存 user_settings(**每人一份**)—— 首页放什么是很个人的事;
* ② 只保留"还存在的模块"(删模块/改 key 之后,老配置里那条认不出来,
* 直接渲染就是一张空卡);
* ③ 另存一份 `home_known` =「用户做这次决定时,世上已知的全部模块」——
* 只有 `现在的模块 - known` 才是**真的新装**、才该补进来。
* ⚠️ 不能写成"不在已选里就补":那样用户**主动关掉**的模块下次请求就被补回来,
* 「可配置显示哪些」直接失效(导航条那边就是这么踩过的)。
*/
declare(strict_types=1);
namespace App;
final class HomeMods
{
/**
* 首页**默认**显示哪些模块、什么顺序。
*
* 比默认导航条短一些:首页是"概览",把九个模块都铺上去就成了一面墙。
* 选的是"每天想看的那几个"。
*/
public const DEFAULT_HOME = ['tasks', 'calendar', 'days', 'movies', 'trips', 'ledger'];
/** 这个人首页上要显示哪些模块(按顺序) */
public static function forPerson(int $personId): array
{
$raw = (string)UserSetting::get($personId, 'home_modules', '');
$dec = $raw !== '' ? json_decode($raw, true) : null;
if (!is_array($dec)) {
return self::DEFAULT_HOME; // 没配过(新装的人 / 重置过的)
}
// ① 只留还存在的模块(去重、保序)
$saved = [];
foreach ($dec as $k) {
$k = (string)$k;
if (Module::exists($k) && !in_array($k, $saved, true)) {
$saved[] = $k;
}
}
// ③ 新装的模块补到末尾(见类注释里的 ③)
$known = [];
$kraw = (string)UserSetting::get($personId, 'home_known', '');
$kdec = $kraw !== '' ? json_decode($kraw, true) : null;
if (is_array($kdec)) {
foreach ($kdec as $k) {
$k = (string)$k;
if (Module::exists($k)) {
$known[$k] = true;
}
}
}
foreach (Module::keys() as $k) {
if (!isset($known[$k]) && !in_array($k, $saved, true)) {
$saved[] = $k; // 真的新装 → 补进来
}
}
return $saved;
}
/** 保存首页模块(顺序即数组顺序;不在数组里的就是关掉的) */
public static function save(int $personId, array $keys): void
{
$out = [];
foreach ($keys as $k) {
$k = (string)$k;
if (Module::exists($k) && !in_array($k, $out, true)) {
$out[] = $k;
}
}
UserSetting::set($personId, 'home_modules', json_encode($out, JSON_UNESCAPED_UNICODE));
// 【关键】同时记下"此刻已知的全部模块",这样才能区分
// 「用户关掉的」与「后来新装的」(见 ③)
UserSetting::set($personId, 'home_known',
json_encode(Module::keys(), JSON_UNESCAPED_UNICODE));
}
/**
* 设置页要用的展示顺序:已选的按现有顺序在前,未选的按模块表顺序在后。
* 这样"关掉再打开"位置不会跳。
*/
public static function orderedForPicker(int $personId): array
{
$on = self::forPerson($personId);
$out = $on;
foreach (Module::keys() as $k) {
if (!in_array($k, $out, true)) {
$out[] = $k;
}
}
return $out;
}
}
+58 -11
View File
@@ -35,11 +35,24 @@ final class Person
$args[] = $familyId; $args[] = $familyId;
$args[] = $kind; $args[] = $kind;
if ($q !== '') { if ($q !== '') {
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)'; // ⚠️ 纯字母/数字的查询词**不能**交给 SQL 的 LIKE 过滤:
$like = '%' . $q . '%'; // 它是"姓名首字母"(如 yyq),SQL 里根本没法把汉字转拼音,
$args[] = $like; // 一 LIKE 就把结果筛成空 —— 后面的 PHP 精筛也就没东西可筛了,
$args[] = $like; // 表现为"搜 yyq 一个人都没有"(功能像没做)。
$args[] = $like; // 所以这种查询词直接跳过 SQL 条件,全量取回来之后在 PHP 里筛
// (名册是个人小列表,几十条,全取完全没问题)。
//
// ⚠️ **也不能**用 SQL 过滤纯数字(手机号):手机号是**加密存的**,
// 库里只有密文,SQL 的 LIKE 永远匹配不到明文数字。
// 数字查询同样交给 PHP 精筛(那里会解密后再比)。
$needSql = (bool)preg_match('/[^A-Za-z0-9]/u', $q);
if ($needSql) {
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
$like = '%' . $q . '%';
$args[] = $like;
$args[] = $like;
$args[] = $like;
}
} }
$sql .= ' ORDER BY p.id ASC'; $sql .= ' ORDER BY p.id ASC';
$st = Database::get()->prepare($sql); $st = Database::get()->prepare($sql);
@@ -59,6 +72,9 @@ final class Person
*/ */
public static function roster(int $familyId, int $viewerId, string $kind = 'member', string $q = ''): array public static function roster(int $familyId, int $viewerId, string $kind = 'member', string $q = ''): array
{ {
// 用户 2026-10-03 第 4 条:名册改成列表,每行要显示「手机号 / 生日 / 与我的关系」
// → 手机号是加密存的,得在这里一并取出密文(下面解密,避免逐行再查一次)。
// → 关系称呼走 Kinship(每人算一次),不在 SQL 里。
$sql = 'SELECT p.*, r.group_id, r.sort_order, r.source AS roster_source, $sql = 'SELECT p.*, r.group_id, r.sort_order, r.source AS roster_source,
a.id AS account_id, a.role AS account_role, a.status AS account_status, a.id AS account_id, a.role AS account_role, a.status AS account_status,
a.last_login_at, n.nickname AS my_nickname a.last_login_at, n.nickname AS my_nickname
@@ -72,16 +88,47 @@ final class Person
$args[] = $viewerId; $args[] = $viewerId;
$args[] = $kind; $args[] = $kind;
if ($q !== '') { if ($q !== '') {
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)'; // ⚠️ SQL 只在**能帮忙**时才粗筛,两个反例:
$like = '%' . $q . '%'; // · 纯字母(yyq)—— 那是"姓名首字母",SQL 里没法把汉字转拼音,
$args[] = $like; // 一旦 LIKE 就把结果筛成空,后面的 PHP 精筛也就没东西可筛了;
$args[] = $like; // · 纯数字(手机号)—— 手机号是**加密存的**,库里只有密文,
$args[] = $like; // LIKE 永远匹配不到明文数字。
// 这两类都交给下面的 PHP 精筛(它会解密、会算首字母)。
// 只含中文/邮箱这类"库里有明文可比"的查询词才加 SQL 条件。
$needSql = (bool)preg_match('/[^A-Za-z0-9]/u', $q);
if ($needSql) {
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
$like = '%' . $q . '%';
$args[] = $like;
$args[] = $like;
$args[] = $like;
}
} }
$sql .= ' ORDER BY r.sort_order ASC, p.id ASC'; $sql .= ' ORDER BY r.sort_order ASC, p.id ASC';
$st = Database::get()->prepare($sql); $st = Database::get()->prepare($sql);
$st->execute($args); $st->execute($args);
return $st->fetchAll(); $rows = $st->fetchAll();
// 用户 2026-10-03 第 4 条:再加**姓名首字母**检索(y / yy / yyq 都命中「杨永全」)。
// ⚠️ 不在 SQL 里做:SQL 侧没法把汉字转拼音(线上也没有现成的函数),
// 硬塞进 SQL 只能靠一堆 LIKE OR,既慢又不可能穷尽。
// 名册是**一个人自己的**小列表(几十条),在 PHP 里过滤完全够快。
// 做法:SQL 只在能帮忙时粗筛(中文/邮箱这类),然后**一律**走这里精筛一次。
// 精筛覆盖:姓名 / 称呼 / 邮箱 / **解密后的手机号** / 姓名首字母。
if ($q !== '') {
$rows = array_values(array_filter($rows, static function (array $r) use ($q): bool {
// 手机号是加密的,Pinyin::matches 认 phone_plain;这里把它带上再判一次
if (!empty($r['phone_encrypted'])) {
try {
$r['phone_plain'] = Crypto::decrypt((string)$r['phone_encrypted']);
} catch (\Throwable $e) {
// 解不开就当没填(不影响其它字段的匹配)
}
}
return Pinyin::matches($r, $q);
}));
}
return $rows;
} }
public static function find(int $id): ?array public static function find(int $id): ?array
+299
View File
@@ -0,0 +1,299 @@
<?php
/**
* 汉字 → 拼音首字母(用户 2026-10-03 第 4 条)。
*
* 用途:名册搜索支持「姓名首字母」——
* 输入 y / yy / yyq 都能搜到「杨永全」。
*
* 两条路(**必须都有**,因为本地与线上环境不同):
*
* ① `Intl` 扩展的 `Collator('zh-Hans-CN-u-co-pinyin')`:拿一张
* 「a..z 各挑一个锚字」的表做二分,就能判断任意汉字落在哪个字母段。
* 线上 PHP 7.4/8.1 都有 intl,走这条最准、零数据。
* ② 没有 intl 时的兜底:**常用姓氏/人名用字表**。
* 本地开发环境没有 intl(实测 `intl: 无`),没有兜底就等于
* 「这个功能本地永远测不了」—— 那是最糟的情况,只能上线才发现问题。
*
* ⚠️ 兜底表覆盖不到的生僻字会**原样保留首字符参与匹配**(不产字母),
* 也就是说:搜不到时是"少匹配",绝不会错匹配。
*/
declare(strict_types=1);
namespace App;
final class Pinyin
{
/** 拼音排序用的锚字(a..z 各一个;没有 i/u/v 开头的常用字,所以是 23 个) */
private const ANCHORS = '阿八擦搭娥发嘎哈击喀垃妈拿哦啪期然撒塌挖昔压匝';
private const LETTERS = 'abcdefghjklmnopqrstwxyz';
/**
* 常用姓氏 + 常见人名字的**首字母表**(兜底用)。
* 只列"作为名字第一个字"出现频率高的;查不到就交给下一档处理。
* 这一份不可能穷尽,所以它只是兜底 —— 线上有 intl 时走的是锚字二分。
*/
private const TABLE = [
// 姓氏(覆盖绝大多数中国姓氏)
'赵a' => 1, '钱q' => 1, '孙s' => 1, '李l' => 1, '周z' => 1, '吴w' => 1, '郑z' => 1,
'王w' => 1, '冯f' => 1, '陈c' => 1, '褚c' => 1, '卫w' => 1, '蒋j' => 1, '沈s' => 1,
'韩h' => 1, '杨y' => 1, '朱z' => 1, '秦q' => 1, '尤y' => 1, '许x' => 1, '何h' => 1,
'吕l' => 1, '施s' => 1, '张z' => 1, '孔k' => 1, '曹c' => 1, '严y' => 1, '华h' => 1,
'金j' => 1, '魏w' => 1, '陶t' => 1, '姜j' => 1, '戚q' => 1, '谢x' => 1, '邹z' => 1,
'喻y' => 1, '柏b' => 1, '水s' => 1, '窦d' => 1, '章z' => 1, '云y' => 1, '苏s' => 1,
'潘p' => 1, '葛g' => 1, '奚x' => 1, '范f' => 1, '彭p' => 1, '郎l' => 1, '鲁l' => 1,
'韦w' => 1, '昌c' => 1, '马m' => 1, '苗m' => 1, '凤f' => 1, '花h' => 1, '方f' => 1,
'俞y' => 1, '任r' => 1, '袁y' => 1, '柳l' => 1, '酆f' => 1, '鲍b' => 1, '史s' => 1,
'唐t' => 1, '费f' => 1, '廉l' => 1, '岑c' => 1, '薛x' => 1, '雷l' => 1, '贺h' => 1,
'倪n' => 1, '汤t' => 1, '滕t' => 1, '殷y' => 1, '罗l' => 1, '毕b' => 1, '郝h' => 1,
'邬w' => 1, '安a' => 1, '常c' => 1, '乐y' => 1, '于y' => 1, '时s' => 1, '傅f' => 1,
'皮p' => 1, '卞b' => 1, '齐q' => 1, '康k' => 1, '伍w' => 1, '余y' => 1, '元y' => 1,
'卜b' => 1, '顾g' => 1, '孟m' => 1, '平p' => 1, '黄h' => 1, '和h' => 1, '穆m' => 1,
'萧x' => 1, '尹y' => 1, '姚y' => 1, '邵s' => 1, '湛z' => 1, '汪w' => 1, '祁q' => 1,
'毛m' => 1, '禹y' => 1, '狄d' => 1, '米m' => 1, '贝b' => 1, '明m' => 1, '臧z' => 1,
'计j' => 1, '伏f' => 1, '成c' => 1, '戴d' => 1, '谈t' => 1, '宋s' => 1, '茅m' => 1,
'庞p' => 1, '熊x' => 1, '纪j' => 1, '舒s' => 1, '屈q' => 1, '项x' => 1, '祝z' => 1,
'董d' => 1, '梁l' => 1, '杜d' => 1, '阮r' => 1, '蓝l' => 1, '闵m' => 1, '席x' => 1,
'季j' => 1, '麻m' => 1, '强q' => 1, '贾j' => 1, '路l' => 1, '娄l' => 1, '危w' => 1,
'江j' => 1, '童t' => 1, '颜y' => 1, '郭g' => 1, '梅m' => 1, '盛s' => 1, '林l' => 1,
'刁d' => 1, '钟z' => 1, '徐x' => 1, '邱q' => 1, '骆l' => 1, '高g' => 1, '夏x' => 1,
'蔡c' => 1, '田t' => 1, '樊f' => 1, '胡h' => 1, '凌l' => 1, '霍h' => 1, '虞y' => 1,
'万w' => 1, '支z' => 1, '柯k' => 1, '昝z' => 1, '管g' => 1, '卢l' => 1, '莫m' => 1,
'房f' => 1, '裘q' => 1, '缪m' => 1, '干g' => 1, '解x' => 1, '应y' => 1, '宗z' => 1,
'丁d' => 1, '宣x' => 1, '邓d' => 1, '单s' => 1, '杭h' => 1, '洪h' => 1, '包b' => 1,
'诸z' => 1, '左z' => 1, '石s' => 1, '崔c' => 1, '吉j' => 1, '钮n' => 1, '龚g' => 1,
'程c' => 1, '嵇j' => 1, '邢x' => 1, '滑h' => 1, '裴p' => 1, '陆l' => 1, '荣r' => 1,
'翁w' => 1, '荀x' => 1, '羊y' => 1, '於y' => 1, '惠h' => 1, '甄z' => 1, '曲q' => 1,
'封f' => 1, '芮r' => 1, '羿y' => 1, '储c' => 1, '靳j' => 1, '汲j' => 1, '邴b' => 1,
'糜m' => 1, '松s' => 1, '井j' => 1, '段d' => 1, '富f' => 1, '巫w' => 1, '乌w' => 1,
'焦j' => 1, '巴b' => 1, '弓g' => 1, '牧m' => 1, '隗k' => 1, '山s' => 1, '谷g' => 1,
'车c' => 1, '侯h' => 1, '宓m' => 1, '蓬p' => 1, '全q' => 1, '郗x' => 1, '班b' => 1,
'仰y' => 1, '秋q' => 1, '仲z' => 1, '伊y' => 1, '宫g' => 1, '宁n' => 1, '仇q' => 1,
'栾l' => 1, '暴b' => 1, '甘g' => 1, '厉l' => 1, '戎r' => 1, '祖z' => 1, '武w' => 1,
'符f' => 1, '刘l' => 1, '景j' => 1, '詹z' => 1, '束s' => 1, '龙l' => 1, '叶y' => 1,
'幸x' => 1, '司s' => 1, '韶s' => 1, '郜g' => 1, '黎l' => 1, '蓟j' => 1, '薄b' => 1,
'印y' => 1, '宿s' => 1, '白b' => 1, '怀h' => 1, '蒲p' => 1, '邰t' => 1, '从c' => 1,
'鄂e' => 1, '索s' => 1, '咸x' => 1, '籍j' => 1, '赖l' => 1, '卓z' => 1, '蔺l' => 1,
'屠t' => 1, '蒙m' => 1, '池c' => 1, '乔q' => 1, '阴y' => 1, '郁y' => 1, '胥x' => 1,
'能n' => 1, '苍c' => 1, '双s' => 1, '闻w' => 1, '莘s' => 1, '党d' => 1, '翟z' => 1,
'谭t' => 1, '贡g' => 1, '劳l' => 1, '逄p' => 1, '姬j' => 1, '申s' => 1, '扶f' => 1,
'堵d' => 1, '冉r' => 1, '宰z' => 1, '郦l' => 1, '雍y' => 1, '郤x' => 1, '璩q' => 1,
'桑s' => 1, '桂g' => 1, '濮p' => 1, '牛n' => 1, '寿s' => 1, '通t' => 1, '边b' => 1,
'扈h' => 1, '燕y' => 1, '冀j' => 1, '郏j' => 1, '浦p' => 1, '尚s' => 1, '农n' => 1,
'温w' => 1, '别b' => 1, '庄z' => 1, '晏y' => 1, '柴c' => 1, '瞿q' => 1, '阎y' => 1,
'充c' => 1, '慕m' => 1, '连l' => 1, '茹r' => 1, '习x' => 1, '宦h' => 1, '艾a' => 1,
'鱼y' => 1, '容r' => 1, '向x' => 1, '古g' => 1, '易y' => 1, '慎s' => 1, '戈g' => 1,
'廖l' => 1, '庾y' => 1, '终z' => 1, '暨j' => 1, '居j' => 1, '衡h' => 1, '步b' => 1,
'都d' => 1, '耿g' => 1, '满m' => 1, '弘h' => 1, '匡k' => 1, '国g' => 1, '文w' => 1,
'寇k' => 1, '广g' => 1, '禄l' => 1, '阙q' => 1, '东d' => 1, '欧o' => 1, '殳s' => 1,
'沃w' => 1, '利l' => 1, '蔚w' => 1, '越y' => 1, '夔k' => 1, '隆l' => 1, '师s' => 1,
'巩g' => 1, '厍s' => 1, '聂n' => 1, '晁c' => 1, '勾g' => 1, '敖a' => 1, '融r' => 1,
'冷l' => 1, '訾z' => 1, '辛x' => 1, '阚k' => 1, '那n' => 1, '简j' => 1, '饶r' => 1,
'空k' => 1, '曾z' => 1, '毋w' => 1, '沙s' => 1, '乜n' => 1, '养y' => 1, '鞠j' => 1,
'须x' => 1, '丰f' => 1, '巢c' => 1, '关g' => 1, '蒯k' => 1, '相x' => 1, '查z' => 1,
'后h' => 1, '荆j' => 1, '红h' => 1, '游y' => 1, '竺z' => 1, '权q' => 1, '逯l' => 1,
'盖g' => 1, '益y' => 1, '桓h' => 1, '公g' => 1,
// 名字里高频的字
'永y' => 1, '全q' => 1, '博b' => 1, '为w' => 1, '明m' => 1, '华h' => 1, '建j' => 1,
'国g' => 1, '文w' => 1, '军j' => 1, '平p' => 1, '志z' => 1, '强q' => 1, '磊l' => 1,
'洋y' => 1, '勇y' => 1, '艳y' => 1, '杰j' => 1, '娟j' => 1, '涛t' => 1, '超c' => 1,
'敏m' => 1, '静j' => 1, '丽l' => 1, '秀x' => 1, '英y' => 1, '娜n' => 1, '丹d' => 1,
'玲l' => 1, '芳f' => 1, '燕y' => 1, '红h' => 1, '霞x' => 1, '梅m' => 1, '兰l' => 1,
'琴q' => 1, '云y' => 1, '飞f' => 1, '鹏p' => 1, '宇y' => 1, '浩h' => 1, '鑫x' => 1,
'亮l' => 1, '刚g' => 1, '辉h' => 1, '峰f' => 1, '龙l' => 1, '波b' => 1, '斌b' => 1,
'春c' => 1, '雨y' => 1, '晨c' => 1, '阳y' => 1, '雪x' => 1, '悦y' => 1, '欣x' => 1,
'怡y' => 1, '佳j' => 1, '思s' => 1, '雨y' => 1, '子z' => 1, '小x' => 1, '大d' => 1,
'老l' => 1, '爸b' => 1, '妈m' => 1, '哥g' => 1, '姐j' => 1, '弟d' => 1, '妹m' => 1,
'爷y' => 1, '奶n' => 1, '叔s' => 1, '姨y' => 1, '舅j' => 1, '嫂s' => 1, '儿e' => 1,
'女n' => 1, '宝b' => 1, '乐l' => 1, '安a' => 1, '康k' => 1, '福f' => 1, '寿s' => 1,
'喜x' => 1, '顺s' => 1, '和h' => 1, '美m' => 1, '好h' => 1, '新x' => 1, '中z' => 1,
'正z' => 1, '长c' => 1, '海h' => 1, '江j' => 1, '河h' => 1, '山s' => 1, '林l' => 1,
'木m' => 1, '金j' => 1, '玉y' => 1, '石s' => 1, '田t' => 1, '力l' => 1, '学x' => 1,
'生s' => 1, '成c' => 1, '立l' => 1, '德d' => 1, '仁r' => 1, '义y' => 1, '礼l' => 1,
'智z' => 1, '信x' => 1, '忠z' => 1, '孝x' => 1, '友y' => 1, '爱a' => 1, '慈c' => 1,
// 家谱称呼里高频的那批字(父/母/伯/叔/姑/舅/姨/岳…)——
// 名册搜索经常会拿这些字当关键词,兜底表里缺了就会出现
// "本地搜 s 搜不到『张三』"这种与环境相关的怪现象。
'父f' => 1, '母m' => 1, '祖z' => 1, '孙s' => 1, '外w' => 1, '表b' => 1, '堂t' => 1,
'侄z' => 1, '甥s' => 1, '婿x' => 1, '媳x' => 1, '伯b' => 1, '姑g' => 1, '岳y' => 1,
'公g' => 1, '婆p' => 1, '妻q' => 1, '夫f' => 1, '丈z' => 1, '娘n' => 1, '爹d' => 1,
'三s' => 1, '四s' => 1, '五w' => 1, '六l' => 1, '七q' => 1, '八b' => 1, '九j' => 1,
'十s' => 1, '百b' => 1, '千q' => 1, '万w' => 1, '一y' => 1, '二e' => 1, '零l' => 1,
'不b' => 1, '的d' => 1, '了l' => 1, '在z' => 1, '有y' => 1, '是s' => 1, '我w' => 1,
'人r' => 1, '家j' => 1, '门m' => 1, '户h' => 1, '先x' => 1, '后h' => 1, '晚w' => 1,
'长c' => 1, '幼y' => 1, '大d' => 1, '小x' => 1, '多d' => 1, '少s' => 1, '高g' => 1,
'矮a' => 1, '胖p' => 1, '瘦s' => 1, '黑h' => 1, '白b' => 1, '花h' => 1, '草c' => 1,
'树s' => 1, '叶y' => 1, '果g' => 1, '苗m' => 1, '阳y' => 1, '光g' => 1, '月y' => 1,
'星x' => 1, '风f' => 1, '雪x' => 1, '雨y' => 1, '雷l' => 1, '电d' => 1, '冰b' => 1,
'州z' => 1, '城c' => 1, '村c' => 1, '镇z' => 1, '县x' => 1, '市s' => 1, '省s' => 1,
'华h' => 1, '青q' => 1, '岛d' => 1, '京j' => 1, '津j' => 1, '冀j' => 1, '鲁l' => 1,
'思s' => 1, '念n' => 1, '想x' => 1, '记j' => 1, '忘w' => 1, '知z' => 1, '道d' => 1,
'说s' => 1, '话h' => 1, '听t' => 1, '看k' => 1, '走z' => 1, '来l' => 1, '去q' => 1,
'回h' => 1, '到d' => 1, '给g' => 1, '拿n' => 1, '做z' => 1, '用y' => 1, '买m' => 1,
'卖m' => 1, '吃c' => 1, '喝h' => 1, '睡s' => 1, '起q' => 1, '坐z' => 1, '站z' => 1,
'跑p' => 1, '跳t' => 1, '唱c' => 1, '写x' => 1, '读d' => 1, '画h' => 1, '算s' => 1,
'作z' => 1, '课k' => 1, '班b' => 1, '校x' => 1, '师s' => 1, '医y' => 1, '院y' => 1,
'药y' => 1, '病b' => 1, '健j' => 1, '体t' => 1, '身s' => 1, '心x' => 1, '手s' => 1,
'脚j' => 1, '眼y' => 1, '耳e' => 1, '鼻b' => 1, '口k' => 1, '头t' => 1, '发f' => 1,
];
/** 首字母查表:'杨' => 'y'。首次调用时从 TABLE 摊平,之后走内存缓存。 */
private static ?array $map = null;
public static function initialMap(): array
{
if (self::$map === null) {
self::$map = [];
foreach (self::TABLE as $kv => $_) {
$k = (string)$kv;
if (mb_strlen($k, 'UTF-8') >= 1) {
$ch = mb_substr($k, 0, 1, 'UTF-8');
$ch = mb_substr($k, 0, mb_strlen($k, 'UTF-8') - 1, 'UTF-8');
$letter = mb_substr((string)$kv, mb_strlen((string)$kv, 'UTF-8') - 1, 1, 'UTF-8');
if ($ch !== '' && $letter !== '') {
self::$map[$ch] = strtolower($letter);
}
}
}
}
return self::$map;
}
/**
* 一个字的拼音首字母(小写)。认不出时返回 ''。
* 有 intl 走锚字二分;没有就走内置表。
*/
public static function initial(string $ch): string
{
if ($ch === '') {
return '';
}
// 已经是 ASCII 字母 → 直接用
if (preg_match('/^[a-zA-Z]$/', $ch)) {
return strtolower($ch);
}
if (preg_match('/^[0-9]$/', $ch)) {
return $ch;
}
// ① intl 拼音排序(线上有)
$viaIntl = self::initialViaIntl($ch);
if ($viaIntl !== null) {
return $viaIntl;
}
// ② 内置表兜底(本地无 intl 时)
return self::initialMap()[$ch] ?? '';
}
/** 用 Intl 的拼音 collation + 锚字二分定位;没有 intl 返回 null */
private static function initialViaIntl(string $ch): ?string
{
static $coll = false;
if ($coll === false) {
$coll = null;
if (class_exists('\\Collator')) {
$c = new \Collator('zh-Hans-CN-u-co-pinyin');
// 排序没真的生效时(compare('阿','匝') 不 < 0)就别用,免得给出错结果
if ($c->compare('阿', '匝') < 0) {
$coll = $c;
}
}
}
if ($coll === null) {
return null;
}
$anchors = self::ANCHORS;
$len = mb_strlen($anchors, 'UTF-8');
$lo = 0;
$hi = $len - 1;
$best = -1;
while ($lo <= $hi) {
$mid = intdiv($lo + $hi, 2);
$a = mb_substr($anchors, $mid, 1, 'UTF-8');
if ($coll->compare($ch, $a) >= 0) {
$best = $mid;
$lo = $mid + 1;
} else {
$hi = $mid - 1;
}
}
if ($best < 0) {
return null;
}
return mb_substr(self::LETTERS, $best, 1, 'UTF-8');
}
/**
* 一串字 → 首字母串(小写)。认不出的字**跳过**(不产字母,也不报错)。
* 例:'杨永全' → 'yyq'。
*/
public static function initials(string $s): string
{
$out = '';
$n = mb_strlen($s, 'UTF-8');
for ($i = 0; $i < $n; $i++) {
$out .= self::initial(mb_substr($s, $i, 1, 'UTF-8'));
}
return $out;
}
/**
* q 是否是 s 的**子序列**(用于"输入 y 也能搜到 yyq"这类前缀式检索)。
*
* 为什么用子序列而不是前缀:
* 用户可能只记得中间某个字(比如"永全" → yq),或干脆跳着打;
* 名册这种小列表宁可宽松一点,也别让人以为搜索坏了。
*/
public static function isSubsequence(string $q, string $s): bool
{
$q = strtolower(trim($q));
$s = strtolower($s);
if ($q === '') {
return true;
}
$i = 0;
$qn = strlen($q);
for ($j = 0, $sn = strlen($s); $j < $sn && $i < $qn; $j++) {
if ($s[$j] === $q[$i]) {
$i++;
}
}
return $i === $qn;
}
/**
* 这个人是否匹配搜索词。
*
* 覆盖:姓名 / 称呼 / 邮箱 / 手机号 的**模糊**包含,以及姓名首字母的**子序列**匹配。
*
* @param array $row 名册行(含 name / email / phone_plain,可选 my_nickname)
* @param string $q 搜索词
*/
public static function matches(array $row, string $q): bool
{
$q = trim($q);
if ($q === '') {
return true;
}
$needle = mb_strtolower($q, 'UTF-8');
foreach (['name', 'my_nickname', 'nickname', 'email', 'phone_plain'] as $k) {
$v = mb_strtolower(trim((string)($row[$k] ?? '')), 'UTF-8');
if ($v !== '' && mb_strpos($v, $needle) !== false) {
return true;
}
}
// 首字母:只对**字母/数字**查询词做子序列匹配,
// 中文查询词走上面的"包含"(否则"永全"会被当成首字母串去比对,永远匹配不上)
if (preg_match('/^[a-z0-9]+$/', $needle)) {
$ini = self::initials((string)($row['name'] ?? ''));
if ($ini !== '' && self::isSubsequence($needle, $ini)) {
return true;
}
// 称呼也参与首字母(比如称呼是"小宝" → xb)
$nickIni = self::initials((string)($row['my_nickname'] ?? ''));
if ($nickIni !== '' && self::isSubsequence($needle, $nickIni)) {
return true;
}
}
return false;
}
}
+321
View File
@@ -0,0 +1,321 @@
<?php
/**
* 重复日程(用户 2026-10-03 第 5、6 条)。
*
* 之前只有一列 `rrule` 被写入与导出,**没有任何展开逻辑** ——
* 日历里同一条日程只会出现一次,"重复"实际上没生效。
* 这里补上:把 RRULE 展开成一段范围内的若干次发生。
*
* 用 RFC 5545 的 RRULE 原文存储(不是自定义格式),三个好处:
* ① ics 导入导出原样带过,与别的日历互通;
* ② CalDAV 客户端本来就认它;
* ③ 不用自己发明一套再写转换。
*
* 支持的子集(够覆盖家庭场景,且实现可验证):
* FREQ=DAILY | WEEKLY | MONTHLY | YEARLY
* INTERVAL=n(每 n 天/周/月/年)
* BYDAY=MO,TU,…(WEEKLY 用;"每个工作日"就是 BYDAY=MO,TU,WE,TH,FR)
* COUNT=n(共重复 n 次) / UNTIL=<UTC 时刻>(重复到某天为止)
*
* ⚠️ 展开只按**日期**比对范围:日程的时刻在事件本身(starts_at),
* 重复出来的每一次共用同一个时刻。这是日历的通行做法
* ("每周三 19:00"里的 19:00 属于事件,不属于规则)。
*/
declare(strict_types=1);
namespace App;
final class Repeat
{
/** 界面上的重复周期 → FREQ 片段 */
public const FREQS = [
'' => '不重复',
'daily' => '每天',
'weekly' => '每周',
'monthly' => '每月',
'yearly' => '每年',
'weekday' => '每个工作日(周一至周五)',
];
/** 结束条件 */
public const ENDS = [
'never' => '一直重复',
'count' => '重复指定次数',
'until' => '重复到某天为止',
];
/** 周一..周日(RFC 5545 的 BYDAY 记号;0=周日,与 date('w') 对齐后转换) */
private const WEEKDAY_CODE = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
/** 展开的硬上限:防止 COUNT 极大或 UNTIL 很远时把请求拖死 */
private const MAX_OCCURRENCES = 2000;
/**
* 界面字段 → RRULE 原文。返回 '' 表示不重复。
*
* @param array $d freq / rep_end / rep_count / rep_until
*/
public static function build(array $d): string
{
$freq = (string)($d['freq'] ?? '');
if (!isset(self::FREQS[$freq]) || $freq === '') {
return '';
}
$parts = [];
switch ($freq) {
case 'daily':
$parts[] = 'FREQ=DAILY';
break;
case 'weekly':
$parts[] = 'FREQ=WEEKLY';
break;
case 'monthly':
$parts[] = 'FREQ=MONTHLY';
break;
case 'yearly':
$parts[] = 'FREQ=YEARLY';
break;
case 'weekday':
// 工作日 = 每周 + 明确点名周一到周五(不要用 INTERVAL=1 靠"跳过周末"实现,
// 那样节假日与调休都对不上,客户端也不认)
$parts[] = 'FREQ=WEEKLY';
$parts[] = 'BYDAY=MO,TU,WE,TH,FR';
break;
}
$end = (string)($d['rep_end'] ?? 'never');
if ($end === 'count') {
$n = (int)($d['rep_count'] ?? 0);
if ($n >= 1) {
// ⚠️ COUNT 含**首次**:用户填 10 就是"一共 10 次"(含第一次)。
// RFC 也是这么算的,别自作聪明 +1。
$parts[] = 'COUNT=' . min($n, self::MAX_OCCURRENCES);
}
} elseif ($end === 'until') {
$u = trim((string)($d['rep_until'] ?? ''));
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $u, $m) && checkdate((int)$m[2], (int)$m[3], (int)$m[1])) {
// UNTIL 一律写成**当天的 23:59:59 UTC**:这样"重复到 12-31"意味着
// 12-31 这一天也包含在内(写成 00:00:00 会把最后那天砍掉)。
$parts[] = 'UNTIL=' . $m[1] . $m[2] . $m[3] . 'T235959Z';
}
}
return implode(';', $parts);
}
/** RRULE 原文 → 结构化(认不出的键忽略,不报错) */
public static function parse(string $rrule): array
{
$out = ['freq' => '', 'interval' => 1, 'byday' => [], 'count' => 0, 'until' => ''];
$rrule = trim($rrule);
if ($rrule === '') {
return $out;
}
foreach (explode(';', $rrule) as $kv) {
$kv = trim($kv);
if ($kv === '') {
continue;
}
$eq = strpos($kv, '=');
if ($eq === false) {
continue;
}
$k = strtoupper(substr($kv, 0, $eq));
$v = substr($kv, $eq + 1);
switch ($k) {
case 'FREQ':
$f = strtoupper($v);
if ($f === 'WEEKLY' && strtoupper($v) !== '' && strpos(strtoupper($rrule), 'BYDAY=MO,TU,WE,TH,FR') !== false) {
$out['freq'] = 'weekday';
} else {
$out['freq'] = ['DAILY' => 'daily', 'WEEKLY' => 'weekly',
'MONTHLY' => 'monthly', 'YEARLY' => 'yearly'][$f] ?? '';
}
break;
case 'INTERVAL':
$out['interval'] = max(1, (int)$v);
break;
case 'BYDAY':
foreach (explode(',', strtoupper($v)) as $d) {
$d = trim($d);
if ($d !== '') {
$out['byday'][] = $d;
}
}
break;
case 'COUNT':
$out['count'] = max(0, (int)$v);
break;
case 'UNTIL':
// 20261231T235959Z → 2026-12-31
if (preg_match('/^(\d{4})(\d{2})(\d{2})/', $v, $m)) {
$out['until'] = $m[1] . '-' . $m[2] . '-' . $m[3];
}
break;
}
}
return $out;
}
/** RRULE → 一句人话(列表/详情页用) */
public static function describe(string $rrule): string
{
$p = self::parse($rrule);
if ($p['freq'] === '') {
return '';
}
$word = self::FREQS[$p['freq']];
if ($p['interval'] > 1) {
$unit = ['daily' => '天', 'weekly' => '周', 'monthly' => '个月', 'yearly' => '年'][$p['freq']] ?? '';
if ($unit !== '' && $p['freq'] !== 'weekday') {
$word = '每 ' . $p['interval'] . ' ' . $unit;
}
}
if ($p['count'] > 0) {
$word .= ',共 ' . $p['count'] . ' 次';
} elseif ($p['until'] !== '') {
$word .= ',直到 ' . $p['until'];
} else {
$word .= ',一直重复';
}
return $word;
}
/**
* 把一条日程在 [from, to] 内的**每一次发生**列出来。
*
* @param string $dtstart 事件开始时刻 'Y-m-d H:i:s'(重复的锚点)
* @param string $rrule 规则原文;'' 表示不重复
* @param array $skips 被跳过的日期 ['Y-m-d' => true](单个发生被取消/被挪走)
* @return array<int,string> 日期列表('Y-m-d'),升序,**不含**被跳过的
*/
public static function occurrences(string $dtstart, string $rrule, string $from, string $to, array $skips = []): array
{
if ($rrule === '') {
$d = substr($dtstart, 0, 10);
return ($d >= $from && $d <= $to) ? [$d] : [];
}
$p = self::parse($rrule);
if ($p['freq'] === '') {
$d = substr($dtstart, 0, 10);
return ($d >= $from && $d <= $to) ? [$d] : [];
}
$start = substr($dtstart, 0, 10);
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $start)) {
return [];
}
$count = 0; // 已生成的发生次数(**含**被跳过的,RFC 就是这么数的)
$out = [];
$guard = 0;
$emit = static function (string $d) use (&$out, &$count, $from, $to, $skips): void {
$count++;
if ($d > $to) {
return; // 超出范围就不再关心
}
if ($d >= $from && !isset($skips[$d])) {
$out[] = $d;
}
};
// 是否还可以继续生成:COUNT 到了 / UNTIL 过了 / 超出范围 / 硬上限
$canGo = static function (string $d) use (&$count, $p, $to): bool {
if ($p['count'] > 0 && $count >= $p['count']) {
return false;
}
if ($p['until'] !== '' && $d > $p['until']) {
return false;
}
return $d <= $to; // 已经超过范围末端
};
$interval = max(1, (int)$p['interval']);
switch ($p['freq']) {
case 'daily':
$d = $start;
while ($guard++ < self::MAX_OCCURRENCES && $canGo($d)) {
$emit($d);
$d = date('Y-m-d', strtotime($d . ' +' . $interval . ' day'));
}
break;
case 'weekly':
case 'weekday':
// BYDAY 缺省 = 与 DTSTART 同一个星期几
$byday = $p['byday'];
if (!$byday) {
$byday = [self::WEEKDAY_CODE[(int)date('w', strtotime($start))]];
}
$codes = array_flip(self::WEEKDAY_CODE); // 'MO' => 1(与 date('w') 一致)
// 从 DTSTART 所在那一周的周一开始走
$weekStart = date('Y-m-d', strtotime($start . ' -' . ((int)date('w', strtotime($start)) + 6) % 7 . ' day'));
$wk = $weekStart;
while ($guard++ < self::MAX_OCCURRENCES && $canGo($wk)) {
// 一周内按周一→周日顺序
$days = [];
for ($i = 0; $i < 7; $i++) {
$day = date('Y-m-d', strtotime($wk . ' +' . $i . ' day'));
$code = self::WEEKDAY_CODE[(int)date('w', strtotime($day))];
if (in_array($code, $byday, true)) {
$days[] = $day;
}
}
sort($days);
foreach ($days as $day) {
if ($day < $start) {
continue; // DTSTART 之前的不算
}
if (!$canGo($day)) {
break 2;
}
$emit($day);
}
$wk = date('Y-m-d', strtotime($wk . ' +' . ($interval * 7) . ' day'));
}
break;
case 'monthly':
$dom = (int)substr($start, 8, 2);
$y = (int)substr($start, 0, 4);
$m = (int)substr($start, 5, 2);
$d = $start;
while ($guard++ < self::MAX_OCCURRENCES && $canGo($d)) {
$emit($d);
// ⚠️ 该月没有这一天(如 31 号遇到只有 30 天的月)→ 按 RFC **跳过该月**,
// 绝不能"顺延到下月 1 号" —— 那样"每月 31 号"会变成一堆 1 号,
// 而且 1 号往往还有别的事,看起来像重复错乱(实测踩到过)。
$m += $interval;
while ($m > 12) {
$m -= 12;
$y++;
}
if (!checkdate($m, $dom, $y)) {
continue; // 这个月没有该日 → 直接看下一个月
}
$d = sprintf('%04d-%02d-%02d', $y, $m, $dom);
}
break;
case 'yearly':
$mm = (int)substr($start, 5, 2);
$dom = (int)substr($start, 8, 2);
$y = (int)substr($start, 0, 4);
$d = $start;
while ($guard++ < self::MAX_OCCURRENCES && $canGo($d)) {
$emit($d);
$y += $interval;
// 2/29 遇平年 → 跳过那年(RFC 行为;要"每年 2/28 提醒"请用纪念日模块)
if (!checkdate($mm, $dom, $y)) {
$d = sprintf('%04d-%02d-01', $y, $mm);
continue;
}
$d = sprintf('%04d-%02d-%02d', $y, $mm, $dom);
}
break;
}
sort($out);
return array_values(array_unique($out));
}
}
+30
View File
@@ -383,6 +383,7 @@ CREATE TABLE IF NOT EXISTS `trip_details` (
`event_id` INT UNSIGNED NOT NULL COMMENT '对应 events(kind="trip")', `event_id` INT UNSIGNED NOT NULL COMMENT '对应 events(kind="trip")',
`cover_key` VARCHAR(255) DEFAULT NULL COMMENT '封面图(存储相对键)', `cover_key` VARCHAR(255) DEFAULT NULL COMMENT '封面图(存储相对键)',
`summary` TEXT DEFAULT NULL COMMENT '这趟旅程的一段话小结', `summary` TEXT DEFAULT NULL COMMENT '这趟旅程的一段话小结',
`is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(0=不显示在列表与日历)',
PRIMARY KEY (`event_id`), PRIMARY KEY (`event_id`),
CONSTRAINT `fk_tdet_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE CONSTRAINT `fk_tdet_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='旅程事件的特有数据(一对一卫星表)' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='旅程事件的特有数据(一对一卫星表)'
@@ -866,6 +867,35 @@ SQL,
} }
}); });
// ---- 重复日程的「单个发生」例外表(版本 13,用户 2026-10-03 第 5 条)----
// 用户要的三档编辑里,「仅此一次」与「跳过这次」都要能"只动某一次"。
// 做法(与 RFC 5545 的 EXDATE 同构):把被取消/被挪走的那一次记在这里,
// 展开时跳过它;如果那次是被"挪到别的时间",就再插一条独立日程。
// ⚠️ 不复用 calendar_events:那是"日程本体",这里是"本体的某一次例外",
// 混在一张表里会让"这条日程重复吗"变成要额外判断的字段。
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `calendar_event_skips` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`event_id` INT UNSIGNED NOT NULL COMMENT '哪条日程(calendar_events.id)',
`skip_date` DATE NOT NULL COMMENT '被跳过的哪一天(重复展开时命中这一天就不出)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_skip` (`event_id`, `skip_date`),
CONSTRAINT `fk_skip_event` FOREIGN KEY (`event_id`) REFERENCES `calendar_events` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='重复日程里被单独取消/挪走的某一次(RFC 5545 的 EXDATE)'
SQL);
// ---- 旅程的活动「是否公开」(版本 13,用户 2026-10-03 第 4 条)----
// 用户原话:「旅程的活动中,只显示 is_public=1 的活动」。
// ⚠️ 放在 **trip_details**(1:1 的卫星表)而不是 events:
// events 是观影/旅程/… 共用的骨架表,加一个只有旅程用的开关
// 会让观影那边多出一个永远为 0 的无意义字段。
// ⚠️ 默认 **1**(公开):老数据升级后必须照旧可见 ——
// 默认 0 会让人以为"旅程全丢了"(这是最危险的一种迁移默认值)。
self::addColumn($db, 'trip_details', 'is_public',
"ADD COLUMN `is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(用户 2026-10-03 第 4 条):0=不显示在旅程列表与日历' AFTER `summary`");
// ---- 纪念日(版本 13,用户第 13 条)---- // ---- 纪念日(版本 13,用户第 13 条)----
// ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的 // ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的
// (见 Anniversary::birthdays),改了生日纪念日就跟着变。 // (见 Anniversary::birthdays),改了生日纪念日就跟着变。
+19 -5
View File
@@ -44,13 +44,18 @@ final class Trip
/** 全部旅程(按出发时间倒序) */ /** 全部旅程(按出发时间倒序) */
public static function all(int $familyId, string $keyword = ''): array public static function all(int $familyId, string $keyword = ''): array
{ {
// ⚠️ 用户 2026-10-03 第 4 条:「旅程的活动中,只显示 is_public=1 的活动」。
// 用 LEFT JOIN + COALESCE 而不是 INNER JOIN:
// 老数据/异常情况下 trip_details 可能缺行,INNER JOIN 会让整趟旅程
// 从列表里消失("行程丢了"比"多显示一条"严重得多),
// COALESCE(...,1) 表示"没有这行就算公开"。
$sql = 'SELECT e.id, e.title, e.starts_at, e.ends_at, e.location, e.note, $sql = 'SELECT e.id, e.title, e.starts_at, e.ends_at, e.location, e.note,
d.cover_key, d.summary, d.cover_key, d.summary, COALESCE(d.is_public, 1) AS is_public,
(SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count, (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count,
(SELECT COUNT(*) FROM trip_nodes n WHERE n.event_id = e.id) AS node_count (SELECT COUNT(*) FROM trip_nodes n WHERE n.event_id = e.id) AS node_count
FROM events e FROM events e
LEFT JOIN trip_details d ON d.event_id = e.id LEFT JOIN trip_details d ON d.event_id = e.id
WHERE e.family_id = ? AND e.kind = ?'; WHERE e.family_id = ? AND e.kind = ? AND COALESCE(d.is_public, 1) = 1';
$bind = [$familyId, 'trip']; $bind = [$familyId, 'trip'];
$kw = trim($keyword); $kw = trim($keyword);
if ($kw !== '') { if ($kw !== '') {
@@ -107,7 +112,9 @@ final class Trip
public static function find(int $familyId, int $id, int $viewerId = 0): ?array public static function find(int $familyId, int $id, int $viewerId = 0): ?array
{ {
$st = Database::get()->prepare( $st = Database::get()->prepare(
"SELECT e.*, d.cover_key, d.summary // ⚠️ 单条详情**不按 is_public 拦**:勾了"不公开"只代表不进列表/日历,
// 直接拿到链接的人(或行程成员)仍应该能打开它 —— 否则非遗漏一次就找不回来。
"SELECT e.*, d.cover_key, d.summary, COALESCE(d.is_public, 1) AS is_public
FROM events e LEFT JOIN trip_details d ON d.event_id = e.id FROM events e LEFT JOIN trip_details d ON d.event_id = e.id
WHERE e.family_id = ? AND e.id = ? AND e.kind = 'trip'" WHERE e.family_id = ? AND e.id = ? AND e.kind = 'trip'"
); );
@@ -176,14 +183,21 @@ final class Trip
$row = [ $row = [
self::keyOrNull($d['cover_key'] ?? ''), self::keyOrNull($d['cover_key'] ?? ''),
self::textOrNull($d['summary'] ?? ''), self::textOrNull($d['summary'] ?? ''),
// 用户第 4 条:是否公开。
// ⚠️ 必须区分「这个键没传」与「用户明确取消勾选」:
// · 没传(程序化创建 / 迁移 / 老调用)→ **按公开(1)**,
// 与建表默认值一致;默认 0 会让数据**静默消失**在列表里,
// 那是最坏的一种失败(用户只会看到"我的行程不见了")。
// · 传了 0(表单里取消勾选)→ 真的不公开。
array_key_exists('is_public', $d) ? (!empty($d['is_public']) ? 1 : 0) : 1,
]; ];
$has = $db->prepare('SELECT COUNT(*) FROM trip_details WHERE event_id = ?'); $has = $db->prepare('SELECT COUNT(*) FROM trip_details WHERE event_id = ?');
$has->execute([$eventId]); $has->execute([$eventId]);
if ((int)$has->fetchColumn() > 0) { if ((int)$has->fetchColumn() > 0) {
$db->prepare('UPDATE trip_details SET cover_key=?, summary=? WHERE event_id=?') $db->prepare('UPDATE trip_details SET cover_key=?, summary=?, is_public=? WHERE event_id=?')
->execute([...$row, $eventId]); ->execute([...$row, $eventId]);
} else { } else {
$db->prepare('INSERT INTO trip_details (cover_key, summary, event_id) VALUES (?,?,?)') $db->prepare('INSERT INTO trip_details (cover_key, summary, is_public, event_id) VALUES (?,?,?,?)')
->execute([...$row, $eventId]); ->execute([...$row, $eventId]);
} }
} }
+34
View File
@@ -47,6 +47,40 @@ error_reporting(E_ALL);
ini_set('display_errors', $debug ? '1' : '0'); ini_set('display_errors', $debug ? '1' : '0');
ini_set('log_errors', '1'); ini_set('log_errors', '1');
/**
* 把错误日志指到**我们够得着的地方**(v24)。
*
* 为什么需要:线上出 500 时最该看的就是错误日志,而宝塔的 `/www/wwwlogs`
* 既不在部署账号的读权限内,也不在 `open_basedir` 允许的路径里
* (本站是 `/www/wwwroot/…:/tmp/`)。这一轮真遇到了:用户报 500,
* 能查的全查了(逐文件哈希、两账号 32 次真实渲染、边界输入、DNS/证书/缓存)
* 全绿,唯独拿不到"当时哪一行炸了"。
*
* 做法:**只需指定 `error_log`** —— PHP 本来就会把 Fatal error(含文件:行号与
* 调用栈)写进去,不需要自己注册 shutdown 钩子。
* (实测:先前那版加了 `register_shutdown_function` 反而多余,
* 钩子自身一旦出错还会**再抛一个 Fatal**,把真正的原因埋掉。)
*
* 落点:优先站点根的 `db/`(敏感配置所在地,不入 Web 根、不进仓库),
* 没有就回落 `sys_get_temp_dir()`(线上 open_basedir 含 `/tmp/`,一定可写)。
* 两处都不可写就保持原样(记日志不该把站点拖垮)。
*
* ⚠️ 只记 PHP 自己的错误文本,**不记**请求体/变量值(可能含口令)。
*/
{
$fbLogFile = '';
foreach ([__DIR__ . '/../db', sys_get_temp_dir()] as $fbDir) {
if (is_dir($fbDir) && is_writable($fbDir)) {
$fbLogFile = rtrim($fbDir, '/\\') . '/familybook-error.log';
break;
}
}
if ($fbLogFile !== '') {
ini_set('log_errors', '1');
ini_set('error_log', $fbLogFile);
}
}
// ---------------- 时区:日期是家书的核心数据,一开始就必须明确 ---------------- // ---------------- 时区:日期是家书的核心数据,一开始就必须明确 ----------------
date_default_timezone_set($GLOBALS['config']['timezone'] ?? 'Asia/Shanghai'); date_default_timezone_set($GLOBALS['config']['timezone'] ?? 'Asia/Shanghai');
+118 -32
View File
@@ -215,31 +215,98 @@ a:hover { text-decoration: underline; }
border-left: 3px solid var(--brand-2); border-left: 3px solid var(--brand-2);
color: var(--muted); font-size: 14px; color: var(--muted); font-size: 14px;
} }
/* ⚠️ .hero-stats / .stat 已随用户 2026-10-03 第 3 条撤掉首页那三个统计,
这里保留规则本身(其它页面可能还在用 .stat 这个类名),不再由首页引用。 */
.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; }
.stat span { font-size: 12px; color: var(--muted); } .stat span { font-size: 12px; color: var(--muted); }
/* 用户第 3 条:标题下那句个人 slogan */
.hero-slogan { color: var(--muted); margin: 4px 0 0; font-size: 14px; }
/* 用户第 3 条:「我的资料 / 我的家谱」换成**图标按钮**(原来是一排文字按钮)。
margin-left:auto 把它推到最右,与原来统计块的位置一致。 */
.hero-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
.icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 40px; height: 40px; border-radius: 12px;
border: 1px solid var(--line); background: var(--surface); color: var(--muted);
transition: border-color .15s ease, color .15s ease, background .15s ease;
}
.icon-btn:hover {
color: var(--brand); border-color: var(--brand-2); background: #FBF7F1;
}
.icon-btn svg { display: block; }
/* ---- 用户第 3 条:首页的统计信息块(问候 + 天气 + 几个数字) ---- */
.home-brief { display: flex; flex-direction: column; gap: 10px; }
.home-brief-hello { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.home-brief-hello b { font-size: 17px; }
.home-brief-weather {
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
padding: 8px 12px; border-radius: 10px; background: var(--line-2);
}
.home-brief-weather .w-icon { font-size: 20px; line-height: 1; }
.home-brief-weather .w-temp { font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }
.home-brief-weather .w-desc { font-size: 14px; }
.home-brief-stats { display: flex; flex-wrap: wrap; gap: 10px 22px; color: var(--muted); font-size: 13px; }
.home-brief-stats .hbs b { color: var(--ink); font-size: 16px; margin-right: 3px; }
/* ---- 用户第 3 条:首页的模块小结卡 ---- */
.mod-cards {
display: grid; gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
}
.mod-card {
display: flex; flex-direction: column; gap: 6px;
padding: 14px 16px; border-radius: var(--radius);
border: 1px solid var(--line); background: var(--surface);
border-left: 3px solid var(--mc, var(--brand));
color: var(--ink); text-decoration: none;
transition: box-shadow .15s ease, border-color .15s ease;
}
.mod-card:hover { box-shadow: var(--shadow); text-decoration: none; }
.mod-card-head { display: flex; align-items: center; gap: 8px; color: var(--mc, var(--brand)); }
.mod-card-icon { display: inline-flex; flex: 0 0 auto; }
.mod-card-name { font-size: 13px; font-weight: 600; }
.mod-card-big { font-size: 20px; font-weight: 600; color: var(--ink); }
.mod-card-lines { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.mod-card-lines li { font-size: 12.5px; color: var(--muted); }
/* ---------------- 成员卡 ---------------- */ /* ---------------- 成员卡 ---------------- */
/* 用户 2026-10-03 第 4 条:名册从「卡片网格」改成**竖向列表**——
一行一个人,左边头像、中间姓名+关系+手机号/生日、右边操作。
列表形态下拖动排序也更直观(上下移动,不会像网格那样左右跳)。 */
.people-grid { .people-grid {
display: grid; gap: 16px; margin-top: 4px; display: flex; flex-direction: column; gap: 8px; margin-top: 4px;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
} }
.person { .person {
display: block; color: var(--ink); position: relative; display: block; color: var(--ink); position: relative;
background: var(--surface); border: 1px solid var(--line); background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); overflow: hidden; border-radius: var(--radius-s);
box-shadow: var(--shadow); transition: border-color .15s ease, box-shadow .15s ease;
transition: transform .15s ease, box-shadow .15s ease;
} }
.person:hover { transform: translateY(-2px); box-shadow: var(--shadow-l); text-decoration: none; } .person:hover { border-color: var(--brand-2); box-shadow: var(--shadow); }
/* 头像:列表里用小方图(正方形、cover 裁切),不再是整块大图 */
.person .ph { .person .ph {
background: #F1E7D6; aspect-ratio: 1 / 1; overflow: hidden; background: #F1E7D6; overflow: hidden;
width: 52px; height: 52px; border-radius: 8px; flex: 0 0 auto;
} }
.person .ph img { width: 100%; height: 100%; object-fit: cover; display: block; } .person .ph img { width: 100%; height: 100%; object-fit: cover; display: block; }
.person .pb { padding: 12px 14px 14px; } .person .person-main {
.person .pname { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 6px; } display: flex; align-items: center; gap: 12px;
.person .pmeta { color: var(--muted); font-size: 13px; margin-top: 2px; } padding: 10px 12px; color: var(--ink); text-decoration: none;
}
.person .pb { flex: 1 1 auto; min-width: 0; }
.person .pname { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.person .pmeta { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
/* 中间那几个"重要信息":手机 / 生日 */
.person .pfacts { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 4px; font-size: 13px; }
.person .pfact { color: var(--ink); display: inline-flex; align-items: baseline; gap: 5px; }
.person .pfact-k {
color: var(--muted); font-size: 11px; padding: 1px 5px; border-radius: 4px;
background: var(--line-2);
}
.badge { .badge {
display: inline-block; padding: 1px 8px; border-radius: 999px; display: inline-block; padding: 1px 8px; border-radius: 999px;
@@ -501,12 +568,19 @@ textarea.input { min-height: 76px; resize: vertical; }
.kin-chart .card_cont .card-inner { display: block; } .kin-chart .card_cont .card-inner { display: block; }
/* v21:卡片尺寸与 setCardDim({w:140, h:120}) 对齐 */ /* v21:卡片尺寸与 setCardDim({w:140, h:120}) 对齐 */
/* 用户 2026-10-03 第 1 条:
· 头像外框改**长方形**(宽 96 × 高 72),宽度基本就是头像显示宽度;
img 用 object-fit: contain —— 既不裁切,也不会把外框撑变形。
· 信息(姓名/称呼)在头像**下方**。
· 卡片不再整张是链接:只有 .kin-nm 是 <a>,右下角是独立的 <button>,
两者分开,避免"点按钮却进了成员页"的误触。
卡片尺寸与 JS 的 setCardDim({w,h}) 必须一致(库按它算间距与连线落点)。 */
.kin-card { .kin-card {
position: absolute; position: absolute;
display: flex; flex-direction: column; align-items: center; display: flex; flex-direction: column; align-items: center;
gap: 0; gap: 0;
width: 140px; height: 120px; width: 118px; height: 132px;
box-sizing: border-box; padding: 4px 4px 3px; box-sizing: border-box; padding: 6px 6px 4px;
text-align: center; text-align: center;
background: var(--surface); background: var(--surface);
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -519,35 +593,36 @@ textarea.input { min-height: 76px; resize: vertical; }
pointer-events: auto; /* 父 .card_cont 是 none,这里恢复 */ pointer-events: auto; /* 父 .card_cont 是 none,这里恢复 */
} }
.kin-card:hover { .kin-card:hover {
box-shadow: var(--shadow-l); border-color: var(--brand-2); text-decoration: none; box-shadow: var(--shadow-l); border-color: var(--brand-2);
}
/* ⚠️ v24 第 12 条:头像是**方形**、不裁切。
卡片高度从 120 加到 136,把这块从 70 放大到 74×74 的正方形;
object-fit 用 contain(不是 cover)—— cover 会把非正方形的头像切掉边,
用户要的正是"所有人的头像都是方形的,别截断"。 */
.kin-card {
width: 148px; height: 138px;
} }
/* 头像外框:**长方形**,宽度贴合头像显示宽度 */
.kin-card .kin-ph { .kin-card .kin-ph {
width: 74px; height: 74px; border-radius: 6px; overflow: hidden; width: 96px; height: 72px; border-radius: 6px; overflow: hidden;
background: #F1E7D6; flex: 0 0 auto; background: #F1E7D6; flex: 0 0 auto;
display: block;
} }
.kin-card .kin-ph img { .kin-card .kin-ph img {
width: 100%; height: 100%; object-fit: contain; display: block; width: 100%; height: 100%; object-fit: contain; display: block;
} }
/* 「⇄ 姻亲」:卡片右下角的小按钮(这一侧的人没画在本图上时才有) */ /* 右下角「以 TA 为中心」:每个人都有一个。
.kin-card .kin-branch { ⚠️ 单独一个 <button>(不在 <a> 里面)—— 这是"误触"的根治办法:
整卡若是链接,手指偏一点就先触发它跳去成员页了。 */
.kin-card .kin-focus {
position: absolute; right: 3px; bottom: 3px; position: absolute; right: 3px; bottom: 3px;
font-size: 10px; line-height: 1; padding: 3px 5px; border-radius: 999px; font-size: 10px; line-height: 1; padding: 3px 6px; border-radius: 999px;
background: var(--brand); color: #fff; cursor: pointer; border: 0; background: var(--brand); color: #fff; cursor: pointer;
box-shadow: 0 1px 3px rgba(43, 38, 32, .25); box-shadow: 0 1px 3px rgba(43, 38, 32, .25);
font-family: inherit;
} }
.kin-card .kin-branch:hover { background: var(--brand-ink); } .kin-card .kin-focus:hover { background: var(--brand-ink); }
/* 姓名是唯一的链接入口(进成员资料页) */
.kin-card .kin-nm { .kin-card .kin-nm {
display: block;
font-size: 12.5px; font-weight: 600; line-height: 1.25; max-width: 100%; font-size: 12.5px; font-weight: 600; line-height: 1.25; max-width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-top: 4px; margin-top: 5px; color: var(--ink); text-decoration: none;
} }
.kin-card .kin-nm:hover { color: var(--brand); text-decoration: underline; }
/* 称呼在姓名下面一行,用品牌色的浅字(参考实现是「(称呼)」) */ /* 称呼在姓名下面一行,用品牌色的浅字(参考实现是「(称呼)」) */
.kin-card .kin-lb { .kin-card .kin-lb {
font-size: 10.5px; color: var(--brand); line-height: 1.2; max-width: 100%; font-size: 10.5px; color: var(--brand); line-height: 1.2; max-width: 100%;
@@ -745,18 +820,27 @@ textarea.input { min-height: 76px; resize: vertical; }
/* 卡片改成 div > a + details:表单不能套在链接里(非法 HTML,浏览器会把 DOM 拆开) */ /* 卡片改成 div > a + details:表单不能套在链接里(非法 HTML,浏览器会把 DOM 拆开) */
.person-main { display: block; color: var(--ink); } .person-main { display: block; color: var(--ink); }
.person-main:hover { text-decoration: none; } .person-main:hover { text-decoration: none; }
/* 列表形态下「⋯ 调整分组」改到行的**右侧**(原来在卡片下方,列表里会撑高一行) */
.person-more { .person-more {
border-top: 1px solid var(--line-2); background: #FCFAF6; position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
border: 0; background: none;
} }
.person-more > summary { .person-more > summary {
list-style: none; cursor: pointer; padding: 7px 14px; list-style: none; cursor: pointer; padding: 4px 10px;
font-size: 12px; color: var(--muted); font-size: 16px; color: var(--muted); border-radius: 8px;
} }
.person-more > summary:hover { background: var(--line-2); color: var(--brand); }
.person-more > summary::-webkit-details-marker { display: none; } .person-more > summary::-webkit-details-marker { display: none; }
.person-more > summary::after { content: ' ▾'; } .person-more > summary::after { content: ' ▾'; }
.person-more[open] > summary::after { content: ' ▴'; } .person-more[open] > summary::after { content: ' ▴'; }
.person-more > summary:hover { color: var(--brand); } .person-more > summary:hover { color: var(--brand); }
.person-move { display: flex; gap: 6px; padding: 0 14px 12px; } .person-more[open] {
position: absolute; right: 8px; top: 8px; transform: none;
background: var(--surface); border: 1px solid var(--line);
border-radius: 10px; box-shadow: var(--shadow-l); z-index: 30;
min-width: 232px; padding-bottom: 4px;
}
.person-move { display: flex; gap: 6px; padding: 8px 12px; flex-wrap: wrap; }
.person-move .select { flex: 1 1 auto; min-width: 0; padding: 6px 8px; font-size: 13px; } .person-move .select { flex: 1 1 auto; min-width: 0; padding: 6px 8px; font-size: 13px; }
.person-move .btn { flex: 0 0 auto; } .person-move .btn { flex: 0 0 auto; }
@@ -766,7 +850,7 @@ textarea.input { min-height: 76px; resize: vertical; }
.roster.is-droppable .person { cursor: grab; } .roster.is-droppable .person { cursor: grab; }
.roster.is-droppable .person.is-dragged { opacity: .45; cursor: grabbing; } .roster.is-droppable .person.is-dragged { opacity: .45; cursor: grabbing; }
.drag-handle { .drag-handle {
position: absolute; left: 8px; top: 8px; z-index: 2; position: absolute; left: 3px; top: 50%; transform: translateY(-50%); z-index: 2;
width: 22px; height: 22px; line-height: 22px; text-align: center; width: 22px; height: 22px; line-height: 22px; text-align: center;
border-radius: 6px; font-size: 14px; border-radius: 6px; font-size: 14px;
color: var(--muted); background: rgba(255, 255, 255, .82); color: var(--muted); background: rgba(255, 255, 255, .82);
@@ -774,6 +858,8 @@ textarea.input { min-height: 76px; resize: vertical; }
pointer-events: none; /* 手柄只是个记号,抓取要落在整张卡上 */ pointer-events: none; /* 手柄只是个记号,抓取要落在整张卡上 */
} }
.drag-handle-inline { color: var(--muted); } .drag-handle-inline { color: var(--muted); }
/* 列表里每行要留出左侧拖柄的位置(否则⠿会压在头像上) */
.roster.is-droppable .person { padding-left: 18px; }
/* 空栏平时收起来(七个预置分组可能只用到三个,摊开会显得名册破破烂烂)—— /* 空栏平时收起来(七个预置分组可能只用到三个,摊开会显得名册破破烂烂)——
但**拖动时必须露出来**,否则"想拖到一个现在没人的分组里"就没地方放。 */ 但**拖动时必须露出来**,否则"想拖到一个现在没人的分组里"就没地方放。 */
+78 -8
View File
@@ -546,17 +546,36 @@
e.preventDefault(); e.preventDefault();
if (e.dataTransfer) { e.dataTransfer.dropEffect = 'move'; } if (e.dataTransfer) { e.dataTransfer.dropEffect = 'move'; }
var grid = block.querySelector('.people-grid'); var grid = block.querySelector('.people-grid');
// 落点 = 指针在谁的上半部分,就插到谁前面(和列表拖拽的直觉一致) /* 落点判定(用户 2026-10-03 第 4 条:"好像只能往后调整,不能往前调整")。
var after = null; 名册已从网格改成**竖向列表**,所以:
指针在某行的**上半部分** → 插到它前面;下半部分 → 插到它后面。
⚠️ 原来只记了"第一个满足 clientY < 中点 的卡片"然后 insertBefore ——
当目标就在**当前拖着的这张之后**时,insertBefore 到它的位置
等于没动(因为 dragged 自己先被移走、后面的元素整体前移一位),
于是"往前拖"看起来完全无效。现在改成显式记录"插到谁前面/后面",
并且在计算时**排除 dragged 自己**,两种方向都成立。 */
var kids = [];
each(grid.querySelectorAll('.person'), function (c) { each(grid.querySelectorAll('.person'), function (c) {
if (after || c === dragging) { return; } if (c !== dragging) { kids.push(c); }
var r = c.getBoundingClientRect();
if (e.clientY < r.top + r.height / 2) { after = c; }
}); });
if (after) { var target = null; // 落点参照物
grid.insertBefore(dragging, after); var before = true; // 插到它的前面还是后面
} else { for (var i = 0; i < kids.length; i++) {
var r = kids[i].getBoundingClientRect();
if (e.clientY < r.top + r.height / 2) {
target = kids[i];
before = true;
break;
}
target = kids[i];
before = false;
}
if (!target) {
grid.appendChild(dragging); grid.appendChild(dragging);
} else if (before) {
grid.insertBefore(dragging, target);
} else {
grid.insertBefore(dragging, target.nextSibling);
} }
}); });
@@ -574,4 +593,55 @@
save(); save();
}); });
})(); })();
// ===== 点页面其它地方 / 按 Esc 就收起下拉菜单 =====
// 用户 2026-10-03 第 3 条:「菜单栏点击后面,应该自动关闭,不应该一直保持打开」。
// 原生 <details> 只在再点一次 summary(或按 Esc)时收起 —— 点别处一律不管,
// 于是菜单会"粘"在屏幕上,挡住下面的内容。
//
// 覆盖全站的 details.menu(观影/任务的设置菜单、成员卡片的调整分组…),
// 不给每个页面各写一份。用事件委托:以后新加的菜单自动生效。
(function () {
var openOne = null;
function closeAll(except) {
var forEach = Array.prototype.forEach;
forEach.call(document.querySelectorAll('details.menu[open]'), function (d) {
if (d !== except) { d.open = false; }
});
if (except) { openOne = except; }
}
// toggle 不冒泡,所以用捕获阶段监听,能拿到所有菜单的开合
document.addEventListener('toggle', function (e) {
var d = e.target;
if (!d || d.tagName !== 'DETAILS' || !d.classList.contains('menu')) { return; }
if (d.open) {
closeAll(d); // 同时只留一个开着
} else if (openOne === d) {
openOne = null;
}
}, true);
document.addEventListener('click', function (e) {
if (!openOne) { return; }
// 点在菜单内部(含 summary)→ 不动,交给原生行为
if (openOne.contains(e.target)) { return; }
openOne.open = false;
openOne = null;
});
// Esc 虽然原生也会收,但这里统一收所有(原生只收获得焦点的那一个)
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeAll(null); openOne = null; }
});
// 键盘 Tab 走到菜单外时也收起,避免"焦点走了菜单还开着"
document.addEventListener('focusin', function (e) {
if (openOne && !openOne.contains(e.target)) {
openOne.open = false;
openOne = null;
}
});
})();
})(); })();
+215 -4
View File
@@ -19,6 +19,8 @@ use App\Crypto;
use App\Database; use App\Database;
use App\Douban; use App\Douban;
use App\Family; use App\Family;
use App\Home;
use App\HomeMods;
use App\Anniversary; use App\Anniversary;
use App\Ics; use App\Ics;
use App\Kinship; use App\Kinship;
@@ -32,6 +34,7 @@ use App\Person;
use App\PersonGroup; use App\PersonGroup;
use App\PersonRoster; use App\PersonRoster;
use App\Relations; use App\Relations;
use App\Repeat;
use App\Resource; use App\Resource;
use App\Schema; use App\Schema;
use App\Setting; use App\Setting;
@@ -200,6 +203,9 @@ function render_me_settings(array $person, array $errors = []): void
'groups' => PersonGroup::orderFor($familyId, $me), 'groups' => PersonGroup::orderFor($familyId, $me),
'groupCounts' => PersonGroup::counts($familyId, $me), 'groupCounts' => PersonGroup::counts($familyId, $me),
'rosterCount' => PersonRoster::countFor($familyId, $me), 'rosterCount' => PersonRoster::countFor($familyId, $me),
// 用户 2026-10-03 第 3 条:首页上显示哪些模块、什么顺序(每人一份)
'homeOrder' => HomeMods::orderedForPicker($me),
'homeOn' => HomeMods::forPerson($me),
], 'settings'); ], 'settings');
} }
@@ -556,6 +562,43 @@ switch ($r) {
} }
$memories = home_memories($familyId); $memories = home_memories($familyId);
// ---- 用户 2026-10-03 第 3 条:首页要显示哪些模块、什么顺序(每人一份)----
$homeMods = HomeMods::forPerson($me);
$moduleCards = Home::moduleCards($familyId, $me, $soon, $tasks);
// 统计信息块:随机问候 + 今日天气 + 近七天的数字
$weekEnd = date('Y-m-d', strtotime('+7 days'));
$st7 = Database::get()->prepare(
"SELECT COUNT(*) FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
WHERE c.family_id = ?
AND DATE(e.starts_at) BETWEEN ? AND ?
AND (c.owner_id = ? OR EXISTS (
SELECT 1 FROM calendar_shares sh
WHERE sh.calendar_id = c.id AND sh.person_id = ?))"
);
$st7->execute([$familyId, $today, $weekEnd, $me, $me]);
$ev7 = (int)$st7->fetchColumn();
$todoN = count($board['todo']);
$lateN = 0;
foreach ([$board['todo'], $board['doing']] as $col) {
foreach ($col as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) {
$lateN++;
}
}
}
$meName = Nickname::display($me, $people[$me] ?? null) ?: (string)($people[$me]['name'] ?? '');
$greeting = Home::greeting($meName, $today, count($soon), $lateN);
// 天气:经纬度在系统设置里配;没配就整块不显示(不猜城市)
$weather = Home::weather(
$familyId,
(string)Setting::get($familyId, 'weather_lat', ''),
(string)Setting::get($familyId, 'weather_lon', ''),
(string)Setting::get($familyId, 'weather_place', '')
);
View::render('home', [ View::render('home', [
'title' => '首页', 'title' => '首页',
'family' => Family::find($familyId), 'family' => Family::find($familyId),
@@ -571,6 +614,13 @@ switch ($r) {
// 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页) // 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页)
'mySlogan' => trim((string)($people[$me]['slogan'] ?? '')), 'mySlogan' => trim((string)($people[$me]['slogan'] ?? '')),
'myNicknames'=> Nickname::mapFor($me), 'myNicknames'=> Nickname::mapFor($me),
// 用户第 3 条新增
'greeting' => $greeting,
'weekday' => Home::weekday($today),
'weather' => $weather,
'stat7' => ['events' => $ev7, 'todos' => $todoN, 'overdue' => $lateN],
'homeModules'=> $homeMods,
'moduleCards'=> $moduleCards,
], 'home'); ], 'home');
break; break;
@@ -612,6 +662,27 @@ switch ($r) {
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']; $row['shown'] = Nickname::display($me, $row) ?: (string)$row['name'];
// 用户 2026-10-03 第 4 条:列表要显示手机号(密文解密)与生日。
// ⚠️ 解密只对**能看名册的人**做(这页本来就要登录 + 在名册里),
// 但页面上仍然只显示"前 3 后 4"以外的形态由模板决定。
$row['phone_plain'] = '';
if (!empty($row['phone_encrypted'])) {
try {
$row['phone_plain'] = Crypto::decrypt((string)$row['phone_encrypted']);
} catch (\Throwable $e) {
$row['phone_plain'] = '';
}
}
// 生日优先显示阳历(真实生日),没有就显示农历
$row['birthday_show'] = Birthday::pretty(Birthday::solarOf($row));
$row['birthday_kind'] = '阳历';
if ($row['birthday_show'] === '') {
$lun = Birthday::dateOnly((string)($row['birthday_lunar'] ?? ''));
if ($lun !== '') {
$row['birthday_show'] = $lun;
$row['birthday_kind'] = '农历';
}
}
} }
unset($row); unset($row);
@@ -891,6 +962,43 @@ switch ($r) {
redirect('settings/me', [], 'modules'); redirect('settings/me', [], 'modules');
break; break;
/**
* 保存「首页上显示哪些模块、什么顺序」(用户 2026-10-03 第 3 条,每人一份)。
*
* 与导航条那套(settings/nav_save)**同一套交互**:勾选 + 拖动/▲▼ 排序。
* 用同一个表单字段名(order[] / on[] / move),前端那段拖拽脚本也就能共用。
*/
case 'settings/home_save':
Auth::requireLogin();
csrf_check();
$me = (int)Auth::personId();
$order = array_values(array_map('strval', (array)($_POST['order'] ?? [])));
$on = array_map('strval', (array)($_POST['on'] ?? []));
// 「▲▼」按钮(没有 JS 也能排序):在当前顺序里跟邻居换一位
$move = (string)($_POST['move'] ?? '');
if ($move !== '' && strpos($move, ':') !== false) {
[$dir, $key] = explode(':', $move, 2);
$all = HomeMods::orderedForPicker($me);
$pos = array_search($key, $all, true);
if ($pos !== false) {
$swap = $dir === 'up' ? $pos - 1 : $pos + 1;
if ($swap >= 0 && $swap < count($all)) {
[$all[$pos], $all[$swap]] = [$all[$swap], $all[$pos]];
}
// 换完之后 order 用新顺序、on 保持原勾选状态
$order = $all;
}
}
$keys = array_values(array_filter(
$order,
static fn($k) => Module::exists($k) && in_array($k, $on, true)
));
HomeMods::save($me, $keys);
flash('ok', '首页显示已保存。');
redirect('settings/me', [], 'home-mods');
break;
/** /**
* 保存模块主题色(用户第 11 条,**家庭一份**,所以只有管理员能改)。 * 保存模块主题色(用户第 11 条,**家庭一份**,所以只有管理员能改)。
*/ */
@@ -1574,6 +1682,10 @@ switch ($r) {
'title' => '设置', 'title' => '设置',
'family' => Family::find((int)Auth::familyId()), 'family' => Family::find((int)Auth::familyId()),
'dbVersion' => $dbVersion, 'dbVersion' => $dbVersion,
// 用户第 3 条:首页天气的坐标(三个都空 = 不显示天气)
'weatherPlace' => (string)Setting::get((int)Auth::familyId(), 'weather_place', ''),
'weatherLat' => (string)Setting::get((int)Auth::familyId(), 'weather_lat', ''),
'weatherLon' => (string)Setting::get((int)Auth::familyId(), 'weather_lon', ''),
'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0, 'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0,
'roleCounts' => $acc, 'roleCounts' => $acc,
'relCount' => Relations::count((int)Auth::familyId()), 'relCount' => Relations::count((int)Auth::familyId()),
@@ -1695,6 +1807,19 @@ switch ($r) {
(string)($cur['name'] ?? '家书'), (string)($cur['name'] ?? '家书'),
trim((string)($_POST['motto'] ?? '')) trim((string)($_POST['motto'] ?? ''))
); );
// 用户第 3 条:天气坐标(家庭级)。只收合法数字,非法值一律当"没填",
// 否则一个手滑的字母会让首页天气接口每次都白请求一次。
foreach ([
'weather_place' => 'weather_place',
'weather_lat' => 'weather_lat',
'weather_lon' => 'weather_lon',
] as $formKey => $settingKey) {
$v = trim((string)($_POST[$formKey] ?? ''));
if ($settingKey !== 'weather_place') {
$v = ($v !== '' && is_numeric($v)) ? $v : '';
}
Setting::set($fid, $settingKey, mb_substr($v, 0, 60, 'UTF-8'));
}
flash('ok', '已保存。'); flash('ok', '已保存。');
redirect('settings'); redirect('settings');
break; break;
@@ -2341,8 +2466,20 @@ switch ($r) {
'location' => trim((string)($_POST['location'] ?? '')), 'location' => trim((string)($_POST['location'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')),
'summary' => trim((string)($_POST['summary'] ?? '')), 'summary' => trim((string)($_POST['summary'] ?? '')),
// 用户 2026-10-03 第 4 条:旅程是否公开(只影响列表与日历,不影响能否直接打开)
// 用户第 4 条:是否公开。
// ⚠️ 三种情况必须分清(少了任何一种都会错):
// ① 表单**没这个字段**(程序化调用 / 老表单 / 迁移)→ 连键都不放,
// 让 Trip::saveDetails 按"默认公开"处理;
// ② 表单提交了 hidden=0(用户取消勾选)→ 明确的 0;
// ③ 勾选 → '1'。
// 之前写成 `?? ''` 会把 ① 也变成"键存在且为空" → 被当成不公开,
// 于是数据在列表里**静默消失**(实测:新建的旅程从列表与日历上都没了)。
'attendees' => filter_family_person_ids((array)($_POST['attendees'] ?? []), $familyId), 'attendees' => filter_family_person_ids((array)($_POST['attendees'] ?? []), $familyId),
]; ];
if (array_key_exists('is_public', $_POST)) {
$d['is_public'] = (string)$_POST['is_public'];
}
$errors = []; $errors = [];
if ($d['title'] === '') { if ($d['title'] === '') {
$errors['title'] = '旅程名字不能为空'; $errors['title'] = '旅程名字不能为空';
@@ -2922,6 +3059,8 @@ switch ($r) {
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''), 'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
'status' => (string)($_POST['status'] ?? 'confirmed'), 'status' => (string)($_POST['status'] ?? 'confirmed'),
'transp' => !empty($_POST['transp']), 'transp' => !empty($_POST['transp']),
// 用户 2026-10-03 第 5、6 条:重复规则(RFC 5545 RRULE 原文)
'rrule' => Repeat::build($_POST),
'note' => $note, 'note' => $note,
'created_by' => $me, 'created_by' => $me,
]); ]);
@@ -2949,10 +3088,19 @@ switch ($r) {
redirect('calendar'); redirect('calendar');
} }
$ev['_can_write'] = Calendar::canWriteEvent($ev, $me); $ev['_can_write'] = Calendar::canWriteEvent($ev, $me);
// 用户第 5 条:从日历点进来时会带 ?d=(那一次是哪天),
// 编辑重复日程时用它定位"仅此一次 / 这一次及以后"。
$occ = trim((string)($_GET['d'] ?? ''));
$occ = preg_match('/^\d{4}-\d{2}-\d{2}$/', $occ) ? $occ : '';
// 编辑页要回填"哪些天被跳过了"(跳过的那次不再出现在日历里)
$ev['skips'] = Calendar::skipsOf((int)$ev['id']);
View::render('calendar/event', [ View::render('calendar/event', [
'title' => (string)$ev['title'], 'title' => (string)$ev['title'],
'ev' => $ev, 'ev' => $ev,
'cal' => $cal, 'cal' => $cal,
'occ' => $occ,
// ⚠️ 不要再传 'ym':模板自己从 $ev['starts_at'] 算(模板里那行更权威,
// 传进来反而会在"改过日期后"与模板算出的值不一致)。
], 'calendar'); ], 'calendar');
break; break;
@@ -2964,13 +3112,17 @@ switch ($r) {
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
$ym = trim((string)($_POST['ym'] ?? '')); $ym = trim((string)($_POST['ym'] ?? ''));
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m'); $ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
// ⚠️ 这里要把**整行**取回来(含 rrule / starts_at / 各字段):
// 改重复日程时,"仅此一次 / 这一次及以后"都要拿旧内容来裁剪或复制,
// 只取 id 的话还得再查一遍(而且容易漏字段)。
$st = Database::get()->prepare( $st = Database::get()->prepare(
'SELECT e.id, e.calendar_id, c.owner_id FROM calendar_events e 'SELECT e.*, c.owner_id FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id JOIN calendars c ON c.id = e.calendar_id
WHERE e.id = ? AND c.family_id = ?' WHERE e.id = ? AND c.family_id = ?'
); );
$st->execute([$id, $familyId]); $st->execute([$id, $familyId]);
$ev = $st->fetch(); $ev = $st->fetch();
$ev2 = $ev; // 旧内容(scope 分支要用,别被后面的 $d 覆盖)
if (!$ev) { if (!$ev) {
flash('err', '找不到这条日程。'); flash('err', '找不到这条日程。');
redirect('calendar', ['ym' => $ym]); redirect('calendar', ['ym' => $ym]);
@@ -2992,7 +3144,7 @@ switch ($r) {
$startsAt = substr($startsAt, 0, 10) . ' 00:00:00'; $startsAt = substr($startsAt, 0, 10) . ' 00:00:00';
$endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null; $endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null;
} }
Calendar::updateEvent($id, [ $d = [
'title' => trim((string)($_POST['title'] ?? '')), 'title' => trim((string)($_POST['title'] ?? '')),
'location' => trim((string)($_POST['location'] ?? '')), 'location' => trim((string)($_POST['location'] ?? '')),
'starts_at' => $startsAt, 'starts_at' => $startsAt,
@@ -3001,8 +3153,67 @@ switch ($r) {
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''), 'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
'status' => (string)($_POST['status'] ?? 'confirmed'), 'status' => (string)($_POST['status'] ?? 'confirmed'),
'transp' => !empty($_POST['transp']), 'transp' => !empty($_POST['transp']),
'rrule' => Repeat::build($_POST),
'note' => trim((string)($_POST['note'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')),
]); ];
/* ---- 用户 2026-10-03 第 5 条:改一条**重复**日程时,先问"改哪些" ----
scope 三档:
all = 整条系列都按新内容改(直接 update 本行,rrule 也跟着改)
future = 这一次**以及以后**的:把原系列截止到"这一次"的前一天,
再新建一条从这一天开始的系列 —— 这样过去的历史原样保留,
以后的全部采用新内容(与 iOS/群晖日历的"此事件及后续"一致)
once = 仅这一次:原系列里跳掉这一天,另建一条只发生一次(detachOnce)
⚠️ 不重复的日程直接按 all 处理(没有"系列"可言)。 */
$scope = (string)($_POST['scope'] ?? 'all');
$occDay = substr(trim((string)($_POST['occ'] ?? '')), 0, 10);
$rruleOld = (string)($ev2['rrule'] ?? '');
if (in_array($scope, ['once', 'future'], true)
&& $rruleOld !== ''
&& preg_match('/^\d{4}-\d{2}-\d{2}$/', $occDay)) {
if ($scope === 'once') {
// 仅这一次:原系列跳掉那天 + 落一条独立单次
Calendar::detachOnce(
['id' => $id, 'calendar_id' => (int)$ev['calendar_id']],
$occDay, $d, $me
);
flash('ok', '只改了这一次。');
} else {
// 这一次及以后:老系列截到前一天,新系列从这天起
$rp = Repeat::parse($rruleOld);
$cut = date('Y-m-d', strtotime($occDay . ' -1 day'));
$rp['until'] = $cut;
$rp['count'] = 0;
// 老系列:只保留到 $cut,并**移除**原来的 days 定义之外的东西
Calendar::updateEvent($id, [
'title' => (string)($ev2['title'] ?? ''),
'location' => (string)($ev2['location'] ?? ''),
'starts_at' => (string)$ev2['starts_at'],
'ends_at' => $ev2['ends_at'] ?: null,
'all_day' => (int)($ev2['all_day'] ?? 0) === 1,
'reminder_minutes' => $ev2['reminder_minutes'] ?? '',
'status' => (string)($ev2['status'] ?? 'confirmed'),
'transp' => (int)($ev2['transp'] ?? 0) === 1,
'rrule' => 'FREQ=' . strtoupper((string)$rp['freq'])
. ($rp['freq'] === 'weekday' ? ';BYDAY=MO,TU,WE,TH,FR' : '')
. ';UNTIL=' . str_replace('-', '', $cut) . 'T235959Z',
'note' => (string)($ev2['note'] ?? ''),
]);
// 新系列:从 $occDay 起,用用户改后的内容
$d2 = $d;
$offsetDays = (int)floor((strtotime(substr((string)$d['starts_at'], 0, 10))
- strtotime(substr((string)$ev2['starts_at'], 0, 10))) / 86400);
$d2['starts_at'] = $occDay . ' ' . substr((string)$d['starts_at'], 11, 8);
$d2['ends_at'] = $d['ends_at'] !== null ? $occDay . ' ' . substr((string)$d['ends_at'], 11, 8) : null;
unset($offsetDays);
$d2['created_by'] = $me;
Calendar::addEvent((int)$ev['calendar_id'], $d2);
flash('ok', '这一次及以后的都改好了(过去的保持不变)。');
}
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
}
// all(或本来就不重复):整条按新内容改
Calendar::updateEvent($id, $d);
$warns = attach_save_files($familyId, Calendar::ATTACH, $id, 'attach', $me); $warns = attach_save_files($familyId, Calendar::ATTACH, $id, 'attach', $me);
if ($warns) { if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns)); flash('err', '有附件没有保存:' . implode(';', $warns));
+144 -1
View File
@@ -56,12 +56,21 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true; $dtSeconds = true; $dtValue = $evF['starts_at'] ?? null; $dtRequired = true; $dtSeconds = true;
require __DIR__ . '/../_datetime.php'; ?> require __DIR__ . '/../_datetime.php'; ?>
</div> </div>
<div class="field ev-dt" data-dt> <?php /* 用户 2026-10-03 第 6 条:「添加结束日期与时间」——
结束是可选的:没填就表示"就一个时刻/就这一天",填了才画成一段。
所以给一个显式开关,而不是让人对着空控件猜"不填行不行"。 */ ?>
<?php $hasEnd = trim((string)($evF['ends_at'] ?? '')) !== ''; ?>
<div class="field ev-dt" data-dt id="ev-end-field"<?= $hasEnd ? '' : ' hidden' ?>>
<label>结束</label> <label>结束</label>
<?php $dtName = 'ends_at'; $dtId = 'ev-end'; <?php $dtName = 'ends_at'; $dtId = 'ev-end';
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true; $dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true;
require __DIR__ . '/../_datetime.php'; ?> require __DIR__ . '/../_datetime.php'; ?>
</div> </div>
<div class="field" id="ev-end-add">
<label>结束时间</label>
<button type="button" class="btn btn-ghost btn-sm" id="ev-end-add-btn">+ 添加结束时间</button>
<span class="field-hint">不填就表示没有明确的结束(只占开始那一刻/那一天)</span>
</div>
<div class="field"> <div class="field">
<label> <label>
@@ -72,6 +81,40 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
<span class="field-hint">勾上就不管具体几点,只占这一天</span> <span class="field-hint">勾上就不管具体几点,只占这一天</span>
</div> </div>
<?php /* 用户 2026-10-03 第 5、6 条:重复日程。
放在「全天」之后的自然位置:先定"哪一天/几点",再定"要不要重复"。
周期用 FREQ 记号,结束条件三档(一直 / N 次 / 到某天),另加"工作日"。 */ ?>
<div class="field">
<label for="ev-freq">重复</label>
<select class="select" id="ev-freq" name="freq">
<?php $rp = \App\Repeat::parse((string)($evF['rrule'] ?? '')); ?>
<?php foreach (\App\Repeat::FREQS as $k => $w): ?>
<option value="<?= e($k) ?>"<?= (string)$rp['freq'] === (string)$k ? ' selected' : '' ?>><?= e($w) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field" id="ev-rep-end-box" hidden>
<label for="ev-rep-end">结束条件</label>
<select class="select" id="ev-rep-end" name="rep_end">
<?php foreach (\App\Repeat::ENDS as $k => $w): ?>
<option value="<?= e($k) ?>"<?= $rp['count'] > 0 ? '' : '' ?>><?= e($w) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field" id="ev-rep-count-box" hidden>
<label for="ev-rep-count">重复次数(含第一次)</label>
<input class="input" type="number" min="1" max="2000" id="ev-rep-count" name="rep_count"
value="<?= (int)($rp['count'] ?: 10) ?>">
</div>
<div class="field" id="ev-rep-until-box" hidden>
<label for="ev-rep-until">重复到</label>
<input class="input" type="date" id="ev-rep-until" name="rep_until"
value="<?= e((string)($rp['until'] ?? '')) ?>">
</div>
<div class="field"> <div class="field">
<label for="ev-status">状态</label> <label for="ev-status">状态</label>
<select class="select" id="ev-status" name="status"> <select class="select" id="ev-status" name="status">
@@ -115,6 +158,106 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
} }
box.addEventListener('change', sync); box.addEventListener('change', sync);
sync(); sync();
/* ---- 用户第 6 条:结束时间 ----
① 「+ 添加结束时间」把结束控件放出来;
② 结束时间**默认跟着开始时间走**:改了开始,若结束还没填(或原本等于旧开始),
就把结束同步过去 —— 常见用法是"改个开始,结束忘了改",结果时长莫名其妙。
但只要用户**自己动过结束**,就不再自动同步(别抢用户的输入)。 */
var startBox = document.querySelector('[data-dt] input.dt-date');
var endField = document.getElementById('ev-end-field');
var endAdd = document.getElementById('ev-end-add');
var endAddBtn = document.getElementById('ev-end-add-btn');
var startField = document.querySelector('.field.ev-dt');
function endInputs() {
if (!endField) return [];
return Array.prototype.slice.call(endField.querySelectorAll('input, select'));
}
function showEnd(on) {
if (endField) { endField.hidden = !on; }
if (endAdd) { endAdd.hidden = on; }
}
var endTouched = false;
endInputs().forEach(function (el) {
el.addEventListener('change', function () { endTouched = true; });
});
if (endAddBtn) {
endAddBtn.addEventListener('click', function () { showEnd(true); endTouched = false; });
}
// 移除结束(改回"没有结束")
if (endField && !endField.querySelector('.ev-end-del')) {
var del = document.createElement('button');
del.type = 'button';
del.className = 'btn btn-ghost btn-sm ev-end-del';
del.textContent = '去掉结束时间';
del.addEventListener('click', function () {
endInputs().forEach(function (el) {
if (el.tagName === 'SELECT') { el.value = '00'; }
else { el.value = ''; }
});
showEnd(false);
endTouched = false;
});
endField.appendChild(del);
}
// 开始变了 → 结束(若没被手动改过)跟着走同一时刻
if (startField) {
startField.addEventListener('change', function () {
if (endTouched || !endField || endField.hidden) return;
var sd = startField.querySelector('input.dt-date');
var sh = startField.querySelector('select.dt-hour');
var sm = startField.querySelector('select.dt-minute');
var ss = startField.querySelector('select.dt-second');
var ed = endField.querySelector('input.dt-date');
var eh = endField.querySelector('select.dt-hour');
var em = endField.querySelector('select.dt-minute');
var es = endField.querySelector('select.dt-second');
if (sd && ed) { ed.value = sd.value; }
if (sh && eh) { eh.value = sh.value; }
if (sm && em) { em.value = sm.value; }
if (ss && es) { es.value = ss.value; }
});
}
// 初值由服务端决定(有 ends_at 就是展开的),JS 只负责"之后"的显隐
showEnd(<?= $hasEnd ? 'true' : 'false' ?>);
/* 重复:选了周期才显示"结束条件";结束条件选"次数"才显示次数框、选"到某天"才显示日期框。
三个框默认都 hidden —— 不重复时一个都不该出现(少即是清楚)。 */
var freq = document.getElementById('ev-freq');
var endBox = document.getElementById('ev-rep-end-box');
var endSel = document.getElementById('ev-rep-end');
var cntBox = document.getElementById('ev-rep-count-box');
var untilBox = document.getElementById('ev-rep-until-box');
function syncRep() {
if (!freq || !endBox) return;
var repeating = freq.value !== '';
endBox.hidden = !repeating;
var how = endSel ? endSel.value : 'never';
if (cntBox) { cntBox.hidden = !repeating || how !== 'count'; }
if (untilBox) { untilBox.hidden = !repeating || how !== 'until'; }
}
if (freq) { freq.addEventListener('change', syncRep); }
if (endSel) { endSel.addEventListener('change', syncRep); }
// 回填:编辑一条已有的重复日程时,把结束条件按库里的值选对
<?php if ($rp['count'] > 0): ?>
if (endSel) { endSel.value = 'count'; }
<?php elseif ($rp['until'] !== ''): ?>
if (endSel) { endSel.value = 'until'; }
<?php endif; ?>
syncRep();
/* 「全天」+「重复」的组合提示:重复日程必须有开始日期,
否则展开时找不到锚点(后端会当成"不重复"处理)。 */
var startDate = document.querySelector('[data-dt] input.dt-date');
if (freq && startDate) {
freq.addEventListener('change', function () {
if (freq.value !== '' && startDate.value === '') {
startDate.setCustomValidity('重复日程要先选开始日期');
} else {
startDate.setCustomValidity('');
}
});
}
})(); })();
</script> </script>
<?php unset($evF, $evAllDayDefault, $evIsAllDay, $evFields, $evAllDay); ?> <?php unset($evF, $evAllDayDefault, $evIsAllDay, $evFields, $evAllDay); ?>
+30
View File
@@ -63,6 +63,36 @@ $canWrite = \App\Auth::canWrite() && !empty($ev['_can_write']);
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
<input type="hidden" name="ym" value="<?= e($ym) ?>"> <input type="hidden" name="ym" value="<?= e($ym) ?>">
<?php /* 这一次是哪一天:从日历点进来时 URL 带 ?d=YYYY-MM-DD。
"仅此一次 / 这一次及以后"都要靠它定位。 */ ?>
<input type="hidden" name="occ" value="<?= e($occ ?? '') ?>">
<?php /* 用户 2026-10-03 第 5 条:改一条**重复**日程时,先选"改哪些"。
只在它真的重复时才出现 —— 不重复的日程问这个是多余的。 */ ?>
<?php if (trim((string)($ev['rrule'] ?? '')) !== ''): ?>
<div class="card card-pad ev-scope">
<b>这是一条重复日程</b>
<p class="small muted" style="margin:6px 0 10px">
<?= e(\App\Repeat::describe((string)$ev['rrule'])) ?>
<?php if (!empty($occ)): ?>
<br>你打开的是 <b><?= e($occ) ?></b> 那一次。
<?php endif; ?>
</p>
<label class="chk-inline"><input type="radio" name="scope" value="all" checked>
<span>改<b>全部</b>(整条系列)</span></label>
<?php if (!empty($occ)): ?>
<label class="chk-inline"><input type="radio" name="scope" value="future">
<span>改<b>这一次及以后</b>(<?= e($occ) ?> 起,过去的保持不变)</span></label>
<label class="chk-inline"><input type="radio" name="scope" value="once">
<span>只改<b>这一次</b>(<?= e($occ) ?>)</span></label>
<?php else: ?>
<p class="small muted" style="margin:6px 0 0">
(从日历点某一天的日程进来,才能选「只改这一次 / 这一次及以后」。)
</p>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="form-grid"> <div class="form-grid">
<?php $evFields = $ev; $evAllDay = false; require __DIR__ . '/_event_fields.php'; ?> <?php $evFields = $ev; $evAllDay = false; require __DIR__ . '/_event_fields.php'; ?>
</div> </div>
+102 -59
View File
@@ -2,93 +2,136 @@
/** /**
* 首页 —— 一块 dashboard(用户 2026-10-01 第 13 条)。 * 首页 —— 一块 dashboard(用户 2026-10-01 第 13 条)。
* *
* 现在首页只回答三个问题:**最近有什么事**、**手头在做什么**、**往年这时候我们在干嘛**。 * 用户 2026-10-03 第 3 条把它重排成:
* · 接下来的日子(两个月内的生日) * · hero:标题 + 个人 slogan + 两个图标按钮(我的资料 / 我的家谱)
* · 统计信息:随机问候语 + 今日天气 + 近七天日程数 / 待办数
* · 未来七天的日程(自定义 + 观影 + 旅行 + 有截止时间的任务,一次汇总) * · 未来七天的日程(自定义 + 观影 + 旅行 + 有截止时间的任务,一次汇总)
* · 进行中的任务(含已过期的待办) * · 手头在做(进行中 + 已逾期的任务)
* · 往年今日(往年的这一天看过的电影 / 走过的旅程) * · 各模块的统计与模块内信息(**可按人在个人设置里开关与排序**)
* · 往年今日
* ⚠️ 「接下来的日子(两个月内的生日)」已去掉:生日/纪念日本来就是日程,
* 已经在「未来七天」里,两块并排是重复。
* *
* ⚠️ **家人卡片已经不在首页了**(用户明确要求):看人去「名册」、看关系去「家谱」, * ⚠️ **家人卡片已经不在首页了**(用户明确要求):看人去「名册」、看关系去「家谱」,
* 首页留给"今天该关心什么"。统计数字仍在,因为那是唯一的全局概览。 * 首页留给"今天该关心什么"。统计数字仍在,因为那是唯一的全局概览。
* *
* ⚠️ 标题下的那句个人口号已按用户要求撤掉(v8):hero 区只留标题与三个统计。 * ⚠️ 标题下的那句个人口号已按用户要求撤掉(v8):hero 区只留标题与三个统计。
*/ */
$kinCount = count($kin);
// 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出 // 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出
$shortKind = \App\Birthday::KINDS;
$today = date('Y-m-d'); $today = date('Y-m-d');
// 首页要显示哪些模块、什么顺序 —— 用户在「个人设置 → 首页上的模块」里定(每人一份)
$homeMods = array_values(array_filter(
(array)($homeModules ?? []),
static fn($k) => \App\Module::exists((string)$k)
));
?> ?>
<?php /* 用户 2026-10-03 第 3 条:
① 标题下显示**个人 slogan**(原来撤掉了,现在要回来);
② 右侧那三块统计(与我有关 / 名册总数 / 亲属连线)**去掉**;
③ 「我的资料 / 我的家谱」保留,但从文字按钮换成**图标按钮**;
④ 「名册」按钮去掉 —— 它与顶部导航条里的「名册」重复。
⚠️ 图标用行内 SVG(不引外部字体/图片),与观影设置下拉同一套做法。 */ ?>
<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 class="hero-main"> <div class="hero-main">
<h1><?= e(\App\Auth::homeTitle()) ?></h1> <h1><?= e(\App\Auth::homeTitle()) ?></h1>
</div> <?php $heroSlogan = trim((string)($mySlogan ?? '')) !== ''
<div class="hero-stats"> ? (string)$mySlogan
<div class="stat"><b><?= (int)$kinCount ?></b><span>与我有关</span></div> : trim((string)($family['motto'] ?? '')); ?>
<div class="stat"><b><?= (int)$allCount ?></b><span>名册总数</span></div> <?php if ($heroSlogan !== ''): ?>
<div class="stat"><b><?= (int)$relCount ?></b><span>亲属连线</span></div> <p class="hero-slogan"><?= e($heroSlogan) ?></p>
<?php endif; ?>
</div> </div>
<?php /* ⚠️ 这两个链接必须带上真实的 person id:曾经 layout.php 里写 <?php /* ⚠️ 这两个链接必须带上真实的 person id:曾经 layout.php 里写
`$me = Auth::check()` 把模板里的 $me 覆盖成布尔值,链接就变成 id=0 `$me = Auth::check()` 把模板里的 $me 覆盖成布尔值,链接就变成 id=0
("补充亲属关系"点进去是空白页)。所以这里显式用 $meId。 */ ?> ("补充亲属关系"点进去是空白页)。所以这里显式用 $meId。 */ ?>
<div class="hero-actions"> <div class="hero-actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/show', ['id' => (int)$meId])) ?>">我的资料</a> <a class="icon-btn" href="<?= e(url('people/show', ['id' => (int)$meId])) ?>"
<a class="btn btn-ghost btn-sm" href="<?= e(url('kin')) ?>">我的家谱</a> title="我的资料" aria-label="我的资料">
<a class="btn btn-ghost btn-sm" href="<?= e(url('people')) ?>">名册</a> <svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="10" cy="7" r="3.4"/><path d="M3.6 17c0-3.3 2.9-5.4 6.4-5.4s6.4 2.1 6.4 5.4"/>
</svg>
</a>
<a class="icon-btn" href="<?= e(url('kin')) ?>"
title="我的家谱" aria-label="我的家谱">
<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="7.4" y="2.2" width="5.2" height="4.2" rx="1"/>
<rect x="2.2" y="13.6" width="5.2" height="4.2" rx="1"/>
<rect x="12.6" y="13.6" width="5.2" height="4.2" rx="1"/>
<path d="M10 6.4v3.4M4.8 13.6v-1.9h10.4v1.9M4.8 11.7h10.4"/>
</svg>
</a>
</div> </div>
</section> </section>
<?php /* ==================== 接下来的日子 ==================== */ <?php /* 用户 2026-10-03 第 3 条:「接下来的日子(两个月内)」整块**去掉** ——
/* ⚠️ 即使近两个月没人过生日,这一块也**照常渲染**(给空态),只是内容为空。 它列的是生日,而生日/纪念日本来就是日程,已经在下面的「未来七天」里出现过,
理由:dashboard 的四块是"每天要看的东西",某一块忽然整块消失会让人以为 两块并排是重复信息。生日的"提前提醒"由日历与纪念日模块负责。 */
功能坏了 / 数据丢了(旧版就是 `if ($birthdays)` 一省略了之)。 ?>
空态还能顺带告诉用户"系统在盯着生日,只是最近没有"。 */ ?>
<?php /* ==================== 统计信息(用户第 3 条) ====================
一句话问候 + 今天的天气 + 几个数字。放在最上面,因为它回答的是
"今天是个什么日子、我该关心什么"——比下面任何一个列表都更该先看到。 */ ?>
<section class="section"> <section class="section">
<h2 class="section-title">接下来的日子 <span class="hint">两个月内</span></h2> <div class="card card-pad home-brief">
<?php if (!$birthdays): ?> <div class="home-brief-hello">
<div class="card card-pad empty"><b>近两个月没有人过生日</b>等到了月份,这里会自动冒出来。</div> <b><?= e((string)($greeting ?? '')) ?></b>
<?php else: ?> <span class="muted small"><?= e(date('n 月 j 日')) ?> <?= e((string)($weekday ?? '')) ?></span>
<div class="bdays"> </div>
<?php foreach ($birthdays as $b): ?> <?php if (!empty($weather)): ?>
<?php <div class="home-brief-weather">
// 同一个人可能出两条(农历与阳历不在同一天);落在同一天时后端已经并成一条, <span class="w-icon" aria-hidden="true"><?= e((string)($weather['icon'] ?? '')) ?></span>
// kinds 里会有多项 —— 这里照着列出来,用户才知道"今天过的是哪个生日"。 <span class="w-temp"><?= e((string)($weather['temp'] ?? '')) ?></span>
$bkinds = (array)($b['kinds'] ?? []); <span class="w-desc"><?= e((string)($weather['text'] ?? '')) ?></span>
$bwords = []; <?php if (($weather['place'] ?? '') !== ''): ?>
foreach ($bkinds as $k) { <span class="muted small"><?= e((string)$weather['place']) ?></span>
$bwords[] = $shortKind[$k] ?? (string)$k; <?php endif; ?>
} </div>
$bshown = (string)($b['shown'] ?? $b['name'] ?? ''); <?php endif; ?>
?> <div class="home-brief-stats">
<a class="bday" href="<?= e(url('people/show', ['id' => (int)$b['id']])) ?>"> <span class="hbs"><b><?= (int)($stat7['events'] ?? 0) ?></b> 条日程(近七天)</span>
<img src="<?= e(avatar_url($b['avatar'])) ?>" alt=""> <span class="hbs"><b><?= (int)($stat7['todos'] ?? 0) ?></b> 件待办</span>
<div> <span class="hbs"><b><?= (int)($stat7['overdue'] ?? 0) ?></b> 件逾期</span>
<b><?= e($bshown) ?></b> </div>
<?php if (!empty($b['label']) && $b['label'] !== '我'): ?> </div>
<span class="badge"><?= e($b['label']) ?></span> </section>
<?php endif; ?>
<div class="d"> <?php /* ==================== 各模块统计(用户第 3 条) ====================
<?php if ((int)$b['days_left'] === 0): ?> 每个模块给一个"一眼看得懂的小结"。用户在个人设置里能挑显示哪些、以及顺序。 */ ?>
就是今天,生日快乐 <?php if ($homeMods && !empty($moduleCards)): ?>
<?php else: ?> <section class="section">
还有 <?= (int)$b['days_left'] ?> 天过生日 <div class="page-head" style="margin-bottom:12px">
<?php endif; ?> <h1 style="font-size:15px">家里的近况</h1>
</div> <div class="actions">
<div class="bday-meta"> <a class="btn btn-ghost btn-sm" href="<?= e(url('settings/me')) ?>#home-mods">调整显示</a>
<span class="bday-date"><?= e((string)$b['date']) ?></span> </div>
<?php foreach ($bwords as $w): ?> </div>
<span class="bd-kind"><?= e($w) ?></span> <div class="mod-cards">
<?php endforeach; ?> <?php foreach ($homeMods as $mk): ?>
<?php if (!empty($b['lunar_label'])): ?> <?php $mc = $moduleCards[$mk] ?? null; if (!$mc) { continue; } ?>
<span class="muted small">农历<?= e((string)$b['lunar_label']) ?></span> <a class="mod-card" href="<?= e((string)$mc['href']) ?>"
<?php endif; ?> style="--mc: <?= e((string)($mc['color'] ?? '#888')) ?>">
</div> <div class="mod-card-head">
<span class="mod-card-icon"><?= $mc['icon'] /* 行内 SVG,自己人写的,不转义 */ ?></span>
<span class="mod-card-name"><?= e((string)$mc['name']) ?></span>
</div> </div>
<?php if (($mc['big'] ?? '') !== ''): ?>
<div class="mod-card-big"><?= e((string)$mc['big']) ?></div>
<?php endif; ?>
<?php if (!empty($mc['lines'])): ?>
<ul class="mod-card-lines">
<?php foreach ($mc['lines'] as $ln): ?>
<li><?= e((string)$ln) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</a> </a>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?>
</section> </section>
<?php endif; ?>
<?php /* ==================== 日程(未来七天) ==================== */ ?> <?php /* ==================== 日程(未来七天) ==================== */ ?>
<section class="section"> <section class="section">
+40 -61
View File
@@ -147,19 +147,11 @@ $datumJson = json_encode(
); );
?> ?>
<?php /* 用户 2026-10-03 第 2 条:标题下的那句说明**去掉**;
右下角的「返回我的家谱」也去掉 —— 与页面里那个工具条重复,
会出现两个"回到我的家谱"(用户明确点名)。现在整页只有标题。 */ ?>
<div class="page-head"> <div class="page-head">
<h1><?= $focusId === (int)$meId ? '我的家谱' : e((string)($people[$focusId]['name'] ?? '') . ' 的家谱') ?></h1> <h1><?= $focusId === (int)$meId ? '我的家谱' : e((string)($people[$focusId]['name'] ?? '') . ' 的家谱') ?></h1>
<span class="sub">
<?= $focusId === (int)$meId
? '以我为中心 · 夫妻并排,子女从两人之间的连线连出'
: '以 TA 为中心(这样 TA 那一侧的姻亲才露得出来)' ?>
</span>
<div class="actions">
<?php if ($focusId !== (int)$meId): ?>
<?php /* v24 第 12 条:从姻亲入口进来的,给一个返回,回到「我的家谱」 */ ?>
<a class="btn btn-ghost" href="<?= e(url('kin')) ?>">← 返回我的家谱</a>
<?php endif; ?>
</div>
</div> </div>
<?php if (!$canChart): ?> <?php if (!$canChart): ?>
@@ -170,11 +162,12 @@ $datumJson = json_encode(
</div> </div>
<?php else: ?> <?php else: ?>
<div class="kin-tools"> <?php /* 用户 2026-10-03 第 2 条:「看全部 / 回到我 / 回到我的家谱」这些按钮
<button type="button" class="btn btn-ghost btn-sm" id="kinFit">看全部</button> 全部去掉。镜头由库自动 fit(updateTree 的 tree_position: 'fit'),
<?php /* 中心是别人时这颗按钮是"跳回我的家谱"(不是在本图里找我的坐标) */ ?> 用户要调整时直接拖拽/滚轮即可,不需要常驻按钮。 */
<button type="button" class="btn btn-ghost btn-sm" id="kinMe"><?= $focusId === (int)$meId ? '回到我' : '回到我的家谱' ?></button> /* ⚠️ 顺带说明:品牌名「家谱」已从导航条撤走,但**模块本身还在** ——
</div> 个人设置里能勾回来,且 URL 直接访问 r=kin 永远有效。
这一点写在这里是为了防止有人"顺手把 kin 路由删掉"。 */ ?>
<!-- ⚠️ `f3` 这个类名是**库的 CSS 作用域**,不是我们起的名字。 <!-- ⚠️ `f3` 这个类名是**库的 CSS 作用域**,不是我们起的名字。
官方示例就是 `<div id="FamilyChart" class="f3">` + 引 family-chart.css; 官方示例就是 `<div id="FamilyChart" class="f3">` + 引 family-chart.css;
@@ -184,14 +177,7 @@ $datumJson = json_encode(
<div class="kin-chart f3" id="kinChart" data-datum="<?= e($datumJson) ?>" <div class="kin-chart f3" id="kinChart" data-datum="<?= e($datumJson) ?>"
data-focus="<?= e((string)$focusId) ?>" data-me-viewer="<?= e((string)$meId) ?>"></div> data-focus="<?= e((string)$focusId) ?>" data-me-viewer="<?= e((string)$meId) ?>"></div>
<p class="tree-legend">
每代一行;<b>一对夫妻之间有一条短横线</b>,
<b>他们的子女一律从这条横线的正中间垂下来</b>(不管是谁登录,孩子都挂在这对夫妻上)。
「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。
可以**拖拽平移**、**滚轮缩放**;点「回到我」把镜头挪回你自己,点卡片进对应成员页。
<br><span class="muted">注:本图由 family-chart 绘制。免费版只画「同一位祖先的血脉后代 + 各人配偶」,
妻子那侧的岳父母、舅子等姻亲不在图内(在名册与成员详情页仍可看到)。</span>
</p>
<!-- 官方用法是「容器带 class="f3" + 引 family-chart.css」(见 family-chart 官方示例)。 <!-- 官方用法是「容器带 class="f3" + 引 family-chart.css」(见 family-chart 官方示例)。
之前没引这份 CSS,靠自己在 app.css 里手补 `.kin-chart svg.main_svg{width/height:100%}` 之前没引这份 CSS,靠自己在 app.css 里手补 `.kin-chart svg.main_svg{width/height:100%}`
@@ -227,30 +213,35 @@ $datumJson = json_encode(
// 会跳过深度截断,等于**不限代数**,有多少画多少。 // 会跳过深度截断,等于**不限代数**,有多少画多少。
// 之前写死了具体代数,真实家庭一超过就被砍掉。 // 之前写死了具体代数,真实家庭一超过就被砍掉。
// 用户要求「全部取出来」,所以这里也不设。 // 用户要求「全部取出来」,所以这里也不设。
.setCardXSpacing(190) .setCardXSpacing(160)
.setCardYSpacing(180); .setCardYSpacing(180);
chart.setCardHtml() chart.setCardHtml()
.setStyle('rect') .setStyle('rect')
.setCardDim({ w: 148, h: 138, mini_tree: false }) .setCardDim({ w: 118, h: 132, mini_tree: false })
.setCardInnerHtmlCreator(function (d) { .setCardInnerHtmlCreator(function (d) {
var p = (d.data && d.data.data) || {}; var p = (d.data && d.data.data) || {};
var id = (d.data && d.data.id) || ''; var id = (d.data && d.data.id) || '';
var cls = 'kin-card' + (p.me ? ' is-me' : '') + (p.ph ? ' is-ph' : ''); var cls = 'kin-card' + (p.me ? ' is-me' : '') + (p.ph ? ' is-ph' : '');
// v24 第 12 条:头像改**方形**(原来是圆角矩形 + cover,把脸裁掉了)。 /* 用户 2026-10-03 第 1 条:
// 卡片本身是 <a> 进成员页;有姻亲没画出来的,再叠一个独立的小按钮 ① 卡片**不再整张是链接** —— 原来整卡是 <a>,右下角那个小按钮也在
// (用 <span role=button> 而不是 <a>:<a> 不能套 <a>)。 <a> 里,手指/鼠标稍偏一点就先触发外层链接跳去成员页(用户反馈
return '<a class="' + cls + '" data-kin-id="' + esc(id) + '" href="/index.php?r=people/show&id=' "容易误触"的根因)。现在卡片是 <div>,只有「姓名」那一块是链接,
+ encodeURIComponent(id) + '">' 右下角按钮是独立的 <button>,两者物理上分开,不会互相抢点击。
② 头像外框改**长方形**、宽度贴合头像本身(CSS 里 .kin-ph 定宽,
img 用 object-fit: contain 不裁切),信息在头像**下方**。
③ 右下角按钮:每个人都有一个 → 以他为中心重画一张家谱
(原来只有"姻亲不在图上"的人才有,现在人人都有)。 */
return '<div class="' + cls + '" data-kin-id="' + esc(id) + '">'
+ '<span class="kin-ph"><img src="' + esc(p.avatar) + '" alt=""></span>' + '<span class="kin-ph"><img src="' + esc(p.avatar) + '" alt=""></span>'
+ '<span class="kin-nm">' + esc(p.name) + '</span>' + '<a class="kin-nm" href="/index.php?r=people/show&id='
+ encodeURIComponent(id) + '" title="打开 ' + esc(p.name) + ' 的资料">'
+ esc(p.name) + '</a>'
+ '<span class="kin-lb">' + esc(p.label) + '</span>' + '<span class="kin-lb">' + esc(p.label) + '</span>'
+ (p.ph ? '<span class="kin-mark">待确认</span>' : '') + (p.ph ? '<span class="kin-mark">待确认</span>' : '')
+ (p.branch + '<button type="button" class="kin-focus" data-focus-id="' + esc(id) + '"'
? '<span class="kin-branch" role="button" tabindex="0" title="看看 ' + ' title="以 ' + esc(p.name) + ' 为中心看家谱">以 TA 为中心</button>'
+ esc(p.branch) + ' 那一侧的家谱" data-branch-id="' + esc(id) + '">⇄ 姻亲</span>' + '</div>';
: '')
+ '</a>';
}); });
chart.editTree().setCanAdd(false); chart.editTree().setCanAdd(false);
@@ -304,41 +295,29 @@ $datumJson = json_encode(
setTimeout(function () { centerOn(focusId); }, 1400); setTimeout(function () { centerOn(focusId); }, 1400);
} }
var btnFit = document.getElementById('kinFit'); /* 用户 2026-10-03 第 2 条:工具条三个按钮(看全部/回到我/回到我的家谱)已撤。
var btnMe = document.getElementById('kinMe'); 镜头由库自动 fit;下面只保留卡片右下角那个「以他为中心」的入口。 */
if (btnFit) {
btnFit.addEventListener('click', function () { /* 用户第 1 条:每张卡片右下角一个「以他为中心」的按钮 —— 点了就以他重画一张家谱。
chart.updateTree({ tree_position: 'fit' }); ⚠️ 这个元素在卡片的 <a> 里,必须 preventDefault + stopPropagation,
}); 否则会先触发外面那个链接跳去成员页(用户反馈"容易误触"就是这个)。
} 另外用 mousedown/pointerdown/click 三处都拦,触摸设备上才稳。 */
/* v24 第 12 条:卡片右下角的「⇄ 姻亲」按钮 —— 以那个人为中心重画一张家谱。 Array.prototype.forEach.call(root.querySelectorAll('.kin-focus'), function (btn) {
⚠️ 这个 span 在卡片的 <a> 里,必须 preventDefault + stopPropagation,
否则会先触发外面那个链接跳去成员页。 */
Array.prototype.forEach.call(root.querySelectorAll('.kin-branch'), function (btn) {
function go(e) { function go(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
var bid = btn.getAttribute('data-branch-id') || ''; var bid = btn.getAttribute('data-focus-id') || '';
if (bid) { window.location.href = '/index.php?r=kin&focus=' + encodeURIComponent(bid); } if (bid) { window.location.href = '/index.php?r=kin&focus=' + encodeURIComponent(bid); }
} }
btn.addEventListener('click', go); btn.addEventListener('click', go);
btn.addEventListener('keydown', function (e) { btn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { go(e); } if (e.key === 'Enter' || e.key === ' ') { go(e); }
}); });
// 键盘/触摸都别让它冒泡到外层链接 ['mousedown', 'pointerdown', 'touchstart'].forEach(function (ev) {
btn.addEventListener('mousedown', function (e) { e.stopPropagation(); }); btn.addEventListener(ev, function (e) { e.stopPropagation(); }, { passive: true });
});
}); });
if (btnMe && meId) {
btnMe.addEventListener('click', function () {
// 图是别人的家谱时,先跳回「我的家谱」再居中
if (focusId !== meId) {
window.location.href = '/index.php?r=kin';
return;
}
centerOn(centerTarget);
});
}
})(); })();
</script> </script>
+24 -9
View File
@@ -58,11 +58,26 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
<?= e($pshow) ?> <?= e($pshow) ?>
<?php if ($pid === (int)$meId): ?><span class="badge badge-me">我</span><?php endif; ?> <?php if ($pid === (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> <?php /* 用户第 4 条:与我的关系放姓名右边(列表里一眼能看到) */ ?>
<div class="pmeta">
<?php if (!empty($p['label']) && $p['label'] !== '我'): ?> <?php if (!empty($p['label']) && $p['label'] !== '我'): ?>
<span class="rel-label"><?= e($p['label']) ?></span> <span class="rel-label"><?= e($p['label']) ?></span>
<?php endif; ?> <?php endif; ?>
</div>
<?php /* 用户第 4 条:中间显示几个重要信息 —— 手机号 / 生日。
关系标签已挪到姓名行,这里只放"事实性"的两项。 */ ?>
<div class="pfacts">
<?php if (($p['phone_plain'] ?? '') !== ''): ?>
<span class="pfact" title="手机号">
<span class="pfact-k">手机</span><?= e((string)$p['phone_plain']) ?>
</span>
<?php endif; ?>
<?php if (($p['birthday_show'] ?? '') !== ''): ?>
<span class="pfact" title="<?= e((string)$p['birthday_kind']) ?>生日">
<span class="pfact-k"><?= e((string)$p['birthday_kind']) ?></span><?= e((string)$p['birthday_show']) ?>
</span>
<?php endif; ?>
</div>
<div class="pmeta">
<?php <?php
$r = $p['account_role'] ?? ''; $r = $p['account_role'] ?? '';
if ($r === 'admin' && ($p['account_status'] ?? '') === 'active') { if ($r === 'admin' && ($p['account_status'] ?? '') === 'active') {
@@ -152,16 +167,16 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
</div> </div>
</div> </div>
<?php if ($canMove && $q === '' && $total > 0): ?> <?php /* 用户第 4 条:这句拖动提示**去掉**(用户直接点名要删)。
<p class="small muted roster-tip" style="margin:0 0 12px"> 功能本身不变,只是不再占版面。 */ ?>
拖动卡片(左边那个 <span class="drag-handle-inline">⠿</span>)就能改顺序,
拖到别的一栏里就是改分组。改的是<b>你自己那份名册</b>,不影响家里其他人。
</p>
<?php endif; ?>
<form class="search" method="get" action="index.php"> <form class="search" method="get" action="index.php">
<input type="hidden" name="r" value="people"> <input type="hidden" name="r" value="people">
<input class="input" name="q" value="<?= e($q) ?>" placeholder="按姓名、称呼或邮箱搜索"> <?php /* 用户第 4 条:除了姓名/称呼/邮箱的模糊搜索,还支持**姓名首字母**
(yyq / yy / y 都能搜到「杨永全」)。后端在 Person::roster 里按
"汉字转首字母后再比对"实现,所以这里只要说清楚用法。 */ ?>
<input class="input" name="q" value="<?= e($q) ?>"
placeholder="姓名 / 称呼 / 邮箱 / 手机号,或姓名首字母(如 yyq)">
<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>
+23
View File
@@ -25,6 +25,29 @@
<b>没写的人才用这里的这句</b>。所以这是一句"兜底",不是全家的统一口号。 <b>没写的人才用这里的这句</b>。所以这是一句"兜底",不是全家的统一口号。
</span> </span>
</div> </div>
<?php /* 用户 2026-10-03 第 3 条:首页要显示"今天的天气",所以得有地方填坐标。
用 Open-Meteo(不需要 key)。只填经纬度,不填城市名 ——
经纬度是"位置",城市名只是给人看的备注。 */ ?>
<div class="field">
<label for="weather_place">天气 · 地点名</label>
<input class="input" id="weather_place" name="weather_place"
value="<?= e((string)($weatherPlace ?? '')) ?>" placeholder="如:青岛">
<span class="tip">只用于显示,随便写。</span>
</div>
<div class="field">
<label for="weather_lat">天气 · 纬度</label>
<input class="input" id="weather_lat" name="weather_lat"
value="<?= e((string)($weatherLat ?? '')) ?>" placeholder="如:36.067">
</div>
<div class="field">
<label for="weather_lon">天气 · 经度</label>
<input class="input" id="weather_lon" name="weather_lon"
value="<?= e((string)($weatherLon ?? '')) ?>" placeholder="如:120.383">
<span class="tip">
<b>三个都留空就不显示天气</b>(首页不会因此出错 —— 不猜城市,猜错比不显示更糟)。
坐标可以在地图网站上右键查。
</span>
</div>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<button class="btn" type="submit">保存</button> <button class="btn" type="submit">保存</button>
+8 -2
View File
@@ -119,8 +119,12 @@ $isAdmin = \App\Auth::isAdmin();
<script> <script>
(function () { (function () {
/* ---- 拖动排序(▲▼ 是不用 JS 的兜底,两条路都能用)---- */ /* ---- 拖动排序(▲▼ 是不用 JS 的兜底,两条路都能用)----
var list = document.getElementById('navOrderList'); ⚠️ 遍历**所有** `ol.order-list`,而不是只找 navOrderList:
本页现在有两块同构的清单(首页上的模块 / 导航条上的模块),
写死 id 的话第二块就没有拖动了 —— 而且看起来"功能好好的",
只是那一块拖不动,很难联想到是这里写死了 id。 */
function bindSort(list) {
if (!list) return; if (!list) return;
var dragEl = null; var dragEl = null;
Array.prototype.forEach.call(list.querySelectorAll('.mod-row'), function (li) { Array.prototype.forEach.call(list.querySelectorAll('.mod-row'), function (li) {
@@ -158,6 +162,8 @@ $isAdmin = \App\Auth::isAdmin();
li.classList.remove('is-drop-before', 'is-drop-after'); li.classList.remove('is-drop-before', 'is-drop-after');
}); });
}); });
}
Array.prototype.forEach.call(document.querySelectorAll('ol.order-list'), bindSort);
/* ---- 色板点一下就换 ---- */ /* ---- 色板点一下就换 ---- */
Array.prototype.forEach.call(document.querySelectorAll('.preset-color[data-for]'), function (el) { Array.prototype.forEach.call(document.querySelectorAll('.preset-color[data-for]'), function (el) {
+47
View File
@@ -139,6 +139,53 @@ $rosterCount = $rosterCount ?? 0;
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- ==================== 首页上的模块(用户 2026-10-03 第 3 条)==================== -->
<?php /* 与下面「导航条上的模块」是同一套交互(勾选 + 拖动/▲▼ 排序),
只是作用对象换成首页 dashboard 上那几张模块卡。
两块都要有:导航条管"能不能进",这里管"首页上先看到什么"。 */ ?>
<div class="card card-pad" id="home-mods" style="margin-top:20px">
<h2 class="section-title">
首页上的模块
<span class="hint">勾上才会出现在首页,拖动可改顺序</span>
</h2>
<p class="small muted" style="margin:0 0 16px">
首页是"一眼看家里近况"的地方,模块太多会变成一面墙 —— 这里只挑你想每天看到的几个。
关掉的模块<b>照样能从顶部导航条进去</b>,只是不在首页上占位置。
</p>
<form method="post" action="<?= e(url('settings/home_save')) ?>" id="homeForm">
<?= csrf_field() ?>
<ol class="order-list mod-order" id="homeOrderList">
<?php
$hOn = array_map('strval', (array)($homeOn ?? []));
$hOrder = (array)($homeOrder ?? []);
?>
<?php foreach ($hOrder as $i => $k): ?>
<li class="order-row mod-row" draggable="true" data-key="<?= e($k) ?>">
<span class="drag-handle" title="拖我排序" aria-hidden="true">⋮⋮</span>
<input type="hidden" name="order[]" value="<?= e($k) ?>">
<label class="mod-pick">
<input type="checkbox" name="on[]" value="<?= e($k) ?>"
<?= in_array($k, $hOn, true) ? 'checked' : '' ?>>
<span class="color-preview"
style="background: <?= e(\App\Module::color($k, (int)\App\Auth::familyId())) ?>;"></span>
<span class="order-name"><?= e(\App\Module::name($k)) ?></span>
</label>
<span class="order-move">
<button class="btn btn-ghost btn-sm" type="submit" name="move" value="up:<?= e($k) ?>"
title="上移" <?= $i === 0 ? 'disabled' : '' ?>>▲</button>
<button class="btn btn-ghost btn-sm" type="submit" name="move" value="down:<?= e($k) ?>"
title="下移" <?= $i === count($hOrder) - 1 ? 'disabled' : '' ?>>▼</button>
</span>
</li>
<?php endforeach; ?>
</ol>
<div class="btn-row">
<button class="btn" type="submit" name="save" value="1">保存首页显示</button>
</div>
</form>
</div>
<!-- ==================== 模块:导航条上显示哪些、各自什么主题色 ==================== --> <!-- ==================== 模块:导航条上显示哪些、各自什么主题色 ==================== -->
<?php <?php
$navKeys = \App\Module::navFor((int)\App\Auth::personId()); $navKeys = \App\Module::navFor((int)\App\Auth::personId());
+19
View File
@@ -75,6 +75,25 @@ if ($isEdit) {
<textarea class="input" id="t-note" name="note" rows="3"><?= e(old('note', $t['note'] ?? '')) ?></textarea> <textarea class="input" id="t-note" name="note" rows="3"><?= e(old('note', $t['note'] ?? '')) ?></textarea>
</div> </div>
<?php /* 用户 2026-10-03 第 4 条:旅程列表只显示公开的(is_public=1)。
默认**勾上**(公开)—— 新建一趟行程却默认藏起来是反直觉的;
编辑时按库里的值回填,老数据 COALESCE 成 1 也是勾上。
⚠️ 提交的是 `is_public=1`,不勾则完全不提交这个键,
saveDetails 里 `!empty($d['is_public'])` 自然得到 0。 */ ?>
<?php $isPub = old('is_public', (string)($t['is_public'] ?? 1)) !== '0'; ?>
<div class="field full">
<label>公开</label>
<label class="chk-inline">
<?php /* ⚠️ 先放一个 hidden=0:不勾选时提交的就是"明确的不公开",
而不是"这个字段没出现" —— 后者会被当成"没传"而默认公开,
于是"取消勾选"看起来毫无作用(经典 checkbox 陷阱)。 */ ?>
<input type="hidden" name="is_public" value="0">
<input type="checkbox" name="is_public" value="1" <?= $isPub ? 'checked' : '' ?>>
<span>在旅程列表与日历里显示这趟行程</span>
</label>
<span class="tip">不勾 = 只留着自己看:不进列表、不上日历,但有链接仍能打开。</span>
</div>
<div class="field full"> <div class="field full">
<label>同行的人</label> <label>同行的人</label>
<?php /* v24 第 11 条:与全局选人组件统一(原来是平铺的 checkbox 网格, <?php /* v24 第 11 条:与全局选人组件统一(原来是平铺的 checkbox 网格,