v14: 观影/家谱/日程/旅程/资源/任务六模块统一,首页改 dashboard

用户 14 条需求一次交付(1-13 功能 + 14 工作方式)。

观影(1-4)
- 返回文案全模块统一为「返回观影」(不再有「返回列表」「返回观影记录」三种叫法)
- 观影人一律用彩色人物标签 person_chip:底色由头像主色算出来(上传时存库),
  瀑布流卡片也按人显示;五处观影模板全部改用它
- 人员显示统一走 person_name()(shown → 我给他起的称呼 → 姓名)
- 影院管理页与年度报告页的上下两块用 .card + .card 统一给间距(原来紧贴成一整块)

家谱(5)
- 树根从「我」换成**辈分最高的祖先**:库画的是"树根的祖先链+后代+树根的兄弟姐妹",
  根=我 时伯父/堂兄/嫂子/侄子一个都不画(12 人夹具只画出 8 人)
- focusMap 按**代数**圈定三代(不再按步数),normalizeLinks 把子女对称挂到父母双方
  —— 修掉"母亲登录看不到孩子"
- 新增「看全部 / 回到我」两个按钮,放在画布外(画布整块被库接管了手势)

统一事件原语(6-9)
- 旅程:TripLog 全量迁入(3 趟旅程 / 21 段行程 / 72 张照片 / 5 个人)。
  复用 events(kind='trip') + trip_details + trip_nodes(计划/执行双轨保留)
- 日程:每人可建日历本(自定义色)+ 共享只读/可写;观影与旅行是每人默认两本,
  内容是**从各模块实时派生**的(不复制,所以改了观影日期日历立刻跟着变);
  没有具体时分 = 全天;月视图参照群晖 Calendar
- 资源:挂在某人名下的附属信息(标题+正文+多附件),可共享给多人并设只读/读写,
  默认折叠、可搜标题与正文
- 任务:看板三列 + 两级子任务 + 评论@人 + 共享 + 循环(完成时按原截止日推下一期)
- 统一附件表:四个模块的附件共用一张,下载地址不落库、一律由 media.php 现算

模块化(10-11)
- App\Module 是模块的唯一来源(加一行 = 导航条/主题色/个人设置三处自动出现)
- 个人设置里可勾选要显示哪些模块、拖动排序(关掉的模块**不会**被兜底补回来)
- 每个模块一套主题色(家庭级),只覆盖 --brand 三兄弟,不碰纸墨底色

其他(12-13)
- 新增成员表单可选所属分组(编辑表单没有:分组是"我和他的关系",只归我自己)
- 首页改 dashboard:接下来的日子 / 未来七天 / 手头在做 / 往年今日。
  家人卡片从首页撤走(看人去名册)

验证(全部通过)
- e2e 542 条(新增 4 模块端到端 60+ 条)
- 桌面几何 368 / 移动端几何 520 / 家谱 12 / 交互 97 / 老库升级 123
- SQL 列名静态校验 0 处可疑(对着真实表结构)
- 反向验证 18 条新注入全部抓住

