补齐未接入代码的功能域:参与人 / 评论 / 花销与分摊
数据库里早就建好了 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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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; ?>
|
||||
@@ -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';
|
||||
?>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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; ?>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 { '&':'&','<':'<','>':'>','"':'"',"'":''' }[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>
|
||||
@@ -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';
|
||||
?>
|
||||
@@ -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>
|
||||
@@ -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; ?>
|
||||
@@ -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>
|
||||
@@ -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; ?>
|
||||
@@ -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>
|
||||
@@ -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; ?>
|
||||
@@ -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>
|
||||
@@ -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; ?>
|
||||
Reference in New Issue
Block a user