Files
TripLog/templates/users/form.php
T
laoyang a79956758b 补齐未接入代码的功能域:参与人 / 评论 / 花销与分摊
数据库里早就建好了 comments、node_members + role_tags、expenses +
expense_splits 三组表,但一直没有代码接入。本次把这三块连上业务,
并新增「节点详情页」作为聚合入口。

新增
- src/Access.php          统一归属校验。此前每个控制器各写各的 family_id
                          条件,容易漏;现在 activity()/node()/target() 一处收敛,
                          requireAdmin() 统一管理员判断。
- src/Comments.php        两级平铺:parent_id 只允许指向顶层,写「回复的回复」
                          时规范化回顶层;删顶层会连同回复一起删。
- src/Members.php         参与人 + 角色标签 + 情绪 + 签到。情绪目录 17 项,
                          全部选用 Emoji 11.0(2018)及以前的码位,避免旧系统
                          字体缺字形显示成空白方块。
- src/Expenses.php        花销与分摊。分摊按「分」为单位 intdiv 平均,
                          余数归最后一人,保证 SUM(share_amount) = total_amount。
- templates/nodes/show.php 节点详情页:基本信息 + 参与人 + 花销 + 讨论 + 附件入口。
- 角色标签管理、评论区、参与人区、花销区各自的模板与控制器。

修复
- 越权:往别人活动的节点上插节点、把别家用户加成参与人,都只校验了
  「节点存在」,没校验「用户与节点同家庭」。现统一走 Access。
- 删活动时节点附件文件删不掉:附件在 /uploads/attachments/ 下,却调了
  只认 /uploads/covers/ 的删除函数,判定不通过 → 文件永久残留。改为
  统一的 deleteUploadFile(),两个目录都认。
- 删有引用记录的用户会 500(activities.created_by / attachments.uploader_id /
  comments.author_id / expenses.payer_user_id 这几处没有级联,也没做前置检查)。

其他
- 活动/节点列表补上附件、评论、参与人、花销四个计数(一次 GROUP BY 查完)。
- 首页布局新增一条迁移易踩的规避:新列上的索引必须排在 ALTER TABLE 之后。
- 新增 .gitignore:config.php(含数据库口令)与 uploads/ 下用户上传的
  头像封面不入库,只保留 uploads/.htaccess 那条禁止执行脚本的规则。
- 新增 config.example.php 作为新环境的配置模板。

本批不改数据库结构,不需要任何数据迁移。

本地与线上验证
- php -l 全绿(PHP 8.4 本地 / 线上 7.4.30 与 8.1.7 双版本)。
- 模板渲染检查 180 项全通过。
- 线上端到端 153 项全通过:登录、活动增删改查、节点流转、
  参与人/角色标签/情绪/签到、评论、花销与分摊、权限、级联清理、
  全站页面无 PHP 报错泄漏、跑完基线逐表核对且既有 id 集合逐字未变。
- 顺带写了 trip_sql_column_check.py:拿真实库表结构静态校验全项目 SQL 的
  列名引用。本次就是靠它之外的 e2e 抓到 travel_modes 并没有 line_name
  (该列属于 trip_nodes),一个列名写错导致节点详情页整页 Fatal。
2026-09-22 22:03:08 +08:00

237 lines
9.3 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">
<div class="avatar-preview" id="avatar-preview">
<img id="avatar-preview-img" src="<?= e($user['avatar'] ?? '') ?>" alt=""
style="<?= empty($user['avatar']) ? 'display:none;' : '' ?>">
<div class="avatar-placeholder" id="avatar-placeholder"
style="<?= !empty($user['avatar']) ? '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>