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
+292
View File
@@ -0,0 +1,292 @@
<?php
/**
* 头像主色 —— 给「人」一个稳定的识别色。
*
* 由来(用户 2026-10-01 的第 2 条反馈):观影人标签一律是灰底,十几个人排在一起
* 分不清谁是谁。既然每个人都有头像,就从头像里算出一种主色当背景色;
* 没有头像(或头像是纯灰)的人,按姓名算一个**稳定的**颜色兜底 ——
* 同一个人在任何页面、任何时候看到的颜色都一样。
*
* 三条刻意的设计:
* · **算一次、存库(persons.avatar_color),不要每次渲染都算**。
* 头像在 OSS 上,每渲染一个标签就下载一次原图 = 页面上 20 个人就是 20 次外网请求。
* 只在「上传/换头像」的那一刻算,之后就是读一列 CHAR(7) —— 成本趋零。
* · **主色不等于背景色**。头像是深色时主色也深,直接当背景会变成黑底黑字。
* 这里一律输出两个派生色:`bg()` 是"往白里兑"的浅色底(保证深色字读得清),
* `fg()` 是"往黑里压"的深色字(保证在浅底上够对比)。主色本身只作为**身份色**存库。
* · **灰片回退到姓名色**。证件照、黑白老照片算不出色相(饱和度极低),
* 硬取会得到一个飘忽不定的"最深的那个灰",每次换图颜色就跳一次 ——
* 不如干脆交给姓名哈希,稳定且可预期。
*/
declare(strict_types=1);
namespace App;
final class AvatarColor
{
/** 判定「这像素算不算有颜色」的色度门槛(max-min)。低于它当成灰。 */
private const CHROMA_MIN = 18;
/**
* 从一张 GD 图像里取主色,返回 '#rrggbb'。
* 取不到(纯灰/纯白/图坏)返回 '' —— 调用方再决定回退。
*
* 做法:缩到 64×64 采样 → 按色相分 36 个桶(每桶 10°)→ 以「色度 × 像素数」加权
* 选最重的桶 → 桶内按色度加权平均 RGB。比"整图取平均"稳得多:
* 整图平均会被大面积的背景(白墙)拖成一片灰,而这里白墙根本进不了桶。
*/
public static function fromGd($im): string
{
if (!$im) {
return '';
}
$w = imagesx($im);
$h = imagesy($im);
if ($w < 1 || $h < 1) {
return '';
}
// 先缩到 64×64:采样点数固定,一张 4000×3000 的原图也不会算很久
$small = $im;
if ($w > 64 || $h > 64) {
$scale = min(64 / $w, 64 / $h);
$sw = max(1, (int)round($w * $scale));
$sh = max(1, (int)round($h * $scale));
$tmp = imagecreatetruecolor($sw, $sh);
if (!$tmp) {
return '';
}
imagecopyresampled($tmp, $im, 0, 0, 0, 0, $sw, $sh, $w, $h);
$small = $tmp;
$w = $sw;
$h = $sh;
}
$buckets = []; // hue桶 => [sumR, sumG, sumB, weight]
for ($y = 0; $y < $h; $y++) {
for ($x = 0; $x < $w; $x++) {
$rgb = imagecolorat($small, $x, $y);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
$mx = max($r, $g, $b);
$mn = min($r, $g, $b);
$chroma = $mx - $mn;
if ($chroma < self::CHROMA_MIN) {
continue; // 灰/白/黑:不参与
}
if ($mx > 246 || $mn < 12) {
continue; // 死白 / 死黑:多半是背景或阴影
}
$hue = self::hueOf($r, $g, $b, $mx, $mn, $chroma);
$bucket = (int)floor($hue / 10);
if ($bucket >= 36) {
$bucket = 35;
}
// 权重 = 色度(越鲜艳越能代表这张图)× 一个"别太暗也别太亮"的系数
$weight = $chroma * (1 - abs(($mx + $mn) / 510 - 0.5));
if (!isset($buckets[$bucket])) {
$buckets[$bucket] = [0, 0, 0, 0.0];
}
$buckets[$bucket][0] += $r * $weight;
$buckets[$bucket][1] += $g * $weight;
$buckets[$bucket][2] += $b * $weight;
$buckets[$bucket][3] += $weight;
}
}
if ($small !== $im) {
imagedestroy($small);
}
if (!$buckets) {
return '';
}
// 取权重最大的桶
$best = null;
$bestW = -1.0;
foreach ($buckets as $b) {
if ($b[3] > $bestW) {
$bestW = $b[3];
$best = $b;
}
}
if ($best === null || $best[3] <= 0) {
return '';
}
return sprintf('#%02X%02X%02X',
(int)round($best[0] / $best[3]),
(int)round($best[1] / $best[3]),
(int)round($best[2] / $best[3]));
}
/** 从图片二进制里取主色(OSS/本地都行);失败返回 '' */
public static function fromBinary(string $bin): string
{
if ($bin === '') {
return '';
}
$im = @imagecreatefromstring($bin);
if ($im === false) {
return '';
}
$hex = self::fromGd($im);
imagedestroy($im);
return $hex;
}
/**
* 姓名兜底色:同样的人永远同一个颜色。
*
* 色相由姓名的 md5 决定(36 个色相里挑一个),饱和度与亮度**写死**在好看的区间内
* —— 不用哈希去碰 S/L,那样会随机出刺眼的荧光色或看不清的深色。
*/
public static function fromName(string $name): string
{
$name = trim($name);
$seed = $name === '' ? '?' : $name;
// ⚠️ 色相取 **0.1° 精度**(3600 档)而不是 360 档:派生出来的底色只保留色相
// (S/L 都被 bg()/fg() 拉到固定区间),所以两个人要能区分,只能靠色相。
// 360 档时十几个人里就撞色(实测:7 个名字里有两组落到同一个色)。
// 干脆用 md5 的整个 32 位当连续色相,不再分档。
$hue = hexdec(substr(md5($seed), 0, 8)) / 4294967296.0;
return self::hslToHex($hue, 0.55, 0.45);
}
/**
* 一个人的展示色:库里算过就用库里的,没算过按姓名兜底。
* 页面上一律走这里 —— 不许各自 `$p['avatar_color'] ?: '#888'`。
*/
public static function for(?array $p): string
{
if (!$p) {
return self::fromName('');
}
$c = self::normalize($p['avatar_color'] ?? null);
if ($c !== '') {
return $c;
}
return self::fromName((string)($p['name'] ?? ''));
}
/** 规整成 '#rrggbb';认不出来返回 '' */
public static function normalize($hex): string
{
$hex = strtolower(trim((string)$hex));
if ($hex === '') {
return '';
}
if ($hex[0] === '#') {
$hex = substr($hex, 1);
}
if (preg_match('/^[0-9a-f]{3}$/', $hex)) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (!preg_match('/^[0-9a-f]{6}$/', $hex)) {
return '';
}
return '#' . $hex;
}
/**
* 背景色:把主色往白里兑。
*
* 兑完统一到 L≈0.90、S≈0.55 —— 不管头像是深蓝还是亮黄,
* 出来的都是"淡淡的那种蓝/黄",深色字压在上面都读得清,
* 同时彼此之间又能一眼区分(这正是用户要的效果)。
*/
public static function bg(?string $hex): string
{
[$h, $s] = self::hsl(self::normalize($hex) ?: self::fromName(''));
$s = $s < 0.18 ? 0.30 : min(0.62, max(0.34, $s));
return self::hslToHex($h, $s, 0.90);
}
/** 文字色:把主色往黑里压,保证在 bg() 上有足够对比 */
public static function fg(?string $hex): string
{
[$h, $s] = self::hsl(self::normalize($hex) ?: self::fromName(''));
$s = $s < 0.18 ? 0.35 : min(0.75, max(0.40, $s));
return self::hslToHex($h, $s, 0.30);
}
/** 边线色(比 bg 深一档,让浅色标签在白底上仍有轮廓) */
public static function bd(?string $hex): string
{
[$h, $s] = self::hsl(self::normalize($hex) ?: self::fromName(''));
$s = $s < 0.18 ? 0.35 : min(0.70, max(0.38, $s));
return self::hslToHex($h, $s, 0.72);
}
// ---------------- 颜色空间小工具 ----------------
/** 0..360 的色相 */
private static function hueOf(int $r, int $g, int $b, int $mx, int $mn, int $chroma): float
{
if ($chroma === 0) {
return 0.0;
}
if ($mx === $r) {
$h = fmod((($g - $b) / $chroma), 6.0);
} elseif ($mx === $g) {
$h = (($b - $r) / $chroma) + 2.0;
} else {
$h = (($r - $g) / $chroma) + 4.0;
}
$h *= 60.0;
if ($h < 0) {
$h += 360.0;
}
return $h;
}
/** @return array{0:float,1:float,2:float} h(0..1) s(0..1) l(0..1) */
private static function hsl(string $hex): array
{
$hex = ltrim($hex, '#');
$r = hexdec(substr($hex, 0, 2)) / 255;
$g = hexdec(substr($hex, 2, 2)) / 255;
$b = hexdec(substr($hex, 4, 2)) / 255;
$mx = max($r, $g, $b);
$mn = min($r, $g, $b);
$l = ($mx + $mn) / 2;
$d = $mx - $mn;
if ($d == 0) {
return [0.0, 0.0, $l];
}
// 分母在 l=0(死黑)/ l=1(死白)时为 0 —— 那时饱和度本来就没有意义,记 0。
$den = 1 - abs(2 * $l - 1);
$s = $den > 1e-9 ? $d / $den : 0.0;
$h = self::hueOf(
(int)round($r * 255), (int)round($g * 255), (int)round($b * 255),
(int)round($mx * 255), (int)round($mn * 255), max(1, (int)round($d * 255))
);
return [$h / 360.0, $s, $l];
}
private static function hslToHex(float $h, float $s, float $l): string
{
$h = $h - floor($h);
if ($s <= 0) {
$v = (int)round($l * 255);
return sprintf('#%02X%02X%02X', $v, $v, $v);
}
$q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s;
$p = 2 * $l - $q;
$conv = static function (float $t) use ($p, $q): float {
$t = $t - floor($t);
if ($t < 1 / 6) {
return $p + ($q - $p) * 6 * $t;
}
if ($t < 1 / 2) {
return $q;
}
if ($t < 2 / 3) {
return $p + ($q - $p) * (2 / 3 - $t) * 6;
}
return $p;
};
$r = (int)round($conv($h + 1 / 3) * 255);
$g = (int)round($conv($h) * 255);
$b = (int)round($conv($h - 1 / 3) * 255);
return sprintf('#%02X%02X%02X', max(0, min(255, $r)), max(0, min(255, $g)), max(0, min(255, $b)));
}
}