feat(kin,movies): 家谱改用第三方库 family-chart;观影设置按钮改纯图标并统一页头度量

家谱(v13):
- 自研布局是死结,不是样式问题:连线的横杆是网格项,只能横跨「父母列→子女列」
  整段区间,中间夹着与血缘无关的人(如媳妇那侧的岳父母)时必然从卡片上穿过。
  换成 family-chart(D3 生态,MIT)后,排布只走本家血脉 + 配偶,姻亲不进画布,
  这个死结在构造上就不存在了。
- 接入踩到并修掉的坑:main_id = data[0].id(「我」必须排最前);
  卡片回调里自定义字段在 d.data.data(双层);库的 family-chart.css 是死文件
  (全挂在 .f3 作用域,运行时没有 .f3 元素);库的 svg 撑满选择器永不命中
  (必须自己补 .kin-chart svg.main_svg);setCanAdd/setOnCardClick 不在 chart 上
  (写错会抛异常,把 updateTree 一起带走 → 整张图空白,页面照样 200)。
- 关掉 setSingleParentEmptyCard:库默认给孩子只登记一位父母的人自动补一张无名
  配偶卡,看着像「凭空多出一个配偶」。
- 卡片用原生 <a> 链成员页,不依赖库的点击回调。

观影按钮(用户本轮原话):
- 「记一场电影」与「设置」高度仍不一致 —— 根因是文字按钮高度由 padding+line-height
  自然推出(43.8px),而 .btn-icon 写死了 40px。抽出 --act-* / --act-h 变量,两边同源。
- 设置按钮去掉「设置」二字,只留图标,图标换成三条横线(☰)。
- 设置按钮在所有页面排最右(.movie-set 用 margin-left:auto 顶到行尾)。
- 除设置外其他按钮都有颜色,与设置区分(主按钮实心 / 次要浅底 / 危险淡红)。
  顺带修掉一个特异性 bug:删除按钮被 .btn 品牌底盖成实心红,与「编辑」难分辨。
- 设置下拉四个条目文字前都加图标(行内 SVG,不引外部字体)。

测试与部署:
- 新增 _fb_kin_check.py(桌面,12 条)+ _fb_kin_falsify.py(6/6)证明
  「整张家谱空白」这类只靠通用几何抓不到的坏法能被抓住。
- _fb_geom_mobile.py 家谱段重写为窄屏语义(库按容器 fit 缩放是正常行为,
  改为断「比例没被压扁 / 缩放不至于看不清」),并加 _fb_kin_mobile_falsify.py(4/4)。
- _fb_btn_check.py(59 条)+ _fb_btn_falsify.py(3/3)覆盖观影页头按钮五条需求。
- familybook_deploy.sh / familybook_smoke.sh 的家谱断言改为第三方库形态,
  设置按钮断言改为 btn-icon 纯图标 + 三条横线 + 条目图标。
