修版心双重内缩与预览图溢出:宽屏整页错位、头像盖住表单

线上反馈「界面更乱了、全都错位、导航条头像非常大、下拉菜单乱七八糟」。
用无头浏览器实测出三处硬伤,都不是样式文件坏了,是布局算式写错。

一、版心被推两次 → 宽屏正文列塌陷(「全都错位」的根)

  .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 写在**容器**上,
  里面的 <img> 完全不受约束,按原始尺寸铺出来:
  头像 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://…)会被拒,
    以前按原始值判断非空 → 渲染出 <img src="">,用户管理列表里两个用户
    就是破图。现在退回首字母色块。头像隐藏域仍保留原始值,避免单纯保存就抹掉数据。
  · 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。
This commit is contained in:
2026-09-23 00:01:05 +08:00
parent 96dceb1a51
commit a43dd06d1f
4 changed files with 132 additions and 37 deletions
+118 -30
View File
@@ -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;
/* 这两类是直接写在 <img>(或首字母 <span>)上的,所以尺寸放这里是对的。
注意别把 .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));
+5 -2
View File
@@ -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() 拒掉返回空串,若还按非空处理,
// 就会渲染出 <img src=""> —— 一个破图占位。
$__avatar = media_avatar((string)($__user['avatar'] ?? ''));
$__initial = $__name !== '' ? mb_substr($__name, 0, 1) : '·';
?>
<!DOCTYPE html>
@@ -42,7 +45,7 @@ $__initial = $__name !== '' ? mb_substr($__name, 0, 1) : '·';
<details class="menu" id="user-menu">
<summary class="menu-trigger" aria-label="打开菜单">
<?php if ($__avatar !== ''): ?>
<img class="avatar-xs" src="<?= e(media_avatar($__avatar)) ?>" alt="">
<img class="avatar-xs" src="<?= e($__avatar) ?>" alt="">
<?php else: ?>
<span class="avatar-xs avatar-initial"><?= e($__initial) ?></span>
<?php endif; ?>
+6 -3
View File
@@ -22,11 +22,14 @@ $isAdmin = \App\Auth::isAdmin();
<div class="form-row">
<label>头像</label>
<div class="avatar-upload">
<?php /* 显示用 media_avatar() 解析后的地址(外站地址会被拒,此时退回首字母块);
隐藏域仍保留原始值,避免"只是打开页面保存"就把数据抹掉 */
$__avUrl = media_avatar((string)($user['avatar'] ?? '')); ?>
<div class="avatar-preview" id="avatar-preview">
<img id="avatar-preview-img" src="<?= e($user['avatar'] ?? '') ?>" alt=""
style="<?= empty($user['avatar']) ? 'display:none;' : '' ?>">
<img id="avatar-preview-img" src="<?= e($__avUrl) ?>" alt=""
style="<?= $__avUrl === '' ? 'display:none;' : '' ?>">
<div class="avatar-placeholder" id="avatar-placeholder"
style="<?= !empty($user['avatar']) ? 'display:none;' : '' ?>">+</div>
style="<?= $__avUrl !== '' ? 'display:none;' : '' ?>">+</div>
</div>
<div class="avatar-upload-actions">
<button type="button" class="btn-sm" id="btn-upload-avatar">选择图片</button>
+3 -2
View File
@@ -23,8 +23,9 @@
<td><?= (int)$u['id'] ?></td>
<td>
<div class="user-cell">
<?php if (!empty($u['avatar'])): ?>
<img src="<?= e($u['avatar']) ?>" alt="" class="avatar-sm">
<?php $__av = media_avatar((string)($u['avatar'] ?? '')); ?>
<?php if ($__av !== ''): ?>
<img src="<?= e($__av) ?>" alt="" class="avatar-sm">
<?php else: ?>
<span class="avatar-sm avatar-placeholder"><?= e(mb_substr($u['name'], 0, 1)) ?></span>
<?php endif; ?>