顺带修掉的既有问题
- 老库升级回填 SQL 按实际存在的列动态拼(缺列不再 1054 炸断升级)
- 全新安装时 Schema::ensure 跑在"人还没建"之前 → 补建日历本三处齐备
- 窄屏导航横向溢出(模块从 4 个加到 8 个后顶栏撑破视口)
This commit is contained in:
2026-10-02 09:28:07 +08:00
parent 7f00d3e553
commit 71a7ec35a0
42 changed files with 6770 additions and 201 deletions
+170
View File
@@ -0,0 +1,170 @@
<?php
/**
* 个人设置里的两块「模块」设置(用户 2026-10-01 第 10、11 条):
*
* ① **导航条上显示哪些模块、什么顺序** —— 每人一份(App\Module::navFor 读的就是它)。
* 勾选 = 出现在导航条上;拖动 / ▲▼ = 排序。
* ② **每个模块的主题色** —— **家庭一份**(settings('module_colors')),
* 所以只有管理员能改:一个家认同一套配色,不然同一个"观影"在爸爸那屏是紫的、
* 妈妈那屏是绿的,反而记不住。非管理员在这里只看得到色板。
*
* 变量(require 前设好):
* $navKeys 我当前导航条上的顺序(只含显示的)
* $colors key => '#rrggbb'
*/
$allKeys = \App\Module::keys();
$onKeys = array_map('strval', (array)($navKeys ?? []));
// 显示顺序:已勾选的按现有顺序在前,未勾选的按 LIST 顺序在后(这样"关掉再打开"位置不跳)
$ordered = array_values(array_filter($allKeys, static fn($k) => in_array($k, $onKeys, true)));
foreach ($allKeys as $k) {
if (!in_array($k, $ordered, true)) {
$ordered[] = $k;
}
}
$isAdmin = \App\Auth::isAdmin();
?>
<!-- ==================== 导航条上的模块 ==================== -->
<div class="card card-pad" id="modules" 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/nav_save')) ?>" id="navForm">
<?= csrf_field() ?>
<ol class="order-list mod-order" id="navOrderList">
<?php foreach ($ordered as $i => $k): ?>
<li class="order-row mod-row" draggable="true" data-key="<?= e($k) ?>">
<span class="drag-handle" title="拖我排序" aria-hidden="true">⋮⋮</span>
<?php /* ⚠️ 顺序就藏在 DOM 里:每个 <li> 自带一个 order[] 隐藏域,
拖动搬的是整个 <li>,所以提交上来的顺序天然就是屏幕上的顺序。
没有 JS 时 ▲▼ 按钮走同一份表单(服务端按 move 参数交换后再存)。 */ ?>
<input type="hidden" name="order[]" value="<?= e($k) ?>">
<label class="mod-pick">
<input type="checkbox" name="on[]" value="<?= e($k) ?>"
<?= in_array($k, $onKeys, true) ? 'checked' : '' ?>>
<span class="color-preview" style="background: <?= e((string)($colors[$k] ?? '#888')) ?>;"></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($ordered) - 1 ? 'disabled' : '' ?>>▼</button>
</span>
</li>
<?php endforeach; ?>
</ol>
<div class="btn-row">
<button class="btn" type="submit" name="save" value="1">保存导航条</button>
<a class="btn btn-ghost" href="<?= e(url('settings/me')) ?>">重来</a>
</div>
</form>
</div>
<!-- ==================== 模块主题色 ==================== -->
<div class="card card-pad" style="margin-top:20px">
<h2 class="section-title">
模块主题色
<span class="hint"><?= $isAdmin ? '全家共用一套' : '由管理员设置' ?></span>
</h2>
<p class="small muted" style="margin:0 0 16px">
进到哪个模块,顶栏高亮与按钮就换成那个模块的颜色 —— 一眼就知道自己在哪儿。
<?php if (!$isAdmin): ?>
<br>这是<b>全家共用</b>的一套配色(不然同一个「观影」在两个人屏幕上两种颜色,反而记不住),
所以只有管理员能改。
<?php endif; ?>
</p>
<?php if (!$isAdmin): ?>
<div class="mod-swatches">
<?php foreach ($allKeys as $k): ?>
<span class="mod-swatch">
<span class="color-preview" style="background: <?= e((string)($colors[$k] ?? '#888')) ?>;"></span>
<?= e(\App\Module::name($k)) ?>
</span>
<?php endforeach; ?>
</div>
<?php else: ?>
<form method="post" action="<?= e(url('settings/module_colors')) ?>">
<?= csrf_field() ?>
<div class="mod-colors">
<?php foreach ($allKeys as $k): ?>
<?php $cid = 'mc-' . $k; ?>
<div class="mod-color-row">
<span class="mod-color-name"><?= e(\App\Module::name($k)) ?></span>
<input type="color" class="color-picker" id="<?= e($cid) ?>" name="c[<?= e($k) ?>]"
value="<?= e((string)($colors[$k] ?? '#888888')) ?>">
<span class="mod-preset">
<?php foreach (\App\Module::PRESET_COLORS as $pc): ?>
<span class="preset-color" data-color="<?= $pc ?>" data-for="<?= e($cid) ?>"
style="background: <?= $pc ?>;" title="<?= $pc ?>"></span>
<?php endforeach; ?>
</span>
</div>
<?php endforeach; ?>
</div>
<div class="btn-row">
<button class="btn" type="submit">保存主题色</button>
<button class="btn btn-ghost" type="submit" name="reset" value="1"
onclick="return confirm('恢复成默认配色?')">恢复默认</button>
</div>
</form>
<?php endif; ?>
</div>
<script>
(function () {
/* ---- 拖动排序(▲▼ 是不用 JS 的兜底,两条路都能用)---- */
var list = document.getElementById('navOrderList');
if (!list) return;
var dragEl = null;
Array.prototype.forEach.call(list.querySelectorAll('.mod-row'), function (li) {
li.addEventListener('dragstart', function (ev) {
dragEl = li;
li.classList.add('is-dragged');
if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; }
});
li.addEventListener('dragend', function () {
li.classList.remove('is-dragged');
dragEl = null;
Array.prototype.forEach.call(list.querySelectorAll('.mod-row'), function (x) {
x.classList.remove('is-drop-before');
});
});
li.addEventListener('dragover', function (ev) {
if (!dragEl || dragEl === li) return;
ev.preventDefault();
var box = li.getBoundingClientRect();
var before = ev.clientY < box.top + box.height / 2;
li.classList.toggle('is-drop-before', before);
li.classList.toggle('is-drop-after', !before);
});
li.addEventListener('dragleave', function () {
li.classList.remove('is-drop-before', 'is-drop-after');
});
li.addEventListener('drop', function (ev) {
if (!dragEl || dragEl === li) return;
ev.preventDefault();
var box = li.getBoundingClientRect();
var before = ev.clientY < box.top + box.height / 2;
/* 搬的是整个 <li> —— 它自带的 order[] 隐藏域跟着走,
所以提交上来的顺序 = 屏幕上的顺序,不用再同步一遍。 */
if (before) { list.insertBefore(dragEl, li); } else { list.insertBefore(dragEl, li.nextSibling); }
li.classList.remove('is-drop-before', 'is-drop-after');
});
});
/* ---- 色板点一下就换 ---- */
Array.prototype.forEach.call(document.querySelectorAll('.preset-color[data-for]'), function (el) {
el.addEventListener('click', function () {
var target = document.getElementById(el.getAttribute('data-for'));
if (target) { target.value = el.getAttribute('data-color'); }
});
});
})();
</script>