- 新增 .gitattributes:vendor 库与 .min.js 标 -text,原样入库不被改写行尾。
This commit is contained in:
2026-10-01 19:35:48 +08:00
parent 4060c18f86
commit 7f00d3e553
13 changed files with 444 additions and 306 deletions
+10
View File
@@ -0,0 +1,10 @@
# 第三方库原样保存:不做行尾转换
#
# 为什么要写这个:本机 core.autocrlf 开着,`.min.js` 这类文本文件被 Git 检出时
# 会被转成 CRLF(`git add` 时会警告"LF will be replaced by CRLF")。
# 对第三方库这是**无谓的改写** —— 一来哈希与上游不一致,
# 二来部署包里带的就是改写后的版本,将来想核对「是不是原版」就没了基准。
# 标记成 binary(-text)后 Git 原样存、原样取。
assets/vendor/** -text
*.min.js -text
*.min.css -text
+195 -121
View File
@@ -21,6 +21,19 @@
--radius-s: 10px; --radius-s: 10px;
--shadow: 0 1px 2px rgba(44, 35, 29, .05); --shadow: 0 1px 2px rgba(44, 35, 29, .05);
--shadow-l: 0 4px 16px rgba(44, 35, 29, .08); --shadow-l: 0 4px 16px rgba(44, 35, 29, .08);
/* ---- 动作按钮的统一度量 ----
用户提过三次「设置按钮和记一场电影按钮高度要一致」。为杜绝再犯,
这里把按钮高度拆成 4 个变量,并算出唯一的高度:
--act-h = 上下内边距 + 行高 + 上下边框 = 9*2 + 14*1.7 + 1*2 = 43.8px
文字按钮靠 padding + line-height 自然撑到这个高度;
**只有图标的方形按钮**(.btn-icon)显式写高度,但取的是同一个 --act-h,
这样两边永远相等,改一处就同步。 */
--act-pad-y: 9px;
--act-pad-x: 16px;
--act-font: 14px;
--act-line: 1.7;
--act-h: calc(var(--act-pad-y) * 2 + var(--act-font) * var(--act-line) + 1px * 2);
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -100,15 +113,41 @@ a:hover { text-decoration: underline; }
border-radius: 0 0 8px 8px; color: var(--muted); border-radius: 0 0 8px 8px; color: var(--muted);
} }
.menu-list .menu-exit:hover { color: var(--brand); } .menu-list .menu-exit:hover { color: var(--brand); }
/* ---- 观影页的「设置」下拉(v11):一个齿轮图标 + <details> 菜单 ---- */ /* ---- 观影页的「设置」下拉:图标按钮 + 永远贴右 ----
.movie-set { position: relative; } 用户明确要求(2026-10-01):
· 去掉「设置」二字,只留**三条横线**图标(☰);
· 在**所有页面都排在最右侧**(位置统一);
· 点开后的每条条目,**文字前都要有图标**;
· 除设置外,其它按钮都要**有颜色**,跟设置区分开。
贴右的做法:`margin-left:auto` 把它推到动作区最右端 —— 这样无论各页把片段
require 在 actions 的哪个位置(有的写在最前、有的写在最后),它都贴右,
不必逐页调顺序,也就不会有"某个页面漏了"的情况。 */
.movie-set { position: relative; margin-left: auto; }
.movie-set > summary { .movie-set > summary {
list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; list-style: none; cursor: pointer; display: inline-flex; align-items: center;
justify-content: center; gap: 6px;
} }
.movie-set > summary::-webkit-details-marker { display: none; } .movie-set > summary::-webkit-details-marker { display: none; }
.movie-set[open] > summary { border-color: var(--brand); color: var(--brand); } .movie-set[open] > summary { border-color: var(--brand); color: var(--brand); }
/* 观影设置菜单是顶栏菜单的复用:位置从右上角改成按钮下方左对齐 */
.movie-set .menu-list { left: 0; right: auto; top: calc(100% + 8px); } /* 方形图标按钮:只有图标、无文字。高度**必须**取 --act-h,
与「记一场电影」等实心按钮同高(那个高度由 --act-pad-y/--act-font/--act-line 推出)。
⚠️ 别在这里另写一个数字(曾写死 40px,比文字按钮的 43.8px 矮了 3.8px,被用户连续投诉)。 */
.btn-icon {
background: #fff; color: var(--ink); border: 1px solid var(--line);
padding: 0 !important; width: var(--act-h); height: var(--act-h); min-width: var(--act-h);
display: inline-flex; align-items: center; justify-content: center;
line-height: 1;
}
.btn-icon:hover { background: #FAF7F1; color: var(--brand); border-color: var(--brand); }
.btn-icon svg { display: block; }
/* 观影设置菜单:从右上角改成**按钮下方右对齐**(按钮贴右,菜单也贴右才不会跑出屏外) */
.movie-set .menu-list { left: auto; right: 0; top: calc(100% + 8px); }
.movie-set .menu-list a { display: flex; align-items: center; gap: 10px; }
.movie-set .menu-list a .mi { flex: 0 0 16px; color: var(--muted); }
.movie-set .menu-list a:hover .mi,
.movie-set .menu-list a.on .mi { color: currentColor; }
.movie-set .menu-list a.on { color: var(--brand); background: var(--line-2); } .movie-set .menu-list a.on { color: var(--brand); background: var(--line-2); }
.link-quiet { color: var(--muted); font-size: 14px; } .link-quiet { color: var(--muted); font-size: 14px; }
.link-quiet:hover { color: var(--brand); } .link-quiet:hover { color: var(--brand); }
@@ -124,21 +163,10 @@ a:hover { text-decoration: underline; }
.page-head h1 { font-size: 22px; font-weight: 600; margin: 0; } .page-head h1 { font-size: 22px; font-weight: 600; margin: 0; }
.page-head .sub { color: var(--muted); font-size: 14px; } .page-head .sub { color: var(--muted); font-size: 14px; }
.page-head .actions { margin-left: auto; display: flex; gap: 8px; align-items: center; } .page-head .actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
/* ---- 页头动作区:所有按钮一律同尺寸 ---- /* ---- 页头动作区的完整规格见**文件靠后**的「页头动作区」一节 ----
⚠️ 用户明确提过两次:「设置按钮和记一场电影按钮高度要一致,现在不好看」 刻意放后面:.btn / .btn-ghost / .btn-icon 等基类定义在文件中部,
「返回等按钮和记一场电影等按钮的大小高度的统一问题」。 CSS 同特异性「后写的赢」——放这里会被它们覆盖(踩过:设置按钮被染成品牌底、
根因是页头里混用了 .btn 与 .btn-sm 两档(padding 9px16px vs 5px12px), btn-ghost 仍是白的)。此处只留布局,样式集中在后面一处,便于对照。 */
并排就一高一矮。这里**强制**让页头动作区的按钮回到基础档:
· 按钮(.btn / .btn-ghost / .btn-danger,含 <details> 的 summary)统一 padding 与字号;
· 表单包裹的按钮(删除那种 <form><button>)也一样 —— 它们才是真正的按钮。
为什么用直接子选择器 + 后代 button:页头里只会有这两类交互元素,
范围收敛、不会误伤正文里的 .btn-sm(那里小一档是刻意的)。 */
.page-head .actions > .btn,
.page-head .actions > .movie-set > summary.btn,
.page-head .actions > form > .btn,
.page-head .actions > form > button.btn {
padding: 9px 16px; font-size: 14px;
}
.section { margin-top: 28px; } .section { margin-top: 28px; }
.section-title { .section-title {
@@ -272,7 +300,8 @@ textarea.input { min-height: 76px; resize: vertical; }
.btn { .btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 9px 16px; font: inherit; font-size: 14px; font-weight: 500; padding: var(--act-pad-y) var(--act-pad-x);
font: inherit; font-size: var(--act-font); line-height: var(--act-line); font-weight: 500;
border-radius: var(--radius-s); border: 1px solid transparent; cursor: pointer; border-radius: var(--radius-s); border: 1px solid transparent; cursor: pointer;
background: var(--brand); color: #fff; transition: background .15s ease; background: var(--brand); color: #fff; transition: background .15s ease;
} }
@@ -396,111 +425,90 @@ textarea.input { min-height: 76px; resize: vertical; }
} }
/* ---------------- 家谱(以我为中心) ---------------- /* ---------------- 家谱(以我为中心) ----------------
v12 改版:**整棵树只有一个 grid**,「辈分行 / 卡片行 / 连线行」都只是它的行。 v13:改由第三方库 **family-chart**(D3 生态,MIT)画,不再自研网格布局。
连线(横杆 .tc-bar / 上引线 .tc-up / 下引线 .tc-down)全是网格项、靠 justify-self 落位,
因此「竖线落在哪一列」由浏览器按列算 —— 没有任何 px 或百分比换算,天然对齐。
⚠️ v11 是「每代各建一个网格」,那是「线乱」的根因:列宽最小值是 minmax(min-content,1fr), 为什么不再自研:v12 的「整棵树一个 grid」几何上对齐了,但连线的横杆是**网格项**,
由各自的内容决定 —— 有夫妻的那一代最小要「两卡 + 短横」、单身那代只要一卡, 只能横跨「从父母列到子女列」的整段列区间 —— 中间只要夹着跟这条血缘无关的人
于是窄屏实测各代列宽 93.5px vs 164/78/78/78px,同一条竖线上下相差 31~62px。 (最典型是配偶带进来的岳父母),横杆就会从他们卡片中间穿过去。这是布局范式的死结,
v12 用一份 grid-template-columns 贯穿全树,这个偏差从构造上就不存在。 */ 不是调样式能救的;换成库的树算法之后这层结构本身就不存在了。
.tree { display: flex; flex-direction: column; align-items: center; gap: 0; }
.tree-inner { ⚠️ 库自己的 family-chart.css **没有引入**:实测库里所有规则都写在 `.f3` 作用域下,
而运行时并不会生成任何 `.f3` 元素(卡片定位全靠 inline style)——引进来是死文件,
白白多 16KB。所以下面这些是**库真正需要**的那几条,全部收在 `.kin-chart` 作用域里。
⚠️ 卡片/连线都不是我们生成的:卡片是库建的真实 <div>(.card_cont > .card > 我们的
.kin-card),连线是 svg 里的 <path class="link">。改版式只能从这两个钩子下手。 */
.kin-chart {
position: relative;
width: 100%; width: 100%;
overflow-x: auto; /* 家里人再多也不挤:横向滚,不改版心 */ /* 太矮三代挤成一团、太高白占屏 */
-webkit-overflow-scrolling: touch; height: clamp(420px, 62vh, 720px);
padding-bottom: 4px; background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
/* 库自己接管拖拽/缩放,别让浏览器插手 */
touch-action: none;
} }
.tree-grid { /* 库在容器里自己建 #f3Canvas(position:relative; width/height:100%),
--col-w: 96px; /* 一列 = 一张卡片宽(窄屏媒体查询里改 78px) */ 再往里插 svg.main_svg 与 #htmlSvg。这里只要保证那两层撑满。 */
--col-gap: 14px; /* 列间距,同时也是夫妻短横的宽度(两处必须一致) */ .kin-chart #f3Canvas,
display: grid; .kin-chart #htmlSvg { width: 100%; height: 100%; }
gap: var(--col-gap);
justify-content: center; /* 列宽固定,整棵树居中(家里人少时不左靠) */ /* ⚠️ 库的 `.f3 svg.main_svg{width:100%;height:100%}` 选择器**永远不会命中**
align-items: start; (SVG 的直接父是 #f3Canvas,中间没有 .f3 元素)→ svg 会退回默认的 300×150,
/* ⚠️ min-width 必须是 min-content:让网格按内容自然展开,.tree-inner 才有东西可滚。 于是库按 300×150 算 fit,整棵树被缩到 0.4 倍、卡片只有 37px 宽。
写成 100% 的话窄屏会把列一路压到 --col-w 以下,同代卡片就会重叠。 */ 这里直接点名修掉。 */
min-width: min-content; .kin-chart svg.main_svg { width: 100%; height: 100%; display: block; }
/* 连线:库默认 stroke="#fff"(深色主题),在浅色纸底上等于隐形 —— 必须覆盖成褐灰。
用 !important 是因为颜色是**写在 path 的行内属性**上的(stroke="#fff"),
普通 CSS 声明(无论特异性)都压不过行内属性;这个钩子是唯一入口。 */
.kin-chart .link,
.kin-chart path.link { stroke: #C9BDA9 !important; stroke-width: 1.5 !important; }
/* 卡片:库给 .card_cont 定位、给 .card 一律加 translate(-50%,-50%)(居中锚点),
我们只负责卡面观感。 */
.kin-chart .card { background: transparent; border: 0; }
.kin-card {
display: flex; flex-direction: column; align-items: center; gap: 3px;
width: 116px; box-sizing: border-box; padding: 9px 8px 8px;
text-align: center;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: var(--shadow);
color: var(--ink);
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
} }
.tree-gen-label { .kin-chart .card:hover .kin-card {
display: flex; align-items: center; gap: 8px; transform: translateY(-2px);
margin: 0 0 8px; padding-left: 2px; box-shadow: var(--shadow-l);
border-color: var(--brand-2);
} }
.tree-gen-label .gname { font-size: 13px; font-weight: 600; color: var(--ink); } .kin-card .kin-ph {
.tree-gen-label .gtag { width: 46px; height: 46px; border-radius: 50%; overflow: hidden;
font-size: 11px; color: var(--muted); background: var(--line-2); background: #F1E7D6; flex: 0 0 auto;
padding: 1px 8px; border-radius: 999px;
} }
/* 一「户」= 网格里的连续若干列(夫妻跨 2 列、单人 1 列;inline 给 grid-column)。 .kin-card .kin-ph img { width: 100%; height: 100%; object-fit: cover; display: block; }
⚠️ width:100% 是必需的:grid 项默认按内容宽(stretch 只在没写 width 时生效), .kin-card .kin-nm { font-size: 13px; font-weight: 600; line-height: 1.3; }
显式撑满它所占的那几列后,「两张卡 + 一根短横」才能正好落进「两列 + 一个间距」。 */ .kin-card .kin-lb { font-size: 11px; color: var(--brand); line-height: 1.35; }
.tree-unit { .kin-card .kin-mark { font-size: 10px; color: var(--faint); }
display: flex; align-items: center; justify-content: center; gap: 0;
position: relative; width: 100%; /* 「我」:品牌色描边 + 光圈 */
} .kin-card.is-me {
/* 夫妻之间的短横:宽度**必须等于列间距 --col-gap** —— 这样「同一户内的两个卡」
与「相邻两户的两个卡」看起来是同一个节奏,且跨两列的户宽度正好等于该跨距。 */
.tree-bond { width: var(--col-gap); flex: 0 0 auto; display: flex; align-items: center; }
/* 夫妻之间的短横(那根"连着的两个人"的横线) */
.tree-bond i {
display: block; width: 100%; height: 2px; border-radius: 2px;
background: var(--line); transform: translateY(2px);
}
.tnode {
display: flex; flex-direction: column; align-items: center; gap: 5px;
/* ⚠️ flex:0 0 auto 是必需的(v11 实测踩到):.tnode 是 .tree-unit(flex)的子项,
默认 flex-shrink:1 会让卡片**先被压**,列地板的 min-content 也跟着变小 ——
等于把「列不许压到卡片以下」这条设计从内部掏空了。卡宽由下面的 width 定,
窄屏另有 78px 的媒体查询(那是刻意的窄版式,不是压缩)。 */
flex: 0 0 auto; width: 96px; padding: 12px 6px 10px; text-align: center;
background: var(--surface); border: 1px solid var(--line);
border-radius: 14px; box-shadow: var(--shadow);
color: var(--ink); transition: transform .15s ease, box-shadow .15s ease;
}
.tnode:hover { transform: translateY(-2px); box-shadow: var(--shadow-l); text-decoration: none; }
.tnode .tph { width: 52px; height: 52px; border-radius: 50%; overflow: hidden; background: #F1E7D6; }
.tnode .tph img { width: 100%; height: 100%; object-fit: cover; display: block; }
.tnode .tname { font-size: 13px; font-weight: 600; line-height: 1.3; }
.tnode .tlabel { font-size: 11px; color: var(--brand); }
.tnode .tph-mark { font-size: 10px; color: var(--faint); }
.tnode.is-me {
border-color: var(--brand); border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(168, 74, 40, .12), var(--shadow); box-shadow: 0 0 0 3px rgba(168, 74, 40, .12), var(--shadow);
} }
.tnode.is-me .tlabel { color: var(--brand-ink); font-weight: 600; } .kin-card.is-me .kin-lb { color: var(--brand-ink); font-weight: 600; }
.tnode.is-placeholder { border-style: dashed; }
/* 因婚姻进图、上下都不接血的旁支(如妻子的父母):虚线边框 + 角标,免得像断线 */
.tnode.is-inlaw { border-style: dashed; background: #FCFAF6; }
.tree-inlaw-tag {
position: absolute; top: -8px; right: 0;
font-size: 10px; color: var(--faint); background: var(--paper);
border: 1px solid var(--line); border-radius: 999px; padding: 0 6px;
}
/* ---- 父母折线(family elbow)----
.tc-bar = 横杆(跨「父母户 → 它的子女们」那几列)
.tc-up = 上引线(落在**父母那一户**的中心,从横杆往上接到父母)
.tc-down = 下引线(落在**子女本人**那一列的中心,从横杆往下接到子女)
⚠️ 三者都是网格项,靠 justify-self 定位,不写任何 left/百分比 —— 这是「线不乱」的关键。 */
.tc-bar {
align-self: end; justify-self: stretch;
height: 30px; position: relative; margin-bottom: -2px;
}
.tc-bar::before {
content: ''; position: absolute; left: 0; right: 0; bottom: 0;
height: 2px; background: var(--line); border-radius: 2px;
}
.tc-up {
align-self: end; justify-self: center;
width: 2px; height: 15px; background: var(--line);
margin-bottom: 14px; /* 横杆在上引线下方 14px 处,两者接得上 */
}
.tc-down {
align-self: start; justify-self: center;
width: 2px; height: 15px; background: var(--line);
margin-top: -2px;
}
.tree-legend { font-size: 12px; color: var(--muted); margin: 14px 2px 0; }
/* 「待确认」(只填了称呼、没对应到真人):虚线 */
.kin-card.is-ph { border-style: dashed; background: #FCFAF6; }
.tree-legend { font-size: 12px; color: var(--muted); margin: 14px 2px 0; }
/* 详情页里的迷你家谱 */ /* 详情页里的迷你家谱 */
.mini-tree { display: flex; flex-direction: column; gap: 8px; } .mini-tree { display: flex; flex-direction: column; gap: 8px; }
.mini-gen { display: flex; flex-wrap: wrap; gap: 8px; } .mini-gen { display: flex; flex-wrap: wrap; gap: 8px; }
@@ -567,11 +575,6 @@ textarea.input { min-height: 76px; resize: vertical; }
@media (max-width: 720px) { @media (max-width: 720px) {
.rel-form { grid-template-columns: 1fr; } .rel-form { grid-template-columns: 1fr; }
.tnode { width: 78px; padding: 8px 4px 7px; }
.tnode .tph { width: 40px; height: 40px; }
/* 窄屏收窄列宽与列间距:卡宽 78px,与上面 .tnode 的窄版式对齐 */
.tree-grid { --col-w: 78px; --col-gap: 8px; }
.tree-unit { padding: 0; }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
@@ -784,6 +787,77 @@ textarea.input { min-height: 76px; resize: vertical; }
.del-wrap > summary { list-style: none; cursor: pointer; } .del-wrap > summary { list-style: none; cursor: pointer; }
.del-wrap[open] { flex: 1 1 100%; } .del-wrap[open] { flex: 1 1 100%; }
.del-confirm { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 8px 0 2px; } .del-confirm { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 8px 0 2px; }
/* ---- 页头动作区:统一尺寸 + 除设置外都要「有颜色」 ----
⚠️ 本节刻意放在**文件靠后**:.btn / .btn-ghost / .btn-danger / .btn-icon
的定义都在前面,CSS 同特异性时「后写的赢」——放前面会被它们覆盖
(实测过:放前面时 .btn-icon 白底被 .btn 的品牌底盖掉、.btn-ghost 也还是白的)。
用户明确提过三次:
·「设置按钮和记一场电影按钮高度要一致」
·「返回等按钮和记一场电影等按钮的大小高度的统一问题」
·「设置按钮…只保留图标,图标换成三条横线;除设置,其他按钮都要有颜色」 */
.page-head .actions > .btn,
.page-head .actions > .movie-set > summary.btn,
.page-head .actions > form > .btn,
.page-head .actions > form > button.btn {
padding: var(--act-pad-y) var(--act-pad-x); font-size: var(--act-font);
}
/* 层 1:主按钮(裸 .btn,无修饰类)—— 品牌实心。
⚠️ `.btn` 同时出现在 `<a class="btn">` 和 `<form><button class="btn">`,
两条路径的选择器都要写出来,且**特异性必须相等**,否则后面层 2/3
按类名匹配的规则压不住它(下层靠"同特异性 + 后写"取胜)。 */
.page-head .actions > .btn:not(.btn-icon),
.page-head .actions > form > .btn:not(.btn-icon),
.page-head .actions > form > button.btn:not(.btn-icon) {
background: var(--brand); color: #fff; border-color: transparent;
}
.page-head .actions > .btn:not(.btn-icon):hover,
.page-head .actions > form > button.btn:not(.btn-icon):hover { background: #944022; }
/* 层 2:次要按钮(.btn-ghost)—— 品牌描边 + 极浅品牌底,比主按钮轻一档。
a / button 两种形态各写一条、特异性对齐(见层 1 的警告)。 */
.page-head .actions > .btn-ghost:not(.btn-icon),
.page-head .actions > a.btn-ghost:not(.btn-icon),
.page-head .actions > form > .btn-ghost:not(.btn-icon),
.page-head .actions > form > button.btn-ghost:not(.btn-icon) {
background: #FDF7F2; color: var(--brand); border-color: rgba(168, 74, 40, .45);
}
.page-head .actions > .btn-ghost:not(.btn-icon):hover,
.page-head .actions > a.btn-ghost:not(.btn-icon):hover,
.page-head .actions > form > .btn-ghost:not(.btn-icon):hover,
.page-head .actions > form > button.btn-ghost:not(.btn-icon):hover {
background: #F7EDE4; border-color: var(--brand);
}
/* 层 3:危险按钮(.btn-danger)—— 淡红底 + 红字,同样要有颜色、但不能看着像主按钮。
⚠️ 实测踩过:只写 `.actions > form > .btn-danger`(0,4,0)会被层 1 的
`.actions > form > button.btn`(0,4,1)压掉 —— 详情页的「删除」按钮
在浏览器里显示成品牌砖红实心,跟「编辑」几乎分不出来。补上 `button.`
形态后特异性升到 (0,4,2),才真正生效。 */
.page-head .actions > .btn-danger:not(.btn-icon),
.page-head .actions > a.btn-danger:not(.btn-icon),
.page-head .actions > form > .btn-danger:not(.btn-icon),
.page-head .actions > form > button.btn-danger:not(.btn-icon) {
background: #FDF1EE; color: var(--red); border-color: rgba(192, 83, 63, .45);
}
.page-head .actions > .btn-danger:not(.btn-icon):hover,
.page-head .actions > a.btn-danger:not(.btn-icon):hover,
.page-head .actions > form > .btn-danger:not(.btn-icon):hover,
.page-head .actions > form > button.btn-danger:not(.btn-icon):hover { background: #F7E4DF; }
/* 「设置」图标按钮:**唯一无色的**(白底描边、方形),与上面所有彩色按钮区分。
padding 归零、做成正方形,但高度必须仍是 --act-h(= 文字按钮的 43.8px)——
写死过 40px,比旁边按钮矮 3.8px,是用户反复反馈的那个问题。 */
.page-head .actions > .movie-set > summary.btn-icon,
.page-head .actions > .movie-set > summary.btn {
padding: 0;
width: var(--act-h); height: var(--act-h); min-width: var(--act-h); line-height: 1;
background: #fff; color: var(--ink); border: 1px solid var(--line);
}
.page-head .actions > .movie-set > summary.btn-icon:hover,
.page-head .actions > .movie-set > summary.btn:hover {
background: #FAF7F1; border-color: var(--brand); color: var(--brand);
}
.movie-set[open] > summary.btn-icon,
.movie-set[open] > summary.btn { border-color: var(--brand); color: var(--brand); }
.btn.danger { color: var(--red); border-color: rgba(192, 83, 63, .35); } .btn.danger { color: var(--red); border-color: rgba(192, 83, 63, .35); }
.btn.danger:hover { background: rgba(192, 83, 63, .08); } .btn.danger:hover { background: rgba(192, 83, 63, .08); }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+184 -155
View File
@@ -1,87 +1,115 @@
<?php <?php
/** /**
* 我的家谱:以我为中心,向上向下各三代。**v12 重写** —— 从「每代各一个网格」改成「全树一个网格」。 * 我的家谱:以我为中心,向上向下各三代。
* *
* 为什么必须重写(v11 实测到的三个真缺陷): * **v13 —— 改用成熟的第三方库 family-chart(D3 生态,MIT)来画。**
* ① **每代各建一个网格 → 各代的列宽可以不一样**。`repeat(N, minmax(min-content,1fr))`
* 的最小值是**内容**决定的:有夫妻的那一代最小要「两卡 + 短横」,单身那代只要一卡。
* 窄屏(430px)实测:父母辈列宽 93.5px、我这一辈 164/78/78/78px —— 列坐标根本不一样,
* 同一条竖线在上下两代相差 31~62px,线自然全乱。
* ② 横杆上的竖线落点用**百分比**算,而 grid 的列间距不参与百分比 —— 列一多就系统性偏移。
* ③ 连线行按「代」切分,与所属的网格**不是同一套列**,无法从几何上保证对齐。
* *
* v12 的做法:**整棵树只用一个 grid**(`$colStyle` 挂在 .tree-grid 上), * 为什么放弃自研(v12 的「单一网格」也救不回来):
* 「辈分行 / 卡片行 / 连线行」只是这张网格里的不同行: * v11「每代各一个网格」的列宽由内容决定,各代对不齐;v12 收成「整棵树一个 grid」,
* · 卡片行:每户占 grid-column 的一段(夫妻占 2 列、单人占 1 列); * 几何上确实对齐了,但仍有一个**结构性死结**:连线的横杆是**网格项**,只能横跨
* · 连线行:横杆、上引线、下引线**都是网格项**,各自占自己的列并 `justify-self:center`, * 「从父母那一列到子女那一列」的整段列区间。于是只要中间夹着**跟这条血缘无关**的人
* 于是「竖线落在哪」是浏览器按列算的 —— 不需要任何 px/百分比换算,天然对齐。 * (最典型的是配偶带进来的岳父母),横杆就会**从他们卡片中间穿过去**。
* 各代的列因此完全一致,横向滚动仍交给 .tree-inner。 * 这不是调样式能修的 —— 横杆的跨度由列位决定,而列位是为「同代并排」服务的,
* 两套诉求在同一张网格里没法同时满足。
* *
* 称呼、姻亲、长幼排序这些语义一个字没改,仍由 App\Kinship::tree() / label() 决定。 * 换成库之后:
* · 布局(谁放哪、怎么折线、怎么避让)全部交给库的树算法,不再由我们用网格硬凑;
* · 库只画**本家血脉 + 配偶**,不画配偶的父母 —— 这正是「横杆穿卡片」的根源被消掉的原因,
* 语义上也算合理(姻亲本来就不是本家的血亲,v11 起就单独标着「姻亲」两个字)。
* · 自带拖拽平移 / 滚轮缩放,家里人多了不用我们做横向滚动。
*
* ⚠️ 接入时踩过的三个坑(都写进注释,别再踩):
* ① 库把**数据数组里的第一个人**当默认"主人物"(createChart 里 `main_id = data[0].id`),
* 所以「我」必须排在数组最前面,否则整棵树会以别人为中心。
* ② 卡片回调拿到的是 `TreeDatum`,它 `.data` 是**整个 Datum**({id,data,rels}),
* 我们自己塞的字段在 **`d.data.data.xxx`**(双层)。写一层就得到空白卡片。
* ③ 库的 family-chart.css **不必引入**:实测库里那些规则**全部挂在 `.f3` 下**,
* 而运行时并不会生成任何 `.f3` 元素(卡片的定位全靠 inline style:
* `.card_cont{position:absolute;transform:translate(...)}`、
* `.cards_view{transform:scale(...)}`)—— 引进来也是死文件,反而多了 16KB。
* 真正需要的三条(svg 撑满、连线颜色、卡片观感)我们自己写,见 app.css 的 `.kin-chart`。
*
* 称呼、头像、「本人」高亮、待确认这些语义都由 App\Kinship::label() / person 记录决定,一字未改。
*/ */
$gens = $tree['generations']; $nodes = $tree['nodes'];
$units = $tree['units'] ?? []; $me = $nodes[$meId] ?? null;
$unitName = $tree['unit_names'] ?? [];
$nodes = $tree['nodes'];
$me = $nodes[$meId] ?? null;
// 代数 → 人类说法 // ---- 转成 family-chart 需要的 Datum 格式 ----
$genWord = function (int $level): string { // ⚠️ 「我」排最前(见上面坑①)。其余按名册顺序跟上,库自己会按辈分重新摆放。
if ($level === 0) { $order = [(int)$meId];
return '我这一辈'; // ⚠️ 必须先判 0:它既不 <0 也不在 $down 里, foreach (array_keys($people) as $pid) {
// 漏判就会显示成「下 0 代」这种没人看得懂的东西 if ((int)$pid !== (int)$meId) {
$order[] = (int)$pid;
} }
if ($level < 0) {
$up = [-3 => '曾祖辈', -2 => '祖辈', -1 => '父母辈'];
return $up[$level] ?? '上 ' . abs($level) . ' 代';
}
$down = [1 => '子女辈', 2 => '孙辈', 3 => '曾孙辈'];
return $down[$level] ?? '下 ' . $level . ' 代';
};
$levels = array_keys($gens);
// ---- 把每一代摊成「列位(slot)」 ----
// 一「户」占几个列位:夫妻 2(并排 + 中间一根短横)、单人 1。
// 整棵树的列数取最宽那一代;**所有代共用这一套列**,竖线才可能对齐。
$slotOf = []; // level => [单元key => ['start'=>起始列位, 'span'=>占几列]]
$maxSlots = 1;
foreach ($levels as $level) {
$s = 0;
foreach (($units[$level] ?? []) as $u) {
$span = $u['spouse'] !== null ? 2 : 1;
$slotOf[$level][$u['key']] = ['start' => $s, 'span' => $span];
$s += $span;
}
// ⚠️ 至少要 1:整棵树只有「我」一个人时也不能写出 repeat(0, …)(那是无效值)
$maxSlots = max($maxSlots, max(1, $s));
} }
// 列宽**固定 = 一卡之宽**(--col-w:桌面 96 / 窄屏 78),不是 1fr。 $datum = [];
// foreach ($order as $pid) {
// ⚠️ 为什么必须是固定值而不是 minmax(96px,1fr):列宽给 1fr 时,宽屏下列被撑到 ~195px, if (!isset($people[$pid]) || !is_array($people[$pid])) {
// 而卡片只有 96px、在户内 flex 居中 —— 于是**卡片中心 ≠ 列中心**。 continue;
// 连线靠 justify-self:center 落在**列**中心,就会与卡片错开(实测夫妻户偏 50px)。 }
// 列宽固定成 96px 后: $p = $people[$pid];
// · 单列户 宽 = 96 = 卡片宽 → 卡片正好居中,中心 = 列中心;
// · 夫妻户 宽 = 96+14+96 = 两卡 + 一根短横 → 内容正好填满,两卡各自落在自己的列上。
// 两列的间距(gap)与夫妻短横同宽,所以「户内的两个卡」与「跨户的两个卡」间距也一致。
// 木桶效应不存在了:列宽恒定 → 卡片永远不会被压到地板以下(重叠问题从构造上消失)。
$colStyle = 'grid-template-columns: repeat(' . (int)$maxSlots . ', var(--col-w));';
// 网格行号:每代占 3 行 —— 「辈分行 / 卡片行 / 连线行」。 // 只连「名册里真的有的人」——树上出现一个查不到的人会画出空白卡
// 行号写死(而不是靠自动排布)是为了让上引线、横杆、下引线精确落到同一行。 $parents = [];
$rowLabel = function (int $li): int { return $li * 3 + 1; }; foreach (\App\Kinship::parents($graph, $pid) as $pp) {
$rowCards = function (int $li): int { return $li * 3 + 2; }; if ($pp !== null && isset($people[$pp])) {
$rowConn = function (int $li): int { return $li * 3 + 3; }; $parents[] = (string)$pp;
}
}
$spouses = [];
foreach (\App\Kinship::spouseIds($graph, $pid) as $sp) {
if (isset($people[$sp])) {
$spouses[] = (string)$sp;
}
}
$kids = [];
foreach (\App\Kinship::childIds($graph, $pid) as $kd) {
if (isset($people[$kd])) {
$kids[] = (string)$kd;
}
}
// 库认 M / F / U 三种(决定卡片底色)
$g = strtolower((string)($p['gender'] ?? ''));
$gender = $g === 'm' ? 'M' : ($g === 'f' ? 'F' : 'U');
$datum[] = [
'id' => (string)$pid,
'data' => [
'gender' => $gender,
'name' => (string)($p['name'] ?? ''),
'label' => \App\Kinship::label($graph, (int)$meId, $pid, $people),
'avatar' => avatar_url($p['avatar'] ?? null),
'me' => $pid === (int)$meId,
'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
],
'rels' => [
'parents' => $parents,
'spouses' => $spouses,
'children' => $kids,
],
];
}
// ⚠️ 名字是用户自己输入的:拼 HTML 前必须转义,否则有人把名字填成一段「能提前闭合
// 脚本块」的标签,就能从脚本里逃出去、把整页结构打断。
// ⚠️ 这里**刻意不写出那个闭合标签的字面量** —— 静态检查(check_inline_js.py)
// 是按原文找脚本块的,注释里写出来会被它当成真的脚本段去解析,报假错。
$datumJson = json_encode(
$datum,
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
);
$canChart = count($datum) > 1;
?> ?>
<div class="page-head"> <div class="page-head">
<h1>我的家谱</h1> <h1>我的家谱</h1>
<span class="sub">同代按长幼从左到右 · 夫妻并排 · 折线表示谁是谁的父母</span> <span class="sub">以我为中心 · 同代按长幼从左到右 · 夫妻并排 · 折线表示谁是谁的父母</span>
</div> </div>
<?php if (!$nodes || count($nodes) <= 1): ?> <?php if (!$canChart): ?>
<div class="empty"> <div class="empty">
<b>还没连上任何亲属关系</b> <b>还没连上任何亲属关系</b>
到「<?= e($me['name'] ?? '自己') ?>」的资料页,把父母、配偶、子女填进去, 到「<?= e($me['name'] ?? '自己') ?>」的资料页,把父母、配偶、子女填进去,
@@ -89,96 +117,97 @@ $rowConn = function (int $li): int { return $li * 3 + 3; };
</div> </div>
<?php else: ?> <?php else: ?>
<div class="tree"> <?php
<div class="tree-inner"> /* 家谱画布。
<div class="tree-grid" style="<?= e($colStyle) ?>"> 高度用 clamp 给:太矮时三代人挤成一团,太高又白占屏。
<?php foreach ($levels as $li => $level): ?> ⚠️ 库初始化那一刻**容器必须已经有真实尺寸**:它按容器宽高算 fit 缩放,
<?php $levelUnits = $units[$level] ?? []; ?> 还在 0×0 时初始化会一路缩到 0.4 倍,卡片小到看不清(实测踩过)。 */
?>
<div class="tree-gen-label" style="grid-column: 1 / -1; grid-row: <?= $rowLabel($li) ?>"> <div class="kin-chart" id="kinChart" data-datum="<?= e($datumJson) ?>"></div>
<span class="gname"><?= e($genWord((int)$level)) ?></span>
<span class="gtag"><?= count($levelUnits) ?> 户</span>
</div>
<?php foreach ($levelUnits as $u): ?>
<?php
$sl = $slotOf[$level][$u['key']];
$unitStyle = 'grid-column: ' . ($sl['start'] + 1) . ' / span ' . $sl['span']
. '; grid-row: ' . $rowCards($li) . ';';
?>
<div class="tree-unit" style="<?= e($unitStyle) ?>">
<?php foreach ($u['ids'] as $k => $id): ?>
<?php if ($k === 1): ?>
<span class="tree-bond" aria-label="夫妻"><i></i></span>
<?php endif; ?>
<?php $n = $nodes[$id]; ?>
<a class="tnode <?= (int)$id === $meId ? 'is-me' : '' ?> <?= $n['ph'] ? 'is-placeholder' : '' ?> <?= $u['inlaw'] && $k === 0 ? 'is-inlaw' : '' ?>"
href="<?= e(url('people/show', ['id' => $n['id']])) ?>"
title="<?= e($n['name'] . ' · ' . $n['label']) ?>">
<span class="tph"><img src="<?= e(avatar_url($n['avatar'])) ?>" alt=""></span>
<span class="tname"><?= e($n['name']) ?></span>
<span class="tlabel"><?= e($n['label']) ?></span>
<?php if ($n['ph']): ?><span class="tph-mark">待确认</span><?php endif; ?>
</a>
<?php endforeach; ?>
<?php if ($u['inlaw']): ?>
<span class="tree-inlaw-tag" title="因婚姻进来的人,不是本家的血亲">姻亲</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php
// ---- 连线行:这一代(父母)↔ 下一代(子女)----
//
// 每个「父母户 + 它的子女们」画一架**折线**(family elbow):
// · 横杆盖住「父母列 → 它的子女各列」;
// · 上引线从横杆中点往上接到父母那一户(落在父母**整户的中心列**上);
// · 每个子女各一根下引线,接进同一根横杆(落在**血亲本人那一列**的中心)。
// 三样都是网格项、都靠 justify-self:center 定位 —— 落点由浏览器按列算,必然对齐。
$childLevel = $levels[$li + 1] ?? null;
if ($childLevel !== null && !empty($units[$childLevel])):
// 父母户key => 它的子女们的列位起点
$kidStarts = [];
foreach ($units[$childLevel] as $ku) {
$pk = $ku['parent_key'];
if ($pk !== null && isset($slotOf[$level][$pk])) {
$kidStarts[$pk][] = $slotOf[$childLevel][$ku['key']]['start'];
}
}
$bars = [];
$downs = [];
foreach ($kidStarts as $pk => $starts) {
$ps = $slotOf[$level][$pk];
$lo = min($ps['start'], min($starts));
$hi = max($ps['start'] + $ps['span'] - 1, max($starts));
$bars[] = ['lo' => $lo, 'hi' => $hi, 'pStart' => $ps['start'], 'pSpan' => $ps['span'],
'who' => ($unitName[$pk]['head'] ?? '') . (($unitName[$pk]['spouse'] ?? null) !== null ? ' 夫妇' : '')];
foreach ($starts as $cs) {
$downs[] = $cs;
}
}
sort($downs);
?>
<?php foreach ($bars as $b): ?>
<?php $span = $b['hi'] - $b['lo'] + 1; ?>
<div class="tc-bar" style="grid-column: <?= $b['lo'] + 1 ?> / span <?= $span ?>; grid-row: <?= $rowConn($li) ?>"
title="<?= e($b['who'] . ' 的子女') ?>"></div>
<div class="tc-up" title="<?= e($b['who']) ?>"
style="grid-column: <?= $b['pStart'] + 1 ?> / span <?= $b['pSpan'] ?>; grid-row: <?= $rowConn($li) ?>"></div>
<?php endforeach; ?>
<?php foreach ($downs as $cs): ?>
<div class="tc-down" style="grid-column: <?= $cs + 1 ?>; grid-row: <?= $rowConn($li) ?>"></div>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
</div>
<p class="tree-legend"> <p class="tree-legend">
「姻亲」= 因为婚姻进到这张图里的人(如我妻子的父母),不是本家的血亲。 「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。
折线横跨的那几户,就是它上方那一户的子女;连线从**血亲本人**头上接出。 图里画的是**本家血脉 + 各自的配偶**;配偶的父母(如岳父母)属姻亲,不在这张图上。
可以直接**拖拽平移**、**滚轮缩放**;点一下卡片进对应成员页。
</p> </p>
<script src="<?= e(asset('/assets/vendor/family-chart/d3.min.js')) ?>"></script>
<script src="<?= e(asset('/assets/vendor/family-chart/family-chart.min.js')) ?>"></script>
<script>
(function () {
var root = document.getElementById('kinChart');
if (!root || typeof f3 === 'undefined') { return; }
var raw;
try {
raw = JSON.parse(root.getAttribute('data-datum') || '[]');
} catch (err) {
raw = [];
}
if (!raw.length) { return; }
// 名字是用户输入 —— 插进 innerHTML 前必须转义(库只负责摆放,不管转义)
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
var chart = f3.createChart('#kinChart', raw)
.setTransitionTime(600)
.setOrientationVertical()
.setShowSiblingsOfMain(true)
.setCardXSpacing(140)
.setCardYSpacing(140);
chart.setCardHtml()
.setStyle('rect')
.setCardDim({ w: 116, h: 104, mini_tree: false })
.setCardInnerHtmlCreator(function (d) {
// ⚠️ 双层:d.data 是整个 Datum,自定义字段在 d.data.data(见文件头坑②)
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' : '');
// ⚠️ 卡片本身就是个 <a>,直接跳成员页 —— **不要**去找库的点击回调。
// 踩过:写了 chart.setOnCardClick(...),而 chart 上根本没有这个方法
// (抛 TypeError),异常把后面的 updateTree() 一起带走了 →
// 整张家谱**一张卡都不画**(页面 200、容器有尺寸、看着"正常")。
// 用 <a> 件不依赖任何库 API,也不怕库版本变了改签名。
return '<a class="' + cls + '" href="/index.php?r=people/show&id='
+ encodeURIComponent(id) + '">'
+ '<span class="kin-ph"><img src="' + esc(p.avatar) + '" alt=""></span>'
+ '<span class="kin-nm">' + esc(p.name) + '</span>'
+ '<span class="kin-lb">' + esc(p.label) + '</span>'
+ (p.ph ? '<span class="kin-mark">待确认</span>' : '')
+ '</a>';
});
// ⚠️⚠️ 关掉库自带的「+ 添加亲属」空卡。
// **setCanAdd 不在 chart 上,在 chart.editTree() 返回的对象上**(实测:
// chart 的原型上是 calculateKinships / updateTree / setCardHtml ...,
// 没有 setCanAdd;editTree() 返回的编辑树对象上才有)。
// 写成 chart.setCanAdd(false) 会抛 TypeError,异常把后面的 updateTree() 一起
// 带走 → 整张图**一张卡都不画**(页面照样 200、容器照样有尺寸,光看页面像"正常")。
// 这个坑在本文件里连着犯了三次(setOnCardClick / setCanAdd 链式调用 / 这一处),
// 所以:**新增库调用前先把方法名在浏览器里打印出来确认,别照文档或习惯猜。**
// 为什么要关:家书不从家谱图上改关系(都在各人资料页里改,那里有权限与校验)。
// 不关的话树上会多一张没有名字的空卡(data-id 是库现生成的 uuid、
// 内容为空、点了跳空页),还会因为排在已婚的「我」旁边而像"多出一个配偶"。
chart.editTree().setCanAdd(false);
// ⚠️ 关掉「单亲补空卡」。库默认(single_parent_empty_card: true)会在
// 「某个孩子只登记了一位父母」时,**自动给那位父母补一个配偶占位卡**,
// 好让孩子在图上"看着有两位父母"。实测:测试夹具里有个孩子**只登记了父亲**
// (没登记母亲),库里就凭空造了一张**没有名字、没有称谓、点了跳空页**的卡
// 挂在父亲旁边 —— 可在用户眼里这就是"我凭空多出一个配偶",比"缺一位家长"糟得多。
// 家书本来就有自己的占位语义(真实库记录 + 「待确认」角标),不需要库再补。
// ⚠️ 这个方法名在 chart 原型上(实测确认过),与 setCanAdd 不在同一处。
chart.setSingleParentEmptyCard(false);
// initial:true 才会画出第一版;fit 让整棵树自适应容器大小
chart.updateTree({ initial: true, tree_position: 'fit' });
window.kinChart = chart;
})();
</script>
<?php endif; ?> <?php endif; ?>
+40 -17
View File
@@ -1,38 +1,61 @@
<?php <?php
/** /**
* 观影模块的「⚙ 设置」下拉 —— 影院管理 / 高级搜索 / 统计 / 年度报告。 * 观影模块的「设置」下拉 —— 影院管理 / 高级搜索 / 统计 / 年度报告。
* *
* 为什么抽成片段:v11 这个下拉只长在观影列表页一个地方;用户随后要求 * 用户明确要求(2026-10-01):
* 「设置这个按钮,在观影详情等页面也都要出现,就出现在返回等按钮的旁边」。 * ① 设置按钮**去掉「设置」二字,只留图标**,图标是**三条横线**(☰);
* 与其把同一段 <details> 复制到六七个页头(改一处就得改六处、迟早漏), * ② 设置按钮在**所有页面都排在最右侧**(统一位置);
* 不如让所有观影页 require 同一个片段。 * ③ 点开后的**每条条目,文字前都要加图标**;
* ④ 除设置外,其它按钮都要**有颜色**,跟设置(无色/描边)区分开。
* *
* 用法(include 前 set 好): * 实现要点:
* $setCurrent 当前页的 key('list' | 'cinemas' | 'search' | 'stats' | 'report' | 'show' | 'form') * · 按钮本身用 .btn-icon(方形、只有图标、无文字)——与「记一场电影」等实心按钮同高;
* —— 用来高亮菜单里对应的那一项。空串表示都不高亮。 * · 靠 CSS 的 `margin-left:auto` 把它推到动作区最右(见 app.css 的 .movie-set 一节)。
* 这样无论各页把片段 require 在 actions 的哪个位置,它都会贴右,不必逐页调顺序。
* · 条目图标用行内 SVG(不引外部字体/图片,避免多一次请求与 FOUC)。
*
* 用法(require 前 set 好):
* $setCurrent 当前页 key('list'|'cinemas'|'search'|'stats'|'report'|'show'|'form'),用来高亮当前项
* $setCanWrite 是否显示「影院管理」(只有能写的人才有管理入口) * $setCanWrite 是否显示「影院管理」(只有能写的人才有管理入口)
*
* ⚠️ 高亮用 `a.on`(.movie-set .menu-list a.on 已有样式)。**不做「隐藏当前页那一项」**:
* 隐藏会让菜单在不同页面长短不一,用户更难认出「这是同一套入口」。
*/ */
$setCurrent = (string)($setCurrent ?? ''); $setCurrent = (string)($setCurrent ?? '');
$setCanWrite = (bool)($setCanWrite ?? false); $setCanWrite = (bool)($setCanWrite ?? false);
$setOn = static function (string $key) use ($setCurrent): string { $setOn = static function (string $key) use ($setCurrent): string {
return $setCurrent === $key ? ' class="on"' : ''; return $setCurrent === $key ? ' class="on"' : '';
}; };
// 条目图标(行内 SVG,16×16,用 currentColor 跟随文字色)
$setIcon = static function (string $name): string {
$paths = [
// 影院管理:放映机/胶片
'cinemas' => '<rect x="2" y="4" width="12" height="8" rx="1.5"/><path d="M4.5 6.5h1.5M7.5 6.5h1.5M10.5 6.5h1.5M4.5 9.5h1.5M7.5 9.5h1.5M10.5 9.5h1.5"/>',
// 高级搜索:放大镜
'search' => '<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/>',
// 统计:柱状图
'stats' => '<path d="M3 13V9M6.5 13V5M10 13V7.5M13.5 13V3"/>',
// 年度报告:文档
'report' => '<path d="M4 2h5l3 3v9H4z"/><path d="M9 2v3h3M6 8h4M6 10.5h4"/>',
];
$d = $paths[$name] ?? '';
if ($d === '') {
return '';
}
return '<svg class="mi" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"'
. ' fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"'
. ' stroke-linejoin="round">' . $d . '</svg>';
};
?> ?>
<details class="movie-set menu"> <details class="movie-set menu">
<summary class="btn btn-ghost" title="观影设置">⚙ 设置</summary> <summary class="btn btn-icon" title="观影设置" aria-label="观影设置"><svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/></svg></summary>
<div class="menu-list"> <div class="menu-list">
<?php if ($setCanWrite): ?> <?php if ($setCanWrite): ?>
<a href="<?= e(url('movies/cinemas')) ?>"<?= $setOn('cinemas') ?>>影院管理</a> <a href="<?= e(url('movies/cinemas')) ?>"<?= $setOn('cinemas') ?>><?= $setIcon('cinemas') ?><span>影院管理</span></a>
<?php endif; ?> <?php endif; ?>
<a href="<?= e(url('movies/search')) ?>"<?= $setOn('search') ?>>高级搜索</a> <a href="<?= e(url('movies/search')) ?>"<?= $setOn('search') ?>><?= $setIcon('search') ?><span>高级搜索</span></a>
<a href="<?= e(url('movies/stats')) ?>"<?= $setOn('stats') ?>>统计</a> <a href="<?= e(url('movies/stats')) ?>"<?= $setOn('stats') ?>><?= $setIcon('stats') ?><span>统计</span></a>
<a href="<?= e(url('movies/report')) ?>"<?= $setOn('report') ?>>年度报告</a> <a href="<?= e(url('movies/report')) ?>"<?= $setOn('report') ?>><?= $setIcon('report') ?><span>年度报告</span></a>
</div> </div>
</details> </details>
<?php <?php
// 用完即清,避免残留在后续 include 里(与 _datetime.php 同一纪律) // 用完即清,避免残留在后续 include 里(与 _datetime.php 同一纪律)
unset($setCurrent, $setCanWrite, $setOn); unset($setCurrent, $setCanWrite, $setOn, $setIcon);
?> ?>
+1 -1
View File
@@ -18,8 +18,8 @@ $canWrite = \App\Auth::canWrite();
<h1>影院管理</h1> <h1>影院管理</h1>
<span class="sub">共 <?= count($cinemas) ?> 家影院</span> <span class="sub">共 <?= count($cinemas) ?> 家影院</span>
<div class="actions"> <div class="actions">
<?php $setCurrent = 'cinemas'; $setCanWrite = $canWrite; require __DIR__ . '/_settings_menu.php'; ?>
<a class="btn btn-ghost" href="<?= e(url('movies')) ?>">返回观影记录</a> <a class="btn btn-ghost" href="<?= e(url('movies')) ?>">返回观影记录</a>
<?php $setCurrent = 'cinemas'; $setCanWrite = $canWrite; require __DIR__ . '/_settings_menu.php'; ?>
</div> </div>
</div> </div>
+2 -3
View File
@@ -32,13 +32,12 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
<h1><?= $isEdit ? '编辑观影记录' : '记一场电影' ?></h1> <h1><?= $isEdit ? '编辑观影记录' : '记一场电影' ?></h1>
<span class="sub"><?= $isEdit ? e($m['title']) : '片名必填,其他都可以以后再补' ?></span> <span class="sub"><?= $isEdit ? e($m['title']) : '片名必填,其他都可以以后再补' ?></span>
<div class="actions"> <div class="actions">
<?php /* 用户要求「设置这个按钮,在观影详情等页面也都要出现,就出现在返回等按钮的旁边」。 <?php /* 表单页没有返回按钮(提交/取消在表单底部),返回放前面、设置贴最右。 */ ?>
表单页没有返回按钮(提交/取消在表单底部),设置就放在页头动作区。 */ ?>
<?php $setCurrent = 'form'; $setCanWrite = \App\Auth::canWrite(); require __DIR__ . '/_settings_menu.php'; ?>
<a class="btn btn-ghost" <a class="btn btn-ghost"
href="<?= e($isEdit ? url('movies/show', ['id' => (int)$m['id']]) : url('movies')) ?>"> href="<?= e($isEdit ? url('movies/show', ['id' => (int)$m['id']]) : url('movies')) ?>">
<?= $isEdit ? '返回详情' : '返回观影' ?> <?= $isEdit ? '返回详情' : '返回观影' ?>
</a> </a>
<?php $setCurrent = 'form'; $setCanWrite = \App\Auth::canWrite(); require __DIR__ . '/_settings_menu.php'; ?>
</div> </div>
</div> </div>
+3 -4
View File
@@ -26,10 +26,9 @@ $fmtNum = static function ($v): string {
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<a class="btn" href="<?= e(url('movies/new')) ?>">记一场电影</a> <a class="btn" href="<?= e(url('movies/new')) ?>">记一场电影</a>
<?php endif; ?> <?php endif; ?>
<?php /* 右侧的「⚙ 设置」—— 影院管理与从 MovieLog 迁来的 <?php /* 「设置」下拉:影院管理 / 高级搜索 / 统计 / 年度报告。
高级搜索 / 统计 / 年度报告都收在这里(顶栏不再单放「影院」)。 ⚠️ 片段必须放在 actions 的**最后**:它靠 margin-left:auto 贴右,
⚠️ 片段里的按钮**不带 btn-sm**:页头动作区必须同高,见 app.css 的 放最后才能保证「所有页面都排在最右侧」(用户 2026-10-01 要求)。 */ ?>
「页头动作统一尺寸」一节(用户明确要求这两个按钮高度一致)。 */ ?>
<?php $setCurrent = 'list'; $setCanWrite = $canWrite; require __DIR__ . '/_settings_menu.php'; ?> <?php $setCurrent = 'list'; $setCanWrite = $canWrite; require __DIR__ . '/_settings_menu.php'; ?>
</div> </div>
</div> </div>
+1 -1
View File
@@ -29,8 +29,8 @@ $fmtDate = static function (?string $d): string {
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</form> </form>
<?php $setCurrent = 'report'; $setCanWrite = \App\Auth::canWrite(); require __DIR__ . '/_settings_menu.php'; ?>
<a class="btn btn-ghost" href="<?= e(url('movies')) ?>">返回观影</a> <a class="btn btn-ghost" href="<?= e(url('movies')) ?>">返回观影</a>
<?php $setCurrent = 'report'; $setCanWrite = \App\Auth::canWrite(); require __DIR__ . '/_settings_menu.php'; ?>
</div> </div>
</div> </div>
+1 -1
View File
@@ -11,8 +11,8 @@
<h1>高级搜索</h1> <h1>高级搜索</h1>
<span class="sub">组合多个条件精确查找</span> <span class="sub">组合多个条件精确查找</span>
<div class="actions"> <div class="actions">
<?php $setCurrent = 'search'; $setCanWrite = \App\Auth::canWrite(); require __DIR__ . '/_settings_menu.php'; ?>
<a class="btn btn-ghost" href="<?= e(url('movies')) ?>">返回观影</a> <a class="btn btn-ghost" href="<?= e(url('movies')) ?>">返回观影</a>
<?php $setCurrent = 'search'; $setCanWrite = \App\Auth::canWrite(); require __DIR__ . '/_settings_menu.php'; ?>
</div> </div>
</div> </div>
+2 -2
View File
@@ -151,9 +151,9 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
<button type="submit" class="btn btn-danger">删除</button> <button type="submit" class="btn btn-danger">删除</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php /* 设置入口要出现在「返回等按钮的旁边」(用户原话) */ ?> <?php /* 设置入口放在 actions 最后 → 永远在最右侧(用户 2026-10-01 要求) */ ?>
<?php $setCurrent = 'show'; $setCanWrite = $canWrite; require __DIR__ . '/_settings_menu.php'; ?>
<a class="btn btn-ghost" href="<?= e(url('movies')) ?>">返回列表</a> <a class="btn btn-ghost" href="<?= e(url('movies')) ?>">返回列表</a>
<?php $setCurrent = 'show'; $setCanWrite = $canWrite; require __DIR__ . '/_settings_menu.php'; ?>
</div> </div>
</div> </div>
+1 -1
View File
@@ -25,8 +25,8 @@ $wd = static function (?string $d): string {
<h1>观影统计</h1> <h1>观影统计</h1>
<span class="sub">一家人的观影概览</span> <span class="sub">一家人的观影概览</span>
<div class="actions"> <div class="actions">
<?php $setCurrent = 'stats'; $setCanWrite = \App\Auth::canWrite(); require __DIR__ . '/_settings_menu.php'; ?>
<a class="btn btn-ghost" href="<?= e(url('movies')) ?>">返回观影</a> <a class="btn btn-ghost" href="<?= e(url('movies')) ?>">返回观影</a>
<?php $setCurrent = 'stats'; $setCanWrite = \App\Auth::canWrite(); require __DIR__ . '/_settings_menu.php'; ?>
</div> </div>
</div> </div>