家谱卡片重做 / 首页控制台 / 名册改列表 / 重复日程与旅程公开
用户 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:
+116
-8
@@ -317,6 +317,75 @@ final class Calendar
|
||||
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 取) */
|
||||
public static function newUid(): string
|
||||
{
|
||||
@@ -468,42 +537,74 @@ final class Calendar
|
||||
}
|
||||
if ($ids) {
|
||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||
// ⚠️ 取数范围要**比 [from,to] 放宽**:重复日程的种子(starts_at)可能远早于
|
||||
// 本月("从去年起每周三"),按 starts_at 直接卡范围会把整个系列漏掉。
|
||||
// 这里取到 `to` 为止、往前不设限,展开阶段再按 [from,to] 裁。
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT * FROM calendar_events
|
||||
WHERE calendar_id IN ($in)
|
||||
AND DATE(starts_at) <= ? AND DATE(COALESCE(ends_at, starts_at)) >= ?
|
||||
AND DATE(starts_at) <= ?
|
||||
ORDER BY starts_at"
|
||||
);
|
||||
$st->execute(array_merge($ids, [$to, $from]));
|
||||
$st->execute(array_merge($ids, [$to]));
|
||||
$calById = [];
|
||||
foreach ($calendars as $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;
|
||||
$sd = substr((string)$e['starts_at'], 0, 10);
|
||||
$ed = substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10);
|
||||
// 跨天日程:在范围内**每天**都出一条(群晖日历就是这么画的)
|
||||
$first = true;
|
||||
foreach (self::eachDay($sd, $ed, $from, $to) as $day) {
|
||||
$tm = substr((string)$e['starts_at'], 11, 5);
|
||||
$rr = (string)($e['rrule'] ?? '');
|
||||
// 用户 2026-10-03 第 5/6 条:重复日程按规则展开成若干次发生;
|
||||
// 不重复的走同一条路(occurrences 会返回它自己那一天)。
|
||||
$days = Repeat::occurrences(
|
||||
(string)$e['starts_at'], $rr, $from, $to,
|
||||
$skips[(int)$e['id']] ?? []
|
||||
);
|
||||
foreach ($days as $day) {
|
||||
// 跨天日程:从这次发生的当天起,按事件本身的长度铺开
|
||||
$span = (int)floor((strtotime($ed) - strtotime($sd)) / 86400);
|
||||
$first = true;
|
||||
foreach (self::eachDay($day, date('Y-m-d', strtotime($day . ' +' . $span . ' day')), $from, $to) as $d2) {
|
||||
$out[] = [
|
||||
'cal' => (int)$e['calendar_id'],
|
||||
'color' => (string)($c['color'] ?? '#4A7C82'),
|
||||
'calname' => (string)($c['name'] ?? ''),
|
||||
'title' => (string)$e['title'],
|
||||
'date' => $day,
|
||||
'date' => $d2,
|
||||
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
|
||||
'time' => ($first && empty($e['all_day'])) ? $tm : '',
|
||||
'all_day' => !empty($e['all_day']),
|
||||
'kind' => 'custom',
|
||||
'ref_id' => (int)$e['id'],
|
||||
'href' => url('calendar/event', ['id' => (int)$e['id']]),
|
||||
// 带上这一天的日期,重复日程点进去才能定位到"改哪一次"
|
||||
'occ' => $day,
|
||||
'repeat' => $rr,
|
||||
'href' => url('calendar/event', ['id' => (int)$e['id'], 'd' => $day]),
|
||||
];
|
||||
$first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 观影(events kind=movie)----
|
||||
if (isset($byKind['movie'])) {
|
||||
@@ -540,14 +641,21 @@ final class Calendar
|
||||
// 现在按 trip_nodes.starts_at 出条目,跨天的节点同样逐天铺开。
|
||||
if (isset($byKind['trip'])) {
|
||||
$c = $byKind['trip'][0];
|
||||
// 用户 2026-10-03 第 4 条:不公开的旅程,节点也不上日历。
|
||||
// COALESCE 兜底:缺 trip_details 行时按"公开"处理(老数据不会凭空消失)。
|
||||
// ⚠️ 别在 SQL 字符串里写 `--` 行内注释:PHP 里那段是普通字符串,
|
||||
// 但 `--` 之后的内容仍是字符串的一部分,SQL 侧被注释掉,
|
||||
// 而 PHP 侧语法检查看不出问题 —— 会变成"语句少半截"的静默错误。
|
||||
$st = Database::get()->prepare(
|
||||
"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,
|
||||
e.id AS event_id, e.title AS trip_title
|
||||
FROM trip_nodes n
|
||||
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
|
||||
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)) >= ?
|
||||
ORDER BY n.starts_at"
|
||||
);
|
||||
|
||||
+393
@@ -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)
|
||||
. '¤t=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>';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+48
-1
@@ -35,12 +35,25 @@ final class Person
|
||||
$args[] = $familyId;
|
||||
$args[] = $kind;
|
||||
if ($q !== '') {
|
||||
// ⚠️ 纯字母/数字的查询词**不能**交给 SQL 的 LIKE 过滤:
|
||||
// 它是"姓名首字母"(如 yyq),SQL 里根本没法把汉字转拼音,
|
||||
// 一 LIKE 就把结果筛成空 —— 后面的 PHP 精筛也就没东西可筛了,
|
||||
// 表现为"搜 yyq 一个人都没有"(功能像没做)。
|
||||
// 所以这种查询词直接跳过 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';
|
||||
$st = Database::get()->prepare($sql);
|
||||
$st->execute($args);
|
||||
@@ -59,6 +72,9 @@ final class Person
|
||||
*/
|
||||
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,
|
||||
a.id AS account_id, a.role AS account_role, a.status AS account_status,
|
||||
a.last_login_at, n.nickname AS my_nickname
|
||||
@@ -72,16 +88,47 @@ final class Person
|
||||
$args[] = $viewerId;
|
||||
$args[] = $kind;
|
||||
if ($q !== '') {
|
||||
// ⚠️ SQL 只在**能帮忙**时才粗筛,两个反例:
|
||||
// · 纯字母(yyq)—— 那是"姓名首字母",SQL 里没法把汉字转拼音,
|
||||
// 一旦 LIKE 就把结果筛成空,后面的 PHP 精筛也就没东西可筛了;
|
||||
// · 纯数字(手机号)—— 手机号是**加密存的**,库里只有密文,
|
||||
// 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';
|
||||
$st = Database::get()->prepare($sql);
|
||||
$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
|
||||
|
||||
+299
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -383,6 +383,7 @@ CREATE TABLE IF NOT EXISTS `trip_details` (
|
||||
`event_id` INT UNSIGNED NOT NULL COMMENT '对应 events(kind="trip")',
|
||||
`cover_key` VARCHAR(255) DEFAULT NULL COMMENT '封面图(存储相对键)',
|
||||
`summary` TEXT DEFAULT NULL COMMENT '这趟旅程的一段话小结',
|
||||
`is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(0=不显示在列表与日历)',
|
||||
PRIMARY KEY (`event_id`),
|
||||
CONSTRAINT `fk_tdet_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE
|
||||
) 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 条)----
|
||||
// ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的
|
||||
// (见 Anniversary::birthdays),改了生日纪念日就跟着变。
|
||||
|
||||
+19
-5
@@ -44,13 +44,18 @@ final class Trip
|
||||
/** 全部旅程(按出发时间倒序) */
|
||||
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,
|
||||
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 trip_nodes n WHERE n.event_id = e.id) AS node_count
|
||||
FROM events e
|
||||
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'];
|
||||
$kw = trim($keyword);
|
||||
if ($kw !== '') {
|
||||
@@ -107,7 +112,9 @@ final class Trip
|
||||
public static function find(int $familyId, int $id, int $viewerId = 0): ?array
|
||||
{
|
||||
$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
|
||||
WHERE e.family_id = ? AND e.id = ? AND e.kind = 'trip'"
|
||||
);
|
||||
@@ -176,14 +183,21 @@ final class Trip
|
||||
$row = [
|
||||
self::keyOrNull($d['cover_key'] ?? ''),
|
||||
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->execute([$eventId]);
|
||||
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]);
|
||||
} 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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,40 @@ error_reporting(E_ALL);
|
||||
ini_set('display_errors', $debug ? '1' : '0');
|
||||
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');
|
||||
|
||||
|
||||
+118
-32
@@ -215,31 +215,98 @@ a:hover { text-decoration: underline; }
|
||||
border-left: 3px solid var(--brand-2);
|
||||
color: var(--muted); font-size: 14px;
|
||||
}
|
||||
/* ⚠️ .hero-stats / .stat 已随用户 2026-10-03 第 3 条撤掉首页那三个统计,
|
||||
这里保留规则本身(其它页面可能还在用 .stat 这个类名),不再由首页引用。 */
|
||||
.hero-stats { margin-left: auto; display: flex; gap: 28px; }
|
||||
.stat { text-align: center; }
|
||||
.stat b { display: block; font-size: 24px; font-weight: 600; line-height: 1.2; }
|
||||
.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 {
|
||||
display: grid; gap: 16px; margin-top: 4px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
display: flex; flex-direction: column; gap: 8px; margin-top: 4px;
|
||||
}
|
||||
.person {
|
||||
display: block; color: var(--ink); position: relative;
|
||||
background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform .15s ease, box-shadow .15s ease;
|
||||
border-radius: var(--radius-s);
|
||||
transition: border-color .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 {
|
||||
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 .pb { padding: 12px 14px 14px; }
|
||||
.person .pname { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 6px; }
|
||||
.person .pmeta { color: var(--muted); font-size: 13px; margin-top: 2px; }
|
||||
.person .person-main {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
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 {
|
||||
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; }
|
||||
|
||||
/* 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 {
|
||||
position: absolute;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 0;
|
||||
width: 140px; height: 120px;
|
||||
box-sizing: border-box; padding: 4px 4px 3px;
|
||||
width: 118px; height: 132px;
|
||||
box-sizing: border-box; padding: 6px 6px 4px;
|
||||
text-align: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
@@ -519,35 +593,36 @@ textarea.input { min-height: 76px; resize: vertical; }
|
||||
pointer-events: auto; /* 父 .card_cont 是 none,这里恢复 */
|
||||
}
|
||||
.kin-card:hover {
|
||||
box-shadow: var(--shadow-l); border-color: var(--brand-2); text-decoration: none;
|
||||
}
|
||||
/* ⚠️ v24 第 12 条:头像是**方形**、不裁切。
|
||||
卡片高度从 120 加到 136,把这块从 70 放大到 74×74 的正方形;
|
||||
object-fit 用 contain(不是 cover)—— cover 会把非正方形的头像切掉边,
|
||||
用户要的正是"所有人的头像都是方形的,别截断"。 */
|
||||
.kin-card {
|
||||
width: 148px; height: 138px;
|
||||
box-shadow: var(--shadow-l); border-color: var(--brand-2);
|
||||
}
|
||||
/* 头像外框:**长方形**,宽度贴合头像显示宽度 */
|
||||
.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;
|
||||
display: block;
|
||||
}
|
||||
.kin-card .kin-ph img {
|
||||
width: 100%; height: 100%; object-fit: contain; display: block;
|
||||
}
|
||||
/* 「⇄ 姻亲」:卡片右下角的小按钮(这一侧的人没画在本图上时才有) */
|
||||
.kin-card .kin-branch {
|
||||
/* 右下角「以 TA 为中心」:每个人都有一个。
|
||||
⚠️ 单独一个 <button>(不在 <a> 里面)—— 这是"误触"的根治办法:
|
||||
整卡若是链接,手指偏一点就先触发它跳去成员页了。 */
|
||||
.kin-card .kin-focus {
|
||||
position: absolute; right: 3px; bottom: 3px;
|
||||
font-size: 10px; line-height: 1; padding: 3px 5px; border-radius: 999px;
|
||||
background: var(--brand); color: #fff; cursor: pointer;
|
||||
font-size: 10px; line-height: 1; padding: 3px 6px; border-radius: 999px;
|
||||
border: 0; background: var(--brand); color: #fff; cursor: pointer;
|
||||
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 {
|
||||
display: block;
|
||||
font-size: 12.5px; font-weight: 600; line-height: 1.25; max-width: 100%;
|
||||
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 {
|
||||
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 拆开) */
|
||||
.person-main { display: block; color: var(--ink); }
|
||||
.person-main:hover { text-decoration: none; }
|
||||
/* 列表形态下「⋯ 调整分组」改到行的**右侧**(原来在卡片下方,列表里会撑高一行) */
|
||||
.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 {
|
||||
list-style: none; cursor: pointer; padding: 7px 14px;
|
||||
font-size: 12px; color: var(--muted);
|
||||
list-style: none; cursor: pointer; padding: 4px 10px;
|
||||
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::after { content: ' ▾'; }
|
||||
.person-more[open] > summary::after { content: ' ▴'; }
|
||||
.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 .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.is-dragged { opacity: .45; cursor: grabbing; }
|
||||
.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;
|
||||
border-radius: 6px; font-size: 14px;
|
||||
color: var(--muted); background: rgba(255, 255, 255, .82);
|
||||
@@ -774,6 +858,8 @@ textarea.input { min-height: 76px; resize: vertical; }
|
||||
pointer-events: none; /* 手柄只是个记号,抓取要落在整张卡上 */
|
||||
}
|
||||
.drag-handle-inline { color: var(--muted); }
|
||||
/* 列表里每行要留出左侧拖柄的位置(否则⠿会压在头像上) */
|
||||
.roster.is-droppable .person { padding-left: 18px; }
|
||||
|
||||
/* 空栏平时收起来(七个预置分组可能只用到三个,摊开会显得名册破破烂烂)——
|
||||
但**拖动时必须露出来**,否则"想拖到一个现在没人的分组里"就没地方放。 */
|
||||
|
||||
+78
-8
@@ -546,17 +546,36 @@
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) { e.dataTransfer.dropEffect = 'move'; }
|
||||
var grid = block.querySelector('.people-grid');
|
||||
// 落点 = 指针在谁的上半部分,就插到谁前面(和列表拖拽的直觉一致)
|
||||
var after = null;
|
||||
/* 落点判定(用户 2026-10-03 第 4 条:"好像只能往后调整,不能往前调整")。
|
||||
名册已从网格改成**竖向列表**,所以:
|
||||
指针在某行的**上半部分** → 插到它前面;下半部分 → 插到它后面。
|
||||
⚠️ 原来只记了"第一个满足 clientY < 中点 的卡片"然后 insertBefore ——
|
||||
当目标就在**当前拖着的这张之后**时,insertBefore 到它的位置
|
||||
等于没动(因为 dragged 自己先被移走、后面的元素整体前移一位),
|
||||
于是"往前拖"看起来完全无效。现在改成显式记录"插到谁前面/后面",
|
||||
并且在计算时**排除 dragged 自己**,两种方向都成立。 */
|
||||
var kids = [];
|
||||
each(grid.querySelectorAll('.person'), function (c) {
|
||||
if (after || c === dragging) { return; }
|
||||
var r = c.getBoundingClientRect();
|
||||
if (e.clientY < r.top + r.height / 2) { after = c; }
|
||||
if (c !== dragging) { kids.push(c); }
|
||||
});
|
||||
if (after) {
|
||||
grid.insertBefore(dragging, after);
|
||||
} else {
|
||||
var target = null; // 落点参照物
|
||||
var before = true; // 插到它的前面还是后面
|
||||
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);
|
||||
} else if (before) {
|
||||
grid.insertBefore(dragging, target);
|
||||
} else {
|
||||
grid.insertBefore(dragging, target.nextSibling);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -574,4 +593,55 @@
|
||||
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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
})();
|
||||
|
||||
@@ -19,6 +19,8 @@ use App\Crypto;
|
||||
use App\Database;
|
||||
use App\Douban;
|
||||
use App\Family;
|
||||
use App\Home;
|
||||
use App\HomeMods;
|
||||
use App\Anniversary;
|
||||
use App\Ics;
|
||||
use App\Kinship;
|
||||
@@ -32,6 +34,7 @@ use App\Person;
|
||||
use App\PersonGroup;
|
||||
use App\PersonRoster;
|
||||
use App\Relations;
|
||||
use App\Repeat;
|
||||
use App\Resource;
|
||||
use App\Schema;
|
||||
use App\Setting;
|
||||
@@ -200,6 +203,9 @@ function render_me_settings(array $person, array $errors = []): void
|
||||
'groups' => PersonGroup::orderFor($familyId, $me),
|
||||
'groupCounts' => PersonGroup::counts($familyId, $me),
|
||||
'rosterCount' => PersonRoster::countFor($familyId, $me),
|
||||
// 用户 2026-10-03 第 3 条:首页上显示哪些模块、什么顺序(每人一份)
|
||||
'homeOrder' => HomeMods::orderedForPicker($me),
|
||||
'homeOn' => HomeMods::forPerson($me),
|
||||
], 'settings');
|
||||
}
|
||||
|
||||
@@ -556,6 +562,43 @@ switch ($r) {
|
||||
}
|
||||
$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', [
|
||||
'title' => '首页',
|
||||
'family' => Family::find($familyId),
|
||||
@@ -571,6 +614,13 @@ switch ($r) {
|
||||
// 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页)
|
||||
'mySlogan' => trim((string)($people[$me]['slogan'] ?? '')),
|
||||
'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');
|
||||
break;
|
||||
|
||||
@@ -612,6 +662,27 @@ switch ($r) {
|
||||
foreach ($rows as &$row) {
|
||||
$row['label'] = Kinship::label($graph, $me, (int)$row['id'], $people);
|
||||
$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);
|
||||
|
||||
@@ -891,6 +962,43 @@ switch ($r) {
|
||||
redirect('settings/me', [], 'modules');
|
||||
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 条,**家庭一份**,所以只有管理员能改)。
|
||||
*/
|
||||
@@ -1574,6 +1682,10 @@ switch ($r) {
|
||||
'title' => '设置',
|
||||
'family' => Family::find((int)Auth::familyId()),
|
||||
'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,
|
||||
'roleCounts' => $acc,
|
||||
'relCount' => Relations::count((int)Auth::familyId()),
|
||||
@@ -1695,6 +1807,19 @@ switch ($r) {
|
||||
(string)($cur['name'] ?? '家书'),
|
||||
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', '已保存。');
|
||||
redirect('settings');
|
||||
break;
|
||||
@@ -2341,8 +2466,20 @@ switch ($r) {
|
||||
'location' => trim((string)($_POST['location'] ?? '')),
|
||||
'note' => trim((string)($_POST['note'] ?? '')),
|
||||
'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),
|
||||
];
|
||||
if (array_key_exists('is_public', $_POST)) {
|
||||
$d['is_public'] = (string)$_POST['is_public'];
|
||||
}
|
||||
$errors = [];
|
||||
if ($d['title'] === '') {
|
||||
$errors['title'] = '旅程名字不能为空';
|
||||
@@ -2922,6 +3059,8 @@ switch ($r) {
|
||||
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
|
||||
'status' => (string)($_POST['status'] ?? 'confirmed'),
|
||||
'transp' => !empty($_POST['transp']),
|
||||
// 用户 2026-10-03 第 5、6 条:重复规则(RFC 5545 RRULE 原文)
|
||||
'rrule' => Repeat::build($_POST),
|
||||
'note' => $note,
|
||||
'created_by' => $me,
|
||||
]);
|
||||
@@ -2949,10 +3088,19 @@ switch ($r) {
|
||||
redirect('calendar');
|
||||
}
|
||||
$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', [
|
||||
'title' => (string)$ev['title'],
|
||||
'ev' => $ev,
|
||||
'cal' => $cal,
|
||||
'occ' => $occ,
|
||||
// ⚠️ 不要再传 'ym':模板自己从 $ev['starts_at'] 算(模板里那行更权威,
|
||||
// 传进来反而会在"改过日期后"与模板算出的值不一致)。
|
||||
], 'calendar');
|
||||
break;
|
||||
|
||||
@@ -2964,13 +3112,17 @@ switch ($r) {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$ym = trim((string)($_POST['ym'] ?? ''));
|
||||
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
|
||||
// ⚠️ 这里要把**整行**取回来(含 rrule / starts_at / 各字段):
|
||||
// 改重复日程时,"仅此一次 / 这一次及以后"都要拿旧内容来裁剪或复制,
|
||||
// 只取 id 的话还得再查一遍(而且容易漏字段)。
|
||||
$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
|
||||
WHERE e.id = ? AND c.family_id = ?'
|
||||
);
|
||||
$st->execute([$id, $familyId]);
|
||||
$ev = $st->fetch();
|
||||
$ev2 = $ev; // 旧内容(scope 分支要用,别被后面的 $d 覆盖)
|
||||
if (!$ev) {
|
||||
flash('err', '找不到这条日程。');
|
||||
redirect('calendar', ['ym' => $ym]);
|
||||
@@ -2992,7 +3144,7 @@ switch ($r) {
|
||||
$startsAt = substr($startsAt, 0, 10) . ' 00:00:00';
|
||||
$endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null;
|
||||
}
|
||||
Calendar::updateEvent($id, [
|
||||
$d = [
|
||||
'title' => trim((string)($_POST['title'] ?? '')),
|
||||
'location' => trim((string)($_POST['location'] ?? '')),
|
||||
'starts_at' => $startsAt,
|
||||
@@ -3001,8 +3153,67 @@ switch ($r) {
|
||||
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
|
||||
'status' => (string)($_POST['status'] ?? 'confirmed'),
|
||||
'transp' => !empty($_POST['transp']),
|
||||
'rrule' => Repeat::build($_POST),
|
||||
'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);
|
||||
if ($warns) {
|
||||
flash('err', '有附件没有保存:' . implode(';', $warns));
|
||||
|
||||
@@ -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;
|
||||
require __DIR__ . '/../_datetime.php'; ?>
|
||||
</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>
|
||||
<?php $dtName = 'ends_at'; $dtId = 'ev-end';
|
||||
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true;
|
||||
require __DIR__ . '/../_datetime.php'; ?>
|
||||
</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">
|
||||
<label>
|
||||
@@ -72,6 +81,40 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
|
||||
<span class="field-hint">勾上就不管具体几点,只占这一天</span>
|
||||
</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">
|
||||
<label for="ev-status">状态</label>
|
||||
<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);
|
||||
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>
|
||||
<?php unset($evF, $evAllDayDefault, $evIsAllDay, $evFields, $evAllDay); ?>
|
||||
|
||||
@@ -63,6 +63,36 @@ $canWrite = \App\Auth::canWrite() && !empty($ev['_can_write']);
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
|
||||
<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">
|
||||
<?php $evFields = $ev; $evAllDay = false; require __DIR__ . '/_event_fields.php'; ?>
|
||||
</div>
|
||||
|
||||
+99
-56
@@ -2,93 +2,136 @@
|
||||
/**
|
||||
* 首页 —— 一块 dashboard(用户 2026-10-01 第 13 条)。
|
||||
*
|
||||
* 现在首页只回答三个问题:**最近有什么事**、**手头在做什么**、**往年这时候我们在干嘛**。
|
||||
* · 接下来的日子(两个月内的生日)
|
||||
* 用户 2026-10-03 第 3 条把它重排成:
|
||||
* · hero:标题 + 个人 slogan + 两个图标按钮(我的资料 / 我的家谱)
|
||||
* · 统计信息:随机问候语 + 今日天气 + 近七天日程数 / 待办数
|
||||
* · 未来七天的日程(自定义 + 观影 + 旅行 + 有截止时间的任务,一次汇总)
|
||||
* · 进行中的任务(含已过期的待办)
|
||||
* · 往年今日(往年的这一天看过的电影 / 走过的旅程)
|
||||
* · 手头在做(进行中 + 已逾期的任务)
|
||||
* · 各模块的统计与模块内信息(**可按人在个人设置里开关与排序**)
|
||||
* · 往年今日
|
||||
* ⚠️ 「接下来的日子(两个月内的生日)」已去掉:生日/纪念日本来就是日程,
|
||||
* 已经在「未来七天」里,两块并排是重复。
|
||||
*
|
||||
* ⚠️ **家人卡片已经不在首页了**(用户明确要求):看人去「名册」、看关系去「家谱」,
|
||||
* 首页留给"今天该关心什么"。统计数字仍在,因为那是唯一的全局概览。
|
||||
*
|
||||
* ⚠️ 标题下的那句个人口号已按用户要求撤掉(v8):hero 区只留标题与三个统计。
|
||||
*/
|
||||
$kinCount = count($kin);
|
||||
// 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出
|
||||
$shortKind = \App\Birthday::KINDS;
|
||||
$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">
|
||||
<img class="hero-logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
|
||||
<div class="hero-main">
|
||||
<h1><?= e(\App\Auth::homeTitle()) ?></h1>
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="stat"><b><?= (int)$kinCount ?></b><span>与我有关</span></div>
|
||||
<div class="stat"><b><?= (int)$allCount ?></b><span>名册总数</span></div>
|
||||
<div class="stat"><b><?= (int)$relCount ?></b><span>亲属连线</span></div>
|
||||
<?php $heroSlogan = trim((string)($mySlogan ?? '')) !== ''
|
||||
? (string)$mySlogan
|
||||
: trim((string)($family['motto'] ?? '')); ?>
|
||||
<?php if ($heroSlogan !== ''): ?>
|
||||
<p class="hero-slogan"><?= e($heroSlogan) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php /* ⚠️ 这两个链接必须带上真实的 person id:曾经 layout.php 里写
|
||||
`$me = Auth::check()` 把模板里的 $me 覆盖成布尔值,链接就变成 id=0
|
||||
("补充亲属关系"点进去是空白页)。所以这里显式用 $meId。 */ ?>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/show', ['id' => (int)$meId])) ?>">我的资料</a>
|
||||
<a class="btn btn-ghost btn-sm" href="<?= e(url('kin')) ?>">我的家谱</a>
|
||||
<a class="btn btn-ghost btn-sm" href="<?= e(url('people')) ?>">名册</a>
|
||||
<a class="icon-btn" href="<?= e(url('people/show', ['id' => (int)$meId])) ?>"
|
||||
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">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<?php /* ==================== 接下来的日子 ==================== */
|
||||
/* ⚠️ 即使近两个月没人过生日,这一块也**照常渲染**(给空态),只是内容为空。
|
||||
理由:dashboard 的四块是"每天要看的东西",某一块忽然整块消失会让人以为
|
||||
功能坏了 / 数据丢了(旧版就是 `if ($birthdays)` 一省略了之)。
|
||||
空态还能顺带告诉用户"系统在盯着生日,只是最近没有"。 */ ?>
|
||||
<?php /* 用户 2026-10-03 第 3 条:「接下来的日子(两个月内)」整块**去掉** ——
|
||||
它列的是生日,而生日/纪念日本来就是日程,已经在下面的「未来七天」里出现过,
|
||||
两块并排是重复信息。生日的"提前提醒"由日历与纪念日模块负责。 */
|
||||
?>
|
||||
|
||||
<?php /* ==================== 统计信息(用户第 3 条) ====================
|
||||
一句话问候 + 今天的天气 + 几个数字。放在最上面,因为它回答的是
|
||||
"今天是个什么日子、我该关心什么"——比下面任何一个列表都更该先看到。 */ ?>
|
||||
<section class="section">
|
||||
<h2 class="section-title">接下来的日子 <span class="hint">两个月内</span></h2>
|
||||
<?php if (!$birthdays): ?>
|
||||
<div class="card card-pad empty"><b>近两个月没有人过生日</b>等到了月份,这里会自动冒出来。</div>
|
||||
<?php else: ?>
|
||||
<div class="bdays">
|
||||
<?php foreach ($birthdays as $b): ?>
|
||||
<?php
|
||||
// 同一个人可能出两条(农历与阳历不在同一天);落在同一天时后端已经并成一条,
|
||||
// kinds 里会有多项 —— 这里照着列出来,用户才知道"今天过的是哪个生日"。
|
||||
$bkinds = (array)($b['kinds'] ?? []);
|
||||
$bwords = [];
|
||||
foreach ($bkinds as $k) {
|
||||
$bwords[] = $shortKind[$k] ?? (string)$k;
|
||||
}
|
||||
$bshown = (string)($b['shown'] ?? $b['name'] ?? '');
|
||||
?>
|
||||
<a class="bday" href="<?= e(url('people/show', ['id' => (int)$b['id']])) ?>">
|
||||
<img src="<?= e(avatar_url($b['avatar'])) ?>" alt="">
|
||||
<div>
|
||||
<b><?= e($bshown) ?></b>
|
||||
<?php if (!empty($b['label']) && $b['label'] !== '我'): ?>
|
||||
<span class="badge"><?= e($b['label']) ?></span>
|
||||
<?php endif; ?>
|
||||
<div class="d">
|
||||
<?php if ((int)$b['days_left'] === 0): ?>
|
||||
就是今天,生日快乐
|
||||
<?php else: ?>
|
||||
还有 <?= (int)$b['days_left'] ?> 天过生日
|
||||
<div class="card card-pad home-brief">
|
||||
<div class="home-brief-hello">
|
||||
<b><?= e((string)($greeting ?? '')) ?></b>
|
||||
<span class="muted small"><?= e(date('n 月 j 日')) ?> <?= e((string)($weekday ?? '')) ?></span>
|
||||
</div>
|
||||
<?php if (!empty($weather)): ?>
|
||||
<div class="home-brief-weather">
|
||||
<span class="w-icon" aria-hidden="true"><?= e((string)($weather['icon'] ?? '')) ?></span>
|
||||
<span class="w-temp"><?= e((string)($weather['temp'] ?? '')) ?></span>
|
||||
<span class="w-desc"><?= e((string)($weather['text'] ?? '')) ?></span>
|
||||
<?php if (($weather['place'] ?? '') !== ''): ?>
|
||||
<span class="muted small"><?= e((string)$weather['place']) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="bday-meta">
|
||||
<span class="bday-date"><?= e((string)$b['date']) ?></span>
|
||||
<?php foreach ($bwords as $w): ?>
|
||||
<span class="bd-kind"><?= e($w) ?></span>
|
||||
<?php endif; ?>
|
||||
<div class="home-brief-stats">
|
||||
<span class="hbs"><b><?= (int)($stat7['events'] ?? 0) ?></b> 条日程(近七天)</span>
|
||||
<span class="hbs"><b><?= (int)($stat7['todos'] ?? 0) ?></b> 件待办</span>
|
||||
<span class="hbs"><b><?= (int)($stat7['overdue'] ?? 0) ?></b> 件逾期</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php /* ==================== 各模块统计(用户第 3 条) ====================
|
||||
每个模块给一个"一眼看得懂的小结"。用户在个人设置里能挑显示哪些、以及顺序。 */ ?>
|
||||
<?php if ($homeMods && !empty($moduleCards)): ?>
|
||||
<section class="section">
|
||||
<div class="page-head" style="margin-bottom:12px">
|
||||
<h1 style="font-size:15px">家里的近况</h1>
|
||||
<div class="actions">
|
||||
<a class="btn btn-ghost btn-sm" href="<?= e(url('settings/me')) ?>#home-mods">调整显示</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mod-cards">
|
||||
<?php foreach ($homeMods as $mk): ?>
|
||||
<?php $mc = $moduleCards[$mk] ?? null; if (!$mc) { continue; } ?>
|
||||
<a class="mod-card" href="<?= e((string)$mc['href']) ?>"
|
||||
style="--mc: <?= e((string)($mc['color'] ?? '#888')) ?>">
|
||||
<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>
|
||||
<?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; ?>
|
||||
<?php if (!empty($b['lunar_label'])): ?>
|
||||
<span class="muted small">农历<?= e((string)$b['lunar_label']) ?></span>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* ==================== 日程(未来七天) ==================== */ ?>
|
||||
<section class="section">
|
||||
|
||||
+40
-61
@@ -147,19 +147,11 @@ $datumJson = json_encode(
|
||||
);
|
||||
?>
|
||||
|
||||
<?php /* 用户 2026-10-03 第 2 条:标题下的那句说明**去掉**;
|
||||
右下角的「返回我的家谱」也去掉 —— 与页面里那个工具条重复,
|
||||
会出现两个"回到我的家谱"(用户明确点名)。现在整页只有标题。 */ ?>
|
||||
<div class="page-head">
|
||||
<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>
|
||||
|
||||
<?php if (!$canChart): ?>
|
||||
@@ -170,11 +162,12 @@ $datumJson = json_encode(
|
||||
</div>
|
||||
<?php else: ?>
|
||||
|
||||
<div class="kin-tools">
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="kinFit">看全部</button>
|
||||
<?php /* 中心是别人时这颗按钮是"跳回我的家谱"(不是在本图里找我的坐标) */ ?>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="kinMe"><?= $focusId === (int)$meId ? '回到我' : '回到我的家谱' ?></button>
|
||||
</div>
|
||||
<?php /* 用户 2026-10-03 第 2 条:「看全部 / 回到我 / 回到我的家谱」这些按钮
|
||||
全部去掉。镜头由库自动 fit(updateTree 的 tree_position: 'fit'),
|
||||
用户要调整时直接拖拽/滚轮即可,不需要常驻按钮。 */
|
||||
/* ⚠️ 顺带说明:品牌名「家谱」已从导航条撤走,但**模块本身还在** ——
|
||||
个人设置里能勾回来,且 URL 直接访问 r=kin 永远有效。
|
||||
这一点写在这里是为了防止有人"顺手把 kin 路由删掉"。 */ ?>
|
||||
|
||||
<!-- ⚠️ `f3` 这个类名是**库的 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) ?>"
|
||||
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 官方示例)。
|
||||
之前没引这份 CSS,靠自己在 app.css 里手补 `.kin-chart svg.main_svg{width/height:100%}`
|
||||
@@ -227,30 +213,35 @@ $datumJson = json_encode(
|
||||
// 会跳过深度截断,等于**不限代数**,有多少画多少。
|
||||
// 之前写死了具体代数,真实家庭一超过就被砍掉。
|
||||
// 用户要求「全部取出来」,所以这里也不设。
|
||||
.setCardXSpacing(190)
|
||||
.setCardXSpacing(160)
|
||||
.setCardYSpacing(180);
|
||||
|
||||
chart.setCardHtml()
|
||||
.setStyle('rect')
|
||||
.setCardDim({ w: 148, h: 138, mini_tree: false })
|
||||
.setCardDim({ w: 118, h: 132, mini_tree: false })
|
||||
.setCardInnerHtmlCreator(function (d) {
|
||||
var p = (d.data && d.data.data) || {};
|
||||
var id = (d.data && d.data.id) || '';
|
||||
var cls = 'kin-card' + (p.me ? ' is-me' : '') + (p.ph ? ' is-ph' : '');
|
||||
// v24 第 12 条:头像改**方形**(原来是圆角矩形 + cover,把脸裁掉了)。
|
||||
// 卡片本身是 <a> 进成员页;有姻亲没画出来的,再叠一个独立的小按钮
|
||||
// (用 <span role=button> 而不是 <a>:<a> 不能套 <a>)。
|
||||
return '<a class="' + cls + '" data-kin-id="' + esc(id) + '" href="/index.php?r=people/show&id='
|
||||
+ encodeURIComponent(id) + '">'
|
||||
/* 用户 2026-10-03 第 1 条:
|
||||
① 卡片**不再整张是链接** —— 原来整卡是 <a>,右下角那个小按钮也在
|
||||
<a> 里,手指/鼠标稍偏一点就先触发外层链接跳去成员页(用户反馈
|
||||
"容易误触"的根因)。现在卡片是 <div>,只有「姓名」那一块是链接,
|
||||
右下角按钮是独立的 <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-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>'
|
||||
+ (p.ph ? '<span class="kin-mark">待确认</span>' : '')
|
||||
+ (p.branch
|
||||
? '<span class="kin-branch" role="button" tabindex="0" title="看看 '
|
||||
+ esc(p.branch) + ' 那一侧的家谱" data-branch-id="' + esc(id) + '">⇄ 姻亲</span>'
|
||||
: '')
|
||||
+ '</a>';
|
||||
+ '<button type="button" class="kin-focus" data-focus-id="' + esc(id) + '"'
|
||||
+ ' title="以 ' + esc(p.name) + ' 为中心看家谱">以 TA 为中心</button>'
|
||||
+ '</div>';
|
||||
});
|
||||
|
||||
chart.editTree().setCanAdd(false);
|
||||
@@ -304,41 +295,29 @@ $datumJson = json_encode(
|
||||
setTimeout(function () { centerOn(focusId); }, 1400);
|
||||
}
|
||||
|
||||
var btnFit = document.getElementById('kinFit');
|
||||
var btnMe = document.getElementById('kinMe');
|
||||
if (btnFit) {
|
||||
btnFit.addEventListener('click', function () {
|
||||
chart.updateTree({ tree_position: 'fit' });
|
||||
});
|
||||
}
|
||||
/* v24 第 12 条:卡片右下角的「⇄ 姻亲」按钮 —— 以那个人为中心重画一张家谱。
|
||||
⚠️ 这个 span 在卡片的 <a> 里,必须 preventDefault + stopPropagation,
|
||||
否则会先触发外面那个链接跳去成员页。 */
|
||||
Array.prototype.forEach.call(root.querySelectorAll('.kin-branch'), function (btn) {
|
||||
/* 用户 2026-10-03 第 2 条:工具条三个按钮(看全部/回到我/回到我的家谱)已撤。
|
||||
镜头由库自动 fit;下面只保留卡片右下角那个「以他为中心」的入口。 */
|
||||
|
||||
/* 用户第 1 条:每张卡片右下角一个「以他为中心」的按钮 —— 点了就以他重画一张家谱。
|
||||
⚠️ 这个元素在卡片的 <a> 里,必须 preventDefault + stopPropagation,
|
||||
否则会先触发外面那个链接跳去成员页(用户反馈"容易误触"就是这个)。
|
||||
另外用 mousedown/pointerdown/click 三处都拦,触摸设备上才稳。 */
|
||||
Array.prototype.forEach.call(root.querySelectorAll('.kin-focus'), function (btn) {
|
||||
function go(e) {
|
||||
e.preventDefault();
|
||||
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); }
|
||||
}
|
||||
btn.addEventListener('click', go);
|
||||
btn.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') { go(e); }
|
||||
});
|
||||
// 键盘/触摸都别让它冒泡到外层链接
|
||||
btn.addEventListener('mousedown', function (e) { e.stopPropagation(); });
|
||||
['mousedown', 'pointerdown', 'touchstart'].forEach(function (ev) {
|
||||
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>
|
||||
|
||||
|
||||
@@ -58,11 +58,26 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
|
||||
<?= e($pshow) ?>
|
||||
<?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; ?>
|
||||
</div>
|
||||
<div class="pmeta">
|
||||
<?php /* 用户第 4 条:与我的关系放姓名右边(列表里一眼能看到) */ ?>
|
||||
<?php if (!empty($p['label']) && $p['label'] !== '我'): ?>
|
||||
<span class="rel-label"><?= e($p['label']) ?></span>
|
||||
<?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
|
||||
$r = $p['account_role'] ?? '';
|
||||
if ($r === 'admin' && ($p['account_status'] ?? '') === 'active') {
|
||||
@@ -152,16 +167,16 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($canMove && $q === '' && $total > 0): ?>
|
||||
<p class="small muted roster-tip" style="margin:0 0 12px">
|
||||
拖动卡片(左边那个 <span class="drag-handle-inline">⠿</span>)就能改顺序,
|
||||
拖到别的一栏里就是改分组。改的是<b>你自己那份名册</b>,不影响家里其他人。
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php /* 用户第 4 条:这句拖动提示**去掉**(用户直接点名要删)。
|
||||
功能本身不变,只是不再占版面。 */ ?>
|
||||
|
||||
<form class="search" method="get" action="index.php">
|
||||
<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>
|
||||
<?php if ($q !== ''): ?>
|
||||
<a class="link-quiet" href="<?= e(url('people')) ?>">清除</a>
|
||||
|
||||
@@ -25,6 +25,29 @@
|
||||
<b>没写的人才用这里的这句</b>。所以这是一句"兜底",不是全家的统一口号。
|
||||
</span>
|
||||
</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 class="btn-row">
|
||||
<button class="btn" type="submit">保存</button>
|
||||
|
||||
@@ -119,8 +119,12 @@ $isAdmin = \App\Auth::isAdmin();
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
/* ---- 拖动排序(▲▼ 是不用 JS 的兜底,两条路都能用)---- */
|
||||
var list = document.getElementById('navOrderList');
|
||||
/* ---- 拖动排序(▲▼ 是不用 JS 的兜底,两条路都能用)----
|
||||
⚠️ 遍历**所有** `ol.order-list`,而不是只找 navOrderList:
|
||||
本页现在有两块同构的清单(首页上的模块 / 导航条上的模块),
|
||||
写死 id 的话第二块就没有拖动了 —— 而且看起来"功能好好的",
|
||||
只是那一块拖不动,很难联想到是这里写死了 id。 */
|
||||
function bindSort(list) {
|
||||
if (!list) return;
|
||||
var dragEl = null;
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Array.prototype.forEach.call(document.querySelectorAll('ol.order-list'), bindSort);
|
||||
|
||||
/* ---- 色板点一下就换 ---- */
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.preset-color[data-for]'), function (el) {
|
||||
|
||||
@@ -139,6 +139,53 @@ $rosterCount = $rosterCount ?? 0;
|
||||
<?php endif; ?>
|
||||
</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
|
||||
$navKeys = \App\Module::navFor((int)\App\Auth::personId());
|
||||
|
||||
@@ -75,6 +75,25 @@ if ($isEdit) {
|
||||
<textarea class="input" id="t-note" name="note" rows="3"><?= e(old('note', $t['note'] ?? '')) ?></textarea>
|
||||
</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">
|
||||
<label>同行的人</label>
|
||||
<?php /* v24 第 11 条:与全局选人组件统一(原来是平铺的 checkbox 网格,
|
||||
|
||||
Reference in New Issue
Block a user