家谱卡片重做 / 首页控制台 / 名册改列表 / 重复日程与旅程公开
用户 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:
@@ -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();
|
||||
$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));
|
||||
|
||||
Reference in New Issue
Block a user