From a43dd06d1fcb74057fc2af9c8f97a6306cbfa5f7 Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 23 Sep 2026 00:01:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E7=89=88=E5=BF=83=E5=8F=8C=E9=87=8D?= =?UTF-8?q?=E5=86=85=E7=BC=A9=E4=B8=8E=E9=A2=84=E8=A7=88=E5=9B=BE=E6=BA=A2?= =?UTF-8?q?=E5=87=BA=EF=BC=9A=E5=AE=BD=E5=B1=8F=E6=95=B4=E9=A1=B5=E9=94=99?= =?UTF-8?q?=E4=BD=8D=E3=80=81=E5=A4=B4=E5=83=8F=E7=9B=96=E4=BD=8F=E8=A1=A8?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上反馈「界面更乱了、全都错位、导航条头像非常大、下拉菜单乱七八糟」。 用无头浏览器实测出三处硬伤,都不是样式文件坏了,是布局算式写错。 一、版心被推两次 → 宽屏正文列塌陷(「全都错位」的根) .topbar 与 .container 各自用了同一份「把内容推到居中」的 padding, 而 .container 本来就已经有 max-width + margin:auto。于是内容被推两次: 1280px 顶栏品牌 x=89 正文 x=178 差 89px 1920px 顶栏品牌 x=409 正文 x=818 差 409px,正文列只剩 262px 2560px 正文列宽度 = 0 内容完全挤没 改法:抽一个 --shell-pad(= max(--gutter, (100% - --container) / 2)), 顶栏与正文共用它,并且 .container 不再设 max-width —— 正文列宽度由 padding 反推,宽屏下正好等于 --container,两边用的是同一个公式,左边缘必然对齐。 用 100% 而非 100vw,避免滚动条让两者差半格。 二、预览容器把图片尺寸写在了外层 div 上 → 400×400 头像盖住整个表单 .avatar-preview / .cover-preview 把 width/height/object-fit 写在**容器**上, 里面的 完全不受约束,按原始尺寸铺出来: 头像 400×400、封面 1920×1080,直接把下面的表单项盖住。 (对照:.card-cover img / .activity-cover img 是写对的。) 改法:容器只负责「框」(定尺寸、overflow:hidden 裁圆角), 尺寸与 object-fit 一律落到 > img 上,且用绝对定位铺满 —— 不用 `display:grid + width:100%`,那种写法列宽会反过来依赖元素自身宽度, 形成循环,浏览器只能把图算成 0 宽(实测踩到过)。 三、上传区被当成小圆框 → 按钮和提示文字压进头像里 .avatar-upload 被套上 84×84 圆形尺寸,而模板把预览、按钮、提示文字都放在 它内部,三样东西全挤在那个小圆里互相重叠。改法:上传容器只做排列 (头像横排、封面竖排),虚线圆框与圆角归预览块,按钮自成一行。 顺带 · 下拉面板加 max-height + overflow,13 个菜单项在矮屏不再顶出屏幕底部; 宽度 max-content 且不换行,并限制不超过视口。 · 头像地址一律先过 media_avatar() 再判断:外站地址(https://…)会被拒, 以前按原始值判断非空 → 渲染出 ,用户管理列表里两个用户 就是破图。现在退回首字母色块。头像隐藏域仍保留原始值,避免单纯保存就抹掉数据。 · CSS 结构体检报告不再写在被检查文件旁边(assets/style.css.syntax.txt 曾被当成站点文件打进部署包);打包排除规则改为「根目录 _ 开头 + 任意层级的工具报告/日志」,并且不再误伤 templates/ 下 _ 前缀的局部模板。 配套的检查能力(本次之前的检查全绿、页面却视觉上是坏的,所以补这一层) · _tooling/_snapshot3.py 按登录态渲染线上页面 + 抓静态资源到本地 · _tooling/_layout_check.py 无头浏览器测量真实几何并断言:顶栏/正文左右边缘 对齐、正文列宽度、无横向溢出、头像恒 26px、预览图不溢出且铺满、预览图 确实加载成功、下拉面板不出屏。6 个页面 × 7 档宽度 = 392 项。 先用线上那份坏样式跑证明它抓得到(89 项失败),再用修复后的跑(0 失败)。 · _tooling/css_syntax_check.py 词法级 CSS 结构体检(跳过注释与字符串后数括号), 比 grep 数大括号可靠 —— 配平不等于合法。 · 部署脚本新增 7 条静态哨兵,锁住上面三个根因。 验证 本地:php -l 55 文件 0 失败;模板渲染 234 项;SQL 15 表无未知列;PDO 0 问题; CSS 202 类全有定义;CSS 结构体检通过;Mime 27 项;内联 JS 9 个文件; 布局几何 392 项全通过。 线上:部署 10 步 EXIT=0(80 项静态断言全 OK / 0 FAIL,含 7 条新哨兵), 双 PHP 渲染 14 条路由字节数完全一致,26 项内容断言全通过 (含照片代理 69/69 字节逐字节一致); 对已部署状态重抓渲染再测布局几何 392 项 0 失败; 端到端 239 项通过 / 0 项失败。 用户管理列表里 example.com 外链出现次数由 2 降为 0。 --- assets/style.css | 148 ++++++++++++++++++++++++++++++-------- templates/layout.php | 7 +- templates/users/form.php | 9 ++- templates/users/index.php | 5 +- 4 files changed, 132 insertions(+), 37 deletions(-) diff --git a/assets/style.css b/assets/style.css index 46b60ea..c03beb0 100644 --- a/assets/style.css +++ b/assets/style.css @@ -42,12 +42,30 @@ --shadow-md: 0 8px 24px rgba(22, 28, 45, .10); --shadow-lg: 0 18px 48px rgba(22, 28, 45, .16); - --container: 1080px; + /* ── 版心 ───────────────────────────────────────────────────────────── + 顶栏与正文必须共用同一套「左右留白」,否则宽屏下两者左边缘会错开。 */ + --container: 1080px; /* 正文列的目标宽度 */ + --gutter: clamp(14px, 3.2vw, 24px); /* 窄屏时内容与视口边缘的最小留白 */ --topbar-h: 60px; + + /* 版心左右留白:视口比 --container 宽时,把它推到「让正文列正好等于 + --container 且居中」的位置;视口不够宽时退化为 --gutter。 + ⚠️ 顶栏与 .container 只共用这一个值,两边都不许再叠加 + `max-width + margin auto` —— 那等于把内容推两次。曾经就是这么错的: + 1920px 下正文列只剩 240px,2560px 下直接塌成 0,整页错位。 + ⚠️ 这里用 100% 而不是 100vw:100vw 含滚动条宽度,会让顶栏比正文 + 偏出半个滚动条。100% 相对各自包含块,两边算出来才严格相等。 */ + --shell-pad: max(var(--gutter), calc((100% - var(--container)) / 2)); } * { box-sizing: border-box; } +/* 图片一律不得宽过所在容器 —— 这是兜底,不是主要手段。 + 主因见下面 .avatar-preview / .cover-preview 的注释:尺寸要写在 img 上。 + (这里只约束宽度,高度仍由各自的规则决定;单靠这条救不了「400×400 的方图 + 塞进 84px 圆框」那种高度溢出。) */ +img { max-width: 100%; } + html { -webkit-text-size-adjust: 100%; } body { @@ -92,7 +110,7 @@ hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; } display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: var(--topbar-h); - padding: 8px max(14px, calc((100vw - var(--container)) / 2)); + padding: 8px var(--shell-pad); /* 与 .container 同一个版心留白 */ background: rgba(255, 255, 255, .88); backdrop-filter: saturate(180%) blur(12px); -webkit-backdrop-filter: saturate(180%) blur(12px); @@ -101,15 +119,18 @@ hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; } .brand { display: inline-flex; align-items: center; gap: 8px; + min-width: 0; /* 允许品牌文字省略,别把顶栏撑破 */ font-weight: 680; font-size: 16px; color: var(--text); letter-spacing: -.01em; } .brand:hover { text-decoration: none; color: var(--text); } .brand-mark { font-size: 18px; line-height: 1; } +.brand-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.topbar-right { display: flex; align-items: center; gap: 10px; } +/* 右侧一整组(新建 + 菜单)整体不许被压缩:窄屏宁可省略名字,也别把菜单挤没 */ +.topbar-right { display: flex; align-items: center; gap: 10px; flex: none; min-width: 0; } /* ── 3. 右上角下拉菜单 ───────────────────────────────────────────────────── */ -.menu { position: relative; } +.menu { position: relative; flex: none; } .menu-trigger { list-style: none; @@ -138,7 +159,15 @@ hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; } .menu-panel { position: absolute; right: 0; top: calc(100% + 8px); - min-width: 216px; + /* 宽:按最长菜单项撑开(不换行),但窄屏下不许宽过视口 */ + width: max-content; + min-width: 224px; + max-width: min(320px, calc(100vw - var(--gutter) * 2)); + /* 高:13 个菜单项接起来有 500 多 px,笔记本(768 高)会顶出屏幕底部。 + 给上限 + 滚动,菜单项再多也不会「乱七八糟」。 */ + max-height: calc(100vh - var(--topbar-h) - 24px); + overflow-y: auto; + overscroll-behavior: contain; padding: 6px; background: var(--surface); border: 1px solid var(--border); @@ -161,6 +190,7 @@ hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; } padding: 8px 10px; border-radius: var(--radius-sm); color: var(--text); font-size: 14px; text-decoration: none; + white-space: nowrap; /* 菜单项不折行,靠面板宽度自适应 */ transition: background .12s; } .menu-item:hover { background: var(--bg-soft); text-decoration: none; color: var(--text); } @@ -174,9 +204,14 @@ hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; } /* ── 4. 容器与页头 ───────────────────────────────────────────────────────── */ .container { - max-width: var(--container); + /* 这里**故意不写 max-width**:正文列宽度由 --shell-pad 反推 —— + 视口够宽时 padding 会把它挤到正好 var(--container) 宽且居中; + 这与 .topbar 用的是同一个公式,所以两者左边缘必然对齐。 + 曾经这里把 max-width 也设成了 --container,再配 `margin: 0 auto` 和同一份 + padding:等于把内容推两次。1920px 下正文列只剩 240px、2560px 下塌成 0, + 是本次「整页错位」的直接原因。改这里前请先想清楚这一点。 */ margin: 0 auto; - padding: 24px max(14px, calc((100vw - var(--container)) / 2)) 64px; + padding: 24px var(--shell-pad) 64px; } .container-guest { max-width: 420px; @@ -760,42 +795,92 @@ select { cursor: pointer; } .crop-container { width: 100%; max-height: 60vh; overflow: hidden; background: #111; border-radius: var(--radius-sm); } .crop-container-wide { max-height: 66vh; } -.crop-container img { max-width: 100%; display: block; } +/* Cropper 需要按原始尺寸接管这张图(自己算内联宽高),不能被 max-width 夹住; + 容器自带 overflow:hidden,所以就算图超出也只会被裁掉,不会撑破页面。 */ +.crop-container img { max-width: none; display: block; } +/* 预览容器只负责「框」:定尺寸、圆角、把图片裁进去。 + ⚠️ width / height / object-fit 必须写在 **img** 上,不能写在外层 div 上 —— + 写错地方图片就完全不受约束,会按原始尺寸铺出来:头像 400×400、封面 + 1920×1080,直接把下面的表单盖住(这就是「头像非常大、整页错位」的根)。 + 对照写对了的 .card-cover img / .activity-cover img。 */ .avatar-preview { + position: relative; flex: none; width: 84px; height: 84px; border-radius: 50%; - object-fit: cover; background: var(--bg-soft); border: 1px solid var(--border); + overflow: hidden; /* 把方图裁成圆,并兜住任何溢出 */ + background: var(--bg-soft); border: 2px dashed var(--border-strong); } .cover-preview { - width: 100%; aspect-ratio: 16 / 9; height: auto; - border-radius: var(--radius); object-fit: cover; background: var(--bg-soft); - border: 1px solid var(--border); + position: relative; + width: 100%; aspect-ratio: 16 / 9; + overflow: hidden; + border-radius: var(--radius); background: var(--bg-soft); + border: 2px dashed var(--border-strong); } -.avatar-placeholder, .avatar-upload { +/* 图和占位块都用绝对定位铺满容器: + 百分比相对 padding box 解析,宽高一定确定。 + ⚠️ 不要用 `display: grid; place-items: center` + `width: 100%` —— + 自动列宽会反过来依赖元素自身宽度,形成循环,浏览器只能把图算成 0 宽 + (实测就是这么坏的:img 宽 0、只剩一个空框)。 */ +.avatar-preview > img, +.cover-preview > img { + position: absolute; inset: 0; + display: block; width: 100%; height: 100%; object-fit: cover; +} +.avatar-preview > .avatar-placeholder, +.cover-preview > .cover-placeholder { + position: absolute; inset: 0; + width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; - width: 84px; height: 84px; border-radius: 50%; +} +/* 上传区只是「预览 + 操作按钮」的布局容器,不是那个虚线圆框本身。 + ⚠️ 这里曾经把 84×84 的圆形尺寸套在 .avatar-upload 上,而模板把预览、 + 按钮、提示文字都放在它**内部** → 三样东西全被塞进那个小圆里互相重叠 + (编辑用户页的「选择图片/移除」就压在头像上)。圆框属于 + .avatar-preview / .cover-preview,容器只负责排列。 */ +.avatar-upload { + display: flex; align-items: center; gap: 16px; + width: auto; height: auto; + border: 0; border-radius: 0; background: none; +} +.cover-upload { + display: flex; flex-direction: column; gap: 12px; + width: 100%; height: auto; + border: 0; border-radius: 0; background: none; +} + +/* "+" 占位块:尺寸已经被 `.avatar-preview > .avatar-placeholder` 铺满了,这里只管样子 */ +.avatar-placeholder { + border-radius: 50%; background: var(--bg-soft); color: var(--text-mute); font-size: 26px; } -.avatar-upload, .cover-upload { - border: 2px dashed var(--border-strong); cursor: pointer; - transition: border-color .15s, background .15s; + +/* 悬停高亮落在预览框上(那才是"会被换掉的东西") */ +.avatar-preview, .cover-preview { transition: border-color .15s, background .15s; } +.avatar-upload:hover .avatar-preview, +.cover-upload:hover .cover-preview { border-color: var(--accent); background: var(--accent-soft); } + +.avatar-upload-actions, .cover-upload-actions { + display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; margin-top: 0; } -.avatar-upload:hover, .cover-upload:hover { border-color: var(--accent); background: var(--accent-soft); } -.cover-upload { - display: flex; align-items: center; justify-content: center; - width: 100%; aspect-ratio: 16 / 9; border-radius: var(--radius); - color: var(--text-mute); font-size: 14px; -} -.avatar-upload-actions, .cover-upload-actions { display: flex; gap: 8px; margin-top: 10px; align-items: center; } /* ── 16. 头像与用户 ──────────────────────────────────────────────────────── */ -.avatar-xs { - width: 26px; height: 26px; border-radius: 50%; - object-fit: cover; flex: none; +/* 这两类是直接写在 (或首字母 )上的,所以尺寸放这里是对的。 + 注意别把 .avatar-placeholder 的 84px 带进来:它常与 .avatar-sm 叠用 + (用户列表里用首字母当头像),靠下面那条组合规则把字号压回 13px。 */ +.avatar-xs, .avatar-sm { + border-radius: 50%; object-fit: cover; flex: none; display: inline-flex; align-items: center; justify-content: center; + overflow: hidden; } +.avatar-xs { width: 26px; height: 26px; } +.avatar-sm { width: 30px; height: 30px; } .avatar-initial { background: var(--accent); color: #fff; font-size: 12px; font-weight: 620; } -.avatar-sm { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; flex: none; } +.avatar-sm.avatar-placeholder, +.avatar-xs.avatar-placeholder { + font-size: 13px; font-weight: 620; + background: var(--accent-soft); color: var(--accent); +} .user-cell { display: flex; align-items: center; gap: 9px; } .user-info { display: inline-flex; align-items: center; gap: 8px; } @@ -906,12 +991,15 @@ select { cursor: pointer; } @media (max-width: 640px) { body { font-size: 14.5px; } - .container { padding: 16px 14px 52px; } + /* 留白仍走 --shell-pad(此时它等于 --gutter):断点里也不能改成固定值, + 否则窄屏下顶栏与正文又会对不齐。 */ + .container { padding: 16px var(--shell-pad) 52px; } .container-guest { padding: 20px 14px; } - .topbar { padding: 8px 14px; } + .topbar { padding: 8px var(--shell-pad); } .brand-text { display: none; } .menu-name { display: none; } .menu-trigger { padding: 4px; gap: 4px; } + .menu-panel { min-width: 208px; } .two-col { grid-template-columns: 1fr; } .card-grid, .photo-grid, .attachment-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); diff --git a/templates/layout.php b/templates/layout.php index d21d6ce..74d837f 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -3,7 +3,10 @@ $__isLoggedIn = \App\Auth::check(); $__user = $__isLoggedIn ? \App\Auth::user() : null; $__isAdmin = $__isLoggedIn && \App\Auth::isAdmin(); $__name = (string)($__user['name'] ?? ($_SESSION['user_name'] ?? '')); -$__avatar = (string)($__user['avatar'] ?? ''); +// 用 media_avatar() 解析后再判断,不能拿原始值判断: +// 外站地址(https://…)会被 media_avatar() 拒掉返回空串,若还按非空处理, +// 就会渲染出 —— 一个破图占位。 +$__avatar = media_avatar((string)($__user['avatar'] ?? '')); $__initial = $__name !== '' ? mb_substr($__name, 0, 1) : '·'; ?> @@ -42,7 +45,7 @@ $__initial = $__name !== '' ? mb_substr($__name, 0, 1) : '·';