修版心双重内缩与预览图溢出:宽屏整页错位、头像盖住表单
线上反馈「界面更乱了、全都错位、导航条头像非常大、下拉菜单乱七八糟」。
用无头浏览器实测出三处硬伤,都不是样式文件坏了,是布局算式写错。
一、版心被推两次 → 宽屏正文列塌陷(「全都错位」的根)
.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:
+118
-30
@@ -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));
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
Reference in New Issue
Block a user