补齐未接入代码的功能域:参与人 / 评论 / 花销与分摊

数据库里早就建好了 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。
This commit is contained in:
2026-09-22 22:03:08 +08:00
parent 6c1345e202
commit a79956758b
49 changed files with 7059 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
<?php
/**
* 节点卡片(计划列 / 执行列共用)
* 依赖外部变量:$n(节点行)、$cardCategory(plan|reality)、$executedPlanIds
*/
$cardCategory = $cardCategory ?? 'plan';
$executedPlanIds = $executedPlanIds ?? [];
$statusLabels = [
'planned' => '待执行',
'ongoing' => '进行中',
'completed' => '已完成',
'cancelled' => '已取消',
];
$st = (string)($n['status'] ?? ($cardCategory === 'plan' ? 'planned' : 'completed'));
if (!isset($statusLabels[$st])) $st = $cardCategory === 'plan' ? 'planned' : 'completed';
$isExecuted = $cardCategory === 'plan' && in_array((int)$n['id'], $executedPlanIds, true);
$sLoc = json_decode_field($n['start_location'] ?? null);
$eLoc = json_decode_field($n['end_location'] ?? null);
$transportParts = [];
if (!empty($n['trip_number'])) $transportParts[] = $n['trip_number'];
if (!empty($n['line_name'])) $transportParts[] = $n['line_name'];
if (!empty($n['carrier'])) $transportParts[] = $n['carrier'];
if (!empty($n['seat_info'])) $transportParts[] = $n['seat_info'];
if (!empty($n['gate'])) $transportParts[] = $n['gate'];
// 卡片上的状态快捷切换:计划节点在"待执行 / 已完成"之间来回,取消后可以恢复
$quickStatus = null;
$quickLabel = '';
if ($cardCategory === 'plan') {
if ($st === 'planned') { $quickStatus = 'completed'; $quickLabel = '✓ 标记完成'; }
elseif ($st === 'completed') { $quickStatus = 'planned'; $quickLabel = '↺ 撤销完成'; }
elseif ($st === 'cancelled') { $quickStatus = 'planned'; $quickLabel = '↺ 恢复'; }
} elseif ($st === 'cancelled') {
$quickStatus = 'completed';
$quickLabel = '↺ 恢复';
}
?>
<div class="node-card node-<?= e($cardCategory) ?><?= $st === 'cancelled' ? ' node-cancelled' : '' ?>">
<div class="node-head">
<span class="node-icon"><?= e($n['node_type_icon']) ?></span>
<span class="node-type"><?= e($n['node_type_name']) ?></span>
<?php if ($cardCategory === 'reality' && empty($n['original_plan_node_id'])): ?>
<span class="tag-temp">临时</span>
<?php endif; ?>
</div>
<div class="node-time">
<?= date('m-d H:i', strtotime($n['start_time'])) ?> - <?= date('H:i', strtotime($n['end_time'])) ?>
</div>
<div class="node-title"><?= e($n['title'] ?: '(无标题)') ?></div>
<?php if (!empty($sLoc['name']) || !empty($eLoc['name'])): ?>
<div class="node-loc">
<?php if (!empty($sLoc['name'])): ?>
<span class="loc-start">📍 <?= e($sLoc['name']) ?></span>
<?php endif; ?>
<?php if (!empty($sLoc['name']) && !empty($eLoc['name'])): ?>
<span class="loc-arrow"> → </span>
<?php endif; ?>
<?php if (!empty($eLoc['name'])): ?>
<span class="loc-end">📍 <?= e($eLoc['name']) ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (!empty($transportParts)): ?>
<div class="node-transport">🚄 <?= e(implode(' · ', $transportParts)) ?></div>
<?php endif; ?>
<?php if ($st === 'cancelled' && !empty($n['cancel_reason'])): ?>
<div class="node-cancel-reason">取消原因:<?= e($n['cancel_reason']) ?></div>
<?php endif; ?>
<div class="node-actions">
<span class="status-tag status-<?= e($st) ?>"><?= e($statusLabels[$st]) ?></span>
<a href="<?= url('nodes/show', ['id' => $n['id']]) ?>" class="node-action-link node-action-strong">详情</a>
<a href="<?= url('attachments/node', ['id' => $n['id']]) ?>" class="node-action-link">
📎 附件 (<?= (int)($n['attachment_count'] ?? 0) ?>)
</a>
<?php if ((int)($n['member_count'] ?? 0) > 0): ?>
<a href="<?= url('nodes/show', ['id' => $n['id']]) ?>#members" class="node-action-link">
👥 <?= (int)$n['member_count'] ?>
</a>
<?php endif; ?>
<?php if ((int)($n['comment_count'] ?? 0) > 0): ?>
<a href="<?= url('nodes/show', ['id' => $n['id']]) ?>#comments" class="node-action-link">
💬 <?= (int)$n['comment_count'] ?>
</a>
<?php endif; ?>
<?php if ((int)($n['expense_count'] ?? 0) > 0): ?>
<a href="<?= url('nodes/show', ['id' => $n['id']]) ?>#expenses" class="node-action-link">
💰 <?= (int)$n['expense_count'] ?>
</a>
<?php endif; ?>
<a href="<?= url('nodes/edit', ['id' => $n['id']]) ?>" class="node-action-link">编辑</a>
<?php if ($quickStatus !== null): ?>
<form method="post" action="<?= url('nodes/status') ?>" class="node-action-form">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="status" value="<?= e($quickStatus) ?>">
<button type="submit" class="node-action-btn"><?= e($quickLabel) ?></button>
</form>
<?php endif; ?>
<?php if ($cardCategory === 'plan'): ?>
<?php if ($isExecuted): ?>
<span class="node-action-note">✓ 已成行</span>
<?php else: ?>
<form method="post" action="<?= url('nodes/fromPlan') ?>" class="node-action-form">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="plan_node_id" value="<?= (int)$n['id'] ?>">
<button type="submit" class="node-action-btn">→ 生成执行记录</button>
</form>
<?php endif; ?>
<?php endif; ?>
<form method="post" action="<?= url('nodes/destroy') ?>" class="node-action-form"
onsubmit="return confirm('确定删除这个节点?该节点的附件也会一并删除。');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<button type="submit" class="node-action-btn danger">删除</button>
</form>
</div>
</div>
+190
View File
@@ -0,0 +1,190 @@
<?php
$isEdit = $activity !== null;
$cover = $isEdit ? (string)($activity['cover_image'] ?? '') : '';
?>
<div class="page-header">
<div>
<h1><?= $isEdit ? '编辑活动' : '新建活动' ?></h1>
<?php if ($isEdit): ?>
<p class="muted">
创建于 <?= e(date('Y-m-d', strtotime($activity['created_at']))) ?>
· 最近更新 <?= e(date('Y-m-d H:i', strtotime($activity['updated_at'] ?? $activity['created_at']))) ?>
</p>
<?php endif; ?>
</div>
<?php if ($isEdit): ?>
<a href="<?= url('activities/show', ['id' => $activity['id']]) ?>" class="btn">返回活动</a>
<?php else: ?>
<a href="<?= url('activities/index') ?>" class="btn">返回列表</a>
<?php endif; ?>
</div>
<form method="post" action="<?= url($isEdit ? 'activities/update' : 'activities/store') ?>" class="form">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$activity['id'] ?>">
<?php endif; ?>
<div class="form-row">
<label>封面图</label>
<div class="cover-upload">
<div class="cover-preview" id="cover-preview">
<img id="cover-preview-img" src="<?= e($cover) ?>" alt=""
style="<?= $cover === '' ? 'display:none;' : '' ?>">
<div class="cover-placeholder" id="cover-placeholder"
style="<?= $cover === '' ? '' : 'display:none;' ?>">
<div style="font-size:32px;">🖼️</div>
<div style="font-size:12px; margin-top:6px;">16:9 封面</div>
</div>
</div>
<div class="cover-upload-actions">
<button type="button" class="btn-sm" id="btn-upload-cover"><?= $cover === '' ? '选择图片' : '更换图片' ?></button>
<button type="button" class="btn-sm btn-danger" id="btn-remove-cover"
style="<?= $cover === '' ? 'display:none;' : '' ?>">移除</button>
<span class="muted" style="font-size:12px;">支持 JPG/PNG/WebP,裁剪为 800×450</span>
</div>
<input type="file" id="cover-file-input" accept="image/*" style="display:none;">
<input type="hidden" name="cover_image" id="cover-url-input" value="<?= e($cover) ?>">
</div>
</div>
<div class="form-row">
<label>活动标题 *</label>
<input type="text" name="title" required maxlength="200" placeholder="例如:2026 盛夏去奥森"
value="<?= e($activity['title'] ?? '') ?>">
</div>
<div class="form-row">
<label>活动简介</label>
<textarea name="description" rows="4" placeholder="简单描述一下这次活动..."><?= e($activity['description'] ?? '') ?></textarea>
</div>
<div class="form-actions">
<a href="<?= url($isEdit ? 'activities/show' : 'activities/index', $isEdit ? ['id' => $activity['id']] : []) ?>" class="btn">取消</a>
<button type="submit" class="btn-primary"><?= $isEdit ? '保存' : '创建' ?></button>
</div>
</form>
<!-- 裁剪模态框 -->
<div class="modal" id="cover-crop-modal" style="display:none;">
<div class="modal-backdrop" id="cover-crop-backdrop"></div>
<div class="modal-content modal-content-wide">
<h2>裁剪封面图</h2>
<div class="crop-container crop-container-wide">
<img id="cover-crop-image" src="" alt="">
</div>
<div class="modal-actions">
<button type="button" class="btn" id="btn-cancel-cover-crop">取消</button>
<button type="button" class="btn-primary" id="btn-confirm-cover-crop">确认裁剪</button>
</div>
</div>
</div>
<script>
(function () {
var fileInput = document.getElementById('cover-file-input');
var btnUpload = document.getElementById('btn-upload-cover');
var btnRemove = document.getElementById('btn-remove-cover');
var previewImg = document.getElementById('cover-preview-img');
var placeholder = document.getElementById('cover-placeholder');
var urlInput = document.getElementById('cover-url-input');
var modal = document.getElementById('cover-crop-modal');
var cropImage = document.getElementById('cover-crop-image');
var backdrop = document.getElementById('cover-crop-backdrop');
var btnCancel = document.getElementById('btn-cancel-cover-crop');
var btnConfirm = document.getElementById('btn-confirm-cover-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 > 15 * 1024 * 1024) { alert('原始图片不能超过 15MB'); 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: 16 / 9,
viewMode: 1,
autoCropArea: 0.9,
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: 800,
height: 450,
imageSmoothingQuality: 'high',
});
var dataUrl = canvas.toDataURL('image/jpeg', 0.9);
fetch('index.php?r=activities/uploadCover', {
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';
btnUpload.textContent = '更换图片';
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';
btnUpload.textContent = '选择图片';
});
})();
</script>
+30
View File
@@ -0,0 +1,30 @@
<div class="page-header">
<h1>活动列表</h1>
</div>
<?php if (empty($activities)): ?>
<div class="empty">
<p>还没有任何活动</p>
<a href="<?= url('activities/create') ?>" class="btn-primary">创建第一个活动</a>
</div>
<?php else: ?>
<div class="card-grid">
<?php foreach ($activities as $a): ?>
<a href="<?= url('activities/show', ['id' => $a['id']]) ?>" class="card">
<?php if (!empty($a['cover_image'])): ?>
<img src="<?= e($a['cover_image']) ?>" alt="" class="card-cover">
<?php else: ?>
<div class="card-cover card-cover-placeholder">📸</div>
<?php endif; ?>
<div class="card-body">
<h3><?= e($a['title']) ?></h3>
<p class="muted"><?= e($a['description'] ?: '暂无简介') ?></p>
<div class="meta">
<span><?= (int)$a['node_count'] ?> 个节点</span>
<span><?= date('Y-m-d', strtotime($a['created_at'])) ?></span>
</div>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
+66
View File
@@ -0,0 +1,66 @@
<div class="page-header">
<div>
<h1><?= e($activity['title']) ?></h1>
<p class="muted"><?= e($activity['description'] ?: '暂无简介') ?></p>
</div>
<div class="actions" style="display:flex; gap:8px; flex-wrap:wrap;">
<a href="<?= url('nodes/create', ['activity_id' => $activity['id'], 'category' => 'plan']) ?>" class="btn">+ 计划节点</a>
<a href="<?= url('nodes/create', ['activity_id' => $activity['id'], 'category' => 'reality']) ?>" class="btn-primary">+ 执行节点</a>
<a href="<?= url('activities/edit', ['id' => $activity['id']]) ?>" class="btn">编辑活动</a>
<form method="post" action="<?= url('activities/destroy') ?>" style="display:inline;"
onsubmit="return confirm('确定删除活动「<?= e($activity['title']) ?>」?\n该活动下的所有节点、附件、评论都会一并删除,且不可恢复。');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$activity['id'] ?>">
<button type="submit" class="btn btn-danger">删除活动</button>
</form>
</div>
</div>
<?php if (!empty($activity['cover_image'])): ?>
<div class="activity-cover">
<img src="<?= e($activity['cover_image']) ?>" alt="">
</div>
<?php endif; ?>
<?php $expenseSummary = $expenseSummary ?? ['budget' => 0.0, 'actual' => 0.0, 'count' => 0, 'diff' => 0.0]; ?>
<?php if ((int)$expenseSummary['count'] > 0): ?>
<div class="activity-stats">
<span class="activity-stat">💰 共 <?= (int)$expenseSummary['count'] ?> 笔</span>
<span class="activity-stat">预算 ¥<?= number_format((float)$expenseSummary['budget'], 2) ?></span>
<span class="activity-stat">实际 ¥<?= number_format((float)$expenseSummary['actual'], 2) ?></span>
<span class="activity-stat <?= (float)$expenseSummary['diff'] > 0 ? 'money-over' : 'money-under' ?>">
<?= (float)$expenseSummary['diff'] > 0 ? '超出预算' : '比预算省' ?>
¥<?= number_format(abs((float)$expenseSummary['diff']), 2) ?>
</span>
</div>
<?php endif; ?>
<div class="timeline-grid">
<section class="timeline-col">
<h2>📋 计划 (<?= count($planNodes) ?>)</h2>
<?php if (empty($planNodes)): ?>
<p class="muted">暂无计划节点</p>
<?php else: ?>
<?php foreach ($planNodes as $n): ?>
<?php $cardCategory = 'plan'; require __DIR__ . '/_node_card.php'; ?>
<?php endforeach; ?>
<?php endif; ?>
</section>
<section class="timeline-col">
<h2>🎬 实际 (<?= count($realityNodes) ?>)</h2>
<?php if (empty($realityNodes)): ?>
<p class="muted">暂无执行节点</p>
<?php else: ?>
<?php foreach ($realityNodes as $n): ?>
<?php $cardCategory = 'reality'; require __DIR__ . '/_node_card.php'; ?>
<?php endforeach; ?>
<?php endif; ?>
</section>
</div>
<?php
$commentTargetType = \App\Comments::TARGET_ACTIVITY;
$commentTargetId = (int)$activity['id'];
require dirname(__DIR__) . '/comments/_block.php';
?>
+22
View File
@@ -0,0 +1,22 @@
<div class="login-wrapper">
<form method="post" action="<?= url('auth/doLogin') ?>" class="login-form">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<h1>🏠 家庭出行记录</h1>
<p class="muted">请登录后继续</p>
<div class="form-row">
<label>手机号</label>
<input type="text" name="phone" required autofocus placeholder="请输入手机号">
</div>
<div class="form-row">
<label>密码</label>
<input type="password" name="password" required placeholder="请输入密码">
</div>
<button type="submit" class="btn-primary" style="width:100%; padding:11px;">登录</button>
<p class="muted" style="margin-top:20px; font-size:12px; text-align:center;">
初始账号:13800000001 / admin123
</p>
</form>
</div>
+43
View File
@@ -0,0 +1,43 @@
<?php
/**
* 评论区(活动详情页 / 节点详情页共用)
* 依赖外部变量:$commentTree、$commentTargetType、$commentTargetId
*/
$tree = $commentTree ?? ['top' => [], 'replies' => [], 'total' => 0];
$ctType = (string)($commentTargetType ?? '');
$ctId = (int)($commentTargetId ?? 0);
?>
<section class="panel" id="comments">
<h2 class="section-title">💬 讨论 (<?= (int)($tree['total'] ?? 0) ?>)</h2>
<?php if (empty($tree['top'])): ?>
<p class="muted">还没有人发言,来说点什么吧</p>
<?php else: ?>
<div class="comment-list">
<?php foreach ($tree['top'] as $c): ?>
<?php $replies = $tree['replies'][(int)$c['id']] ?? []; ?>
<div class="comment-thread">
<?php require __DIR__ . '/_comment.php'; ?>
<?php if (!empty($replies)): ?>
<div class="comment-replies">
<?php foreach ($replies as $c): ?>
<?php require __DIR__ . '/_comment.php'; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<form method="post" action="<?= url('comments/store') ?>" class="comment-form">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="target_type" value="<?= e($ctType) ?>">
<input type="hidden" name="target_id" value="<?= (int)$ctId ?>">
<textarea name="content" rows="3" maxlength="2000" required
placeholder="写下你的想法、提醒或者吐槽…(最多 2000 字)"></textarea>
<div class="form-actions">
<button type="submit" class="btn-primary">发表评论</button>
</div>
</form>
</section>
+53
View File
@@ -0,0 +1,53 @@
<?php
/**
* 单条评论(顶层 / 回复共用)
* 依赖外部变量:$c(评论行)、$ctType、$ctId
*/
$row = $c;
$canDelete = \App\Auth::isAdmin() || (int)$row['author_id'] === (int)\App\Auth::id();
$authorName = (string)($row['author_name'] ?? '');
if ($authorName === '') $authorName = '已注销用户';
?>
<div class="comment-item<?= $row['parent_id'] !== null ? ' comment-child' : '' ?>"
id="comment-<?= (int)$row['id'] ?>">
<div class="comment-head">
<?php if (!empty($row['author_avatar'])): ?>
<img src="<?= e($row['author_avatar']) ?>" alt="" class="avatar-sm">
<?php else: ?>
<span class="avatar-sm avatar-placeholder"><?= e(mb_substr($authorName, 0, 1)) ?></span>
<?php endif; ?>
<span class="comment-author"><?= e($authorName) ?></span>
<?php if (($row['author_role'] ?? '') === 'admin'): ?>
<span class="role-tag role-admin">管理员</span>
<?php endif; ?>
<span class="comment-time"><?= date('Y-m-d H:i', strtotime((string)$row['created_at'])) ?></span>
<?php if ($canDelete): ?>
<form method="post" action="<?= url('comments/destroy') ?>" class="comment-del-form"
onsubmit="return confirm('确定删除这条评论?它下面的回复也会一起删除。');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$row['id'] ?>">
<input type="hidden" name="target_type" value="<?= e($ctType) ?>">
<input type="hidden" name="target_id" value="<?= (int)$ctId ?>">
<button type="submit" class="comment-del">删除</button>
</form>
<?php endif; ?>
</div>
<div class="comment-body"><?= nl2br(e($row['content'])) ?></div>
<details class="comment-reply-box">
<summary>回复</summary>
<form method="post" action="<?= url('comments/store') ?>" class="comment-form comment-form-inline">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="target_type" value="<?= e($ctType) ?>">
<input type="hidden" name="target_id" value="<?= (int)$ctId ?>">
<input type="hidden" name="parent_id" value="<?= (int)$row['id'] ?>">
<textarea name="content" rows="2" maxlength="2000" required
placeholder="回复 <?= e($authorName) ?>…"></textarea>
<div class="form-actions">
<button type="submit" class="btn-sm btn-primary-sm">发表回复</button>
</div>
</form>
</details>
</div>
+58
View File
@@ -0,0 +1,58 @@
<?php $__isLoggedIn = \App\Auth::check(); ?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($pageTitle ?? '家庭出行记录') ?></title>
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="/assets/vendor/cropper/cropper.min.css">
<script src="/assets/vendor/cropper/cropper.min.js"></script>
<script>
window._AMapSecurityConfig = {
securityJsCode: '<?= e(app_config()['amap']['security_js'] ?? '') ?>',
};
window.__AMAP_LOADED = false;
window.__AMAP_ERROR = null;
</script>
<script src="https://webapi.amap.com/maps?v=2.0&key=<?= e(app_config()['amap']['key'] ?? '') ?>&plugin=AMap.AutoComplete"
onload="window.__AMAP_LOADED = true;"
onerror="window.__AMAP_ERROR = 'script load failed';">
</script>
</head>
<body>
<?php if ($__isLoggedIn): ?>
<header class="topbar">
<a href="<?= url('activities/index') ?>" class="brand">🏠 家庭出行记录</a>
<nav>
<a href="<?= url('activities/index') ?>">活动列表</a>
<a href="<?= url('activities/create') ?>" class="btn-primary">+ 新建活动</a>
<?php if (\App\Auth::isAdmin()): ?>
<a href="<?= url('node_types/index') ?>">节点类型</a>
<a href="<?= url('vehicles/index') ?>">车辆</a>
<a href="<?= url('travel_modes/index') ?>">出行方式</a>
<a href="<?= url('role_tags/index') ?>">角色标签</a>
<a href="<?= url('users/index') ?>">用户管理</a>
<?php endif; ?>
<span class="user-info">
<a href="<?= url('users/edit', ['id' => \App\Auth::id()]) ?>" style="color:#555; text-decoration:none;">
<?= e($_SESSION['user_name'] ?? '') ?>
</a>
</span>
<a href="<?= url('auth/logout') ?>" class="logout-link">退出</a>
</nav>
</header>
<?php endif; ?>
<main class="container<?= $__isLoggedIn ? '' : ' container-guest' ?>">
<?php if ($err = flash('error')): ?>
<div class="alert"><?= e($err) ?></div>
<?php endif; ?>
<?php require $content; ?>
</main>
<?php if ($__isLoggedIn): ?>
<footer class="footer">家庭出行记录 · V1.0</footer>
<?php endif; ?>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
<?php $isEdit = $type !== null; ?>
<div class="page-header">
<div>
<h1><?= $isEdit ? '编辑节点类型' : '新增节点类型' ?></h1>
<?php if ($isEdit && (int)$type['is_system'] === 1): ?>
<p class="muted">这是系统内置类型,可以修改显示名称和图标,但不可删除</p>
<?php endif; ?>
</div>
<a href="<?= url('node_types/index') ?>" class="btn">返回列表</a>
</div>
<form method="post" action="<?= url($isEdit ? 'node_types/update' : 'node_types/store') ?>" class="form">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$type['id'] ?>">
<?php endif; ?>
<div class="form-row">
<label>名称 *</label>
<input type="text" name="name" required maxlength="30"
value="<?= e($type['name'] ?? '') ?>" placeholder="例如:游玩、用餐、赶路">
<p class="muted" style="font-size:12px; margin-top:6px;">最多 30 个字符</p>
</div>
<div class="form-row">
<label>图标(emoji)</label>
<input type="text" name="icon" maxlength="8"
value="<?= e($type['icon'] ?? '') ?>" placeholder="例如:🎡 🍽️ 🚗"
id="icon-input">
<div class="emoji-picker" id="emoji-picker">
<?php
$emojiSuggestions = [
'🚗','🚌','🚇','🚄','✈️','🚕','🚲','🚶','⛴️','🛵',
'🎡','🎢','🎭','🎬','🎨','🏞️','🏖️','⛰️','🏛️','🎪',
'🍽️','🍜','🍕','🍔','☕','🍰','🍺','🍣','🥘','🍱',
'😴','🛌','💤','🧘','🚿','🏨',
'⚠️','❗','🆘','🔥','💡',
'🛍️','🛒','🎁','💰','🏠','🏥','📸','🎉','👨‍👩‍👧','🌟',
];
foreach ($emojiSuggestions as $e):
?>
<button type="button" class="emoji-btn" data-emoji="<?= $e ?>"><?= $e ?></button>
<?php endforeach; ?>
</div>
<p class="muted" style="font-size:12px; margin-top:6px;">点击下方表情快速填入</p>
</div>
<div class="form-actions">
<a href="<?= url('node_types/index') ?>" class="btn">取消</a>
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
<script>
(function () {
var iconInput = document.getElementById('icon-input');
var picker = document.getElementById('emoji-picker');
if (!picker || !iconInput) return;
picker.addEventListener('click', function (e) {
var btn = e.target.closest('.emoji-btn');
if (!btn) return;
iconInput.value = btn.getAttribute('data-emoji');
iconInput.focus();
});
})();
</script>
+54
View File
@@ -0,0 +1,54 @@
<div class="page-header">
<div>
<h1>节点类型管理</h1>
<p class="muted">自定义活动的节点类型,如游玩、用餐、赶路等</p>
</div>
<a href="<?= url('node_types/create') ?>" class="btn-primary">+ 新增类型</a>
</div>
<?php if (empty($types)): ?>
<div class="empty"><p>暂无节点类型</p></div>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<th style="width:70px;">ID</th>
<th style="width:70px;">图标</th>
<th>名称</th>
<th style="width:100px;">来源</th>
<th style="width:100px;">使用次数</th>
<th style="width:160px;">操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($types as $t): ?>
<tr>
<td><?= (int)$t['id'] ?></td>
<td style="font-size:22px;"><?= e($t['icon'] ?: '·') ?></td>
<td><?= e($t['name']) ?></td>
<td>
<?php if ((int)$t['is_system'] === 1): ?>
<span class="role-tag role-admin">系统内置</span>
<?php else: ?>
<span class="role-tag role-user">自定义</span>
<?php endif; ?>
</td>
<td class="muted"><?= (int)$t['node_count'] ?> 个节点</td>
<td>
<a href="<?= url('node_types/edit', ['id' => $t['id']]) ?>" class="btn-sm">编辑</a>
<?php if ((int)$t['is_system'] === 0): ?>
<form method="post" action="<?= url('node_types/destroy') ?>" style="display:inline"
onsubmit="return confirm('确定删除类型「<?= e($t['name']) ?>」?');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<button type="submit" class="btn-sm btn-danger">删除</button>
</form>
<?php else: ?>
<span class="muted" style="font-size:12px;">不可删除</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
+159
View File
@@ -0,0 +1,159 @@
<?php
/**
* 节点花销面板
* 依赖外部变量:$node、$expenses、$expenseSummary、$familyUsers、
* $categories、$currencies(code=>中文名)、$expenseSymbols(code=>符号)、$currentUserId
*/
$expenses = $expenses ?? [];
$familyUsers = $familyUsers ?? [];
$categories = $categories ?? [];
$currencies = $currencies ?? [];
$expenseSymbols = $expenseSymbols ?? [];
$expenseSummary = $expenseSummary ?? ['budget' => 0.0, 'actual' => 0.0, 'count' => 0, 'diff' => 0.0];
$budget = (float)$expenseSummary['budget'];
$actual = (float)$expenseSummary['actual'];
$diff = (float)$expenseSummary['diff'];
$money = static function (float $v): string {
return number_format($v, 2);
};
?>
<section class="panel" id="expenses">
<h2 class="section-title">💰 花销 (<?= (int)$expenseSummary['count'] ?>)</h2>
<div class="expense-summary">
<div class="expense-stat">
<span class="muted">预算</span>
<strong>¥<?= $money($budget) ?></strong>
</div>
<div class="expense-stat">
<span class="muted">实际</span>
<strong>¥<?= $money($actual) ?></strong>
</div>
<div class="expense-stat">
<span class="muted"><?= $diff > 0 ? '超出预算' : '比预算省' ?></span>
<strong class="<?= $diff > 0 ? 'money-over' : 'money-under' ?>">¥<?= $money(abs($diff)) ?></strong>
</div>
</div>
<?php if (empty($expenses)): ?>
<p class="muted">还没有记账</p>
<?php else: ?>
<div class="expense-list">
<?php foreach ($expenses as $ex):
$sym = $expenseSymbols[$ex['currency']] ?? '';
$splits = [];
foreach (($ex['splits'] ?? []) as $sp) {
$sname = (string)($sp['user_name'] ?? '');
if ($sname === '') $sname = '已注销';
$splits[] = $sname . ' ' . $sym . $money((float)$sp['share_amount']);
}
?>
<div class="expense-row">
<div class="expense-main">
<span class="expense-cat"><?= e($ex['category']) ?></span>
<?php if ((int)$ex['is_budget'] === 1): ?>
<span class="expense-kind expense-kind-budget">预算</span>
<?php else: ?>
<span class="expense-kind expense-kind-actual">实际</span>
<?php endif; ?>
<span class="expense-desc"><?= e($ex['description'] ?: $ex['category']) ?></span>
<span class="expense-amount"><?= e($sym) ?><?= $money((float)$ex['total_amount']) ?></span>
</div>
<div class="expense-sub">
付款:<?= e($ex['payer_name'] ?: '未知') ?>
<?php if (!empty($splits)): ?>
· 分摊:<?= e(implode(' / ', $splits)) ?>
<?php endif; ?>
<form method="post" action="<?= url('expenses/destroy') ?>" style="display:inline"
onsubmit="return confirm('删除这笔花销?');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$ex['id'] ?>">
<button type="submit" class="comment-del">删除</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<details class="inline-panel">
<summary>+ 记一笔花销</summary>
<form method="post" action="<?= url('expenses/store') ?>" class="form form-plain">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
<div class="form-row two-col">
<div>
<label>金额 *</label>
<input type="text" name="total_amount" required placeholder="例如:268.50">
</div>
<div>
<label>币种</label>
<select name="currency">
<?php foreach ($currencies as $code => $label): ?>
<option value="<?= e($code) ?>"><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-row two-col">
<div>
<label>类别</label>
<select name="category">
<?php foreach ($categories as $cat): ?>
<option value="<?= e($cat) ?>"><?= e($cat) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label>付款人 *</label>
<select name="payer_user_id" required>
<?php foreach ($familyUsers as $u): ?>
<option value="<?= (int)$u['id'] ?>"
<?= (int)$u['id'] === (int)$currentUserId ? ' selected' : '' ?>>
<?= e($u['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-row">
<label>说明</label>
<input type="text" name="description" maxlength="200" placeholder="例如:三人门票">
</div>
<div class="form-row">
<label>性质</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="is_budget" value="0" checked> 实际花费
</label>
<label class="radio-label">
<input type="radio" name="is_budget" value="1"> 预算
</label>
</div>
</div>
<div class="form-row">
<label>分摊人(默认全家平摊,可只勾选部分人)</label>
<div class="check-grid">
<?php foreach ($familyUsers as $u): ?>
<label class="checkbox-label">
<input type="checkbox" name="split_user_ids[]"
value="<?= (int)$u['id'] ?>" checked>
<span><?= e($u['name']) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
</details>
</section>
+140
View File
@@ -0,0 +1,140 @@
<?php
/**
* 节点参与人面板
* 依赖外部变量:$node、$members、$familyUsers、$roleTags(id=>name)、$emotions(emoji=>中文)
*/
$members = $members ?? [];
$familyUsers = $familyUsers ?? [];
$roleTags = $roleTags ?? [];
$emotions = $emotions ?? [];
$joinedIds = [];
foreach ($members as $m) {
$joinedIds[(int)$m['user_id']] = true;
}
$notJoined = [];
foreach ($familyUsers as $u) {
if (!isset($joinedIds[(int)$u['id']])) $notJoined[] = $u;
}
?>
<section class="panel" id="members">
<h2 class="section-title">👥 参与人 (<?= count($members) ?>)</h2>
<?php if (empty($members)): ?>
<p class="muted">还没有添加参与人</p>
<?php else: ?>
<div class="member-list">
<?php foreach ($members as $m):
$mName = (string)($m['name'] ?? '');
if ($mName === '') $mName = '已注销用户';
?>
<div class="member-row">
<?php if (!empty($m['avatar'])): ?>
<img src="<?= e($m['avatar']) ?>" alt="" class="avatar-sm">
<?php else: ?>
<span class="avatar-sm avatar-placeholder"><?= e(mb_substr($mName, 0, 1)) ?></span>
<?php endif; ?>
<span class="member-name"><?= e($mName) ?></span>
<?php foreach ($m['role_tag_names'] as $tagName): ?>
<span class="member-role"><?= e($tagName) ?></span>
<?php endforeach; ?>
<?php if (!empty($m['emotion'])): ?>
<span class="member-emotion" title="<?= e($emotions[$m['emotion']] ?? '') ?>">
<?= e($m['emotion']) ?><?= e($emotions[$m['emotion']] ?? '') ?>
</span>
<?php endif; ?>
<?php if (!empty($m['check_in_time'])): ?>
<span class="member-checkin">✓ 签到 <?= date('m-d H:i', strtotime((string)$m['check_in_time'])) ?></span>
<?php endif; ?>
<form method="post" action="<?= url('members/destroy') ?>" class="member-del-form"
onsubmit="return confirm('把 <?= e($mName) ?> 从本节点移出?');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
<input type="hidden" name="user_id" value="<?= (int)$m['user_id'] ?>">
<button type="submit" class="comment-del">移出</button>
</form>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($notJoined)): ?>
<form method="post" action="<?= url('members/addAll') ?>" style="display:inline"
onsubmit="return confirm('把家庭里的 <?= count($familyUsers) ?> 位成员全部加入这个节点?');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
<button type="submit" class="btn-sm">+ 一键加入全家(<?= count($notJoined) ?> 人待加入)</button>
</form>
<?php endif; ?>
<details class="inline-panel">
<summary>+ 添加 / 修改参与人</summary>
<form method="post" action="<?= url('members/store') ?>" class="form form-plain">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
<div class="form-row">
<label>成员 *</label>
<select name="user_id" required>
<option value="">— 请选择 —</option>
<?php foreach ($familyUsers as $u): ?>
<option value="<?= (int)$u['id'] ?>">
<?= e($u['name']) ?><?= isset($joinedIds[(int)$u['id']]) ? '(已加入,保存将覆盖)' : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php if (!empty($roleTags)): ?>
<div class="form-row">
<label>角色标签(可多选)</label>
<div class="check-grid">
<?php foreach ($roleTags as $tagId => $tagName): ?>
<label class="checkbox-label">
<input type="checkbox" name="role_tag_ids[]" value="<?= (int)$tagId ?>">
<span><?= e($tagName) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<p class="muted" style="font-size:12px;">
还没有角色标签,去
<a href="<?= url('role_tags/index') ?>">角色标签管理</a>
先建几个(例如:司机 / 摄影师 / 记账员)
</p>
<?php endif; ?>
<div class="form-row">
<label>心情(可选)</label>
<div class="emotion-grid">
<label class="emotion-option">
<input type="radio" name="emotion" value="" checked>
<span class="emotion-face">🚫</span>
<span class="emotion-text">不记录</span>
</label>
<?php foreach ($emotions as $emoji => $label): ?>
<label class="emotion-option" title="<?= e($label) ?>">
<input type="radio" name="emotion" value="<?= e($emoji) ?>">
<span class="emotion-face"><?= e($emoji) ?></span>
<span class="emotion-text"><?= e($label) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="form-row">
<label>签到时间(可选,留空表示未签到)</label>
<input type="datetime-local" name="check_in_time">
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
</details>
</section>
+139
View File
@@ -0,0 +1,139 @@
<div class="page-header">
<div>
<h1>节点附件</h1>
<p class="muted">
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>">
<?= e($node['activity_title']) ?>
</a>
·
<?= e($node['title'] ?: '无标题节点') ?>
·
<?= $node['node_category'] === 'plan' ? '计划' : '执行' ?>
</p>
</div>
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>" class="btn">返回活动</a>
</div>
<form method="post" action="<?= url('attachments/upload') ?>" enctype="multipart/form-data" class="form" style="margin-bottom:24px;">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
<div class="form-row">
<label>上传附件</label>
<div class="drop-zone" id="drop-zone">
<input type="file" name="files[]" id="file-input" multiple style="display:none;">
<div class="drop-zone-inner">
<div style="font-size:32px;">📎</div>
<p style="margin:6px 0 0;">点击或拖拽文件到这里</p>
<p class="muted" style="font-size:12px; margin:4px 0 0;">
支持 JPG / PNG / GIF / WebP / PDF / DOC / XLS / MP4 等,单个不超过 20MB
</p>
</div>
</div>
<div id="file-list" class="file-list"></div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" id="btn-submit-upload" style="display:none;">开始上传</button>
</div>
</form>
<h2 style="font-size:16px; margin: 32px 0 12px;">已上传(<?= count($attachments) ?>)</h2>
<?php if (empty($attachments)): ?>
<div class="empty">
<p>暂无附件,上传第一张机票/门票/照片吧</p>
</div>
<?php else: ?>
<div class="attachment-grid">
<?php foreach ($attachments as $att):
$url = $att['file_url'];
$name = $att['file_name'] ?: basename($url);
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$isImage = in_array($ext, ['jpg','jpeg','png','gif','webp','heic'], true);
$filePath = dirname(__DIR__, 2) . $url;
$size = is_file($filePath) ? filesize($filePath) : 0;
$sizeText = $size > 0 ? ($size < 1024 * 1024
? round($size / 1024, 1) . ' KB'
: round($size / 1024 / 1024, 2) . ' MB') : '';
?>
<div class="attachment-card">
<a href="<?= e($url) ?>" target="_blank" class="attachment-link">
<?php if ($isImage): ?>
<img src="<?= e($url) ?>" alt="" class="attachment-thumb">
<?php else: ?>
<div class="attachment-icon">
<?php
$icons = ['pdf'=>'📄','doc'=>'📝','docx'=>'📝','xls'=>'📊','xlsx'=>'📊','mp4'=>'🎬','mov'=>'🎬','zip'=>'🗜️'];
echo $icons[$ext] ?? '📎';
?>
</div>
<?php endif; ?>
</a>
<div class="attachment-meta">
<div class="attachment-name" title="<?= e($name) ?>"><?= e($name) ?></div>
<div class="attachment-sub">
<?= e($att['uploader_name'] ?: '未知') ?>
<?php if ($sizeText): ?> · <?= e($sizeText) ?><?php endif; ?>
</div>
<form method="post" action="<?= url('attachments/destroy') ?>" style="display:inline;"
onsubmit="return confirm('确定删除此附件?');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$att['id'] ?>">
<button type="submit" class="attachment-delete" title="删除">×</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<script>
(function () {
var dropZone = document.getElementById('drop-zone');
var fileInput = document.getElementById('file-input');
var fileList = document.getElementById('file-list');
var btnSubmit = document.getElementById('btn-submit-upload');
if (!dropZone || !fileInput) return;
dropZone.addEventListener('click', function () { fileInput.click(); });
dropZone.addEventListener('dragover', function (e) {
e.preventDefault();
dropZone.classList.add('dragging');
});
dropZone.addEventListener('dragleave', function () {
dropZone.classList.remove('dragging');
});
dropZone.addEventListener('drop', function (e) {
e.preventDefault();
dropZone.classList.remove('dragging');
if (e.dataTransfer.files.length) {
fileInput.files = e.dataTransfer.files;
refreshList();
}
});
fileInput.addEventListener('change', refreshList);
function refreshList() {
fileList.innerHTML = '';
var files = fileInput.files;
if (!files.length) {
btnSubmit.style.display = 'none';
return;
}
for (var i = 0; i < files.length; i++) {
var f = files[i];
var size = f.size < 1024 * 1024
? (f.size / 1024).toFixed(1) + ' KB'
: (f.size / 1024 / 1024).toFixed(2) + ' MB';
var row = document.createElement('div');
row.className = 'file-row';
row.textContent = '📎 ' + f.name + ' (' + size + ')';
fileList.appendChild(row);
}
btnSubmit.style.display = 'inline-block';
}
})();
</script>
+369
View File
@@ -0,0 +1,369 @@
<?php
$isEdit = $isEdit ?? false;
$n = $node; // null=新建;数组=编辑
$curStatus = $n['status'] ?? ($category === 'plan' ? 'planned' : 'completed');
$statusLabels = [
'planned' => '待执行',
'ongoing' => '进行中',
'completed' => '已完成',
'cancelled' => '已取消',
];
// 计划节点不会有"进行中",执行节点默认就是已完成
$statusOptions = $category === 'plan'
? ['planned', 'completed', 'cancelled']
: ['completed', 'ongoing', 'planned', 'cancelled'];
$dtLocal = static function ($value): string {
if (empty($value)) return '';
$ts = strtotime((string)$value);
return $ts ? date('Y-m-d\TH:i', $ts) : '';
};
$sLoc = json_decode_field($n['start_location'] ?? null);
$eLoc = json_decode_field($n['end_location'] ?? null);
?>
<div class="page-header">
<div>
<h1><?= $isEdit ? '编辑' : '新建' ?><?= $category === 'plan' ? '计划' : '执行' ?>节点</h1>
<p class="muted">
活动:
<a href="<?= url('activities/show', ['id' => $activity['id']]) ?>"><?= e($activity['title']) ?></a>
<?php if ($isEdit && !empty($n['original_plan_node_id'])): ?>
· <span class="tag-default">由计划节点生成</span>
<?php endif; ?>
</p>
</div>
<a href="<?= url('activities/show', ['id' => $activity['id']]) ?>" class="btn">返回活动</a>
</div>
<form method="post" action="<?= url($isEdit ? 'nodes/update' : 'nodes/store') ?>" class="form">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="activity_id" value="<?= (int)$activity['id'] ?>">
<input type="hidden" name="node_category" value="<?= e($category) ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<?php endif; ?>
<?php if (!$isEdit && $category === 'reality' && !empty($planNodes)): ?>
<div class="form-row">
<label>关联计划节点(可选)</label>
<select name="original_plan_node_id">
<option value="">— 不关联,此为临时新增 —</option>
<?php foreach ($planNodes as $p): ?>
<option value="<?= (int)$p['id'] ?>">
<?= e($p['title'] ?: '(无标题)') ?> · <?= date('m-d H:i', strtotime($p['start_time'])) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<div class="form-row">
<label>节点类型 *</label>
<select name="node_type_id" required>
<?php foreach ($nodeTypes as $t): ?>
<option value="<?= (int)$t['id'] ?>"
<?= (int)($n['node_type_id'] ?? 0) === (int)$t['id'] ? ' selected' : '' ?>>
<?= e($t['icon'] . ' ' . $t['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-row two-col">
<div>
<label>开始时间 *</label>
<input type="datetime-local" name="start_time" required value="<?= e($dtLocal($n['start_time'] ?? null)) ?>">
</div>
<div>
<label>结束时间 *</label>
<input type="datetime-local" name="end_time" required value="<?= e($dtLocal($n['end_time'] ?? null)) ?>">
</div>
</div>
<div class="form-row">
<label>标题</label>
<input type="text" name="title" maxlength="200" placeholder="例如:出发去奥森"
value="<?= e($n['title'] ?? '') ?>">
</div>
<fieldset class="fieldset">
<legend>起点信息(可选)</legend>
<div class="form-row">
<label>起点名称(输入地址自动补全)</label>
<input type="text" name="start_location_name" id="start-location-input"
placeholder="输入关键词,如「北京南站」「奥森公园」"
autocomplete="off"
value="<?= e($sLoc['name'] ?? '') ?>">
<input type="hidden" name="start_location_address" id="start-location-address" value="<?= e($sLoc['address'] ?? '') ?>">
<input type="hidden" name="start_location_lat" id="start-location-lat" value="<?= e($sLoc['lat'] ?? '') ?>">
<input type="hidden" name="start_location_lng" id="start-location-lng" value="<?= e($sLoc['lng'] ?? '') ?>">
</div>
</fieldset>
<fieldset class="fieldset">
<legend>终点信息(可选)</legend>
<div class="form-row">
<label>终点名称(输入地址自动补全)</label>
<input type="text" name="end_location_name" id="end-location-input"
placeholder="输入关键词,如「国家大剧院」「三里屯」"
autocomplete="off"
value="<?= e($eLoc['name'] ?? '') ?>">
<input type="hidden" name="end_location_address" id="end-location-address" value="<?= e($eLoc['address'] ?? '') ?>">
<input type="hidden" name="end_location_lat" id="end-location-lat" value="<?= e($eLoc['lat'] ?? '') ?>">
<input type="hidden" name="end_location_lng" id="end-location-lng" value="<?= e($eLoc['lng'] ?? '') ?>">
</div>
</fieldset>
<fieldset class="fieldset">
<legend>出行方式(可选)</legend>
<div class="form-row">
<label>选择方式</label>
<select name="travel_mode_id" id="travel-mode-select">
<option value="" data-mode-type="">— 不使用 —</option>
<?php foreach ($travelModes as $tm): ?>
<option value="<?= (int)$tm['id'] ?>" data-mode-type="<?= e($tm['mode_type']) ?>"
<?= (int)($n['travel_mode_id'] ?? 0) === (int)$tm['id'] ? ' selected' : '' ?>>
<?= e(($modeLabels[$tm['mode_type']] ?? $tm['mode_type']) . (!empty($tm['plate_number']) ? ' · ' . $tm['plate_number'] : '')) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-row transport-detail" data-fields="train plane bus subway ship other" id="field-line_name">
<label>线路名称</label>
<input type="text" name="line_name" maxlength="100" placeholder="例如:京沪高铁、地铁10号线"
value="<?= e($n['line_name'] ?? '') ?>">
</div>
<div class="form-row transport-detail" data-fields="train plane bus taxi bike ship other" id="field-carrier">
<label>承运公司</label>
<input type="text" name="carrier" maxlength="100" placeholder="例如:中国铁路、中国国航、滴滴"
value="<?= e($n['carrier'] ?? '') ?>">
</div>
<div class="form-row transport-detail" data-fields="train plane bus subway ship other" id="field-trip_number">
<label>车次 / 航班号 / 线路号</label>
<input type="text" name="trip_number" maxlength="50" placeholder="例如:G1234、CA1234"
value="<?= e($n['trip_number'] ?? '') ?>">
</div>
<div class="form-row transport-detail" data-fields="train plane ship other" id="field-seat_info">
<label>座位号 / 车厢</label>
<input type="text" name="seat_info" maxlength="100" placeholder="例如:5车12A、商务舱3A"
value="<?= e($n['seat_info'] ?? '') ?>">
</div>
<div class="form-row transport-detail" data-fields="train plane ship" id="field-gate">
<label>登机口 / 站台</label>
<input type="text" name="gate" maxlength="20" placeholder="例如:B23、T3航站楼C15"
value="<?= e($n['gate'] ?? '') ?>">
</div>
</fieldset>
<div class="form-row">
<label>详细描述</label>
<textarea name="description" rows="4" placeholder="记录一些细节..."><?= e($n['description'] ?? '') ?></textarea>
</div>
<?php if ($isEdit): ?>
<fieldset class="fieldset">
<legend>执行状态</legend>
<div class="form-row">
<label>状态</label>
<select name="status">
<?php foreach ($statusOptions as $st): ?>
<option value="<?= e($st) ?>" <?= $curStatus === $st ? ' selected' : '' ?>>
<?= e($statusLabels[$st]) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-row">
<label>取消原因(状态选「已取消」时填写)</label>
<input type="text" name="cancel_reason" maxlength="500" placeholder="例如:下雨,临时改行程"
value="<?= e($n['cancel_reason'] ?? '') ?>">
</div>
</fieldset>
<?php endif; ?>
<div class="form-actions">
<a href="<?= url('activities/show', ['id' => $activity['id']]) ?>" class="btn">取消</a>
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
<script>
(function () {
// ===== 出行方式动态字段 =====
var modeSelect = document.getElementById('travel-mode-select');
if (modeSelect) {
var details = document.querySelectorAll('.transport-detail');
var refresh = function () {
var opt = modeSelect.options[modeSelect.selectedIndex];
var modeType = opt ? (opt.getAttribute('data-mode-type') || '') : '';
details.forEach(function (el) {
var allow = el.getAttribute('data-fields').split(' ');
el.style.display = allow.indexOf(modeType) !== -1 ? '' : 'none';
});
};
modeSelect.addEventListener('change', refresh);
refresh();
}
// ===== 高德地址自动补全(自定义下拉) =====
var maxWait = 5000; // 最多等 5 秒
var waited = 0;
function waitForAmap(callback) {
if (window.__AMAP_LOADED && typeof AMap !== 'undefined' && AMap.AutoComplete) {
callback();
return;
}
if (window.__AMAP_ERROR) {
console.error('[AMap] 脚本加载失败:', window.__AMAP_ERROR);
return;
}
if (waited >= maxWait) {
console.error('[AMap] 等待超时。可能原因:');
console.error(' 1. Key 无效或未绑定域名白名单');
console.error(' 2. 安全密钥不匹配');
console.error(' 3. 网络无法访问 webapi.amap.com');
return;
}
waited += 200;
setTimeout(function () { waitForAmap(callback); }, 200);
}
waitForAmap(function () {
setupAutoComplete('start-location-input', 'start');
setupAutoComplete('end-location-input', 'end');
});
function setupAutoComplete(inputId, prefix) {
var input = document.getElementById(inputId);
if (!input) return;
var addrField = document.getElementById(prefix + '-location-address');
var latField = document.getElementById(prefix + '-location-lat');
var lngField = document.getElementById(prefix + '-location-lng');
var dropdown = document.createElement('div');
dropdown.className = 'amap-dropdown';
dropdown.style.display = 'none';
var wrapper = input.closest('.form-row') || input.parentElement;
wrapper.style.position = 'relative';
wrapper.appendChild(dropdown);
var autoComplete = new AMap.AutoComplete({});
var currentIndex = -1;
var currentResults = [];
function renderDropdown(results) {
currentResults = results || [];
currentIndex = -1;
if (!currentResults.length) {
dropdown.style.display = 'none';
return;
}
dropdown.innerHTML = currentResults.map(function (item, i) {
var name = item.name || '';
var district = item.district || '';
var address = item.address || '';
var sub = [district, address].filter(Boolean).join(' ');
return '<div class="amap-dropdown-item" data-index="' + i + '">' +
'<div class="amap-dropdown-name">' + escapeHtml(name) + '</div>' +
(sub ? '<div class="amap-dropdown-sub">' + escapeHtml(sub) + '</div>' : '') +
'</div>';
}).join('');
dropdown.style.display = 'block';
dropdown.querySelectorAll('.amap-dropdown-item').forEach(function (el) {
el.addEventListener('mousedown', function (ev) {
ev.preventDefault(); // 防止 input 失焦
var idx = parseInt(el.getAttribute('data-index'), 10);
selectItem(currentResults[idx]);
});
});
}
function selectItem(item) {
if (!item) return;
input.value = item.name || '';
if (addrField) {
addrField.value = item.address || item.district || '';
}
if (item.location) {
if (latField) latField.value = item.location.lat;
if (lngField) lngField.value = item.location.lng;
}
dropdown.style.display = 'none';
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c];
});
}
var searchTimer = null;
input.addEventListener('input', function () {
var keyword = input.value.trim();
clearTimeout(searchTimer);
if (keyword.length < 2) {
dropdown.style.display = 'none';
return;
}
searchTimer = setTimeout(function () {
autoComplete.search(keyword, function (status, result) {
if (status === 'complete' && result && result.tips) {
renderDropdown(result.tips);
} else {
dropdown.style.display = 'none';
}
});
}, 300);
});
input.addEventListener('blur', function () {
setTimeout(function () { dropdown.style.display = 'none'; }, 200);
});
input.addEventListener('focus', function () {
if (currentResults.length) dropdown.style.display = 'block';
});
input.addEventListener('keydown', function (e) {
if (dropdown.style.display === 'none') return;
var items = dropdown.querySelectorAll('.amap-dropdown-item');
if (!items.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
currentIndex = (currentIndex + 1) % items.length;
highlight(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
currentIndex = (currentIndex - 1 + items.length) % items.length;
highlight(items);
} else if (e.key === 'Enter') {
if (currentIndex >= 0 && currentResults[currentIndex]) {
e.preventDefault();
selectItem(currentResults[currentIndex]);
}
} else if (e.key === 'Escape') {
dropdown.style.display = 'none';
}
});
function highlight(items) {
items.forEach(function (el, i) {
el.classList.toggle('active', i === currentIndex);
});
}
}
})();
</script>
+128
View File
@@ -0,0 +1,128 @@
<?php
/**
* 节点详情:基础信息 + 参与人 + 花销 + 讨论
* 一个节点一页,把挂在这个节点上的所有记录集中起来
*/
use App\Comments;
$statusLabels = [
'planned' => '待执行',
'ongoing' => '进行中',
'completed' => '已完成',
'cancelled' => '已取消',
];
$st = (string)($node['status'] ?? 'planned');
if (!isset($statusLabels[$st])) $st = 'planned';
$sLoc = json_decode_field($node['start_location'] ?? null);
$eLoc = json_decode_field($node['end_location'] ?? null);
$transportParts = [];
foreach (['trip_number', 'line_name', 'carrier', 'seat_info', 'gate'] as $field) {
if (!empty($node[$field])) $transportParts[] = $node[$field];
}
$durationText = '';
if (!empty($node['duration_minutes'])) {
$mins = (int)$node['duration_minutes'];
$durationText = $mins >= 60 ? intdiv($mins, 60) . ' 小时 ' . ($mins % 60) . ' 分' : $mins . ' 分钟';
}
?>
<div class="page-header">
<div>
<h1><?= e(($node['node_type_icon'] ?? '') . ' ' . ($node['title'] ?: '(无标题节点)')) ?></h1>
<p class="muted">
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>"><?= e($node['activity_title']) ?></a>
· <?= $node['node_category'] === 'plan' ? '计划节点' : '执行节点' ?>
· <span class="status-tag status-<?= e($st) ?>"><?= e($statusLabels[$st]) ?></span>
<?php if ($node['node_category'] === 'reality' && !empty($node['original_plan_node_id'])): ?>
· <span class="tag-default">由计划节点生成</span>
<?php endif; ?>
</p>
</div>
<div class="actions" style="display:flex; gap:8px; flex-wrap:wrap;">
<a href="<?= url('nodes/edit', ['id' => $node['id']]) ?>" class="btn">编辑节点</a>
<a href="<?= url('attachments/node', ['id' => $node['id']]) ?>" class="btn">📎 附件 (<?= (int)$attachmentCount ?>)</a>
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>" class="btn">返回活动</a>
<form method="post" action="<?= url('nodes/destroy') ?>" style="display:inline;"
onsubmit="return confirm('确定删除这个节点?它的附件、参与记录、花销和讨论都会一并删除。');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$node['id'] ?>">
<button type="submit" class="btn btn-danger">删除节点</button>
</form>
</div>
</div>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">时间</span>
<span class="detail-value">
<?= date('Y-m-d H:i', strtotime((string)$node['start_time'])) ?>
→
<?= date('Y-m-d H:i', strtotime((string)$node['end_time'])) ?>
<?php if ($durationText !== ''): ?>
<span class="muted">(<?= e($durationText) ?>)</span>
<?php endif; ?>
</span>
</div>
<div class="detail-item">
<span class="detail-label">类型</span>
<span class="detail-value"><?= e($node['node_type_name'] ?? '未分类') ?></span>
</div>
<?php if (!empty($node['travel_mode_label'])): ?>
<div class="detail-item">
<span class="detail-label">出行方式</span>
<span class="detail-value"><?= e($node['travel_mode_label']) ?></span>
</div>
<?php endif; ?>
<?php if (!empty($transportParts)): ?>
<div class="detail-item">
<span class="detail-label">交通细节</span>
<span class="detail-value"><?= e(implode(' · ', $transportParts)) ?></span>
</div>
<?php endif; ?>
<?php if (!empty($sLoc['name']) || !empty($sLoc['address'])): ?>
<div class="detail-item">
<span class="detail-label">起点</span>
<span class="detail-value">
📍 <?= e($sLoc['name'] ?? '') ?>
<?php if (!empty($sLoc['address'])): ?>
<span class="muted"><?= e($sLoc['address']) ?></span>
<?php endif; ?>
</span>
</div>
<?php endif; ?>
<?php if (!empty($eLoc['name']) || !empty($eLoc['address'])): ?>
<div class="detail-item">
<span class="detail-label">终点</span>
<span class="detail-value">
📍 <?= e($eLoc['name'] ?? '') ?>
<?php if (!empty($eLoc['address'])): ?>
<span class="muted"><?= e($eLoc['address']) ?></span>
<?php endif; ?>
</span>
</div>
<?php endif; ?>
<?php if (!empty($node['description'])): ?>
<div class="detail-item detail-item-wide">
<span class="detail-label">描述</span>
<span class="detail-value"><?= nl2br(e($node['description'])) ?></span>
</div>
<?php endif; ?>
<?php if ($st === 'cancelled' && !empty($node['cancel_reason'])): ?>
<div class="detail-item detail-item-wide">
<span class="detail-label">取消原因</span>
<span class="detail-value node-cancel-reason"><?= e($node['cancel_reason']) ?></span>
</div>
<?php endif; ?>
</div>
<?php require __DIR__ . '/_members.php'; ?>
<?php require __DIR__ . '/_expenses.php'; ?>
<?php
$commentTargetType = Comments::TARGET_NODE;
$commentTargetId = (int)$node['id'];
require dirname(__DIR__) . '/comments/_block.php';
?>
+64
View File
@@ -0,0 +1,64 @@
<?php $isEdit = $tag !== null; ?>
<div class="page-header">
<div>
<h1><?= $isEdit ? '编辑角色标签' : '新增角色标签' ?></h1>
<p class="muted">标签只用于说明「这个人在这一站担当什么」,不影响权限</p>
</div>
<a href="<?= url('role_tags/index') ?>" class="btn">返回列表</a>
</div>
<form method="post" action="<?= url($isEdit ? 'role_tags/update' : 'role_tags/store') ?>" class="form">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$tag['id'] ?>">
<?php endif; ?>
<div class="form-row">
<label>名称 *</label>
<input type="text" name="name" required maxlength="30"
value="<?= e($tag['name'] ?? '') ?>" placeholder="例如:司机、摄影师、记账员">
<p class="muted" style="font-size:12px; margin-top:6px;">最多 30 个字符,同一家庭内不能重名</p>
</div>
<div class="form-row">
<label>图标(emoji,可选)</label>
<input type="text" name="icon" maxlength="8" readonly
value="<?= e($tag['icon'] ?? '') ?>" placeholder="点击下方表情填入"
id="icon-input">
<div class="emoji-picker" id="emoji-picker">
<?php
// 只用 Emoji 11.0(2018-06)及以前的码位,更新的码位在旧系统字体里没有字形
$emojiSuggestions = [
'🚗','🧭','📷','🎥','💰','🧾','🍽️','🛏️','🎫','🎒',
'🧳','🗺️','☂️','🕶️','🎈','🍼','💊','🐶','🌞','🎣',
'🧺','🛒','📱','🔋','⛽','🅿️','🚻','🛁','🧦','👟',
];
foreach ($emojiSuggestions as $emoji):
?>
<button type="button" class="emoji-btn" data-emoji="<?= $emoji ?>"><?= $emoji ?></button>
<?php endforeach; ?>
</div>
<p class="muted" style="font-size:12px; margin-top:6px;">
点击表情快速填入;留空也可以
</p>
</div>
<div class="form-actions">
<a href="<?= url('role_tags/index') ?>" class="btn">取消</a>
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
<script>
(function () {
var iconInput = document.getElementById('icon-input');
var picker = document.getElementById('emoji-picker');
if (!picker || !iconInput) return;
picker.addEventListener('click', function (ev) {
var btn = ev.target.closest('.emoji-btn');
if (!btn) return;
iconInput.value = btn.getAttribute('data-emoji');
iconInput.focus();
});
})();
</script>
+45
View File
@@ -0,0 +1,45 @@
<div class="page-header">
<div>
<h1>角色标签管理</h1>
<p class="muted">给参与人贴的标签,例如:司机、摄影师、记账员、拎包的</p>
</div>
<a href="<?= url('role_tags/create') ?>" class="btn-primary">+ 新增角色</a>
</div>
<?php if (empty($tags)): ?>
<div class="empty">
<p>还没有角色标签</p>
<a href="<?= url('role_tags/create') ?>" class="btn-primary">创建第一个角色</a>
</div>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<th style="width:70px;">ID</th>
<th style="width:70px;">图标</th>
<th>名称</th>
<th style="width:120px;">被引用</th>
<th style="width:160px;">操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($tags as $t): ?>
<tr>
<td><?= (int)$t['id'] ?></td>
<td style="font-size:22px;"><?= e($t['icon'] ?: '·') ?></td>
<td><?= e($t['name']) ?></td>
<td class="muted"><?= (int)$t['used_count'] ?> 处参与人</td>
<td>
<a href="<?= url('role_tags/edit', ['id' => $t['id']]) ?>" class="btn-sm">编辑</a>
<form method="post" action="<?= url('role_tags/destroy') ?>" style="display:inline"
onsubmit="return confirm('确定删除角色「<?= e($t['name']) ?>」?已使用它的参与人记录会被同步摘掉这个标签。');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<button type="submit" class="btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
+87
View File
@@ -0,0 +1,87 @@
<?php $isEdit = $mode !== null; ?>
<div class="page-header">
<div>
<h1><?= $isEdit ? '编辑出行方式' : '新增出行方式' ?></h1>
<?php if ($isEdit): ?>
<p class="muted">ID:<?= (int)$mode['id'] ?></p>
<?php endif; ?>
</div>
<a href="<?= url('travel_modes/index') ?>" class="btn">返回列表</a>
</div>
<form method="post" action="<?= url($isEdit ? 'travel_modes/update' : 'travel_modes/store') ?>" class="form">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$mode['id'] ?>">
<?php endif; ?>
<div class="form-row">
<label>交通类型 *</label>
<select name="mode_type" id="mode-type-select" required>
<?php foreach ($modeLabels as $key => $label): ?>
<option value="<?= e($key) ?>" <?= ($mode['mode_type'] ?? '') === $key ? 'selected' : '' ?>>
<?= e($label) ?>
</option>
<?php endforeach; ?>
</select>
<p class="muted" style="font-size:12px; margin-top:6px;">
具体的车次、航班号、座位号在「活动节点」中填写,此处只维护出行方式本身。
</p>
</div>
<div class="form-row mode-field" id="field-vehicle_id" style="display:none;">
<label>选择车辆 *</label>
<select name="vehicle_id" id="vehicle-select">
<option value="">— 请选择车辆 —</option>
<?php foreach ($vehicles as $v): ?>
<option value="<?= (int)$v['id'] ?>"
data-vtype="<?= e($v['vehicle_type']) ?>"
<?= (int)($mode['vehicle_id'] ?? 0) === (int)$v['id'] ? 'selected' : '' ?>>
<?= e($v['plate_number'] . ' · ' . $v['model_name'] . ($v['vehicle_type'] === 'rental' ? '(租)' : '')) ?>
</option>
<?php endforeach; ?>
</select>
<p class="muted" style="font-size:12px; margin-top:6px;">
自有车/租车类型只能选择对应车辆。若列表为空,请先维护车辆库。
</p>
</div>
<div class="form-row">
<label>备注</label>
<textarea name="notes" rows="3" placeholder="其他补充说明,如常走的路线、使用偏好等"><?= e($mode['notes'] ?? '') ?></textarea>
</div>
<div class="form-actions">
<a href="<?= url('travel_modes/index') ?>" class="btn">取消</a>
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
<script>
(function () {
var modeSelect = document.getElementById('mode-type-select');
if (!modeSelect) return;
var vehicleField = document.getElementById('field-vehicle_id');
var vehicleSelect = document.getElementById('vehicle-select');
function refresh() {
var type = modeSelect.value;
var isDrive = (type === 'drive_own' || type === 'drive_rental');
vehicleField.style.display = isDrive ? '' : 'none';
if (vehicleSelect) {
var want = type === 'drive_own' ? 'own' : (type === 'drive_rental' ? 'rental' : null);
Array.prototype.forEach.call(vehicleSelect.options, function (opt) {
if (!opt.value) return;
var vt = opt.getAttribute('data-vtype');
opt.hidden = (want && vt !== want);
if (opt.hidden && opt.selected) opt.selected = false;
});
}
}
modeSelect.addEventListener('change', refresh);
refresh();
})();
</script>
+60
View File
@@ -0,0 +1,60 @@
<div class="page-header">
<div>
<h1>出行方式管理</h1>
<p class="muted">管理家庭常用的交通方式。具体的车次、航班号在「活动节点」中填写</p>
</div>
<a href="<?= url('travel_modes/create') ?>" class="btn-primary">+ 新增出行方式</a>
</div>
<?php if (empty($modes)): ?>
<div class="empty">
<p>暂无出行方式</p>
<a href="<?= url('travel_modes/create') ?>" class="btn-primary">添加第一个</a>
</div>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<th style="width:60px;">ID</th>
<th style="width:140px;">类型</th>
<th>备注</th>
<th style="width:90px;">使用</th>
<th style="width:140px;">操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($modes as $m): ?>
<tr>
<td><?= (int)$m['id'] ?></td>
<td>
<span class="mode-tag mode-<?= e($m['mode_type']) ?>">
<?= e($modeLabels[$m['mode_type']] ?? $m['mode_type']) ?>
</span>
<?php if (!empty($m['plate_number'])): ?>
<div class="muted" style="font-size:12px; margin-top:4px;">
<?= e($m['plate_number'] . ' ' . $m['model_name']) ?>
</div>
<?php endif; ?>
</td>
<td>
<?php if (!empty($m['notes'])): ?>
<?= e($m['notes']) ?>
<?php else: ?>
<span class="muted">—</span>
<?php endif; ?>
</td>
<td class="muted"><?= (int)$m['node_count'] ?> 次</td>
<td>
<a href="<?= url('travel_modes/edit', ['id' => $m['id']]) ?>" class="btn-sm">编辑</a>
<form method="post" action="<?= url('travel_modes/destroy') ?>" style="display:inline"
onsubmit="return confirm('确定删除该出行方式?');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<button type="submit" class="btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
+237
View File
@@ -0,0 +1,237 @@
<?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>
+69
View File
@@ -0,0 +1,69 @@
<div class="page-header">
<h1>用户管理</h1>
<a href="<?= url('users/create') ?>" class="btn-primary">+ 新增用户</a>
</div>
<?php if (empty($users)): ?>
<div class="empty"><p>暂无用户</p></div>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>姓名</th>
<th>角色</th>
<th>状态</th>
<th>创建时间</th>
<th style="width:140px;">操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $u): ?>
<tr>
<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 else: ?>
<span class="avatar-sm avatar-placeholder"><?= e(mb_substr($u['name'], 0, 1)) ?></span>
<?php endif; ?>
<span><?= e($u['name']) ?></span>
<?php if ($u['id'] == \App\Auth::id()): ?>
<span class="tag-self">我</span>
<?php endif; ?>
</div>
</td>
<td>
<?php
$roleLabels = ['admin' => '管理员', 'user' => '普通用户', 'guest' => '待激活'];
$roleClasses = ['admin' => 'role-admin', 'user' => 'role-user', 'guest' => 'role-guest'];
?>
<span class="role-tag <?= $roleClasses[$u['role']] ?? '' ?>">
<?= e($roleLabels[$u['role']] ?? $u['role']) ?>
</span>
</td>
<td>
<?php if ($u['status'] === 'active'): ?>
<span class="status-active">● 正常</span>
<?php else: ?>
<span class="status-inactive">● 停用</span>
<?php endif; ?>
</td>
<td class="muted"><?= date('Y-m-d', strtotime($u['created_at'])) ?></td>
<td>
<a href="<?= url('users/edit', ['id' => $u['id']]) ?>" class="btn-sm">编辑</a>
<?php if ($u['id'] != \App\Auth::id()): ?>
<form method="post" action="<?= url('users/destroy') ?>" style="display:inline"
onsubmit="return confirm('确定删除用户「<?= e($u['name']) ?>」?此操作不可恢复。');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$u['id'] ?>">
<button type="submit" class="btn-sm btn-danger">删除</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
+112
View File
@@ -0,0 +1,112 @@
<?php $isEdit = $vehicle !== null; ?>
<div class="page-header">
<div>
<h1><?= $isEdit ? '编辑车辆' : '新增车辆' ?></h1>
<?php if ($isEdit): ?>
<p class="muted">ID:<?= (int)$vehicle['id'] ?></p>
<?php endif; ?>
</div>
<a href="<?= url('vehicles/index') ?>" class="btn">返回列表</a>
</div>
<form method="post" action="<?= url($isEdit ? 'vehicles/update' : 'vehicles/store') ?>" class="form">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$vehicle['id'] ?>">
<?php endif; ?>
<div class="form-row">
<label>车牌号 *</label>
<input type="text" name="plate_number" required maxlength="20"
value="<?= e($vehicle['plate_number'] ?? '') ?>"
placeholder="例如:京A·88888 或 沪C12345"
style="text-transform:uppercase;">
<p class="muted" style="font-size:12px; margin-top:6px;">同一家庭内车牌号不可重复</p>
</div>
<div class="form-row">
<label>车型名称 *</label>
<input type="text" name="model_name" required maxlength="100"
value="<?= e($vehicle['model_name'] ?? '') ?>"
placeholder="例如:特斯拉 Model Y、大众 帕萨特">
</div>
<div class="form-row">
<label>车辆类型 *</label>
<div class="radio-group">
<?php foreach ($typeLabels as $key => $label): ?>
<label class="radio-label">
<input type="radio" name="vehicle_type" value="<?= e($key) ?>"
<?= ($vehicle['vehicle_type'] ?? 'own') === $key ? 'checked' : '' ?>>
<span><?= e($label) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<!-- 租车信息(仅租车类型显示) -->
<div class="rental-only">
<div class="form-row">
<label>租车公司</label>
<input type="text" name="rental_company" maxlength="100"
value="<?= e($vehicle['rental_company'] ?? '') ?>"
placeholder="例如:神州租车、一嗨租车">
</div>
<div class="form-row">
<label>租车订单号</label>
<input type="text" name="rental_order_no" maxlength="50"
value="<?= e($vehicle['rental_order_no'] ?? '') ?>"
placeholder="例如:RENT20260801001">
</div>
</div>
<!-- 默认自有车(仅自有车类型显示) -->
<div class="own-only">
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" name="is_default" value="1"
<?= !empty($vehicle['is_default']) ? 'checked' : '' ?>>
<span>设为默认自有车(同一家庭只能有一辆)</span>
</label>
<p class="muted" style="font-size:12px; margin-top:6px;">
选择后,其他自有车的默认标记会被自动取消。
</p>
</div>
</div>
<div class="form-row">
<label>备注</label>
<textarea name="notes" rows="3" placeholder="其他补充说明"><?= e($vehicle['notes'] ?? '') ?></textarea>
</div>
<div class="form-actions">
<a href="<?= url('vehicles/index') ?>" class="btn">取消</a>
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
<script>
(function () {
var radios = document.querySelectorAll('input[name="vehicle_type"]');
if (!radios.length) return;
var rentalBlocks = document.querySelectorAll('.rental-only');
var ownBlocks = document.querySelectorAll('.own-only');
function refresh() {
var val = 'own';
radios.forEach(function (r) { if (r.checked) val = r.value; });
rentalBlocks.forEach(function (el) {
el.style.display = (val === 'rental') ? '' : 'none';
});
ownBlocks.forEach(function (el) {
el.style.display = (val === 'own') ? '' : 'none';
});
}
radios.forEach(function (r) { r.addEventListener('change', refresh); });
refresh();
})();
</script>
+67
View File
@@ -0,0 +1,67 @@
<div class="page-header">
<div>
<h1>车辆管理</h1>
<p class="muted">管理家庭的自有车和租用车辆,供出行方式引用</p>
</div>
<a href="<?= url('vehicles/create') ?>" class="btn-primary">+ 新增车辆</a>
</div>
<?php if (empty($vehicles)): ?>
<div class="empty">
<p>暂无车辆</p>
<a href="<?= url('vehicles/create') ?>" class="btn-primary">添加第一辆</a>
</div>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<th style="width:60px;">ID</th>
<th style="width:130px;">车牌号</th>
<th>车型</th>
<th style="width:100px;">类型</th>
<th style="width:130px;">租车信息</th>
<th style="width:90px;">使用</th>
<th style="width:140px;">操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($vehicles as $v): ?>
<tr>
<td><?= (int)$v['id'] ?></td>
<td>
<strong style="letter-spacing:0.5px;"><?= e($v['plate_number']) ?></strong>
<?php if (!empty($v['is_default']) && $v['vehicle_type'] === 'own'): ?>
<span class="tag-default" title="默认自有车">默认</span>
<?php endif; ?>
</td>
<td><?= e($v['model_name']) ?></td>
<td>
<span class="veh-tag veh-<?= e($v['vehicle_type']) ?>">
<?= e($typeLabels[$v['vehicle_type']] ?? $v['vehicle_type']) ?>
</span>
</td>
<td>
<?php if ($v['vehicle_type'] === 'rental' && !empty($v['rental_company'])): ?>
<div><?= e($v['rental_company']) ?></div>
<?php if (!empty($v['rental_order_no'])): ?>
<div class="muted" style="font-size:12px;"><?= e($v['rental_order_no']) ?></div>
<?php endif; ?>
<?php else: ?>
<span class="muted">—</span>
<?php endif; ?>
</td>
<td class="muted"><?= (int)$v['mode_count'] ?> 次</td>
<td>
<a href="<?= url('vehicles/edit', ['id' => $v['id']]) ?>" class="btn-sm">编辑</a>
<form method="post" action="<?= url('vehicles/destroy') ?>" style="display:inline"
onsubmit="return confirm('确定删除车辆「<?= e($v['plate_number']) ?>」?');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$v['id'] ?>">
<button type="submit" class="btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>