Files
TripLog/templates/users/form.php
T
laoyang a43dd06d1f 修版心双重内缩与预览图溢出:宽屏整页错位、头像盖住表单
线上反馈「界面更乱了、全都错位、导航条头像非常大、下拉菜单乱七八糟」。
用无头浏览器实测出三处硬伤,都不是样式文件坏了,是布局算式写错。

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

  .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。
2026-09-23 00:01:05 +08:00

240 lines
9.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
$isEdit = $user !== null;
$roleLabels = ['admin' => '管理员', 'user' => '普通用户', 'guest' => '待激活'];
$isAdmin = \App\Auth::isAdmin();
?>
<div class="page-header">
<div>
<h1><?= $isEdit ? '编辑用户' : '新增用户' ?></h1>
<?php if ($isEdit): ?>
<p class="muted">用户 ID:<?= (int)$user['id'] ?></p>
<?php endif; ?>
</div>
<a href="<?= url('users/index') ?>" class="btn">返回列表</a>
</div>
<form method="post" action="<?= url($isEdit ? 'users/update' : 'users/store') ?>" class="form">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$user['id'] ?>">
<?php endif; ?>
<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($__avUrl) ?>" alt=""
style="<?= $__avUrl === '' ? 'display:none;' : '' ?>">
<div class="avatar-placeholder" id="avatar-placeholder"
style="<?= $__avUrl !== '' ? 'display:none;' : '' ?>">+</div>
</div>
<div class="avatar-upload-actions">
<button type="button" class="btn-sm" id="btn-upload-avatar">选择图片</button>
<button type="button" class="btn-sm btn-danger" id="btn-remove-avatar"
style="<?= empty($user['avatar']) ? 'display:none;' : '' ?>">移除</button>
<span class="muted" style="font-size:12px;">支持 JPG/PNG/WebP,裁剪为 400×400</span>
</div>
<input type="file" id="avatar-file-input" accept="image/*" style="display:none;">
<input type="hidden" name="avatar" id="avatar-url-input" value="<?= e($user['avatar'] ?? '') ?>">
</div>
</div>
<div class="form-row">
<label>姓名 *</label>
<input type="text" name="name" required maxlength="50"
value="<?= e($user['name'] ?? '') ?>" placeholder="例如:张明">
</div>
<?php if ($isAdmin): ?>
<div class="form-row">
<label>角色 *</label>
<select name="role" id="role-select" required>
<?php foreach ($roleLabels as $key => $label): ?>
<option value="<?= e($key) ?>" <?= ($user['role'] ?? 'guest') === $key ? 'selected' : '' ?>>
<?= e($label) ?>
</option>
<?php endforeach; ?>
</select>
<p class="muted" style="font-size:12px; margin-top:6px;">
待激活用户无需手机号/密码,仅供活动记录中标记,不能登录系统。
</p>
</div>
<?php endif; ?>
<div class="form-row" id="phone-field">
<label>手机号 <span id="phone-required">*</span></label>
<input type="text" name="phone" maxlength="11"
placeholder="<?= $isEdit ? '留空表示不修改手机号' : '请输入 11 位手机号' ?>">
</div>
<div class="form-row" id="password-field">
<label>密码 <?= $isEdit ? '' : '*' ?></label>
<input type="password" name="password"
placeholder="<?= $isEdit ? '留空表示不修改密码' : '请输入密码' ?>">
</div>
<div class="form-row">
<label>身份证号</label>
<input type="text" name="id_card" maxlength="18"
placeholder="<?= $isEdit ? '留空表示不修改' : '选填' ?>">
<p class="muted" style="font-size:12px; margin-top:6px;">
身份证号将被加密存储,用于未来关联车票/机票。
</p>
</div>
<?php if ($isAdmin && $isEdit): ?>
<div class="form-row">
<label>状态</label>
<select name="status">
<option value="active" <?= ($user['status'] ?? '') === 'active' ? 'selected' : '' ?>>正常</option>
<option value="inactive" <?= ($user['status'] ?? '') === 'inactive' ? 'selected' : '' ?>>停用</option>
</select>
</div>
<?php endif; ?>
<div class="form-actions">
<a href="<?= url('users/index') ?>" class="btn">取消</a>
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
<!-- 裁剪模态框 -->
<div class="modal" id="crop-modal" style="display:none;">
<div class="modal-backdrop" id="crop-backdrop"></div>
<div class="modal-content">
<h2>裁剪头像</h2>
<div class="crop-container">
<img id="crop-image" src="" alt="">
</div>
<div class="modal-actions">
<button type="button" class="btn" id="btn-cancel-crop">取消</button>
<button type="button" class="btn-primary" id="btn-confirm-crop">确认裁剪</button>
</div>
</div>
</div>
<script>
(function () {
// ===== 角色切换逻辑 =====
var roleSelect = document.getElementById('role-select');
if (roleSelect) {
var phoneField = document.getElementById('phone-field');
var pwField = document.getElementById('password-field');
var phoneRequired = document.getElementById('phone-required');
var refresh = function () {
var role = roleSelect.value;
var needs = (role === 'admin' || role === 'user');
phoneRequired.style.display = needs ? 'inline' : 'none';
phoneField.style.opacity = needs ? '1' : '0.5';
pwField.style.opacity = needs ? '1' : '0.5';
};
roleSelect.addEventListener('change', refresh);
refresh();
}
// ===== 头像上传与裁剪 =====
var fileInput = document.getElementById('avatar-file-input');
var btnUpload = document.getElementById('btn-upload-avatar');
var btnRemove = document.getElementById('btn-remove-avatar');
var previewImg = document.getElementById('avatar-preview-img');
var placeholder = document.getElementById('avatar-placeholder');
var urlInput = document.getElementById('avatar-url-input');
var modal = document.getElementById('crop-modal');
var cropImage = document.getElementById('crop-image');
var backdrop = document.getElementById('crop-backdrop');
var btnCancel = document.getElementById('btn-cancel-crop');
var btnConfirm = document.getElementById('btn-confirm-crop');
var csrfToken = '<?= e(csrf_token()) ?>';
if (!btnUpload) return;
var cropper = null;
btnUpload.addEventListener('click', function () { fileInput.click(); });
fileInput.addEventListener('change', function (e) {
var file = e.target.files && e.target.files[0];
if (!file) return;
if (!/^image\//.test(file.type)) { alert('请选择图片文件'); return; }
if (file.size > 10 * 1024 * 1024) { alert('原始图片不能超过 10MB'); return; }
var reader = new FileReader();
reader.onload = function (ev) {
cropImage.src = ev.target.result;
modal.style.display = 'flex';
if (cropper) cropper.destroy();
cropper = new Cropper(cropImage, {
aspectRatio: 1,
viewMode: 1,
autoCropArea: 0.85,
background: false,
movable: true,
zoomable: true,
rotatable: false,
scalable: false,
});
};
reader.readAsDataURL(file);
});
function closeCrop() {
modal.style.display = 'none';
if (cropper) { cropper.destroy(); cropper = null; }
fileInput.value = '';
}
btnCancel.addEventListener('click', closeCrop);
backdrop.addEventListener('click', closeCrop);
btnConfirm.addEventListener('click', function () {
if (!cropper) return;
btnConfirm.disabled = true;
btnConfirm.textContent = '上传中...';
var canvas = cropper.getCroppedCanvas({
width: 400, height: 400, imageSmoothingQuality: 'high',
});
var dataUrl = canvas.toDataURL('image/jpeg', 0.9);
fetch('index.php?r=users/uploadAvatar', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ image: dataUrl, old: urlInput.value }),
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
.then(function (res) {
btnConfirm.disabled = false;
btnConfirm.textContent = '确认裁剪';
if (!res.ok || res.data.error) {
alert(res.data.error || '上传失败');
return;
}
urlInput.value = res.data.url;
previewImg.src = res.data.url;
previewImg.style.display = 'block';
placeholder.style.display = 'none';
btnRemove.style.display = 'inline-block';
closeCrop();
})
.catch(function (err) {
btnConfirm.disabled = false;
btnConfirm.textContent = '确认裁剪';
alert('网络错误:' + err.message);
});
});
btnRemove.addEventListener('click', function () {
urlInput.value = '';
previewImg.src = '';
previewImg.style.display = 'none';
placeholder.style.display = 'flex';
btnRemove.style.display = 'none';
});
})();
</script>