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
+287 -4
View File
@@ -183,6 +183,13 @@ a:hover { text-decoration: underline; }
box-shadow: var(--shadow);
}
.card-pad { padding: 20px; }
/* 相邻的两块之间一定要有缝 —— 影院管理页「添加影院」与「影院列表」、
年度报告的几张卡,曾经紧贴成一个整块,看不出是两个东西。
统一在这里给间距,而不是每个页面各写一次(写一次就会漏一处)。 */
.card + .card { margin-top: 18px; }
/* ⚠️ 上面**只写 .card + .card**:.section 自己有 margin-top:28px,
若在这里再写 `.card + .section { margin-top:18px }`,特异性 0,2,0 会压过
`.section` 的 0,1,0,反而把原本 28px 的间距改小 —— 修 A 不能顺手弄坏 B。 */
/* 首页头图 */
.hero {
@@ -439,6 +446,11 @@ textarea.input { min-height: 76px; resize: vertical; }
⚠️ 卡片/连线都不是我们生成的:卡片是库建的真实 <div>(.card_cont > .card > 我们的
.kin-card),连线是 svg 里的 <path class="link">。改版式只能从这两个钩子下手。 */
/* v14:树根换成「最高祖先」之后整棵树会很大,光靠拖很累 ——
这两个按钮把「看全部」与「回到我」分开。放在画布**外面**,
免得被 svg 的拖拽吞掉点击(画布整块都被库接管了手势)。 */
.kin-tools { display: flex; gap: 8px; justify-content: flex-end; margin: 0 0 10px; }
.kin-chart {
position: relative;
width: 100%;
@@ -999,9 +1011,19 @@ textarea.input { min-height: 76px; resize: vertical; }
display: inline-flex; align-items: center; gap: 4px; font-size: 12px;
padding: 2px 8px; border-radius: 12px; background: var(--line-2);
color: var(--ink); text-decoration: none;
/* 底色/字色/边线由 person_chip() 写成行内样式(每人一个颜色,来自他的头像主色);
这里只给**没有行内样式时的兜底**(旧的灰底标签):1px 透明边,
这样有边线和没边线的两种标签高度完全一致,不会一行高一行矮。 */
border: 1px solid transparent;
}
.viewer-tag:hover { text-decoration: none; background: var(--line); }
.viewer-tag img { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; }
.viewer-tag:hover { text-decoration: none; filter: brightness(.96); }
.viewer-tag .chip-av { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; }
.viewer-tag .chip-av-text {
background: var(--faint); color: #fff;
display: inline-flex; align-items: center; justify-content: center;
font-size: 10px; line-height: 1;
}
.viewer-tag .chip-name { white-space: nowrap; }
/* ---- 详情页 ---- */
.detail-hero {
@@ -1042,8 +1064,8 @@ textarea.input { min-height: 76px; resize: vertical; }
.empty-hint { color: var(--muted); font-size: 14px; margin: 0; }
.viewers-list { display: flex; flex-wrap: wrap; gap: 12px; }
.viewers-list .viewer-tag { padding: 4px 12px 4px 6px; font-size: 14px; }
.viewers-list .viewer-tag img,
.viewers-list .rv-avatar-text { width: 28px; height: 28px; }
.viewers-list .viewer-tag .chip-av { width: 28px; height: 28px; }
.viewers-list .viewer-tag .chip-av-text { font-size: 13px; }
.cinema-info {
display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; font-size: 15px;
}
@@ -1464,3 +1486,264 @@ textarea.input { min-height: 76px; resize: vertical; }
font-size: 14px; padding: 4px 12px; border-radius: 999px;
background: var(--line-2); color: var(--brand-ink); border: 1px solid var(--line);
}
/* ==================== v12:旅程 / 日程 / 资源 / 任务 ==================== */
/* 四个新模块共用一套"卡片骨架"(.card + .card-pad + .cinema-h2 的小标题),
下面只写各模块**特有**的部件,避免又造一套并行的卡片。 */
/* ---- 旅程 ---- */
.trip-list { display: flex; flex-direction: column; gap: 12px; }
.trip-card {
display: flex; gap: 16px; background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px; text-decoration: none; color: inherit;
transition: transform .18s ease, box-shadow .18s ease;
}
.trip-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-l); text-decoration: none; }
.trip-cover {
width: 132px; height: 96px; flex: 0 0 auto; border-radius: var(--radius-s);
overflow: hidden; background: #F1E7D6; display: flex; align-items: center; justify-content: center;
}
.trip-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.trip-cover-empty { font-size: 34px; opacity: .55; }
.trip-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.trip-title { font-size: 17px; font-weight: 600; margin: 0; }
.trip-meta { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; color: var(--muted); }
.trip-sum { margin: 0; font-size: 14px; color: var(--muted); line-height: 1.6; }
.trip-people { display: flex; flex-wrap: wrap; gap: 4px 8px; }
.trip-hero { display: flex; gap: 20px; flex-wrap: wrap; }
.trip-hero-cover {
width: 260px; max-width: 100%; aspect-ratio: 4 / 3; flex: 0 0 auto; border-radius: var(--radius-s);
overflow: hidden; background: #F1E7D6; display: flex; align-items: center; justify-content: center;
}
.trip-hero-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.trip-hero-body { flex: 1 1 320px; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.trip-loc { margin: 0; font-size: 14px; color: var(--muted); }
.cover-now { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.cover-now img { width: 96px; height: 64px; object-fit: cover; border-radius: 8px; display: block; }
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; }
.photo-cell { margin: 0; background: #F1E7D6; border-radius: var(--radius-s); overflow: hidden; }
.photo-cell a { display: block; }
.photo-cell img { width: 100%; height: 120px; object-fit: cover; display: block; }
.photo-del { padding: 6px; text-align: center; }
/* ---- 行程节点 ---- */
.node-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.node-row {
display: flex; gap: 12px; align-items: flex-start; padding: 12px 14px;
border: 1px solid var(--line-2); border-radius: var(--radius-s); background: #FDFBF7;
}
.node-row.status-cancelled { opacity: .62; }
.node-row.status-done .node-head b { color: var(--muted); }
.node-ico { font-size: 20px; flex: 0 0 auto; line-height: 1.4; }
.node-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 5px; }
.node-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.node-head b { font-size: 15px; }
.node-badge {
font-size: 11px; padding: 1px 8px; border-radius: 999px;
background: var(--line-2); color: var(--muted); white-space: nowrap;
}
.node-row.status-done .node-badge { background: #E6F0DC; color: #4A7226; }
.node-place, .node-time { font-size: 12px; color: var(--muted); }
.node-detail { margin: 0; font-size: 14px; line-height: 1.65; }
.node-ops { flex: 0 0 auto; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; }
.node-edit > summary { cursor: pointer; }
.node-add { margin-top: 14px; border-top: 1px solid var(--line-2); padding-top: 12px; }
.node-add > summary { cursor: pointer; font-size: 14px; }
.node-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
.nf-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.nf-grid .field.full { grid-column: 1 / -1; }
.nf-members { display: flex; flex-wrap: wrap; gap: 6px 16px; }
/* ---- 附件行(旅程节点 / 资源 / 任务共用) ---- */
.attach-row { display: flex; flex-wrap: wrap; gap: 8px; }
.attach-item {
display: inline-flex; align-items: center; gap: 5px; font-size: 13px;
padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line);
background: var(--surface); color: var(--brand-ink); text-decoration: none;
}
.attach-item:hover { border-color: var(--brand); text-decoration: none; }
/* ---- 日程(月视图,群晖 Calendar 的外观) ---- */
.cal-wrap { display: grid; grid-template-columns: 230px 1fr; gap: 18px; align-items: start; }
.cal-side { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; }
.cal-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.cal-item { display: flex; flex-direction: column; gap: 4px; }
.cal-item > .node-badge { align-self: flex-start; }
.cal-toggle { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; }
.cal-toggle input { accent-color: var(--brand); }
.cal-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cal-note { font-size: 12px; color: var(--faint); line-height: 1.6; margin: 12px 0 0; }
.cal-main { min-width: 0; }
.cal-add { margin-bottom: 18px; }
.cal-grid-card { padding: 0; overflow: hidden; }
.cal-head { display: grid; grid-template-columns: repeat(7, 1fr); border-bottom: 1px solid var(--line); }
.cal-wd { padding: 8px 0; text-align: center; font-size: 12px; color: var(--muted); }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); }
.cal-cell {
min-height: 104px; padding: 6px 6px 8px; border-right: 1px solid var(--line-2);
border-bottom: 1px solid var(--line-2); display: flex; flex-direction: column; gap: 4px;
}
.cal-cell:nth-child(7n) { border-right: 0; }
.cal-cell:nth-last-child(-n+7) { border-bottom: 0; }
.cal-cell.is-out { background: #FCFAF6; }
.cal-cell.is-out .cal-dnum { color: var(--faint); }
.cal-cell.is-today { background: #FDF6F1; }
.cal-dnum { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; padding-left: 2px; }
.cal-cell.is-today .cal-dnum {
color: #fff; background: var(--brand); border-radius: 999px;
width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;
}
.cal-evs { display: flex; flex-direction: column; gap: 3px; }
.cal-ev {
display: block; font-size: 12px; line-height: 1.35; color: #fff;
padding: 2px 6px; border-radius: 5px; text-decoration: none;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cal-ev:hover { text-decoration: none; filter: brightness(1.08); }
.cal-ev.is-allday { font-weight: 500; }
.cal-more { font-size: 11px; color: var(--faint); padding-left: 2px; }
.ev-del { margin-top: 16px; }
.cal-share-name { font-size: 13px; color: var(--muted); }
@media (max-width: 860px) {
.cal-wrap { grid-template-columns: 1fr; }
.cal-cell { min-height: 78px; }
}
/* ---- 资源 ---- */
.res-list { display: flex; flex-direction: column; gap: 8px; }
.res-item { border: 1px solid var(--line-2); border-radius: var(--radius-s); background: #FDFBF7; }
.res-head {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 12px 14px; cursor: pointer; list-style: none;
}
.res-head::-webkit-details-marker { display: none; }
.res-head:hover { background: #FAF7F1; }
.res-title { font-size: 15px; font-weight: 600; }
.res-n { font-size: 12px; color: var(--faint); }
.res-body { padding: 0 14px 14px; display: flex; flex-direction: column; gap: 10px; }
.res-size { font-size: 11px; color: var(--faint); }
.res-inline { display: inline; margin: 0; }
.res-shares { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.res-label { font-size: 12px; color: var(--muted); }
.res-ops { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.res-share-add > summary { cursor: pointer; }
/* ---- 任务看板 ---- */
.kanban { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; align-items: start; }
.kb-col { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.kb-head {
display: flex; align-items: center; gap: 8px; padding: 12px 16px;
border-bottom: 1px solid var(--line-2); font-size: 14px;
}
.kb-head .kb-n { margin-left: auto; font-size: 12px; color: var(--faint); }
.kb-body { padding: 12px; display: flex; flex-direction: column; gap: 10px; min-height: 90px; }
.kb-body.is-over { background: #FDF6F1; outline: 2px dashed var(--brand-2); outline-offset: -4px; }
.kb-card {
border: 1px solid var(--line-2); border-radius: var(--radius-s); background: #FDFBF7;
padding: 10px 12px; display: flex; flex-direction: column; gap: 6px;
}
.kb-card.is-dragging { opacity: .45; }
.kb-card.is-overdue { border-color: rgba(192, 83, 63, .4); }
.kb-title { font-size: 14px; font-weight: 500; color: var(--ink); }
.kb-title:hover { color: var(--brand); }
.kb-meta { display: flex; flex-wrap: wrap; gap: 4px 8px; font-size: 12px; color: var(--muted); }
.kb-due.is-late, .kb-late { color: var(--red); }
.kb-ico { white-space: nowrap; }
.kb-sub { display: flex; align-items: center; gap: 8px; }
.kb-bar { flex: 1 1 auto; height: 5px; border-radius: 999px; background: var(--line-2); overflow: hidden; }
.kb-bar i { display: block; height: 100%; background: var(--green); }
.kb-sub-n { font-size: 11px; color: var(--faint); font-variant-numeric: tabular-nums; }
.kb-foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.kb-move { margin-left: auto; display: flex; gap: 4px; }
.kb-empty { padding: 6px 2px; }
.hidden-form { display: none; }
/* ---- 任务详情 ---- */
.sub-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.sub-row {
display: flex; align-items: center; gap: 10px; padding: 7px 10px;
border: 1px solid var(--line-2); border-radius: var(--radius-s); background: #FDFBF7;
}
.sub-row.is-done .sub-title { color: var(--muted); text-decoration: line-through; }
.sub-check {
background: none; border: 0; cursor: pointer; font-size: 17px; line-height: 1;
padding: 0 2px; color: var(--brand);
}
.sub-check.is-static { cursor: default; color: var(--muted); }
.sub-title { flex: 1 1 auto; min-width: 0; font-size: 14px; }
.cmt-list { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.cmt-row { border-top: 1px solid var(--line-2); padding-top: 10px; }
.cmt-who { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cmt-time { font-size: 11px; color: var(--faint); }
.cmt-body { font-size: 14px; line-height: 1.7; margin: 6px 0; }
.cmt-body .mention { color: var(--brand-ink); background: #F6E3D7; border-radius: 4px; padding: 0 3px; }
.cmt-form { margin-top: 6px; }
.task-danger { margin-top: 18px; display: flex; justify-content: flex-end; }
@media (max-width: 860px) {
.kanban { grid-template-columns: 1fr; }
.nf-grid { grid-template-columns: 1fr; }
}
/* ---- 个人设置里的「模块」两块 ---- */
.mod-order .mod-row { cursor: grab; }
.mod-order .mod-row.is-dragged { opacity: .45; cursor: grabbing; }
.mod-order .mod-row.is-drop-before { box-shadow: inset 0 3px 0 var(--brand); }
.mod-order .mod-row.is-drop-after { box-shadow: inset 0 -3px 0 var(--brand); }
.mod-pick { display: flex; align-items: center; gap: 8px; flex: 1 1 auto; min-width: 0; cursor: pointer; }
.mod-pick input { accent-color: var(--brand); }
.mod-swatches { display: flex; flex-wrap: wrap; gap: 8px 18px; }
.mod-swatch { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
.mod-colors { display: flex; flex-direction: column; gap: 10px; }
.mod-color-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.mod-color-name { flex: 0 0 76px; font-size: 14px; }
.mod-preset { display: flex; flex-wrap: wrap; gap: 4px; }
.mod-preset .preset-color { cursor: pointer; }
/* ---- 首页 dashboard ---- */
.hero-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.dash-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.dash-row {
display: flex; align-items: baseline; gap: 10px; padding: 8px 2px;
border-top: 1px solid var(--line-2); flex-wrap: wrap;
}
.dash-row:first-child { border-top: 0; }
.dash-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.dash-when { flex: 0 0 96px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
.dash-what { flex: 1 1 auto; min-width: 0; font-size: 14px; }
.dash-tag { flex: 0 0 auto; font-size: 11px; color: var(--faint); }
@media (max-width: 620px) {
.dash-when { flex: 0 0 auto; }
}
/* ---- v14:导航条从 4 个模块变成最多 8 个,窄屏必须能横向滑动 ----
⚠️ 这是本轮**实测出来的回归**:模块从 4 个加到 8 个之后,
375px 视口下 scrollWidth=472 > clientWidth=375,**整页横向溢出**
(顶栏把 body 撑宽了)。以前 4 个模块刚好塞得下,所以从没有人给 .nav 写过窄屏规则。
修法用「导航自己横向滚动」而不是「换行」:
换行会把顶栏从 60px 撑成两行,sticky 顶栏一高一矮很难看,而且滚动时页面会跳。
⚠️ 断点必须是 **720px**(站点既有的"窄屏留白 16px"断点),不能用 860:
746px 宽时若只改顶栏留白,就会出现「顶栏 x=16、正文 x=24」错位 8px
(几何检查会直接报出来)。窄屏规则只在站点真正切到小留白的那一档生效。 */
@media (max-width: 720px) {
.topbar-inner { gap: 12px; padding: 0 16px; }
/* min-width:0 是关键:flex 子项默认 min-width:auto,会拒绝被压缩,
于是内部再 overflow-x 也撑不出滚动条,而是把父级一起顶宽。 */
.nav {
flex: 1 1 auto;
min-width: 0;
margin-left: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; /* Firefox */
}
.nav::-webkit-scrollbar { display: none; } /* Chrome/Safari:藏掉滚动条,仍可滑 */
.nav a { flex: 0 0 auto; padding: 6px 10px; }
.topbar-right { margin-left: 0; gap: 6px; }
/* 窄屏把「家书」两个字收起来,只留图标 —— 名字占的宽度正好够多放两个模块 */
.brand span { display: none; }
.who .who-name { display: none; }
}