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:
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user