补齐未接入代码的功能域:参与人 / 评论 / 花销与分摊
数据库里早就建好了 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:
+15
@@ -0,0 +1,15 @@
|
||||
# ── 本地配置(含数据库口令与高德 Key),不入库 ──────────────────────────────
|
||||
# 部署时由服务器上的 config.php 提供;新环境请复制 config.example.php 再改。
|
||||
config.php
|
||||
|
||||
# ── 用户上传内容(头像 / 封面 / 附件),不入库 ──────────────────────────────
|
||||
# 只保留那行「禁止执行脚本」的保护规则,否则任何人上传的 .php 都能被直接访问。
|
||||
uploads/*
|
||||
!uploads/.htaccess
|
||||
|
||||
# ── 临时产物 ────────────────────────────────────────────────────────────────
|
||||
*.bak
|
||||
*.bak.*
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,968 @@
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||
background: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
line-height: 1.6;
|
||||
}
|
||||
a { color: #007aff; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.topbar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e5e5e7;
|
||||
padding: 14px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.brand { font-weight: 600; font-size: 17px; color: #1d1d1f; }
|
||||
.topbar nav { display: flex; gap: 16px; align-items: center; }
|
||||
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
gap: 16px;
|
||||
}
|
||||
.page-header h1 { margin: 0 0 4px; font-size: 24px; }
|
||||
.page-header .muted { margin: 0; }
|
||||
|
||||
.btn, .btn-primary {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d2d2d7;
|
||||
background: #fff;
|
||||
color: #1d1d1f;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.btn:hover, .btn-primary:hover { background: #f0f0f2; text-decoration: none; }
|
||||
.btn-primary { background: #007aff; color: #fff; border-color: #007aff; }
|
||||
.btn-primary:hover { background: #0066d6; }
|
||||
|
||||
.alert {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffe08a;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
color: #664d03;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e5e7;
|
||||
}
|
||||
.empty p { margin: 0 0 16px; color: #86868b; }
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.card {
|
||||
display: block;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e5e7;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
transition: 0.15s;
|
||||
}
|
||||
.card:hover { text-decoration: none; transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
|
||||
.card-cover {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: #e5e5e7;
|
||||
}
|
||||
.card-cover-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
color: #c7c7cc;
|
||||
}
|
||||
.card-body { padding: 14px 16px; }
|
||||
.card-body h3 { margin: 0 0 6px; font-size: 16px; }
|
||||
.card-body .muted { color: #86868b; font-size: 13px; margin: 0 0 10px; }
|
||||
.card .meta { display: flex; justify-content: space-between; font-size: 12px; color: #86868b; }
|
||||
|
||||
.timeline-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.timeline-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.timeline-col h2 { font-size: 16px; margin: 0 0 12px; color: #555; }
|
||||
|
||||
.node-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e5e5e7;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.node-plan { border-left: 4px solid #c7c7cc; }
|
||||
.node-reality { border-left: 4px solid #007aff; }
|
||||
.node-head { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #555; margin-bottom: 4px; }
|
||||
.node-icon { font-size: 16px; }
|
||||
.node-time { font-size: 12px; color: #86868b; margin-bottom: 4px; }
|
||||
.node-title { font-weight: 600; font-size: 15px; }
|
||||
.node-loc { font-size: 13px; color: #555; margin-top: 4px; }
|
||||
.tag-temp {
|
||||
background: #fff3cd;
|
||||
color: #664d03;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.form { background: #fff; border-radius: 12px; padding: 24px; border: 1px solid #e5e5e7; max-width: 720px; }
|
||||
.form-row { margin-bottom: 18px; }
|
||||
.form-row label { display: block; font-size: 13px; color: #555; margin-bottom: 6px; font-weight: 500; }
|
||||
.form-row input[type=text],
|
||||
.form-row input[type=password],
|
||||
.form-row input[type=datetime-local],
|
||||
.form-row textarea,
|
||||
.form-row select {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid #d2d2d7;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
.form-row textarea { resize: vertical; }
|
||||
.form-row input:focus, .form-row textarea:focus, .form-row select:focus {
|
||||
outline: none;
|
||||
border-color: #007aff;
|
||||
box-shadow: 0 0 0 3px rgba(0,122,255,0.15);
|
||||
}
|
||||
.form-row.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
@media (max-width: 600px) {
|
||||
.form-row.two-col { grid-template-columns: 1fr; }
|
||||
}
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 10px; padding-top: 8px; }
|
||||
|
||||
.footer { text-align: center; color: #86868b; font-size: 12px; padding: 40px 20px 20px; }
|
||||
.muted { color: #86868b; }
|
||||
/* ====== 登录页 ====== */
|
||||
.container-guest { display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||
.login-wrapper { width: 100%; max-width: 380px; padding: 24px; }
|
||||
.login-form {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 32px 28px;
|
||||
border: 1px solid #e5e5e7;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.04);
|
||||
}
|
||||
.login-form h1 { margin: 0 0 4px; font-size: 22px; text-align: center; }
|
||||
.login-form .muted { text-align: center; margin: 0 0 24px; font-size: 13px; }
|
||||
|
||||
.user-info {
|
||||
padding: 4px 10px;
|
||||
background: #f0f0f2;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
}
|
||||
.logout-link { font-size: 13px; color: #86868b; }
|
||||
.logout-link:hover { color: #ff3b30; }
|
||||
/* ====== 数据表格 ====== */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e5e7;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.data-table th, .data-table td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #f0f0f2;
|
||||
}
|
||||
.data-table th {
|
||||
background: #fafafa;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
}
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
.data-table tbody tr:hover { background: #fafafa; }
|
||||
|
||||
.user-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar-sm {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.avatar-placeholder {
|
||||
background: #e5e5e7;
|
||||
color: #86868b;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.role-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.role-admin { background: #ffe5e5; color: #c0392b; }
|
||||
.role-user { background: #e0f0ff; color: #007aff; }
|
||||
.role-guest { background: #f0f0f2; color: #86868b; }
|
||||
|
||||
.status-active { color: #34c759; font-size: 12px; }
|
||||
.status-inactive { color: #86868b; font-size: 12px; }
|
||||
|
||||
.tag-self {
|
||||
background: #007aff;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d2d2d7;
|
||||
background: #fff;
|
||||
color: #1d1d1f;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-sm:hover { background: #f0f0f2; text-decoration: none; }
|
||||
.btn-danger { color: #ff3b30; border-color: #ffd0cc; }
|
||||
.btn-danger:hover { background: #ffe5e5; }
|
||||
/* ====== 头像上传 ====== */
|
||||
.avatar-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.avatar-preview {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 2px dashed #d2d2d7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatar-placeholder {
|
||||
font-size: 32px;
|
||||
color: #c7c7cc;
|
||||
font-weight: 300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.avatar-upload-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ====== 裁剪模态框 ====== */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
width: 90%;
|
||||
max-width: 520px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.modal-content h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.crop-container {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
background: #f0f0f2;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.crop-container img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
/* ====== 活动封面上传 ====== */
|
||||
.cover-upload {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cover-preview {
|
||||
width: 240px;
|
||||
height: 135px; /* 16:9 */
|
||||
border-radius: 10px;
|
||||
background: #f0f0f2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 2px dashed #d2d2d7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cover-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.cover-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #c7c7cc;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.cover-upload-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 宽版裁剪模态框(16:9) */
|
||||
.modal-content-wide { max-width: 800px; }
|
||||
.crop-container-wide {
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 70vh;
|
||||
}
|
||||
/* ====== Emoji 选择器 ====== */
|
||||
.emoji-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 40px);
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e5e5e7;
|
||||
border-radius: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.emoji-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 20px;
|
||||
border: 1px solid transparent;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
transition: 0.15s;
|
||||
}
|
||||
.emoji-btn:hover {
|
||||
background: #f0f0f2;
|
||||
border-color: #d2d2d7;
|
||||
}
|
||||
.emoji-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
/* ====== 出行方式类型标签 ====== */
|
||||
.mode-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: #f0f0f2;
|
||||
color: #555;
|
||||
}
|
||||
.mode-tag.mode-drive_own { background: #e0f0ff; color: #007aff; }
|
||||
.mode-tag.mode-drive_rental { background: #fff3e0; color: #b26a00; }
|
||||
.mode-tag.mode-train { background: #e8f5e9; color: #1b7a2f; }
|
||||
.mode-tag.mode-plane { background: #e3f2fd; color: #0d47a1; }
|
||||
.mode-tag.mode-bus { background: #fff8e1; color: #8a6d00; }
|
||||
.mode-tag.mode-subway { background: #f3e5f5; color: #6a1b9a; }
|
||||
.mode-tag.mode-taxi { background: #fff3cd; color: #856404; }
|
||||
.mode-tag.mode-bike { background: #e0f7fa; color: #00695c; }
|
||||
.mode-tag.mode-walk { background: #f1f8e9; color: #558b2f; }
|
||||
.mode-tag.mode-ship { background: #e1f5fe; color: #01579b; }
|
||||
.mode-tag.mode-other { background: #f0f0f2; color: #555; }
|
||||
/* ====== 表单 fieldset(起点/终点分组) ====== */
|
||||
.fieldset {
|
||||
border: 1px solid #e5e5e7;
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px 4px;
|
||||
margin: 0 0 18px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.fieldset legend {
|
||||
padding: 0 8px;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
.fieldset .form-row:last-child { margin-bottom: 12px; }
|
||||
|
||||
.node-loc .loc-arrow { color: #c7c7cc; margin: 0 4px; }
|
||||
.node-loc .loc-start { color: #86868b; }
|
||||
.node-loc .loc-end { color: #007aff; }
|
||||
/* ====== 车辆管理 ====== */
|
||||
.veh-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.veh-tag.veh-own { background: #e0f0ff; color: #007aff; }
|
||||
.veh-tag.veh-rental { background: #fff3e0; color: #b26a00; }
|
||||
|
||||
.tag-default {
|
||||
display: inline-block;
|
||||
background: #34c759;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
.radio-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
.radio-label input { margin: 0; }
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
.checkbox-label input { margin: 0; }
|
||||
/* ====== 节点交通细节 ====== */
|
||||
.node-transport {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #1b7a2f;
|
||||
background: #f0f9f2;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ====== 高德地址自动补全(自定义下拉) ====== */
|
||||
.amap-dropdown {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: 1000;
|
||||
margin-top: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid #d2d2d7;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.amap-dropdown-item {
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f2;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.amap-dropdown-item:last-child { border-bottom: none; }
|
||||
.amap-dropdown-item:hover,
|
||||
.amap-dropdown-item.active {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
.amap-dropdown-name {
|
||||
font-size: 14px;
|
||||
color: #1d1d1f;
|
||||
font-weight: 500;
|
||||
}
|
||||
.amap-dropdown-sub {
|
||||
font-size: 12px;
|
||||
color: #86868b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
/* ====== 节点附件 ====== */
|
||||
.node-actions {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f0f0f2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.node-action-link {
|
||||
font-size: 12px;
|
||||
color: #007aff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.node-action-link:hover { text-decoration: underline; }
|
||||
.node-action-form { display: inline; margin: 0; }
|
||||
.node-action-btn {
|
||||
font-size: 12px;
|
||||
color: #007aff;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.node-action-btn:hover { text-decoration: underline; }
|
||||
.node-action-btn.danger { color: #ff3b30; }
|
||||
.node-action-note { font-size: 12px; color: #34c759; }
|
||||
.status-tag {
|
||||
font-size: 11px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-planned { background: #f0f0f2; color: #555; }
|
||||
.status-ongoing { background: #e0f0ff; color: #007aff; }
|
||||
.status-completed { background: #e3f8e7; color: #1a7f37; }
|
||||
.status-cancelled { background: #ffe5e5; color: #b3261e; }
|
||||
.node-cancelled .node-title { text-decoration: line-through; color: #86868b; }
|
||||
.node-cancel-reason { font-size: 12px; color: #b3261e; margin-top: 4px; }
|
||||
/* ====== 活动详情:封面 ====== */
|
||||
.activity-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
background: #f0f0f2;
|
||||
}
|
||||
.activity-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
|
||||
.node-attachment-link {
|
||||
font-size: 12px;
|
||||
color: #007aff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.node-attachment-link:hover { text-decoration: underline; }
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed #d2d2d7;
|
||||
border-radius: 12px;
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
background: #fafafa;
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.dragging {
|
||||
border-color: #007aff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
.drop-zone-inner { color: #555; }
|
||||
|
||||
.file-list {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.file-row {
|
||||
padding: 6px 12px;
|
||||
background: #f0f0f2;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.attachment-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.attachment-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e5e7;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.attachment-link {
|
||||
display: block;
|
||||
background: #f5f5f7;
|
||||
aspect-ratio: 4 / 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
}
|
||||
.attachment-link:hover { text-decoration: none; }
|
||||
.attachment-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.attachment-icon {
|
||||
font-size: 48px;
|
||||
color: #86868b;
|
||||
}
|
||||
.attachment-meta {
|
||||
padding: 8px 10px;
|
||||
position: relative;
|
||||
}
|
||||
.attachment-name {
|
||||
font-size: 13px;
|
||||
color: #1d1d1f;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: 20px;
|
||||
}
|
||||
.attachment-sub {
|
||||
font-size: 11px;
|
||||
color: #86868b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.attachment-delete {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
color: #c7c7cc;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.attachment-delete:hover { color: #ff3b30; }
|
||||
/* ====== 通用面板(节点详情页 / 评论区) ====== */
|
||||
.panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e5e7;
|
||||
border-radius: 12px;
|
||||
padding: 20px 22px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
margin: 0 0 14px;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
.inline-panel {
|
||||
margin-top: 14px;
|
||||
border-top: 1px dashed #e5e5e7;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.inline-panel > summary {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #007aff;
|
||||
list-style: none;
|
||||
outline: none;
|
||||
}
|
||||
.inline-panel > summary::-webkit-details-marker { display: none; }
|
||||
.inline-panel > summary:hover { text-decoration: underline; }
|
||||
.inline-panel[open] > summary { margin-bottom: 14px; }
|
||||
/* 面板内的表单不再套一层白卡片 */
|
||||
.form.form-plain {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
max-width: 640px;
|
||||
}
|
||||
.btn-primary-sm {
|
||||
background: #007aff;
|
||||
color: #fff;
|
||||
border-color: #007aff;
|
||||
}
|
||||
.btn-primary-sm:hover { background: #0066d6; }
|
||||
|
||||
/* ====== 节点详情:基础信息 ====== */
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e5e7;
|
||||
border-radius: 12px;
|
||||
padding: 18px 22px;
|
||||
}
|
||||
.detail-item { display: flex; flex-direction: column; gap: 2px; }
|
||||
.detail-item-wide { grid-column: 1 / -1; }
|
||||
.detail-label { font-size: 12px; color: #86868b; }
|
||||
.detail-value { font-size: 14px; color: #1d1d1f; }
|
||||
|
||||
/* ====== 活动详情:花销汇总条 ====== */
|
||||
.activity-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e5e7;
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 18px;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
}
|
||||
.activity-stat { white-space: nowrap; }
|
||||
.money-over { color: #b3261e; }
|
||||
.money-under { color: #1a7f37; }
|
||||
.node-action-strong { font-weight: 600; }
|
||||
|
||||
/* ====== 评论 ====== */
|
||||
.comment-list { display: flex; flex-direction: column; gap: 14px; }
|
||||
.comment-thread { border-bottom: 1px solid #f0f0f2; padding-bottom: 12px; }
|
||||
.comment-thread:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.comment-item { padding: 4px 0; }
|
||||
.comment-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.comment-author { font-weight: 600; color: #1d1d1f; }
|
||||
.comment-time { color: #86868b; font-size: 12px; }
|
||||
.comment-body {
|
||||
font-size: 14px;
|
||||
color: #1d1d1f;
|
||||
margin: 6px 0 6px 36px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.comment-del-form { margin-left: auto; }
|
||||
.comment-del {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: #c7c7cc;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.comment-del:hover { color: #ff3b30; text-decoration: underline; }
|
||||
.comment-reply-box { margin-left: 36px; }
|
||||
.comment-reply-box > summary {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #007aff;
|
||||
list-style: none;
|
||||
outline: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.comment-reply-box > summary::-webkit-details-marker { display: none; }
|
||||
.comment-reply-box > summary:hover { text-decoration: underline; }
|
||||
.comment-replies {
|
||||
margin-left: 36px;
|
||||
margin-top: 10px;
|
||||
padding-left: 14px;
|
||||
border-left: 2px solid #f0f0f2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.comment-form { margin-top: 16px; }
|
||||
.comment-form-inline { margin-top: 8px; }
|
||||
.comment-form textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d2d2d7;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
background: #fff;
|
||||
}
|
||||
.comment-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: #007aff;
|
||||
box-shadow: 0 0 0 3px rgba(0,122,255,0.15);
|
||||
}
|
||||
.comment-form .form-actions { padding-top: 8px; }
|
||||
|
||||
/* ====== 参与人 ====== */
|
||||
.member-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.member-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 12px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f2;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.member-name { font-size: 14px; font-weight: 500; color: #1d1d1f; }
|
||||
.member-role {
|
||||
font-size: 12px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
background: #e0f0ff;
|
||||
color: #0066d6;
|
||||
}
|
||||
.member-emotion { font-size: 13px; color: #b26a00; }
|
||||
.member-checkin { font-size: 12px; color: #1a7f37; }
|
||||
.member-del-form { margin-left: auto; }
|
||||
|
||||
/* 情绪单选:只显示表情,文字用 title 提示 */
|
||||
.emotion-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e5e5e7;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.emotion-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 6px 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
.emotion-option:hover { background: #fff; border-color: #d2d2d7; }
|
||||
.emotion-option input { margin: 0; }
|
||||
.emotion-option:has(input:checked) { background: #fff; border-color: #007aff; }
|
||||
.emotion-face { font-size: 20px; line-height: 1.2; }
|
||||
.emotion-text { font-size: 11px; color: #86868b; }
|
||||
|
||||
.check-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 6px 12px;
|
||||
}
|
||||
|
||||
/* ====== 花销 ====== */
|
||||
.expense-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f2;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.expense-stat { display: flex; flex-direction: column; gap: 2px; }
|
||||
.expense-stat .muted { font-size: 12px; }
|
||||
.expense-stat strong { font-size: 16px; }
|
||||
.expense-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.expense-row {
|
||||
border: 1px solid #f0f0f2;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
}
|
||||
.expense-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.expense-cat {
|
||||
font-size: 12px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
background: #f0f0f2;
|
||||
color: #555;
|
||||
}
|
||||
.expense-kind { font-size: 11px; padding: 1px 6px; border-radius: 4px; }
|
||||
.expense-kind-budget { background: #fff3e0; color: #b26a00; }
|
||||
.expense-kind-actual { background: #e3f8e7; color: #1a7f37; }
|
||||
.expense-desc { font-size: 14px; color: #1d1d1f; }
|
||||
.expense-amount {
|
||||
margin-left: auto;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
.expense-sub {
|
||||
font-size: 12px;
|
||||
color: #86868b;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/*!
|
||||
* Cropper.js v1.6.1
|
||||
* https://fengyuanchen.github.io/cropperjs
|
||||
*
|
||||
* Copyright 2015-present Chen Fengyuan
|
||||
* Released under the MIT license
|
||||
*
|
||||
* Date: 2023-09-17T03:44:17.565Z
|
||||
*/.cropper-container{direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
|
||||
Vendored
+10
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
/**
|
||||
* 配置模板。
|
||||
*
|
||||
* 部署时把本文件复制为 config.php 再按实际环境填写;config.php 不入库(见 .gitignore)。
|
||||
* 所有项都支持用环境变量覆盖,方便在不同机器上跑同一份代码。
|
||||
*/
|
||||
return [
|
||||
'db' => [
|
||||
'host' => getenv('DB_HOST') ?: '127.0.0.1',
|
||||
'port' => (int)(getenv('DB_PORT') ?: 3306),
|
||||
'name' => getenv('DB_NAME') ?: 'trip',
|
||||
'user' => getenv('DB_USER') ?: 'trip',
|
||||
'pass' => getenv('DB_PASS') ?: '',
|
||||
'charset' => 'utf8mb4',
|
||||
],
|
||||
'app' => [
|
||||
'name' => '家庭出行记录',
|
||||
'timezone' => 'Asia/Shanghai',
|
||||
],
|
||||
|
||||
// 高德地图:key 用于服务端接口,security_js 用于前端 JS API 2.0 安全密钥
|
||||
'amap' => [
|
||||
'key' => '',
|
||||
'security_js' => '',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/src/Database.php';
|
||||
require __DIR__ . '/src/Auth.php';
|
||||
require __DIR__ . '/src/helpers.php';
|
||||
require __DIR__ . '/src/Comments.php';
|
||||
require __DIR__ . '/src/Members.php';
|
||||
require __DIR__ . '/src/Expenses.php';
|
||||
require __DIR__ . '/src/Access.php';
|
||||
require __DIR__ . '/src/Controllers/AuthController.php';
|
||||
require __DIR__ . '/src/Controllers/AttachmentController.php';
|
||||
require __DIR__ . '/src/Controllers/ActivityController.php';
|
||||
require __DIR__ . '/src/Controllers/NodeController.php';
|
||||
require __DIR__ . '/src/Controllers/UserController.php';
|
||||
require __DIR__ . '/src/Controllers/NodeTypeController.php';
|
||||
require __DIR__ . '/src/Controllers/TravelModeController.php';
|
||||
require __DIR__ . '/src/Controllers/VehicleController.php';
|
||||
require __DIR__ . '/src/Controllers/RoleTagController.php';
|
||||
require __DIR__ . '/src/Controllers/MemberController.php';
|
||||
require __DIR__ . '/src/Controllers/CommentController.php';
|
||||
require __DIR__ . '/src/Controllers/ExpenseController.php';
|
||||
|
||||
use App\Auth;
|
||||
use App\Controllers\AuthController;
|
||||
use App\Controllers\ActivityController;
|
||||
use App\Controllers\AttachmentController;
|
||||
use App\Controllers\NodeController;
|
||||
use App\Controllers\UserController;
|
||||
use App\Controllers\NodeTypeController;
|
||||
use App\Controllers\TravelModeController;
|
||||
use App\Controllers\VehicleController;
|
||||
use App\Controllers\RoleTagController;
|
||||
use App\Controllers\MemberController;
|
||||
use App\Controllers\CommentController;
|
||||
use App\Controllers\ExpenseController;
|
||||
|
||||
session_start();
|
||||
date_default_timezone_set('Asia/Shanghai');
|
||||
|
||||
$route = $_GET['r'] ?? 'activities/index';
|
||||
|
||||
// 白名单:无需登录即可访问
|
||||
$publicRoutes = ['auth/login', 'auth/doLogin'];
|
||||
|
||||
// 未登录时,强制跳转登录页
|
||||
if (!in_array($route, $publicRoutes, true) && !Auth::check()) {
|
||||
redirect('auth/login');
|
||||
}
|
||||
|
||||
$map = [
|
||||
// 认证
|
||||
'auth/login' => [AuthController::class, 'login'],
|
||||
'auth/doLogin' => [AuthController::class, 'doLogin'],
|
||||
'auth/logout' => [AuthController::class, 'logout'],
|
||||
|
||||
// 活动
|
||||
'activities/index' => [ActivityController::class, 'index'],
|
||||
'activities/create' => [ActivityController::class, 'create'],
|
||||
'activities/store' => [ActivityController::class, 'store'],
|
||||
'activities/edit' => [ActivityController::class, 'edit'],
|
||||
'activities/update' => [ActivityController::class, 'update'],
|
||||
'activities/destroy' => [ActivityController::class, 'destroy'],
|
||||
'activities/show' => [ActivityController::class, 'show'],
|
||||
|
||||
// 节点附件
|
||||
'attachments/node' => [AttachmentController::class, 'node'],
|
||||
'attachments/upload' => [AttachmentController::class, 'upload'],
|
||||
'attachments/destroy' => [AttachmentController::class, 'destroy'],
|
||||
'activities/uploadCover' => [ActivityController::class, 'uploadCover'],
|
||||
|
||||
// 节点
|
||||
'nodes/create' => [NodeController::class, 'create'],
|
||||
'nodes/store' => [NodeController::class, 'store'],
|
||||
'nodes/show' => [NodeController::class, 'show'],
|
||||
'nodes/edit' => [NodeController::class, 'edit'],
|
||||
'nodes/update' => [NodeController::class, 'update'],
|
||||
'nodes/destroy' => [NodeController::class, 'destroy'],
|
||||
'nodes/status' => [NodeController::class, 'status'],
|
||||
'nodes/fromPlan' => [NodeController::class, 'fromPlan'],
|
||||
|
||||
// 节点参与人
|
||||
'members/store' => [MemberController::class, 'store'],
|
||||
'members/destroy' => [MemberController::class, 'destroy'],
|
||||
'members/addAll' => [MemberController::class, 'addAll'],
|
||||
|
||||
// 评论(活动级 + 节点级)
|
||||
'comments/store' => [CommentController::class, 'store'],
|
||||
'comments/destroy' => [CommentController::class, 'destroy'],
|
||||
|
||||
// 花销与分摊
|
||||
'expenses/store' => [ExpenseController::class, 'store'],
|
||||
'expenses/destroy' => [ExpenseController::class, 'destroy'],
|
||||
|
||||
// 角色标签
|
||||
'role_tags/index' => [RoleTagController::class, 'index'],
|
||||
'role_tags/create' => [RoleTagController::class, 'create'],
|
||||
'role_tags/store' => [RoleTagController::class, 'store'],
|
||||
'role_tags/edit' => [RoleTagController::class, 'edit'],
|
||||
'role_tags/update' => [RoleTagController::class, 'update'],
|
||||
'role_tags/destroy' => [RoleTagController::class, 'destroy'],
|
||||
|
||||
// 用户
|
||||
'users/index' => [UserController::class, 'index'],
|
||||
'users/create' => [UserController::class, 'create'],
|
||||
'users/store' => [UserController::class, 'store'],
|
||||
'users/edit' => [UserController::class, 'edit'],
|
||||
'users/update' => [UserController::class, 'update'],
|
||||
'users/destroy' => [UserController::class, 'destroy'],
|
||||
'users/uploadAvatar' => [UserController::class, 'uploadAvatar'],
|
||||
|
||||
// 节点类型
|
||||
'node_types/index' => [NodeTypeController::class, 'index'],
|
||||
'node_types/create' => [NodeTypeController::class, 'create'],
|
||||
'node_types/store' => [NodeTypeController::class, 'store'],
|
||||
'node_types/edit' => [NodeTypeController::class, 'edit'],
|
||||
'node_types/update' => [NodeTypeController::class, 'update'],
|
||||
'node_types/destroy' => [NodeTypeController::class, 'destroy'],
|
||||
|
||||
// 出行方式
|
||||
'travel_modes/index' => [TravelModeController::class, 'index'],
|
||||
'travel_modes/create' => [TravelModeController::class, 'create'],
|
||||
'travel_modes/store' => [TravelModeController::class, 'store'],
|
||||
'travel_modes/edit' => [TravelModeController::class, 'edit'],
|
||||
'travel_modes/update' => [TravelModeController::class, 'update'],
|
||||
'travel_modes/destroy' => [TravelModeController::class, 'destroy'],
|
||||
|
||||
// 车辆
|
||||
'vehicles/index' => [VehicleController::class, 'index'],
|
||||
'vehicles/create' => [VehicleController::class, 'create'],
|
||||
'vehicles/store' => [VehicleController::class, 'store'],
|
||||
'vehicles/edit' => [VehicleController::class, 'edit'],
|
||||
'vehicles/update' => [VehicleController::class, 'update'],
|
||||
'vehicles/destroy' => [VehicleController::class, 'destroy'],
|
||||
];
|
||||
|
||||
if (!isset($map[$route])) {
|
||||
http_response_code(404);
|
||||
exit('404 页面不存在: ' . htmlspecialchars($route));
|
||||
}
|
||||
|
||||
[$class, $method] = $map[$route];
|
||||
(new $class())->$method();
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
/**
|
||||
* 归属校验:所有「按 id 取资源」的地方都从这里走,
|
||||
* 保证「不是自己家庭的数据一律当作不存在(404)」
|
||||
*/
|
||||
class Access
|
||||
{
|
||||
public static function familyId(): int
|
||||
{
|
||||
return (int)(Auth::familyId() ?? 0);
|
||||
}
|
||||
|
||||
/** 取当前家庭的活动,否则 404 */
|
||||
public static function activity(int $id): array
|
||||
{
|
||||
$row = self::activityOrNull($id);
|
||||
if ($row === null) {
|
||||
http_response_code(404);
|
||||
exit('活动不存在');
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
public static function activityOrNull(int $id): ?array
|
||||
{
|
||||
if ($id <= 0) return null;
|
||||
$stmt = Database::get()->prepare('SELECT * FROM activities WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
return ($row && (int)$row['family_id'] === self::familyId()) ? $row : null;
|
||||
}
|
||||
|
||||
/** 取当前家庭的行程节点(连同所属活动),否则 404 */
|
||||
public static function node(int $id): array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
http_response_code(404);
|
||||
exit('节点不存在');
|
||||
}
|
||||
$stmt = Database::get()->prepare('
|
||||
SELECT tn.*, a.family_id, a.title AS activity_title, a.id AS activity_id
|
||||
FROM trip_nodes tn
|
||||
JOIN activities a ON a.id = tn.activity_id
|
||||
WHERE tn.id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row || (int)$row['family_id'] !== self::familyId()) {
|
||||
http_response_code(404);
|
||||
exit('节点不存在');
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验「评论/附件」这类软关联目标确实属于当前家庭
|
||||
* @return array{ok: bool, error?: string}
|
||||
*/
|
||||
public static function target(string $targetType, int $targetId): array
|
||||
{
|
||||
if ($targetType === Comments::TARGET_ACTIVITY) {
|
||||
return self::activityOrNull($targetId) !== null
|
||||
? ['ok' => true]
|
||||
: ['ok' => false, 'error' => '活动不存在'];
|
||||
}
|
||||
if ($targetType === Comments::TARGET_NODE) {
|
||||
$stmt = Database::get()->prepare('
|
||||
SELECT a.family_id FROM trip_nodes tn
|
||||
JOIN activities a ON a.id = tn.activity_id WHERE tn.id = ?');
|
||||
$stmt->execute([$targetId]);
|
||||
$familyId = $stmt->fetchColumn();
|
||||
return ($familyId !== false && (int)$familyId === self::familyId())
|
||||
? ['ok' => true]
|
||||
: ['ok' => false, 'error' => '节点不存在'];
|
||||
}
|
||||
return ['ok' => false, 'error' => '不支持的目标类型'];
|
||||
}
|
||||
|
||||
/** 仅管理员可进入的页面 */
|
||||
public static function requireAdmin(): void
|
||||
{
|
||||
if (!Auth::isAdmin()) {
|
||||
http_response_code(403);
|
||||
exit('仅管理员可访问此页面');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
class Auth
|
||||
{
|
||||
private static ?array $cachedUser = null;
|
||||
|
||||
public static function check(): bool
|
||||
{
|
||||
return !empty($_SESSION['user_id']);
|
||||
}
|
||||
|
||||
public static function id(): ?int
|
||||
{
|
||||
return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
|
||||
}
|
||||
|
||||
public static function familyId(): ?int
|
||||
{
|
||||
return isset($_SESSION['family_id']) ? (int)$_SESSION['family_id'] : null;
|
||||
}
|
||||
|
||||
public static function isAdmin(): bool
|
||||
{
|
||||
return ($_SESSION['user_role'] ?? '') === 'admin';
|
||||
}
|
||||
|
||||
public static function user(): ?array
|
||||
{
|
||||
if (!self::check()) return null;
|
||||
if (self::$cachedUser !== null) return self::$cachedUser;
|
||||
|
||||
$stmt = Database::get()->prepare('SELECT id, family_id, name, role, avatar FROM users WHERE id = ?');
|
||||
$stmt->execute([self::id()]);
|
||||
$u = $stmt->fetch();
|
||||
self::$cachedUser = $u ?: null;
|
||||
return self::$cachedUser;
|
||||
}
|
||||
|
||||
public static function login(array $user): void
|
||||
{
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user_id'] = (int)$user['id'];
|
||||
$_SESSION['family_id'] = (int)$user['family_id'];
|
||||
$_SESSION['user_name'] = $user['name'];
|
||||
$_SESSION['user_role'] = $user['role'];
|
||||
}
|
||||
|
||||
public static function logout(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$p = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000,
|
||||
$p['path'], $p['domain'], $p['secure'], $p['httponly']);
|
||||
}
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过手机号 + 密码尝试登录
|
||||
* @return array|null 成功返回用户数组,失败返回 null
|
||||
*/
|
||||
public static function attempt(string $phone, string $password): ?array
|
||||
{
|
||||
$phone = trim($phone);
|
||||
if ($phone === '' || $password === '') return null;
|
||||
|
||||
$phoneHash = hash('sha256', $phone);
|
||||
$stmt = Database::get()->prepare(
|
||||
'SELECT * FROM users WHERE phone_hash = ? AND role IN ("admin","user") AND status = "active" LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$phoneHash]);
|
||||
$user = $stmt->fetch();
|
||||
if (!$user) return null;
|
||||
if (!password_verify($password, (string)($user['password_hash'] ?? ''))) return null;
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
/**
|
||||
* 评论与讨论
|
||||
*
|
||||
* 采用「两级平铺」而不是递归嵌套:parent_id 只允许指向顶层评论,
|
||||
* 写「回复的回复」时规范化回顶层。这样库里最多两层,UI 永远只缩进一次,
|
||||
* 来回多少轮都不会越缩越窄,也不必递归查询。
|
||||
*/
|
||||
class Comments
|
||||
{
|
||||
public const TARGET_ACTIVITY = 'activity';
|
||||
public const TARGET_NODE = 'trip_node';
|
||||
|
||||
public const MAX_LENGTH = 2000;
|
||||
|
||||
public static function targetTypes(): array
|
||||
{
|
||||
return [self::TARGET_ACTIVITY, self::TARGET_NODE];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某目标的评论,两级平铺
|
||||
* @return array{top: array, replies: array<int, array>, total: int}
|
||||
*/
|
||||
public static function tree(string $targetType, int $targetId): array
|
||||
{
|
||||
$stmt = Database::get()->prepare('
|
||||
SELECT c.*, u.name AS author_name, u.avatar AS author_avatar, u.role AS author_role
|
||||
FROM comments c
|
||||
LEFT JOIN users u ON u.id = c.author_id
|
||||
WHERE c.target_type = ? AND c.target_id = ?
|
||||
ORDER BY c.created_at ASC, c.id ASC');
|
||||
$stmt->execute([$targetType, $targetId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$top = [];
|
||||
$replies = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($row['parent_id'] === null) {
|
||||
$top[] = $row;
|
||||
} else {
|
||||
$replies[(int)$row['parent_id']][] = $row;
|
||||
}
|
||||
}
|
||||
// 回复挂到一个不存在的父评论上(父被删过)时,把它们提到顶层,避免评论消失
|
||||
$topIdSet = [];
|
||||
foreach ($top as $t) {
|
||||
$topIdSet[(int)$t['id']] = true;
|
||||
}
|
||||
foreach ($replies as $pid => $list) {
|
||||
if (!isset($topIdSet[(int)$pid])) {
|
||||
foreach ($list as $orphan) {
|
||||
$orphan['parent_id'] = null;
|
||||
$top[] = $orphan;
|
||||
}
|
||||
unset($replies[$pid]);
|
||||
}
|
||||
}
|
||||
usort($top, static fn($a, $b) => [$a['created_at'], $a['id']] <=> [$b['created_at'], $b['id']]);
|
||||
|
||||
return [
|
||||
'top' => $top,
|
||||
'replies' => $replies,
|
||||
'total' => count($rows),
|
||||
];
|
||||
}
|
||||
|
||||
/** 各目标下的评论数(一次查完,避免 N+1) */
|
||||
public static function countFor(string $targetType, array $targetIds): array
|
||||
{
|
||||
if (empty($targetIds)) return [];
|
||||
$ph = implode(',', array_fill(0, count($targetIds), '?'));
|
||||
$stmt = Database::get()->prepare("SELECT target_id, COUNT(*) AS cnt
|
||||
FROM comments WHERE target_type = ? AND target_id IN ($ph) GROUP BY target_id");
|
||||
$stmt->execute(array_merge([$targetType], array_values($targetIds)));
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$out[(int)$row['target_id']] = (int)$row['cnt'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发表评论
|
||||
* @return array{ok: bool, error?: string, id?: int}
|
||||
*/
|
||||
public static function create(string $targetType, int $targetId, int $authorId, string $content, ?int $parentId = null): array
|
||||
{
|
||||
if (!in_array($targetType, self::targetTypes(), true)) {
|
||||
return ['ok' => false, 'error' => '评论目标无效'];
|
||||
}
|
||||
$content = trim($content);
|
||||
if ($content === '') {
|
||||
return ['ok' => false, 'error' => '评论内容不能为空'];
|
||||
}
|
||||
if (mb_strlen($content) > self::MAX_LENGTH) {
|
||||
return ['ok' => false, 'error' => '评论不能超过 ' . self::MAX_LENGTH . ' 个字'];
|
||||
}
|
||||
|
||||
// 父评论必须属于同一个目标;若是「回复的回复」,规范化回顶层
|
||||
$normalizedParent = null;
|
||||
if ($parentId !== null && $parentId > 0) {
|
||||
$stmt = Database::get()->prepare('SELECT id, parent_id FROM comments
|
||||
WHERE id = ? AND target_type = ? AND target_id = ?');
|
||||
$stmt->execute([$parentId, $targetType, $targetId]);
|
||||
$parent = $stmt->fetch();
|
||||
if ($parent) {
|
||||
$normalizedParent = $parent['parent_id'] === null
|
||||
? (int)$parent['id']
|
||||
: (int)$parent['parent_id'];
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = Database::get()->prepare('INSERT INTO comments (author_id, target_type, target_id, parent_id, content)
|
||||
VALUES (?, ?, ?, ?, ?)');
|
||||
$stmt->execute([$authorId, $targetType, $targetId, $normalizedParent, $content]);
|
||||
|
||||
return ['ok' => true, 'id' => (int)Database::get()->lastInsertId()];
|
||||
}
|
||||
|
||||
/** 删除评论;删顶层时把它的回复一起删掉(外键是 SET NULL,会留下孤儿) */
|
||||
public static function delete(int $id, int $actorId, bool $isAdmin): bool
|
||||
{
|
||||
$db = Database::get();
|
||||
$stmt = $db->prepare('SELECT * FROM comments WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$comment = $stmt->fetch();
|
||||
if (!$comment) return false;
|
||||
if ((int)$comment['author_id'] !== $actorId && !$isAdmin) return false;
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM comments WHERE parent_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM comments WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 删除某个目标(活动/节点)下的全部评论,供删除活动、删除节点时调用 */
|
||||
public static function purgeTarget(string $targetType, int $targetId, ?\PDO $db = null): int
|
||||
{
|
||||
$db = $db ?? Database::get();
|
||||
$stmt = $db->prepare('DELETE FROM comments WHERE target_type = ? AND target_id = ?');
|
||||
$stmt->execute([$targetType, $targetId]);
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Auth;
|
||||
use App\Comments;
|
||||
use App\Database;
|
||||
use App\Expenses;
|
||||
|
||||
class ActivityController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$db = Database::get();
|
||||
$familyId = Auth::familyId();
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM activities WHERE family_id = ? ORDER BY created_at DESC');
|
||||
$stmt->execute([$familyId]);
|
||||
$activities = $stmt->fetchAll();
|
||||
|
||||
// 每个活动的节点数:一次 GROUP BY 查完,避免逐个 COUNT 的 N+1
|
||||
if (!empty($activities)) {
|
||||
$ids = array_column($activities, 'id');
|
||||
$ph = implode(',', array_fill(0, count($ids), '?'));
|
||||
$s = $db->prepare("SELECT activity_id, COUNT(*) AS cnt
|
||||
FROM trip_nodes WHERE activity_id IN ($ph) GROUP BY activity_id");
|
||||
$s->execute($ids);
|
||||
$counts = [];
|
||||
foreach ($s->fetchAll() as $row) {
|
||||
$counts[(int)$row['activity_id']] = (int)$row['cnt'];
|
||||
}
|
||||
foreach ($activities as &$a) {
|
||||
$a['node_count'] = $counts[(int)$a['id']] ?? 0;
|
||||
}
|
||||
unset($a);
|
||||
}
|
||||
|
||||
view('activities/index', [
|
||||
'activities' => $activities,
|
||||
'pageTitle' => '活动列表',
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
view('activities/form', [
|
||||
'activity' => null,
|
||||
'pageTitle' => '新建活动',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(): void
|
||||
{
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
$familyId = Auth::familyId();
|
||||
$userId = Auth::id();
|
||||
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
if ($title === '') {
|
||||
flash('error', '标题不能为空');
|
||||
redirect('activities/create');
|
||||
}
|
||||
if (mb_strlen($title) > 200) {
|
||||
flash('error', '标题不能超过 200 个字符');
|
||||
redirect('activities/create');
|
||||
}
|
||||
|
||||
$cover = $this->normalizeCoverUrl($_POST['cover_image'] ?? '');
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO activities (family_id, title, description, cover_image, created_by) VALUES (?, ?, ?, ?, ?)');
|
||||
$stmt->execute([
|
||||
$familyId,
|
||||
$title,
|
||||
trim($_POST['description'] ?? ''),
|
||||
$cover,
|
||||
$userId,
|
||||
]);
|
||||
$id = (int)$db->lastInsertId();
|
||||
|
||||
// 创建者自动成为 owner
|
||||
$stmt = $db->prepare('INSERT INTO activity_members (activity_id, user_id, permission) VALUES (?, ?, "owner")');
|
||||
$stmt->execute([$id, $userId]);
|
||||
|
||||
flash('error', '活动创建成功');
|
||||
redirect('activities/show', ['id' => $id]);
|
||||
}
|
||||
|
||||
public function edit(): void
|
||||
{
|
||||
$activity = $this->fetchOwnActivity((int)($_GET['id'] ?? 0));
|
||||
|
||||
view('activities/form', [
|
||||
'activity' => $activity,
|
||||
'pageTitle' => '编辑活动',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(): void
|
||||
{
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
|
||||
$activity = $this->fetchOwnActivity((int)($_POST['id'] ?? 0));
|
||||
$id = (int)$activity['id'];
|
||||
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
if ($title === '') {
|
||||
flash('error', '标题不能为空');
|
||||
redirect('activities/edit', ['id' => $id]);
|
||||
}
|
||||
if (mb_strlen($title) > 200) {
|
||||
flash('error', '标题不能超过 200 个字符');
|
||||
redirect('activities/edit', ['id' => $id]);
|
||||
}
|
||||
|
||||
$cover = $this->normalizeCoverUrl($_POST['cover_image'] ?? '');
|
||||
$oldCover = (string)($activity['cover_image'] ?? '');
|
||||
|
||||
$stmt = $db->prepare('UPDATE activities SET title = ?, description = ?, cover_image = ? WHERE id = ?');
|
||||
$stmt->execute([
|
||||
$title,
|
||||
trim($_POST['description'] ?? ''),
|
||||
$cover,
|
||||
$id,
|
||||
]);
|
||||
|
||||
// 封面换过或移除后,删掉不再引用的旧文件
|
||||
if ($oldCover !== '' && $oldCover !== $cover) {
|
||||
$this->deleteCoverFile($oldCover);
|
||||
}
|
||||
|
||||
flash('error', '保存成功');
|
||||
redirect('activities/show', ['id' => $id]);
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
|
||||
$activity = $this->fetchOwnActivity((int)($_POST['id'] ?? 0));
|
||||
$id = (int)$activity['id'];
|
||||
|
||||
// 附件表是软关联(target_type + target_id),没有外键级联,必须手工清
|
||||
$files = [];
|
||||
$stmt = $db->prepare('SELECT id, file_url FROM attachments
|
||||
WHERE target_type = "activity" AND target_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$files[] = $row['file_url'];
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('SELECT id FROM trip_nodes WHERE activity_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$nodeIds = array_map('intval', $stmt->fetchAll(\PDO::FETCH_COLUMN));
|
||||
if (!empty($nodeIds)) {
|
||||
$ph = implode(',', array_fill(0, count($nodeIds), '?'));
|
||||
$stmt = $db->prepare("SELECT file_url FROM attachments
|
||||
WHERE target_type = \"trip_node\" AND target_id IN ($ph)");
|
||||
$stmt->execute($nodeIds);
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$files[] = $row['file_url'];
|
||||
}
|
||||
$stmt = $db->prepare("DELETE FROM attachments
|
||||
WHERE target_type = \"trip_node\" AND target_id IN ($ph)");
|
||||
$stmt->execute($nodeIds);
|
||||
}
|
||||
$stmt = $db->prepare('DELETE FROM attachments WHERE target_type = "activity" AND target_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
// 评论同样是软关联
|
||||
if (!empty($nodeIds)) {
|
||||
$ph = implode(',', array_fill(0, count($nodeIds), '?'));
|
||||
$stmt = $db->prepare("DELETE FROM comments WHERE target_type = \"trip_node\" AND target_id IN ($ph)");
|
||||
$stmt->execute($nodeIds);
|
||||
}
|
||||
$stmt = $db->prepare('DELETE FROM comments WHERE target_type = "activity" AND target_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
// 删活动:外键会把 trip_nodes / activity_members / node_members / expenses 一起带走
|
||||
$stmt = $db->prepare('DELETE FROM activities WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, Auth::familyId()]);
|
||||
|
||||
// 数据库删干净了再删物理文件,避免删早了但事务回滚留下破损记录
|
||||
// 注意:附件文件在 /uploads/attachments/ 下,不能拿「只认 covers」的
|
||||
// deleteCoverFile() 去删 —— 那样判定不通过,文件会变成永远删不掉的孤儿
|
||||
foreach ($files as $url) {
|
||||
$this->deleteUploadFile((string)$url);
|
||||
}
|
||||
if (!empty($activity['cover_image'])) {
|
||||
$this->deleteCoverFile((string)$activity['cover_image']);
|
||||
}
|
||||
|
||||
flash('error', '活动已删除');
|
||||
redirect('activities/index');
|
||||
}
|
||||
|
||||
public function show(): void
|
||||
{
|
||||
$db = Database::get();
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
|
||||
$activity = $this->fetchOwnActivity($id, false);
|
||||
if (!$activity) {
|
||||
http_response_code(404);
|
||||
exit('活动不存在');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('SELECT tn.*, nt.name AS node_type_name, nt.icon AS node_type_icon
|
||||
FROM trip_nodes tn
|
||||
LEFT JOIN node_types nt ON nt.id = tn.node_type_id
|
||||
WHERE tn.activity_id = ? AND tn.node_category = "plan"
|
||||
ORDER BY tn.start_time ASC');
|
||||
$stmt->execute([$id]);
|
||||
$planNodes = $stmt->fetchAll();
|
||||
$this->attachCounts($db, $planNodes);
|
||||
|
||||
$stmt = $db->prepare('SELECT tn.*, nt.name AS node_type_name, nt.icon AS node_type_icon
|
||||
FROM trip_nodes tn
|
||||
LEFT JOIN node_types nt ON nt.id = tn.node_type_id
|
||||
WHERE tn.activity_id = ? AND tn.node_category = "reality"
|
||||
ORDER BY tn.start_time ASC');
|
||||
$stmt->execute([$id]);
|
||||
$realityNodes = $stmt->fetchAll();
|
||||
$this->attachCounts($db, $realityNodes);
|
||||
|
||||
// 计划节点 → 已生成执行记录的计划节点 id(用于在计划卡片上标记「已成行」)
|
||||
$stmt = $db->prepare('SELECT original_plan_node_id FROM trip_nodes
|
||||
WHERE activity_id = ? AND original_plan_node_id IS NOT NULL');
|
||||
$stmt->execute([$id]);
|
||||
$executedPlanIds = array_map('intval', $stmt->fetchAll(\PDO::FETCH_COLUMN));
|
||||
|
||||
view('activities/show', [
|
||||
'activity' => $activity,
|
||||
'planNodes' => $planNodes,
|
||||
'realityNodes' => $realityNodes,
|
||||
'executedPlanIds' => $executedPlanIds,
|
||||
'expenseSummary' => Expenses::summaryForActivity($id),
|
||||
'commentTree' => Comments::tree(Comments::TARGET_ACTIVITY, $id),
|
||||
'pageTitle' => $activity['title'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传活动封面(接收前端裁剪后的 base64 图片)
|
||||
*/
|
||||
public function uploadCover(): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if (!Auth::check()) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => '未登录'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!hash_equals($_SESSION['_csrf'] ?? '', $token)) {
|
||||
http_response_code(419);
|
||||
echo json_encode(['error' => 'CSRF 校验失败'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$body = file_get_contents('php://input');
|
||||
$data = json_decode($body, true);
|
||||
$base64 = $data['image'] ?? '';
|
||||
|
||||
if (!preg_match('/^data:image\/(png|jpeg|jpg|webp);base64,(.+)$/i', $base64, $m)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => '无效的图片格式'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ext = strtolower($m[1]);
|
||||
if ($ext === 'jpeg') $ext = 'jpg';
|
||||
$raw = base64_decode($m[2], true);
|
||||
|
||||
if ($raw === false) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => '图片解码失败'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 封面体积上限 8MB(比头像宽松)
|
||||
if (strlen($raw) > 8 * 1024 * 1024) {
|
||||
http_response_code(413);
|
||||
echo json_encode(['error' => '图片不能超过 8MB'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_buffer($finfo, $raw);
|
||||
finfo_close($finfo);
|
||||
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$dir = dirname(__DIR__, 2) . '/uploads/covers';
|
||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||
|
||||
$filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||
$savePath = $dir . '/' . $filename;
|
||||
|
||||
if (file_put_contents($savePath, $raw) === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => '保存文件失败'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 删除被替换掉的旧封面
|
||||
$this->deleteCoverFile((string)($_POST['old'] ?? ''));
|
||||
|
||||
echo json_encode([
|
||||
'url' => '/uploads/covers/' . $filename,
|
||||
'size' => strlen($raw),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前家庭下的活动;$allowMissing=true 时不存在返回 null 而不中断
|
||||
*/
|
||||
private function fetchOwnActivity(int $id, bool $allowMissing = false): ?array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
if ($allowMissing) return null;
|
||||
http_response_code(404);
|
||||
exit('活动不存在');
|
||||
}
|
||||
|
||||
$stmt = Database::get()->prepare('SELECT * FROM activities WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$activity = $stmt->fetch();
|
||||
|
||||
if (!$activity || (int)$activity['family_id'] !== Auth::familyId()) {
|
||||
if ($allowMissing) return null;
|
||||
http_response_code(404);
|
||||
exit('活动不存在');
|
||||
}
|
||||
return $activity;
|
||||
}
|
||||
|
||||
/** 只接受本站 covers 目录下的相对地址,其余一律视为未设置 */
|
||||
private function normalizeCoverUrl(string $url): ?string
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '') return null;
|
||||
if (strpos($url, '/uploads/covers/') !== 0) return null;
|
||||
if (strpos($url, '..') !== false) return null;
|
||||
return $url;
|
||||
}
|
||||
|
||||
/** 只删除本站上传目录下的文件(封面 / 附件),其余一律不碰 */
|
||||
private function deleteUploadFile(string $url): void
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '' || strpos($url, '..') !== false) return;
|
||||
if (strpos($url, '/uploads/covers/') !== 0 && strpos($url, '/uploads/attachments/') !== 0) {
|
||||
return;
|
||||
}
|
||||
$file = dirname(__DIR__, 2) . $url;
|
||||
if (is_file($file)) @unlink($file);
|
||||
}
|
||||
|
||||
private function deleteCoverFile(string $url): void
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '' || strpos($url, '/uploads/covers/') !== 0 || strpos($url, '..') !== false) {
|
||||
return;
|
||||
}
|
||||
$file = dirname(__DIR__, 2) . $url;
|
||||
if (is_file($file)) @unlink($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* 给一批节点补上「附件数 / 参与人数 / 评论数 / 花销笔数」
|
||||
* 每个指标各一条 GROUP BY 查询,避免每张卡片单独查(N+1)
|
||||
*/
|
||||
private function attachCounts(\PDO $db, array &$nodes): void
|
||||
{
|
||||
if (empty($nodes)) return;
|
||||
$ids = array_column($nodes, 'id');
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
|
||||
$counts = [
|
||||
'attachment_count' => $this->groupCount($db,
|
||||
"SELECT target_id, COUNT(*) AS cnt FROM attachments
|
||||
WHERE target_type = 'trip_node' AND target_id IN ($placeholders)
|
||||
GROUP BY target_id", $ids),
|
||||
'comment_count' => $this->groupCount($db,
|
||||
"SELECT target_id, COUNT(*) AS cnt FROM comments
|
||||
WHERE target_type = 'trip_node' AND target_id IN ($placeholders)
|
||||
GROUP BY target_id", $ids),
|
||||
'member_count' => $this->groupCount($db,
|
||||
"SELECT trip_node_id AS target_id, COUNT(*) AS cnt FROM node_members
|
||||
WHERE trip_node_id IN ($placeholders) GROUP BY trip_node_id", $ids),
|
||||
'expense_count' => $this->groupCount($db,
|
||||
"SELECT trip_node_id AS target_id, COUNT(*) AS cnt FROM expenses
|
||||
WHERE trip_node_id IN ($placeholders) GROUP BY trip_node_id", $ids),
|
||||
];
|
||||
|
||||
foreach ($nodes as &$n) {
|
||||
foreach ($counts as $key => $map) {
|
||||
$n[$key] = $map[(int)$n['id']] ?? 0;
|
||||
}
|
||||
}
|
||||
unset($n);
|
||||
}
|
||||
|
||||
/** @return array<int, int> id => 计数 */
|
||||
private function groupCount(\PDO $db, string $sql, array $params): array
|
||||
{
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute(array_values($params));
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$out[(int)$row['target_id']] = (int)$row['cnt'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Auth;
|
||||
use App\Database;
|
||||
|
||||
class AttachmentController
|
||||
{
|
||||
private const ALLOWED_MIMES = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
'image/heic' => 'heic',
|
||||
'application/pdf' => 'pdf',
|
||||
'application/msword' => 'doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||
'application/vnd.ms-excel' => 'xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||
'application/zip' => 'zip',
|
||||
'video/mp4' => 'mp4',
|
||||
'video/quicktime' => 'mov',
|
||||
];
|
||||
|
||||
private const MAX_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
public function node(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
$nodeId = (int)($_GET['id'] ?? 0);
|
||||
$node = $this->fetchNodeWithAccess($nodeId);
|
||||
|
||||
$db = Database::get();
|
||||
$stmt = $db->prepare('SELECT a.*, u.name AS uploader_name
|
||||
FROM attachments a
|
||||
LEFT JOIN users u ON u.id = a.uploader_id
|
||||
WHERE a.target_type = "trip_node" AND a.target_id = ?
|
||||
ORDER BY a.created_at DESC');
|
||||
$stmt->execute([$nodeId]);
|
||||
$attachments = $stmt->fetchAll();
|
||||
|
||||
view('nodes/attachments', [
|
||||
'node' => $node,
|
||||
'attachments' => $attachments,
|
||||
'pageTitle' => '节点附件:' . ($node['title'] ?: '无标题'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function upload(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
csrf_check();
|
||||
|
||||
$nodeId = (int)($_POST['node_id'] ?? 0);
|
||||
$this->fetchNodeWithAccess($nodeId);
|
||||
|
||||
if (empty($_FILES['files']) || !is_array($_FILES['files']['name'])) {
|
||||
flash('error', '未选择文件');
|
||||
redirect('attachments/node', ['id' => $nodeId]);
|
||||
}
|
||||
|
||||
$files = $this->restructureFiles($_FILES['files']);
|
||||
$uploaded = 0;
|
||||
$errors = [];
|
||||
|
||||
$db = Database::get();
|
||||
$uploadDir = dirname(__DIR__, 2) . '/uploads/attachments';
|
||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
$errors[] = $file['name'] . ': 上传错误';
|
||||
continue;
|
||||
}
|
||||
if ($file['size'] > self::MAX_SIZE) {
|
||||
$errors[] = $file['name'] . ': 超过 20MB';
|
||||
continue;
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if (!isset(self::ALLOWED_MIMES[$mime])) {
|
||||
$errors[] = $file['name'] . ': 不支持的类型';
|
||||
continue;
|
||||
}
|
||||
|
||||
$ext = self::ALLOWED_MIMES[$mime];
|
||||
$filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||
$dest = $uploadDir . '/' . $filename;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||
$errors[] = $file['name'] . ': 保存失败';
|
||||
continue;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO attachments
|
||||
(target_type, target_id, uploader_id, file_url, file_name)
|
||||
VALUES ("trip_node", ?, ?, ?, ?)');
|
||||
$stmt->execute([
|
||||
$nodeId,
|
||||
Auth::id(),
|
||||
'/uploads/attachments/' . $filename,
|
||||
$file['name'],
|
||||
]);
|
||||
$uploaded++;
|
||||
}
|
||||
|
||||
$msg = "成功上传 {$uploaded} 个文件";
|
||||
if (!empty($errors)) {
|
||||
$msg .= ';失败:' . implode(',', $errors);
|
||||
}
|
||||
flash('error', $msg);
|
||||
redirect('attachments/node', ['id' => $nodeId]);
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
csrf_check();
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$db = Database::get();
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM attachments WHERE id = ? AND target_type = "trip_node"');
|
||||
$stmt->execute([$id]);
|
||||
$att = $stmt->fetch();
|
||||
if (!$att) {
|
||||
flash('error', '附件不存在');
|
||||
redirect('activities/index');
|
||||
}
|
||||
|
||||
$this->fetchNodeWithAccess((int)$att['target_id']);
|
||||
|
||||
$url = $att['file_url'];
|
||||
if (strpos($url, '/uploads/attachments/') === 0) {
|
||||
$file = dirname(__DIR__, 2) . $url;
|
||||
if (is_file($file)) @unlink($file);
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM attachments WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
flash('error', '附件已删除');
|
||||
redirect('attachments/node', ['id' => (int)$att['target_id']]);
|
||||
}
|
||||
|
||||
private function restructureFiles(array $files): array
|
||||
{
|
||||
$result = [];
|
||||
$count = count($files['name']);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
if (empty($files['name'][$i])) continue;
|
||||
$result[] = [
|
||||
'name' => $files['name'][$i],
|
||||
'type' => $files['type'][$i],
|
||||
'tmp_name' => $files['tmp_name'][$i],
|
||||
'error' => $files['error'][$i],
|
||||
'size' => $files['size'][$i],
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function fetchNodeWithAccess(int $nodeId): array
|
||||
{
|
||||
$db = Database::get();
|
||||
$stmt = $db->prepare('
|
||||
SELECT tn.*, a.family_id, a.id AS activity_id, a.title AS activity_title
|
||||
FROM trip_nodes tn
|
||||
JOIN activities a ON a.id = tn.activity_id
|
||||
WHERE tn.id = ?
|
||||
');
|
||||
$stmt->execute([$nodeId]);
|
||||
$node = $stmt->fetch();
|
||||
|
||||
if (!$node) {
|
||||
http_response_code(404);
|
||||
exit('节点不存在');
|
||||
}
|
||||
if ((int)$node['family_id'] !== Auth::familyId()) {
|
||||
http_response_code(403);
|
||||
exit('无权访问此节点');
|
||||
}
|
||||
return $node;
|
||||
}
|
||||
|
||||
private function requireLogin(): void
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
redirect('auth/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Auth;
|
||||
|
||||
class AuthController
|
||||
{
|
||||
public function login(): void
|
||||
{
|
||||
if (Auth::check()) {
|
||||
redirect('activities/index');
|
||||
}
|
||||
view('auth/login', ['pageTitle' => '登录']);
|
||||
}
|
||||
|
||||
public function doLogin(): void
|
||||
{
|
||||
csrf_check();
|
||||
|
||||
$phone = trim($_POST['phone'] ?? '');
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
|
||||
$user = Auth::attempt($phone, $password);
|
||||
if (!$user) {
|
||||
flash('error', '手机号或密码错误');
|
||||
redirect('auth/login');
|
||||
}
|
||||
|
||||
Auth::login($user);
|
||||
redirect('activities/index');
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
Auth::logout();
|
||||
redirect('auth/login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Access;
|
||||
use App\Auth;
|
||||
use App\Comments;
|
||||
use App\Database;
|
||||
|
||||
/**
|
||||
* 评论:活动级和节点级共用一套(靠 target_type 区分)
|
||||
*/
|
||||
class CommentController
|
||||
{
|
||||
public function store(): void
|
||||
{
|
||||
csrf_check();
|
||||
|
||||
$targetType = (string)($_POST['target_type'] ?? '');
|
||||
$targetId = (int)($_POST['target_id'] ?? 0);
|
||||
|
||||
$check = Access::target($targetType, $targetId);
|
||||
if (!$check['ok']) {
|
||||
http_response_code(404);
|
||||
exit($check['error']);
|
||||
}
|
||||
|
||||
$parentId = (int)($_POST['parent_id'] ?? 0);
|
||||
$result = Comments::create(
|
||||
$targetType,
|
||||
$targetId,
|
||||
(int)Auth::id(),
|
||||
(string)($_POST['content'] ?? ''),
|
||||
$parentId > 0 ? $parentId : null
|
||||
);
|
||||
|
||||
flash('error', $result['ok'] ? '评论已发布' : (string)$result['error']);
|
||||
$this->backTo($targetType, $targetId);
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
csrf_check();
|
||||
|
||||
$targetType = (string)($_POST['target_type'] ?? '');
|
||||
$targetId = (int)($_POST['target_id'] ?? 0);
|
||||
|
||||
$check = Access::target($targetType, $targetId);
|
||||
if (!$check['ok']) {
|
||||
http_response_code(404);
|
||||
exit($check['error']);
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
// 评论必须真的挂在这个目标上,否则拿别人的 id 也能删
|
||||
$stmt = Database::get()->prepare('SELECT id FROM comments
|
||||
WHERE id = ? AND target_type = ? AND target_id = ?');
|
||||
$stmt->execute([$id, $targetType, $targetId]);
|
||||
if (!$stmt->fetch()) {
|
||||
http_response_code(404);
|
||||
exit('评论不存在');
|
||||
}
|
||||
|
||||
if (Comments::delete($id, (int)Auth::id(), Auth::isAdmin())) {
|
||||
flash('error', '评论已删除');
|
||||
} else {
|
||||
flash('error', '只能删除自己发表的评论');
|
||||
}
|
||||
$this->backTo($targetType, $targetId);
|
||||
}
|
||||
|
||||
/** 回到评论所属的页面并定位到评论区 */
|
||||
private function backTo(string $targetType, int $targetId): void
|
||||
{
|
||||
if ($targetType === Comments::TARGET_ACTIVITY) {
|
||||
redirect('activities/show', ['id' => $targetId], 'comments');
|
||||
}
|
||||
redirect('nodes/show', ['id' => $targetId], 'comments');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Access;
|
||||
use App\Expenses;
|
||||
use App\Database;
|
||||
|
||||
/**
|
||||
* 花销记账:一笔花销挂在执行/计划节点上,按人分摊
|
||||
*/
|
||||
class ExpenseController
|
||||
{
|
||||
public function store(): void
|
||||
{
|
||||
csrf_check();
|
||||
|
||||
$nodeId = (int)($_POST['node_id'] ?? 0);
|
||||
$node = Access::node($nodeId); // 归属校验,非本家庭一律 404
|
||||
|
||||
$splitIds = $_POST['split_user_ids'] ?? [];
|
||||
if (!is_array($splitIds)) {
|
||||
$splitIds = [$splitIds];
|
||||
}
|
||||
$splitIds = array_map('intval', $splitIds);
|
||||
|
||||
$result = Expenses::create(
|
||||
$nodeId,
|
||||
(int)($_POST['payer_user_id'] ?? 0),
|
||||
(string)($_POST['total_amount'] ?? ''),
|
||||
(string)($_POST['currency'] ?? 'CNY'),
|
||||
(string)($_POST['category'] ?? '其他'),
|
||||
(string)($_POST['description'] ?? ''),
|
||||
(string)($_POST['is_budget'] ?? '0') === '1',
|
||||
$splitIds
|
||||
);
|
||||
|
||||
if ($result['ok']) {
|
||||
$n = count($splitIds) > 0 ? count($splitIds) : 1;
|
||||
flash('error', '已记一笔,按 ' . $n . ' 人分摊');
|
||||
} else {
|
||||
flash('error', (string)$result['error']);
|
||||
}
|
||||
|
||||
redirect('nodes/show', ['id' => (int)$node['id']], 'expenses');
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
csrf_check();
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
$stmt = Database::get()->prepare('
|
||||
SELECT e.id, tn.id AS node_id
|
||||
FROM expenses e
|
||||
JOIN trip_nodes tn ON tn.id = e.trip_node_id
|
||||
JOIN activities a ON a.id = tn.activity_id
|
||||
WHERE e.id = ? AND a.family_id = ?');
|
||||
$stmt->execute([$id, Access::familyId()]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
exit('花销记录不存在');
|
||||
}
|
||||
|
||||
Expenses::delete($id);
|
||||
|
||||
flash('error', '已删除这笔花销');
|
||||
redirect('nodes/show', ['id' => (int)$row['node_id']], 'expenses');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Access;
|
||||
use App\Auth;
|
||||
use App\Members;
|
||||
|
||||
/**
|
||||
* 节点参与人:加人 / 改角色情绪 / 移出 / 一键把全家加进来
|
||||
*/
|
||||
class MemberController
|
||||
{
|
||||
public function store(): void
|
||||
{
|
||||
csrf_check();
|
||||
$nodeId = (int)($_POST['node_id'] ?? 0);
|
||||
Access::node($nodeId); // 归属校验,非本家庭一律 404
|
||||
|
||||
$roleTagIds = $_POST['role_tag_ids'] ?? [];
|
||||
if (!is_array($roleTagIds)) {
|
||||
$roleTagIds = [$roleTagIds];
|
||||
}
|
||||
|
||||
$result = Members::save(
|
||||
$nodeId,
|
||||
(int)($_POST['user_id'] ?? 0),
|
||||
array_map('intval', $roleTagIds),
|
||||
trim((string)($_POST['emotion'] ?? '')),
|
||||
(string)($_POST['check_in_time'] ?? '')
|
||||
);
|
||||
|
||||
flash('error', $result['ok'] ? '已保存参与人' : $result['error']);
|
||||
redirect('nodes/show', ['id' => $nodeId], 'members');
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
csrf_check();
|
||||
$nodeId = (int)($_POST['node_id'] ?? 0);
|
||||
Access::node($nodeId);
|
||||
|
||||
Members::remove($nodeId, (int)($_POST['user_id'] ?? 0));
|
||||
flash('error', '已移出该参与人');
|
||||
redirect('nodes/show', ['id' => $nodeId], 'members');
|
||||
}
|
||||
|
||||
/** 把家庭里的成员一次性全部加入这个节点(已存在的会被更新,不会重复) */
|
||||
public function addAll(): void
|
||||
{
|
||||
csrf_check();
|
||||
$nodeId = (int)($_POST['node_id'] ?? 0);
|
||||
$node = Access::node($nodeId);
|
||||
|
||||
$users = Members::familyUsers((int)$node['family_id']);
|
||||
$ids = array_map(static fn($u) => (int)$u['id'], $users);
|
||||
|
||||
Members::addMany($nodeId, $ids);
|
||||
|
||||
flash('error', '已把 ' . count($ids) . ' 位家庭成员加入本节点');
|
||||
redirect('nodes/show', ['id' => $nodeId], 'members');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Access;
|
||||
use App\Auth;
|
||||
use App\Comments;
|
||||
use App\Database;
|
||||
use App\Expenses;
|
||||
use App\Members;
|
||||
|
||||
class NodeController
|
||||
{
|
||||
/** 计划节点只可能有 planned / completed / cancelled */
|
||||
private const PLAN_STATUSES = ['planned', 'completed', 'cancelled'];
|
||||
/** 执行节点本身就是"已完成",额外允许 cancelled */
|
||||
private const REALITY_STATUSES = ['completed', 'cancelled', 'ongoing', 'planned'];
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$db = Database::get();
|
||||
$activityId = (int)($_GET['activity_id'] ?? 0);
|
||||
$category = $_GET['category'] ?? 'plan';
|
||||
$category = in_array($category, ['plan', 'reality'], true) ? $category : 'plan';
|
||||
|
||||
$activity = $this->fetchOwnActivity($activityId);
|
||||
|
||||
view('nodes/form', $this->formData($db, $activity, $category, null));
|
||||
}
|
||||
|
||||
public function edit(): void
|
||||
{
|
||||
$db = Database::get();
|
||||
$node = $this->fetchOwnNode((int)($_GET['id'] ?? 0));
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM activities WHERE id = ?');
|
||||
$stmt->execute([$node['activity_id']]);
|
||||
$activity = $stmt->fetch();
|
||||
|
||||
view('nodes/form', $this->formData($db, $activity, $node['node_category'], $node));
|
||||
}
|
||||
|
||||
public function store(): void
|
||||
{
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
|
||||
$activity = $this->fetchOwnActivity((int)($_POST['activity_id'] ?? 0));
|
||||
$category = $_POST['node_category'] ?? 'plan';
|
||||
if (!in_array($category, ['plan', 'reality'], true)) {
|
||||
exit('无效的节点分类');
|
||||
}
|
||||
|
||||
$input = $this->collectInput($db, $activity, $category);
|
||||
if ($input['error'] !== null) {
|
||||
flash('error', $input['error']);
|
||||
redirect('nodes/create', ['activity_id' => $activity['id'], 'category' => $category]);
|
||||
}
|
||||
|
||||
$data = $input['data'];
|
||||
$sql = 'INSERT INTO trip_nodes
|
||||
(activity_id, node_type_id, node_category, original_plan_node_id,
|
||||
start_time, end_time, duration_minutes,
|
||||
start_location, end_location,
|
||||
title, description, travel_mode_id, vehicle_id,
|
||||
line_name, carrier, trip_number, seat_info, gate, status, cancel_reason)
|
||||
VALUES
|
||||
(:activity_id, :node_type_id, :node_category, :original_plan_node_id,
|
||||
:start_time, :end_time, :duration_minutes,
|
||||
:start_location, :end_location,
|
||||
:title, :description, :travel_mode_id, :vehicle_id,
|
||||
:line_name, :carrier, :trip_number, :seat_info, :gate, :status, :cancel_reason)';
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($data);
|
||||
|
||||
flash('error', '节点已保存');
|
||||
redirect('activities/show', ['id' => (int)$activity['id']]);
|
||||
}
|
||||
|
||||
public function update(): void
|
||||
{
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
|
||||
$node = $this->fetchOwnNode((int)($_POST['id'] ?? 0));
|
||||
$category = $node['node_category'];
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM activities WHERE id = ?');
|
||||
$stmt->execute([$node['activity_id']]);
|
||||
$activity = $stmt->fetch();
|
||||
|
||||
$input = $this->collectInput($db, $activity, $category, $node);
|
||||
if ($input['error'] !== null) {
|
||||
flash('error', $input['error']);
|
||||
redirect('nodes/edit', ['id' => (int)$node['id']]);
|
||||
}
|
||||
|
||||
$data = $input['data'];
|
||||
unset($data['activity_id'], $data['node_category'], $data['original_plan_node_id']);
|
||||
$data['id'] = (int)$node['id'];
|
||||
|
||||
$sql = 'UPDATE trip_nodes SET
|
||||
node_type_id = :node_type_id,
|
||||
start_time = :start_time, end_time = :end_time, duration_minutes = :duration_minutes,
|
||||
start_location = :start_location, end_location = :end_location,
|
||||
title = :title, description = :description,
|
||||
travel_mode_id = :travel_mode_id, vehicle_id = :vehicle_id,
|
||||
line_name = :line_name, carrier = :carrier, trip_number = :trip_number,
|
||||
seat_info = :seat_info, gate = :gate, status = :status, cancel_reason = :cancel_reason
|
||||
WHERE id = :id';
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($data);
|
||||
|
||||
flash('error', '保存成功');
|
||||
redirect('activities/show', ['id' => (int)$node['activity_id']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片上的状态快捷切换(计划节点:标记完成 / 撤销完成)
|
||||
*/
|
||||
public function status(): void
|
||||
{
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
|
||||
$node = $this->fetchOwnNode((int)($_POST['id'] ?? 0));
|
||||
$status = (string)($_POST['status'] ?? '');
|
||||
|
||||
$allowed = $node['node_category'] === 'plan' ? self::PLAN_STATUSES : self::REALITY_STATUSES;
|
||||
if (!in_array($status, $allowed, true)) {
|
||||
flash('error', '无效的状态');
|
||||
redirect('activities/show', ['id' => (int)$node['activity_id']]);
|
||||
}
|
||||
|
||||
$reason = trim($_POST['cancel_reason'] ?? '');
|
||||
$stmt = $db->prepare('UPDATE trip_nodes SET status = ?, cancel_reason = ? WHERE id = ?');
|
||||
$stmt->execute([
|
||||
$status,
|
||||
$status === 'cancelled' ? ($reason !== '' ? $reason : null) : null,
|
||||
(int)$node['id'],
|
||||
]);
|
||||
|
||||
$labels = ['planned' => '已恢复为待执行', 'completed' => '已标记为完成', 'cancelled' => '已取消', 'ongoing' => '已标记为进行中'];
|
||||
flash('error', $labels[$status] ?? '状态已更新');
|
||||
redirect('activities/show', ['id' => (int)$node['activity_id']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用计划节点生成一条执行记录(复制字段,保留 original_plan_node_id 指向计划节点)
|
||||
*/
|
||||
public function fromPlan(): void
|
||||
{
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
|
||||
$plan = $this->fetchOwnNode((int)($_POST['plan_node_id'] ?? 0));
|
||||
if ($plan['node_category'] !== 'plan') {
|
||||
flash('error', '只有计划节点可以生成为执行记录');
|
||||
redirect('activities/show', ['id' => (int)$plan['activity_id']]);
|
||||
}
|
||||
|
||||
// 已经生成过就不重复生成
|
||||
$chk = $db->prepare('SELECT id FROM trip_nodes WHERE original_plan_node_id = ? LIMIT 1');
|
||||
$chk->execute([(int)$plan['id']]);
|
||||
$exists = $chk->fetchColumn();
|
||||
if ($exists) {
|
||||
flash('error', '该计划节点已经有执行记录了');
|
||||
redirect('activities/show', ['id' => (int)$plan['activity_id']]);
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO trip_nodes
|
||||
(activity_id, node_type_id, node_category, original_plan_node_id,
|
||||
start_time, end_time, duration_minutes,
|
||||
start_location, end_location,
|
||||
title, description, travel_mode_id, vehicle_id,
|
||||
line_name, carrier, trip_number, seat_info, gate, status)
|
||||
VALUES (?, ?, "reality", ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "completed")');
|
||||
$stmt->execute([
|
||||
(int)$plan['activity_id'],
|
||||
(int)$plan['node_type_id'],
|
||||
(int)$plan['id'],
|
||||
$plan['start_time'],
|
||||
$plan['end_time'],
|
||||
$plan['duration_minutes'],
|
||||
$plan['start_location'],
|
||||
$plan['end_location'],
|
||||
$plan['title'],
|
||||
$plan['description'],
|
||||
$plan['travel_mode_id'],
|
||||
$plan['vehicle_id'],
|
||||
$plan['line_name'],
|
||||
$plan['carrier'],
|
||||
$plan['trip_number'],
|
||||
$plan['seat_info'],
|
||||
$plan['gate'],
|
||||
]);
|
||||
|
||||
flash('error', '已根据计划节点生成执行记录,可继续编辑补充实际情况');
|
||||
redirect('activities/show', ['id' => (int)$plan['activity_id']]);
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
|
||||
$node = $this->fetchOwnNode((int)($_POST['id'] ?? 0));
|
||||
|
||||
// 软关联的附件先查出来,删完数据再删文件
|
||||
$stmt = $db->prepare('SELECT file_url FROM attachments
|
||||
WHERE target_type = "trip_node" AND target_id = ?');
|
||||
$stmt->execute([(int)$node['id']]);
|
||||
$files = $stmt->fetchAll(\PDO::FETCH_COLUMN);
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM attachments WHERE target_type = "trip_node" AND target_id = ?');
|
||||
$stmt->execute([(int)$node['id']]);
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM comments WHERE target_type = "trip_node" AND target_id = ?');
|
||||
$stmt->execute([(int)$node['id']]);
|
||||
|
||||
// 若有执行记录指向本计划节点,外键是 ON DELETE SET NULL,会自动置空
|
||||
$stmt = $db->prepare('DELETE FROM trip_nodes WHERE id = ?');
|
||||
$stmt->execute([(int)$node['id']]);
|
||||
|
||||
foreach ($files as $url) {
|
||||
$this->deleteAttachmentFile((string)$url);
|
||||
}
|
||||
|
||||
flash('error', '节点已删除');
|
||||
redirect('activities/show', ['id' => (int)$node['activity_id']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点详情页:把挂在这个节点上的参与人、花销、讨论集中到一页
|
||||
*/
|
||||
public function show(): void
|
||||
{
|
||||
$db = Database::get();
|
||||
$nodeId = (int)($_GET['id'] ?? 0);
|
||||
|
||||
$node = Access::node($nodeId); // 含 family_id / activity_id / activity_title
|
||||
|
||||
// 节点类型(本家庭)
|
||||
$stmt = $db->prepare('SELECT name, icon FROM node_types WHERE id = ?');
|
||||
$stmt->execute([(int)$node['node_type_id']]);
|
||||
$type = $stmt->fetch();
|
||||
if (!$type) $type = ['name' => '未分类', 'icon' => '·'];
|
||||
|
||||
// 出行方式(连同车辆信息拼成一行展示文本)
|
||||
$travelModeLabel = '';
|
||||
if (!empty($node['travel_mode_id'])) {
|
||||
$stmt = $db->prepare('
|
||||
SELECT tm.mode_type, v.plate_number, v.model_name
|
||||
FROM travel_modes tm
|
||||
LEFT JOIN vehicles v ON v.id = tm.vehicle_id
|
||||
WHERE tm.id = ?');
|
||||
$stmt->execute([(int)$node['travel_mode_id']]);
|
||||
$tm = $stmt->fetch();
|
||||
if ($tm) {
|
||||
$label = TravelModeController::MODE_LABELS[$tm['mode_type']] ?? $tm['mode_type'];
|
||||
if (!empty($tm['plate_number'])) {
|
||||
$label .= ' · ' . $tm['plate_number'];
|
||||
} elseif (!empty($tm['model_name'])) {
|
||||
$label .= ' · ' . $tm['model_name'];
|
||||
}
|
||||
// 线路名属于节点自身(trip_nodes.line_name),不是出行方式表的列
|
||||
if (!empty($node['line_name'])) {
|
||||
$label .= ' · ' . $node['line_name'];
|
||||
}
|
||||
$travelModeLabel = $label;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('SELECT COUNT(*) FROM attachments
|
||||
WHERE target_type = "trip_node" AND target_id = ?');
|
||||
$stmt->execute([$nodeId]);
|
||||
$attachmentCount = (int)$stmt->fetchColumn();
|
||||
|
||||
$familyId = (int)$node['family_id'];
|
||||
|
||||
view('nodes/show', [
|
||||
'node' => array_merge($node, [
|
||||
'node_type_name' => (string)$type['name'],
|
||||
'node_type_icon' => (string)$type['icon'],
|
||||
'travel_mode_label' => $travelModeLabel,
|
||||
]),
|
||||
'attachmentCount' => $attachmentCount,
|
||||
'members' => Members::forNode($nodeId),
|
||||
'familyUsers' => Members::familyUsers($familyId),
|
||||
'roleTags' => Members::roleTagMap($familyId),
|
||||
'emotions' => Members::EMOTIONS,
|
||||
'expenses' => Expenses::forNode($nodeId),
|
||||
'expenseSummary' => Expenses::summaryForNode($nodeId),
|
||||
'categories' => Expenses::CATEGORIES,
|
||||
'currencies' => Expenses::currencyLabels(),
|
||||
'expenseSymbols' => Expenses::SYMBOLS,
|
||||
'currentUserId' => (int)Auth::id(),
|
||||
'commentTree' => Comments::tree(Comments::TARGET_NODE, $nodeId),
|
||||
'pageTitle' => $node['title'] !== null && $node['title'] !== ''
|
||||
? (string)$node['title']
|
||||
: '节点详情',
|
||||
]);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 内部辅助
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** 组装 nodes/form 模板所需数据 */
|
||||
private function formData(\PDO $db, array $activity, string $category, ?array $node): array
|
||||
{
|
||||
$stmt = $db->prepare('SELECT * FROM node_types WHERE family_id = ? ORDER BY is_system DESC, id ASC');
|
||||
$stmt->execute([$activity['family_id']]);
|
||||
$nodeTypes = $stmt->fetchAll();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT tm.*, v.plate_number, v.model_name
|
||||
FROM travel_modes tm
|
||||
LEFT JOIN vehicles v ON v.id = tm.vehicle_id
|
||||
WHERE tm.family_id = ?
|
||||
ORDER BY FIELD(tm.mode_type, "drive_own","drive_rental","train","plane","bus","subway","taxi","bike","walk","ship","other"), tm.id ASC
|
||||
');
|
||||
$stmt->execute([$activity['family_id']]);
|
||||
$travelModes = $stmt->fetchAll();
|
||||
|
||||
// 执行节点可以关联计划节点(编辑已有执行节点时不再改关联关系)
|
||||
$planNodes = [];
|
||||
if ($category === 'reality' && $node === null) {
|
||||
$s = $db->prepare('SELECT id, title, start_time FROM trip_nodes WHERE activity_id = ? AND node_category = "plan" ORDER BY start_time');
|
||||
$s->execute([$activity['id']]);
|
||||
$planNodes = $s->fetchAll();
|
||||
}
|
||||
|
||||
$isEdit = $node !== null;
|
||||
|
||||
return [
|
||||
'modeLabels' => TravelModeController::MODE_LABELS,
|
||||
'activity' => $activity,
|
||||
'category' => $category,
|
||||
'nodeTypes' => $nodeTypes,
|
||||
'travelModes' => $travelModes,
|
||||
'planNodes' => $planNodes,
|
||||
'node' => $node,
|
||||
'isEdit' => $isEdit,
|
||||
'pageTitle' => ($isEdit ? '编辑' : '新建') . ($category === 'plan' ? '计划' : '执行') . '节点',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并整理表单输入
|
||||
* @return array{error: ?string, data: array}
|
||||
*/
|
||||
private function collectInput(\PDO $db, array $activity, string $category, ?array $node = null): array
|
||||
{
|
||||
$startTime = $this->normalizeDateTime((string)($_POST['start_time'] ?? ''));
|
||||
$endTime = $this->normalizeDateTime((string)($_POST['end_time'] ?? ''));
|
||||
if ($startTime === null || $endTime === null) {
|
||||
return ['error' => '开始和结束时间必填', 'data' => []];
|
||||
}
|
||||
if (strtotime($endTime) < strtotime($startTime)) {
|
||||
return ['error' => '结束时间不能早于开始时间', 'data' => []];
|
||||
}
|
||||
|
||||
$nodeTypeId = (int)($_POST['node_type_id'] ?? 0);
|
||||
$chk = $db->prepare('SELECT id FROM node_types WHERE id = ? AND family_id = ?');
|
||||
$chk->execute([$nodeTypeId, $activity['family_id']]);
|
||||
if (!$chk->fetch()) {
|
||||
return ['error' => '节点类型不存在', 'data' => []];
|
||||
}
|
||||
|
||||
$travelModeId = !empty($_POST['travel_mode_id']) ? (int)$_POST['travel_mode_id'] : null;
|
||||
$vehicleId = null;
|
||||
if ($travelModeId) {
|
||||
$s = $db->prepare('SELECT vehicle_id FROM travel_modes WHERE id = ? AND family_id = ?');
|
||||
$s->execute([$travelModeId, $activity['family_id']]);
|
||||
$row = $s->fetch();
|
||||
if (!$row) {
|
||||
return ['error' => '出行方式不存在', 'data' => []];
|
||||
}
|
||||
$vehicleId = $row['vehicle_id'] !== null ? (int)$row['vehicle_id'] : null;
|
||||
}
|
||||
|
||||
// 状态:计划节点默认待执行,执行节点默认已完成
|
||||
$defaultStatus = $category === 'plan' ? 'planned' : 'completed';
|
||||
$status = (string)($_POST['status'] ?? $defaultStatus);
|
||||
$allowed = $category === 'plan' ? self::PLAN_STATUSES : self::REALITY_STATUSES;
|
||||
if (!in_array($status, $allowed, true)) {
|
||||
$status = $node['status'] ?? $defaultStatus;
|
||||
}
|
||||
|
||||
$originalPlanId = null;
|
||||
if ($category === 'reality' && $node === null && !empty($_POST['original_plan_node_id'])) {
|
||||
$originalPlanId = (int)$_POST['original_plan_node_id'];
|
||||
// 关联的计划节点必须属于同一个活动,否则视为未关联
|
||||
$s = $db->prepare('SELECT id FROM trip_nodes WHERE id = ? AND activity_id = ? AND node_category = "plan"');
|
||||
$s->execute([$originalPlanId, $activity['id']]);
|
||||
if (!$s->fetch()) {
|
||||
$originalPlanId = null;
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
'activity_id' => (int)$activity['id'],
|
||||
'node_type_id' => $nodeTypeId,
|
||||
'node_category' => $category,
|
||||
'original_plan_node_id' => $originalPlanId,
|
||||
'start_time' => $startTime,
|
||||
'end_time' => $endTime,
|
||||
'duration_minutes' => $this->calcDuration($startTime, $endTime),
|
||||
'start_location' => $this->buildLocation('start'),
|
||||
'end_location' => $this->buildLocation('end'),
|
||||
'title' => trim($_POST['title'] ?? ''),
|
||||
'description' => trim($_POST['description'] ?? ''),
|
||||
'travel_mode_id' => $travelModeId,
|
||||
'vehicle_id' => $vehicleId,
|
||||
'line_name' => $this->strOrNull('line_name'),
|
||||
'carrier' => $this->strOrNull('carrier'),
|
||||
'trip_number' => $this->strOrNull('trip_number'),
|
||||
'seat_info' => $this->strOrNull('seat_info'),
|
||||
'gate' => $this->strOrNull('gate'),
|
||||
'status' => $status,
|
||||
'cancel_reason' => $status === 'cancelled' ? $this->strOrNull('cancel_reason') : null,
|
||||
];
|
||||
|
||||
return ['error' => null, 'data' => $data];
|
||||
}
|
||||
|
||||
/** datetime-local 传上来是 2026-09-12T10:30,统一成 MySQL 认的 "Y-m-d H:i:s" */
|
||||
private function normalizeDateTime(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') return null;
|
||||
$value = str_replace('T', ' ', $value);
|
||||
$ts = strtotime($value);
|
||||
if ($ts === false) return null;
|
||||
return date('Y-m-d H:i:s', $ts);
|
||||
}
|
||||
|
||||
private function fetchOwnActivity(int $id): array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
http_response_code(404);
|
||||
exit('活动不存在');
|
||||
}
|
||||
$stmt = Database::get()->prepare('SELECT * FROM activities WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$activity = $stmt->fetch();
|
||||
if (!$activity || (int)$activity['family_id'] !== Auth::familyId()) {
|
||||
http_response_code(404);
|
||||
exit('活动不存在');
|
||||
}
|
||||
return $activity;
|
||||
}
|
||||
|
||||
private function fetchOwnNode(int $id): array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
http_response_code(404);
|
||||
exit('节点不存在');
|
||||
}
|
||||
$stmt = Database::get()->prepare('
|
||||
SELECT tn.*, a.family_id
|
||||
FROM trip_nodes tn
|
||||
JOIN activities a ON a.id = tn.activity_id
|
||||
WHERE tn.id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$node = $stmt->fetch();
|
||||
if (!$node || (int)$node['family_id'] !== Auth::familyId()) {
|
||||
http_response_code(404);
|
||||
exit('节点不存在');
|
||||
}
|
||||
return $node;
|
||||
}
|
||||
|
||||
private function deleteAttachmentFile(string $url): void
|
||||
{
|
||||
if (strpos($url, '/uploads/attachments/') !== 0 || strpos($url, '..') !== false) {
|
||||
return;
|
||||
}
|
||||
$file = dirname(__DIR__, 2) . $url;
|
||||
if (is_file($file)) @unlink($file);
|
||||
}
|
||||
|
||||
private function calcDuration(string $start, string $end): int
|
||||
{
|
||||
$s = strtotime($start);
|
||||
$e = strtotime($end);
|
||||
return max(0, (int)round(($e - $s) / 60));
|
||||
}
|
||||
|
||||
private function buildLocation(string $which): ?string
|
||||
{
|
||||
$name = trim($_POST["{$which}_location_name"] ?? '');
|
||||
$address = trim($_POST["{$which}_location_address"] ?? '');
|
||||
if ($name === '' && $address === '') return null;
|
||||
|
||||
$lat = $_POST["{$which}_location_lat"] ?? null;
|
||||
$lng = $_POST["{$which}_location_lng"] ?? null;
|
||||
return json_encode([
|
||||
'name' => $name,
|
||||
'address' => $address,
|
||||
'lat' => ($lat === '' || $lat === null) ? null : $lat,
|
||||
'lng' => ($lng === '' || $lng === null) ? null : $lng,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
private function strOrNull(string $key): ?string
|
||||
{
|
||||
$v = trim($_POST[$key] ?? '');
|
||||
return $v === '' ? null : $v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Auth;
|
||||
use App\Database;
|
||||
|
||||
class NodeTypeController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$this->requireAdmin();
|
||||
|
||||
$db = Database::get();
|
||||
$familyId = Auth::familyId();
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM node_types WHERE family_id = ? ORDER BY is_system DESC, id ASC');
|
||||
$stmt->execute([$familyId]);
|
||||
$types = $stmt->fetchAll();
|
||||
|
||||
// 统计每个类型被引用的节点数
|
||||
foreach ($types as &$t) {
|
||||
$s = $db->prepare('SELECT COUNT(*) FROM trip_nodes WHERE node_type_id = ?');
|
||||
$s->execute([$t['id']]);
|
||||
$t['node_count'] = (int)$s->fetchColumn();
|
||||
}
|
||||
unset($t);
|
||||
|
||||
view('node_types/index', [
|
||||
'types' => $types,
|
||||
'pageTitle' => '节点类型管理',
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->requireAdmin();
|
||||
view('node_types/form', [
|
||||
'type' => null,
|
||||
'pageTitle' => '新增节点类型',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(): void
|
||||
{
|
||||
$this->requireAdmin();
|
||||
csrf_check();
|
||||
|
||||
$db = Database::get();
|
||||
$familyId = Auth::familyId();
|
||||
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$icon = trim($_POST['icon'] ?? '');
|
||||
|
||||
if ($name === '') {
|
||||
flash('error', '名称不能为空');
|
||||
redirect('node_types/create');
|
||||
}
|
||||
if (mb_strlen($name) > 30) {
|
||||
flash('error', '名称不能超过 30 个字符');
|
||||
redirect('node_types/create');
|
||||
}
|
||||
|
||||
// 唯一性校验
|
||||
$chk = $db->prepare('SELECT id FROM node_types WHERE family_id = ? AND name = ? LIMIT 1');
|
||||
$chk->execute([$familyId, $name]);
|
||||
if ($chk->fetch()) {
|
||||
flash('error', '该类型名称已存在');
|
||||
redirect('node_types/create');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO node_types (family_id, name, icon, is_system) VALUES (?, ?, ?, FALSE)');
|
||||
$stmt->execute([$familyId, $name, $icon ?: null]);
|
||||
|
||||
flash('error', '创建成功');
|
||||
redirect('node_types/index');
|
||||
}
|
||||
|
||||
public function edit(): void
|
||||
{
|
||||
$this->requireAdmin();
|
||||
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$db = Database::get();
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM node_types WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, Auth::familyId()]);
|
||||
$type = $stmt->fetch();
|
||||
if (!$type) {
|
||||
http_response_code(404);
|
||||
exit('节点类型不存在');
|
||||
}
|
||||
|
||||
view('node_types/form', [
|
||||
'type' => $type,
|
||||
'pageTitle' => '编辑节点类型',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(): void
|
||||
{
|
||||
$this->requireAdmin();
|
||||
csrf_check();
|
||||
|
||||
$db = Database::get();
|
||||
$familyId = Auth::familyId();
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM node_types WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, $familyId]);
|
||||
$type = $stmt->fetch();
|
||||
if (!$type) {
|
||||
http_response_code(404);
|
||||
exit('节点类型不存在');
|
||||
}
|
||||
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$icon = trim($_POST['icon'] ?? '');
|
||||
|
||||
if ($name === '') {
|
||||
flash('error', '名称不能为空');
|
||||
redirect('node_types/edit', ['id' => $id]);
|
||||
}
|
||||
if (mb_strlen($name) > 30) {
|
||||
flash('error', '名称不能超过 30 个字符');
|
||||
redirect('node_types/edit', ['id' => $id]);
|
||||
}
|
||||
|
||||
// 唯一性检查(排除自身)
|
||||
$chk = $db->prepare('SELECT id FROM node_types WHERE family_id = ? AND name = ? AND id != ? LIMIT 1');
|
||||
$chk->execute([$familyId, $name, $id]);
|
||||
if ($chk->fetch()) {
|
||||
flash('error', '该类型名称已被其他类型使用');
|
||||
redirect('node_types/edit', ['id' => $id]);
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('UPDATE node_types SET name = ?, icon = ? WHERE id = ?');
|
||||
$stmt->execute([$name, $icon ?: null, $id]);
|
||||
|
||||
flash('error', '保存成功');
|
||||
redirect('node_types/index');
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
$this->requireAdmin();
|
||||
csrf_check();
|
||||
|
||||
$db = Database::get();
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM node_types WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, Auth::familyId()]);
|
||||
$type = $stmt->fetch();
|
||||
if (!$type) {
|
||||
flash('error', '节点类型不存在');
|
||||
redirect('node_types/index');
|
||||
}
|
||||
|
||||
if ((int)$type['is_system'] === 1) {
|
||||
flash('error', '系统内置类型不能删除');
|
||||
redirect('node_types/index');
|
||||
}
|
||||
|
||||
// 检查是否被引用
|
||||
$cnt = $db->prepare('SELECT COUNT(*) FROM trip_nodes WHERE node_type_id = ?');
|
||||
$cnt->execute([$id]);
|
||||
$usedCount = (int)$cnt->fetchColumn();
|
||||
|
||||
if ($usedCount > 0) {
|
||||
flash('error', "该类型已被 {$usedCount} 个节点使用,无法删除");
|
||||
redirect('node_types/index');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM node_types WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
flash('error', '删除成功');
|
||||
redirect('node_types/index');
|
||||
}
|
||||
|
||||
private function requireAdmin(): void
|
||||
{
|
||||
if (!Auth::isAdmin()) {
|
||||
http_response_code(403);
|
||||
exit('仅管理员可访问此页面');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Access;
|
||||
use App\Auth;
|
||||
use App\Database;
|
||||
|
||||
/**
|
||||
* 角色标签库(司机 / 摄影担当 / 记账员 …)
|
||||
* 节点参与人就是从这堆标签里挑
|
||||
*/
|
||||
class RoleTagController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
Access::requireAdmin();
|
||||
|
||||
$db = Database::get();
|
||||
$familyId = Access::familyId();
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM role_tags WHERE family_id = ? ORDER BY id ASC');
|
||||
$stmt->execute([$familyId]);
|
||||
$tags = $stmt->fetchAll();
|
||||
|
||||
// 每个标签被多少个节点的参与人引用(role_tag_ids 是 JSON 数组)
|
||||
foreach ($tags as &$t) {
|
||||
$s = $db->prepare('SELECT COUNT(*) FROM node_members
|
||||
WHERE role_tag_ids IS NOT NULL AND JSON_CONTAINS(role_tag_ids, ?)');
|
||||
$s->execute([(string)(int)$t['id']]);
|
||||
$t['used_count'] = (int)$s->fetchColumn();
|
||||
}
|
||||
unset($t);
|
||||
|
||||
view('role_tags/index', [
|
||||
'tags' => $tags,
|
||||
'pageTitle' => '角色标签管理',
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
Access::requireAdmin();
|
||||
view('role_tags/form', ['tag' => null, 'pageTitle' => '新增角色标签']);
|
||||
}
|
||||
|
||||
public function store(): void
|
||||
{
|
||||
Access::requireAdmin();
|
||||
csrf_check();
|
||||
|
||||
$db = Database::get();
|
||||
$familyId = Access::familyId();
|
||||
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$icon = trim($_POST['icon'] ?? '');
|
||||
|
||||
if ($name === '') {
|
||||
flash('error', '名称不能为空');
|
||||
redirect('role_tags/create');
|
||||
}
|
||||
if (mb_strlen($name) > 30) {
|
||||
flash('error', '名称不能超过 30 个字符');
|
||||
redirect('role_tags/create');
|
||||
}
|
||||
if ($icon !== '' && mb_strlen($icon) > 4) {
|
||||
flash('error', '图标最多放一个表情');
|
||||
redirect('role_tags/create');
|
||||
}
|
||||
|
||||
$chk = $db->prepare('SELECT id FROM role_tags WHERE family_id = ? AND name = ? LIMIT 1');
|
||||
$chk->execute([$familyId, $name]);
|
||||
if ($chk->fetch()) {
|
||||
flash('error', '该角色名称已存在');
|
||||
redirect('role_tags/create');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO role_tags (family_id, name, icon) VALUES (?, ?, ?)');
|
||||
$stmt->execute([$familyId, $name, $icon ?: null]);
|
||||
|
||||
flash('error', '创建成功');
|
||||
redirect('role_tags/index');
|
||||
}
|
||||
|
||||
public function edit(): void
|
||||
{
|
||||
Access::requireAdmin();
|
||||
view('role_tags/form', [
|
||||
'tag' => $this->fetchTag((int)($_GET['id'] ?? 0)),
|
||||
'pageTitle' => '编辑角色标签',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(): void
|
||||
{
|
||||
Access::requireAdmin();
|
||||
csrf_check();
|
||||
|
||||
$db = Database::get();
|
||||
$familyId = Access::familyId();
|
||||
$tag = $this->fetchTag((int)($_POST['id'] ?? 0));
|
||||
$id = (int)$tag['id'];
|
||||
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$icon = trim($_POST['icon'] ?? '');
|
||||
|
||||
if ($name === '') {
|
||||
flash('error', '名称不能为空');
|
||||
redirect('role_tags/edit', ['id' => $id]);
|
||||
}
|
||||
if (mb_strlen($name) > 30) {
|
||||
flash('error', '名称不能超过 30 个字符');
|
||||
redirect('role_tags/edit', ['id' => $id]);
|
||||
}
|
||||
if ($icon !== '' && mb_strlen($icon) > 4) {
|
||||
flash('error', '图标最多放一个表情');
|
||||
redirect('role_tags/edit', ['id' => $id]);
|
||||
}
|
||||
|
||||
$chk = $db->prepare('SELECT id FROM role_tags WHERE family_id = ? AND name = ? AND id != ? LIMIT 1');
|
||||
$chk->execute([$familyId, $name, $id]);
|
||||
if ($chk->fetch()) {
|
||||
flash('error', '该角色名称已被其他角色使用');
|
||||
redirect('role_tags/edit', ['id' => $id]);
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('UPDATE role_tags SET name = ?, icon = ? WHERE id = ?');
|
||||
$stmt->execute([$name, $icon ?: null, $id]);
|
||||
|
||||
flash('error', '保存成功');
|
||||
redirect('role_tags/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除角色标签:被引用时不阻止,而是先把它从各节点参与人身上摘掉
|
||||
* (角色标签是轻量配置,删不掉会让人很憋屈;节点参与人本身不受影响)
|
||||
*/
|
||||
public function destroy(): void
|
||||
{
|
||||
Access::requireAdmin();
|
||||
csrf_check();
|
||||
|
||||
$db = Database::get();
|
||||
$tag = $this->fetchTag((int)($_POST['id'] ?? 0));
|
||||
$id = (int)$tag['id'];
|
||||
|
||||
$stmt = $db->prepare('SELECT id, role_tag_ids FROM node_members
|
||||
WHERE role_tag_ids IS NOT NULL AND JSON_CONTAINS(role_tag_ids, ?)');
|
||||
$stmt->execute([(string)$id]);
|
||||
$affected = $stmt->fetchAll();
|
||||
|
||||
$upd = $db->prepare('UPDATE node_members SET role_tag_ids = ? WHERE id = ?');
|
||||
foreach ($affected as $row) {
|
||||
$ids = json_decode((string)$row['role_tag_ids'], true);
|
||||
$ids = is_array($ids) ? array_filter(array_map('intval', $ids), static fn($v) => $v !== $id) : [];
|
||||
$upd->execute([$ids ? json_encode(array_values($ids)) : null, (int)$row['id']]);
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM role_tags WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, Access::familyId()]);
|
||||
|
||||
$msg = '删除成功';
|
||||
if ($affected) {
|
||||
$msg .= ';已同时从 ' . count($affected) . ' 处参与人记录中移除该角色';
|
||||
}
|
||||
flash('error', $msg);
|
||||
redirect('role_tags/index');
|
||||
}
|
||||
|
||||
private function fetchTag(int $id): array
|
||||
{
|
||||
$stmt = Database::get()->prepare('SELECT * FROM role_tags WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, Access::familyId()]);
|
||||
$tag = $stmt->fetch();
|
||||
if (!$tag) {
|
||||
http_response_code(404);
|
||||
exit('角色标签不存在');
|
||||
}
|
||||
return $tag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Auth;
|
||||
use App\Database;
|
||||
|
||||
class TravelModeController
|
||||
{
|
||||
public const MODE_LABELS = [
|
||||
'drive_own' => '自驾(自有车)',
|
||||
'drive_rental' => '自驾(租车)',
|
||||
'train' => '火车/高铁',
|
||||
'plane' => '飞机',
|
||||
'bus' => '大巴/公交',
|
||||
'subway' => '地铁',
|
||||
'taxi' => '出租车/网约车',
|
||||
'bike' => '骑行',
|
||||
'walk' => '步行',
|
||||
'ship' => '轮渡/游船',
|
||||
'other' => '其他',
|
||||
];
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
|
||||
$db = Database::get();
|
||||
$familyId = Auth::familyId();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT tm.*, v.plate_number, v.model_name
|
||||
FROM travel_modes tm
|
||||
LEFT JOIN vehicles v ON v.id = tm.vehicle_id
|
||||
WHERE tm.family_id = ?
|
||||
ORDER BY FIELD(tm.mode_type, "drive_own","drive_rental","train","plane","bus","subway","taxi","bike","walk","ship","other"), tm.id ASC
|
||||
');
|
||||
$stmt->execute([$familyId]);
|
||||
$modes = $stmt->fetchAll();
|
||||
|
||||
foreach ($modes as &$m) {
|
||||
$s = $db->prepare('SELECT COUNT(*) FROM trip_nodes WHERE travel_mode_id = ?');
|
||||
$s->execute([$m['id']]);
|
||||
$m['node_count'] = (int)$s->fetchColumn();
|
||||
}
|
||||
unset($m);
|
||||
|
||||
view('travel_modes/index', [
|
||||
'modes' => $modes,
|
||||
'modeLabels' => self::MODE_LABELS,
|
||||
'pageTitle' => '出行方式管理',
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
view('travel_modes/form', [
|
||||
'mode' => null,
|
||||
'vehicles' => $this->fetchVehicles(),
|
||||
'modeLabels' => self::MODE_LABELS,
|
||||
'pageTitle' => '新增出行方式',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
csrf_check();
|
||||
|
||||
$db = Database::get();
|
||||
$familyId = Auth::familyId();
|
||||
|
||||
$modeType = $_POST['mode_type'] ?? '';
|
||||
if (!isset(self::MODE_LABELS[$modeType])) {
|
||||
flash('error', '无效的交通类型');
|
||||
redirect('travel_modes/create');
|
||||
}
|
||||
|
||||
$vehicleId = $this->validateVehicle($modeType, $db, $familyId, 'travel_modes/create');
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO travel_modes (family_id, mode_type, vehicle_id, notes) VALUES (?, ?, ?, ?)');
|
||||
$stmt->execute([
|
||||
$familyId,
|
||||
$modeType,
|
||||
$vehicleId,
|
||||
$this->strOrNull('notes'),
|
||||
]);
|
||||
|
||||
flash('error', '创建成功');
|
||||
redirect('travel_modes/index');
|
||||
}
|
||||
|
||||
public function edit(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$db = Database::get();
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM travel_modes WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, Auth::familyId()]);
|
||||
$mode = $stmt->fetch();
|
||||
if (!$mode) {
|
||||
http_response_code(404);
|
||||
exit('出行方式不存在');
|
||||
}
|
||||
|
||||
view('travel_modes/form', [
|
||||
'mode' => $mode,
|
||||
'vehicles' => $this->fetchVehicles(),
|
||||
'modeLabels' => self::MODE_LABELS,
|
||||
'pageTitle' => '编辑出行方式',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
csrf_check();
|
||||
|
||||
$db = Database::get();
|
||||
$familyId = Auth::familyId();
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM travel_modes WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, $familyId]);
|
||||
if (!$stmt->fetch()) {
|
||||
http_response_code(404);
|
||||
exit('出行方式不存在');
|
||||
}
|
||||
|
||||
$modeType = $_POST['mode_type'] ?? '';
|
||||
if (!isset(self::MODE_LABELS[$modeType])) {
|
||||
flash('error', '无效的交通类型');
|
||||
redirect('travel_modes/edit', ['id' => $id]);
|
||||
}
|
||||
|
||||
$vehicleId = $this->validateVehicle($modeType, $db, $familyId, 'travel_modes/edit', ['id' => $id]);
|
||||
|
||||
$stmt = $db->prepare('UPDATE travel_modes SET mode_type = ?, vehicle_id = ?, notes = ? WHERE id = ?');
|
||||
$stmt->execute([
|
||||
$modeType,
|
||||
$vehicleId,
|
||||
$this->strOrNull('notes'),
|
||||
$id,
|
||||
]);
|
||||
|
||||
flash('error', '保存成功');
|
||||
redirect('travel_modes/index');
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
csrf_check();
|
||||
|
||||
$db = Database::get();
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM travel_modes WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, Auth::familyId()]);
|
||||
if (!$stmt->fetch()) {
|
||||
flash('error', '出行方式不存在');
|
||||
redirect('travel_modes/index');
|
||||
}
|
||||
|
||||
$cnt = $db->prepare('SELECT COUNT(*) FROM trip_nodes WHERE travel_mode_id = ?');
|
||||
$cnt->execute([$id]);
|
||||
$usedCount = (int)$cnt->fetchColumn();
|
||||
if ($usedCount > 0) {
|
||||
flash('error', "该出行方式已被 {$usedCount} 个节点使用,无法删除");
|
||||
redirect('travel_modes/index');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM travel_modes WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
flash('error', '删除成功');
|
||||
redirect('travel_modes/index');
|
||||
}
|
||||
|
||||
private function validateVehicle(string $modeType, \PDO $db, int $familyId, string $route, array $params = []): ?int
|
||||
{
|
||||
if (!in_array($modeType, ['drive_own', 'drive_rental'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$vehicleId = (int)($_POST['vehicle_id'] ?? 0);
|
||||
if ($vehicleId <= 0) {
|
||||
flash('error', '自驾类型必须选择车辆');
|
||||
redirect($route, $params);
|
||||
}
|
||||
$chk = $db->prepare('SELECT id, vehicle_type FROM vehicles WHERE id = ? AND family_id = ?');
|
||||
$chk->execute([$vehicleId, $familyId]);
|
||||
$veh = $chk->fetch();
|
||||
if (!$veh) {
|
||||
flash('error', '车辆不存在');
|
||||
redirect($route, $params);
|
||||
}
|
||||
if ($modeType === 'drive_own' && $veh['vehicle_type'] !== 'own') {
|
||||
flash('error', '自有车类型必须选择自有车辆');
|
||||
redirect($route, $params);
|
||||
}
|
||||
if ($modeType === 'drive_rental' && $veh['vehicle_type'] !== 'rental') {
|
||||
flash('error', '租车类型必须选择租用车辆');
|
||||
redirect($route, $params);
|
||||
}
|
||||
return $vehicleId;
|
||||
}
|
||||
|
||||
private function fetchVehicles(): array
|
||||
{
|
||||
$stmt = Database::get()->prepare('SELECT * FROM vehicles WHERE family_id = ? ORDER BY vehicle_type ASC, is_default DESC, id ASC');
|
||||
$stmt->execute([Auth::familyId()]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
private function strOrNull(string $key): ?string
|
||||
{
|
||||
$v = trim($_POST[$key] ?? '');
|
||||
return $v === '' ? null : $v;
|
||||
}
|
||||
|
||||
private function requireLogin(): void
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
redirect('auth/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Auth;
|
||||
use App\Database;
|
||||
|
||||
class UserController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$this->requireAdmin();
|
||||
|
||||
$db = Database::get();
|
||||
$familyId = Auth::familyId();
|
||||
|
||||
$stmt = $db->prepare('SELECT id, family_id, name, phone_encrypted, id_card_encrypted, role, avatar, status, created_at
|
||||
FROM users WHERE family_id = ? ORDER BY
|
||||
FIELD(role, "admin", "user", "guest"), id ASC');
|
||||
$stmt->execute([$familyId]);
|
||||
$users = $stmt->fetchAll();
|
||||
|
||||
view('users/index', [
|
||||
'users' => $users,
|
||||
'pageTitle' => '用户管理',
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->requireAdmin();
|
||||
view('users/form', [
|
||||
'user' => null,
|
||||
'isSelf' => false,
|
||||
'pageTitle' => '新增用户',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(): void
|
||||
{
|
||||
$this->requireAdmin();
|
||||
csrf_check();
|
||||
|
||||
$db = Database::get();
|
||||
$familyId = Auth::familyId();
|
||||
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$phone = trim($_POST['phone'] ?? '');
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
$role = $_POST['role'] ?? 'guest';
|
||||
$idCard = trim($_POST['id_card'] ?? '');
|
||||
$avatar = trim($_POST['avatar'] ?? '');
|
||||
$status = $_POST['status'] ?? 'active';
|
||||
|
||||
// 校验
|
||||
if ($name === '') {
|
||||
flash('error', '姓名不能为空');
|
||||
redirect('users/create');
|
||||
}
|
||||
if (!in_array($role, ['admin', 'user', 'guest'], true)) {
|
||||
$role = 'guest';
|
||||
}
|
||||
|
||||
// 普通用户 / 管理员必须有手机号和密码
|
||||
if (in_array($role, ['admin', 'user'], true)) {
|
||||
if ($phone === '' || $password === '') {
|
||||
flash('error', '管理员/普通用户必须填写手机号和密码');
|
||||
redirect('users/create');
|
||||
}
|
||||
if (!preg_match('/^1[3-9]\d{9}$/', $phone)) {
|
||||
flash('error', '手机号格式不正确');
|
||||
redirect('users/create');
|
||||
}
|
||||
// 手机号唯一性检查
|
||||
$phoneHash = hash('sha256', $phone);
|
||||
$chk = $db->prepare('SELECT id FROM users WHERE phone_hash = ? LIMIT 1');
|
||||
$chk->execute([$phoneHash]);
|
||||
if ($chk->fetch()) {
|
||||
flash('error', '该手机号已被使用');
|
||||
redirect('users/create');
|
||||
}
|
||||
}
|
||||
|
||||
$phoneEncrypted = $phone !== '' ? $this->encrypt($phone) : null;
|
||||
$phoneHash = $phone !== '' ? hash('sha256', $phone) : null;
|
||||
$idCardEncrypted = $idCard !== '' ? $this->encrypt($idCard) : null;
|
||||
$passwordHash = $password !== '' ? password_hash($password, PASSWORD_DEFAULT) : null;
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO users
|
||||
(family_id, name, phone_encrypted, phone_hash, id_card_encrypted, password_hash, role, avatar, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
$stmt->execute([
|
||||
$familyId, $name, $phoneEncrypted, $phoneHash, $idCardEncrypted,
|
||||
$passwordHash, $role, $avatar ?: null, $status,
|
||||
]);
|
||||
|
||||
flash('error', '用户创建成功'); // 复用 error flash 显示
|
||||
redirect('users/index');
|
||||
}
|
||||
|
||||
public function edit(): void
|
||||
{
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$currentId = Auth::id();
|
||||
|
||||
// 只能编辑自己,或管理员编辑任何人
|
||||
if (!Auth::isAdmin() && $id !== $currentId) {
|
||||
http_response_code(403);
|
||||
exit('无权编辑此用户');
|
||||
}
|
||||
|
||||
$db = Database::get();
|
||||
$stmt = $db->prepare('SELECT * FROM users WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, Auth::familyId()]);
|
||||
$user = $stmt->fetch();
|
||||
if (!$user) {
|
||||
http_response_code(404);
|
||||
exit('用户不存在');
|
||||
}
|
||||
|
||||
view('users/form', [
|
||||
'user' => $user,
|
||||
'isSelf' => $id === $currentId,
|
||||
'pageTitle' => '编辑用户:' . $user['name'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(): void
|
||||
{
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$currentId = Auth::id();
|
||||
$isSelf = $id === $currentId;
|
||||
|
||||
if (!Auth::isAdmin() && !$isSelf) {
|
||||
http_response_code(403);
|
||||
exit('无权编辑此用户');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM users WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, Auth::familyId()]);
|
||||
$user = $stmt->fetch();
|
||||
if (!$user) {
|
||||
http_response_code(404);
|
||||
exit('用户不存在');
|
||||
}
|
||||
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$phone = trim($_POST['phone'] ?? '');
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
$role = $_POST['role'] ?? $user['role'];
|
||||
$idCard = trim($_POST['id_card'] ?? '');
|
||||
$avatar = trim($_POST['avatar'] ?? '');
|
||||
$status = $_POST['status'] ?? $user['status'];
|
||||
|
||||
if ($name === '') {
|
||||
flash('error', '姓名不能为空');
|
||||
redirect('users/edit', ['id' => $id]);
|
||||
}
|
||||
|
||||
// 非管理员不能改自己的角色和状态
|
||||
if (!Auth::isAdmin()) {
|
||||
$role = $user['role'];
|
||||
$status = $user['status'];
|
||||
}
|
||||
if (!in_array($role, ['admin', 'user', 'guest'], true)) {
|
||||
$role = $user['role'];
|
||||
}
|
||||
if (!in_array($status, ['active', 'inactive'], true)) {
|
||||
$status = $user['status'];
|
||||
}
|
||||
|
||||
// 手机号只有在"非空且与原值不同"时才算修改
|
||||
$phoneChanged = false;
|
||||
if ($phone !== '') {
|
||||
$phoneHash = hash('sha256', $phone);
|
||||
if ($phoneHash !== ($user['phone_hash'] ?? '')) {
|
||||
if (!preg_match('/^1[3-9]\d{9}$/', $phone)) {
|
||||
flash('error', '手机号格式不正确');
|
||||
redirect('users/edit', ['id' => $id]);
|
||||
}
|
||||
$chk = $db->prepare('SELECT id FROM users WHERE phone_hash = ? AND id != ? LIMIT 1');
|
||||
$chk->execute([$phoneHash, $id]);
|
||||
if ($chk->fetch()) {
|
||||
flash('error', '该手机号已被其他用户使用');
|
||||
redirect('users/edit', ['id' => $id]);
|
||||
}
|
||||
$phoneChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 关键修正:编辑时的必填校验
|
||||
// 只有当"角色是 admin/user" 且 "当前用户既没有原手机号,也没有本次填的新手机号" 时才报错
|
||||
$hasPhone = !empty($user['phone_hash']) || $phoneChanged;
|
||||
if (in_array($role, ['admin', 'user'], true) && !$hasPhone) {
|
||||
flash('error', '管理员/普通用户必须填写手机号');
|
||||
redirect('users/edit', ['id' => $id]);
|
||||
}
|
||||
|
||||
// 组装更新字段
|
||||
$updates = [
|
||||
'name = ?',
|
||||
'role = ?',
|
||||
'avatar = ?',
|
||||
'status = ?',
|
||||
];
|
||||
$params = [$name, $role, $avatar ?: null, $status];
|
||||
|
||||
if ($phoneChanged) {
|
||||
$updates[] = 'phone_encrypted = ?';
|
||||
$updates[] = 'phone_hash = ?';
|
||||
$params[] = $this->encrypt($phone);
|
||||
$params[] = hash('sha256', $phone);
|
||||
}
|
||||
|
||||
if ($idCard !== '') {
|
||||
$updates[] = 'id_card_encrypted = ?';
|
||||
$params[] = $this->encrypt($idCard);
|
||||
}
|
||||
|
||||
if ($password !== '') {
|
||||
$updates[] = 'password_hash = ?';
|
||||
$params[] = password_hash($password, PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
$params[] = $id;
|
||||
$sql = 'UPDATE users SET ' . implode(', ', $updates) . ' WHERE id = ?';
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
// 如果编辑的是自己,同步 session
|
||||
if ($isSelf) {
|
||||
$_SESSION['user_name'] = $name;
|
||||
$_SESSION['user_role'] = $role;
|
||||
}
|
||||
|
||||
flash('error', '保存成功');
|
||||
redirect('users/index');
|
||||
}
|
||||
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
$this->requireAdmin();
|
||||
csrf_check();
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id === Auth::id()) {
|
||||
flash('error', '不能删除自己');
|
||||
redirect('users/index');
|
||||
}
|
||||
|
||||
$db = Database::get();
|
||||
$stmt = $db->prepare('SELECT role FROM users WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, Auth::familyId()]);
|
||||
$user = $stmt->fetch();
|
||||
if (!$user) {
|
||||
flash('error', '用户不存在');
|
||||
redirect('users/index');
|
||||
}
|
||||
|
||||
// 如果是最后一个管理员,不允许删除
|
||||
if ($user['role'] === 'admin') {
|
||||
$cnt = $db->prepare('SELECT COUNT(*) FROM users WHERE family_id = ? AND role = "admin"');
|
||||
$cnt->execute([Auth::familyId()]);
|
||||
if ((int)$cnt->fetchColumn() <= 1) {
|
||||
flash('error', '至少需要保留一位管理员');
|
||||
redirect('users/index');
|
||||
}
|
||||
}
|
||||
|
||||
// 这些外键没有级联删除,被引用时直接 DELETE 会抛异常导致 500,先挡下来
|
||||
$refs = [];
|
||||
$checks = [
|
||||
['SELECT COUNT(*) FROM activities WHERE created_by = ?', '创建的活动'],
|
||||
['SELECT COUNT(*) FROM attachments WHERE uploader_id = ?', '上传的附件'],
|
||||
['SELECT COUNT(*) FROM comments WHERE author_id = ?', '发表的评论'],
|
||||
['SELECT COUNT(*) FROM expenses WHERE payer_user_id = ?', '记录的花销'],
|
||||
];
|
||||
foreach ($checks as [$sql, $label]) {
|
||||
$s = $db->prepare($sql);
|
||||
$s->execute([$id]);
|
||||
$c = (int)$s->fetchColumn();
|
||||
if ($c > 0) {
|
||||
$refs[] = "{$label} {$c} 条";
|
||||
}
|
||||
}
|
||||
if (!empty($refs)) {
|
||||
flash('error', '该用户还有' . implode('、', $refs) . ',不能删除;可以改为「停用」。');
|
||||
redirect('users/index');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM users WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
flash('error', '用户已删除');
|
||||
redirect('users/index');
|
||||
}
|
||||
|
||||
private function requireAdmin(): void
|
||||
{
|
||||
if (!Auth::isAdmin()) {
|
||||
http_response_code(403);
|
||||
exit('仅管理员可访问此页面');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简易对称加密(密钥从 env 取,没有则用默认)
|
||||
* 生产环境请改用环境变量存放密钥
|
||||
*/
|
||||
private function encrypt(string $plain): string
|
||||
{
|
||||
$key = getenv('APP_ENC_KEY') ?: 'family-trip-default-key-change-me';
|
||||
$iv = random_bytes(16);
|
||||
$cipher = openssl_encrypt($plain, 'AES-256-CBC', hash('sha256', $key, true), OPENSSL_RAW_DATA, $iv);
|
||||
return base64_encode($iv . $cipher);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传头像(接收前端裁剪后的 base64 图片)
|
||||
*/
|
||||
public function uploadAvatar(): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if (!Auth::check()) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => '未登录'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!hash_equals($_SESSION['_csrf'] ?? '', $token)) {
|
||||
http_response_code(419);
|
||||
echo json_encode(['error' => 'CSRF 校验失败'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$body = file_get_contents('php://input');
|
||||
$data = json_decode($body, true);
|
||||
$base64 = $data['image'] ?? '';
|
||||
|
||||
if (!preg_match('/^data:image\/(png|jpeg|jpg|webp);base64,(.+)$/i', $base64, $m)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => '无效的图片格式'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ext = strtolower($m[1]);
|
||||
if ($ext === 'jpeg') $ext = 'jpg';
|
||||
$raw = base64_decode($m[2], true);
|
||||
|
||||
if ($raw === false) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => '图片解码失败'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (strlen($raw) > 5 * 1024 * 1024) {
|
||||
http_response_code(413);
|
||||
echo json_encode(['error' => '图片不能超过 5MB'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_buffer($finfo, $raw);
|
||||
finfo_close($finfo);
|
||||
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$dir = dirname(__DIR__, 2) . '/uploads/avatars';
|
||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||
|
||||
$filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||
$savePath = $dir . '/' . $filename;
|
||||
|
||||
if (file_put_contents($savePath, $raw) === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => '保存文件失败'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$this->deleteLocalAvatar($_POST['old'] ?? '');
|
||||
|
||||
echo json_encode([
|
||||
'url' => '/uploads/avatars/' . $filename,
|
||||
'size' => strlen($raw),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
private function deleteLocalAvatar(string $url): void
|
||||
{
|
||||
if ($url === '' || strpos($url, '/uploads/avatars/') !== 0) return;
|
||||
$file = dirname(__DIR__, 2) . $url;
|
||||
if (is_file($file)) @unlink($file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Auth;
|
||||
use App\Database;
|
||||
|
||||
class VehicleController
|
||||
{
|
||||
public const TYPE_LABELS = [
|
||||
'own' => '自有车',
|
||||
'rental' => '租车',
|
||||
];
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
|
||||
$db = Database::get();
|
||||
$familyId = Auth::familyId();
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM vehicles WHERE family_id = ?
|
||||
ORDER BY vehicle_type ASC, is_default DESC, id ASC');
|
||||
$stmt->execute([$familyId]);
|
||||
$vehicles = $stmt->fetchAll();
|
||||
|
||||
// 统计每个车辆被出行方式引用次数
|
||||
foreach ($vehicles as &$v) {
|
||||
$s = $db->prepare('SELECT COUNT(*) FROM travel_modes WHERE vehicle_id = ?');
|
||||
$s->execute([$v['id']]);
|
||||
$v['mode_count'] = (int)$s->fetchColumn();
|
||||
}
|
||||
unset($v);
|
||||
|
||||
view('vehicles/index', [
|
||||
'vehicles' => $vehicles,
|
||||
'typeLabels' => self::TYPE_LABELS,
|
||||
'pageTitle' => '车辆管理',
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
view('vehicles/form', [
|
||||
'vehicle' => null,
|
||||
'typeLabels' => self::TYPE_LABELS,
|
||||
'pageTitle' => '新增车辆',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
csrf_check();
|
||||
|
||||
$db = Database::get();
|
||||
$familyId = Auth::familyId();
|
||||
|
||||
$data = $this->collectInput();
|
||||
if ($data['error']) {
|
||||
flash('error', $data['error']);
|
||||
redirect('vehicles/create');
|
||||
}
|
||||
|
||||
// 车牌唯一性
|
||||
$chk = $db->prepare('SELECT id FROM vehicles WHERE family_id = ? AND plate_number = ? LIMIT 1');
|
||||
$chk->execute([$familyId, $data['plate_number']]);
|
||||
if ($chk->fetch()) {
|
||||
flash('error', '该车牌号已存在');
|
||||
redirect('vehicles/create');
|
||||
}
|
||||
|
||||
// 如果是默认自有车,先把其他自有车的 is_default 清掉
|
||||
if ($data['vehicle_type'] === 'own' && $data['is_default']) {
|
||||
$db->prepare('UPDATE vehicles SET is_default = 0 WHERE family_id = ? AND vehicle_type = "own"')
|
||||
->execute([$familyId]);
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO vehicles
|
||||
(family_id, plate_number, model_name, vehicle_type, rental_company, rental_order_no, is_default, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
$stmt->execute([
|
||||
$familyId,
|
||||
$data['plate_number'],
|
||||
$data['model_name'],
|
||||
$data['vehicle_type'],
|
||||
$data['rental_company'],
|
||||
$data['rental_order_no'],
|
||||
$data['is_default'] ? 1 : 0,
|
||||
$data['notes'],
|
||||
]);
|
||||
|
||||
flash('error', '创建成功');
|
||||
redirect('vehicles/index');
|
||||
}
|
||||
|
||||
public function edit(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$db = Database::get();
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM vehicles WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, Auth::familyId()]);
|
||||
$vehicle = $stmt->fetch();
|
||||
if (!$vehicle) {
|
||||
http_response_code(404);
|
||||
exit('车辆不存在');
|
||||
}
|
||||
|
||||
view('vehicles/form', [
|
||||
'vehicle' => $vehicle,
|
||||
'typeLabels' => self::TYPE_LABELS,
|
||||
'pageTitle' => '编辑车辆',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
csrf_check();
|
||||
|
||||
$db = Database::get();
|
||||
$familyId = Auth::familyId();
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM vehicles WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, $familyId]);
|
||||
$old = $stmt->fetch();
|
||||
if (!$old) {
|
||||
http_response_code(404);
|
||||
exit('车辆不存在');
|
||||
}
|
||||
|
||||
$data = $this->collectInput();
|
||||
if ($data['error']) {
|
||||
flash('error', $data['error']);
|
||||
redirect('vehicles/edit', ['id' => $id]);
|
||||
}
|
||||
|
||||
// 车牌唯一性(排除自身)
|
||||
$chk = $db->prepare('SELECT id FROM vehicles WHERE family_id = ? AND plate_number = ? AND id != ? LIMIT 1');
|
||||
$chk->execute([$familyId, $data['plate_number'], $id]);
|
||||
if ($chk->fetch()) {
|
||||
flash('error', '该车牌号已被其他车辆使用');
|
||||
redirect('vehicles/edit', ['id' => $id]);
|
||||
}
|
||||
|
||||
// 如果改为默认自有车,清掉其他默认
|
||||
if ($data['vehicle_type'] === 'own' && $data['is_default']) {
|
||||
$db->prepare('UPDATE vehicles SET is_default = 0 WHERE family_id = ? AND vehicle_type = "own" AND id != ?')
|
||||
->execute([$familyId, $id]);
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('UPDATE vehicles SET
|
||||
plate_number = ?, model_name = ?, vehicle_type = ?,
|
||||
rental_company = ?, rental_order_no = ?, is_default = ?, notes = ?
|
||||
WHERE id = ?');
|
||||
$stmt->execute([
|
||||
$data['plate_number'],
|
||||
$data['model_name'],
|
||||
$data['vehicle_type'],
|
||||
$data['rental_company'],
|
||||
$data['rental_order_no'],
|
||||
$data['is_default'] ? 1 : 0,
|
||||
$data['notes'],
|
||||
$id,
|
||||
]);
|
||||
|
||||
flash('error', '保存成功');
|
||||
redirect('vehicles/index');
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
csrf_check();
|
||||
|
||||
$db = Database::get();
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM vehicles WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, Auth::familyId()]);
|
||||
$vehicle = $stmt->fetch();
|
||||
if (!$vehicle) {
|
||||
flash('error', '车辆不存在');
|
||||
redirect('vehicles/index');
|
||||
}
|
||||
|
||||
// 检查是否被出行方式引用
|
||||
$cnt = $db->prepare('SELECT COUNT(*) FROM travel_modes WHERE vehicle_id = ?');
|
||||
$cnt->execute([$id]);
|
||||
$usedCount = (int)$cnt->fetchColumn();
|
||||
if ($usedCount > 0) {
|
||||
flash('error', "该车辆已被 {$usedCount} 条出行方式引用,无法删除");
|
||||
redirect('vehicles/index');
|
||||
}
|
||||
|
||||
// 检查是否被节点直接引用(vehicle_id 在 trip_nodes 上也可能存在)
|
||||
$cnt2 = $db->prepare('SELECT COUNT(*) FROM trip_nodes WHERE vehicle_id = ?');
|
||||
$cnt2->execute([$id]);
|
||||
$nodeCount = (int)$cnt2->fetchColumn();
|
||||
if ($nodeCount > 0) {
|
||||
flash('error', "该车辆已被 {$nodeCount} 个节点使用,无法删除");
|
||||
redirect('vehicles/index');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM vehicles WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
flash('error', '删除成功');
|
||||
redirect('vehicles/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 $_POST 收集车辆数据并做基本校验
|
||||
* @return array {error: string|null, plate_number, model_name, vehicle_type, rental_company, rental_order_no, is_default, notes}
|
||||
*/
|
||||
private function collectInput(): array
|
||||
{
|
||||
$plateNumber = strtoupper(trim($_POST['plate_number'] ?? ''));
|
||||
$modelName = trim($_POST['model_name'] ?? '');
|
||||
$vehicleType = $_POST['vehicle_type'] ?? 'own';
|
||||
$rentalCompany = trim($_POST['rental_company'] ?? '');
|
||||
$rentalOrderNo = trim($_POST['rental_order_no'] ?? '');
|
||||
$isDefault = !empty($_POST['is_default']);
|
||||
$notes = trim($_POST['notes'] ?? '');
|
||||
|
||||
if ($plateNumber === '') {
|
||||
return ['error' => '车牌号不能为空'];
|
||||
}
|
||||
if (mb_strlen($plateNumber) > 20) {
|
||||
return ['error' => '车牌号不能超过 20 个字符'];
|
||||
}
|
||||
if ($modelName === '') {
|
||||
return ['error' => '车型名称不能为空'];
|
||||
}
|
||||
if (!in_array($vehicleType, ['own', 'rental'], true)) {
|
||||
return ['error' => '无效的车辆类型'];
|
||||
}
|
||||
// 自有车不能是默认租车
|
||||
if ($vehicleType === 'rental') {
|
||||
$isDefault = false;
|
||||
}
|
||||
|
||||
return [
|
||||
'error' => null,
|
||||
'plate_number' => $plateNumber,
|
||||
'model_name' => $modelName,
|
||||
'vehicle_type' => $vehicleType,
|
||||
'rental_company' => $vehicleType === 'rental' ? ($rentalCompany ?: null) : null,
|
||||
'rental_order_no' => $vehicleType === 'rental' ? ($rentalOrderNo ?: null) : null,
|
||||
'is_default' => $isDefault,
|
||||
'notes' => $notes ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
private function requireLogin(): void
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
redirect('auth/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
|
||||
class Database
|
||||
{
|
||||
private static ?PDO $instance = null;
|
||||
|
||||
public static function get(): PDO
|
||||
{
|
||||
if (self::$instance instanceof PDO) {
|
||||
return self::$instance;
|
||||
}
|
||||
$config = require dirname(__DIR__) . '/config.php';
|
||||
$db = $config['db'];
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$db['host'], $db['port'], $db['name'], $db['charset']
|
||||
);
|
||||
self::$instance = new PDO($dsn, $db['user'], $db['pass'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
return self::$instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
/**
|
||||
* 花销与分摊
|
||||
*
|
||||
* 表结构:expenses(挂在 trip_nodes 上,is_budget 区分"预算"和"实际")
|
||||
* + expense_splits(这笔钱按人分摊,谁承担多少)
|
||||
*/
|
||||
class Expenses
|
||||
{
|
||||
public const CATEGORIES = ['餐饮', '门票', '交通', '住宿', '购物', '娱乐', '其他'];
|
||||
|
||||
/** 展示用的货币符号(库里只存三字母代码) */
|
||||
public const SYMBOLS = ['CNY' => '¥', 'JPY' => '¥', 'USD' => '$', 'EUR' => '€', 'HKD' => 'HK$'];
|
||||
|
||||
public const MAX_AMOUNT = 99999999.99;
|
||||
|
||||
public static function currencyLabels(): array
|
||||
{
|
||||
return ['CNY' => '人民币 ¥', 'JPY' => '日元 ¥', 'USD' => '美元 $', 'EUR' => '欧元 €', 'HKD' => '港币 HK$'];
|
||||
}
|
||||
|
||||
/** 某个节点下的花销,带付款人和分摊明细 */
|
||||
public static function forNode(int $nodeId): array
|
||||
{
|
||||
$db = Database::get();
|
||||
$stmt = $db->prepare('
|
||||
SELECT e.*, u.name AS payer_name, u.avatar AS payer_avatar
|
||||
FROM expenses e
|
||||
LEFT JOIN users u ON u.id = e.payer_user_id
|
||||
WHERE e.trip_node_id = ?
|
||||
ORDER BY e.is_budget ASC, e.created_at ASC, e.id ASC');
|
||||
$stmt->execute([$nodeId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
if (empty($rows)) return [];
|
||||
|
||||
$ids = array_column($rows, 'id');
|
||||
$ph = implode(',', array_fill(0, count($ids), '?'));
|
||||
$s = $db->prepare("SELECT es.*, u.name AS user_name
|
||||
FROM expense_splits es
|
||||
LEFT JOIN users u ON u.id = es.user_id
|
||||
WHERE es.expense_id IN ($ph)
|
||||
ORDER BY es.id ASC");
|
||||
$s->execute($ids);
|
||||
$splits = [];
|
||||
foreach ($s->fetchAll() as $row) {
|
||||
$splits[(int)$row['expense_id']][] = $row;
|
||||
}
|
||||
foreach ($rows as &$row) {
|
||||
$row['splits'] = $splits[(int)$row['id']] ?? [];
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** 活动下的花销合计(预算 / 实际),以及各节点的小计 */
|
||||
public static function summaryForActivity(int $activityId): array
|
||||
{
|
||||
$db = Database::get();
|
||||
$stmt = $db->prepare('
|
||||
SELECT tn.id AS node_id, e.is_budget, SUM(e.total_amount) AS amount, COUNT(*) AS cnt
|
||||
FROM expenses e
|
||||
JOIN trip_nodes tn ON tn.id = e.trip_node_id
|
||||
WHERE tn.activity_id = ?
|
||||
GROUP BY tn.id, e.is_budget');
|
||||
$stmt->execute([$activityId]);
|
||||
|
||||
$byNode = [];
|
||||
$budgetTotal = 0.0;
|
||||
$actualTotal = 0.0;
|
||||
$count = 0;
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$nodeId = (int)$row['node_id'];
|
||||
$isBudget = (int)$row['is_budget'] === 1;
|
||||
$amount = (float)$row['amount'];
|
||||
$byNode[$nodeId][$isBudget ? 'budget' : 'actual'] = $amount;
|
||||
if ($isBudget) {
|
||||
$budgetTotal += $amount;
|
||||
} else {
|
||||
$actualTotal += $amount;
|
||||
}
|
||||
$count += (int)$row['cnt'];
|
||||
}
|
||||
|
||||
return [
|
||||
'budget' => $budgetTotal,
|
||||
'actual' => $actualTotal,
|
||||
'count' => $count,
|
||||
'by_node' => $byNode,
|
||||
'diff' => $actualTotal - $budgetTotal,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增一笔花销,并把金额按人平均分摊(尾差归最后一人,保证分摊之和等于总额)
|
||||
* @return array{ok: bool, error?: string, id?: int}
|
||||
*/
|
||||
public static function create(int $nodeId, int $payerId, string $amountRaw, string $currency, string $category, string $description, bool $isBudget, array $splitUserIds): array
|
||||
{
|
||||
$db = Database::get();
|
||||
|
||||
$amountRaw = str_replace([',', ',', ' '], '', trim($amountRaw));
|
||||
if ($amountRaw === '' || !is_numeric($amountRaw)) {
|
||||
return ['ok' => false, 'error' => '金额必须是数字'];
|
||||
}
|
||||
$amount = round((float)$amountRaw, 2);
|
||||
if ($amount <= 0) {
|
||||
return ['ok' => false, 'error' => '金额必须大于 0'];
|
||||
}
|
||||
if ($amount > self::MAX_AMOUNT) {
|
||||
return ['ok' => false, 'error' => '金额过大'];
|
||||
}
|
||||
|
||||
if (!isset(self::currencyLabels()[$currency])) {
|
||||
$currency = 'CNY';
|
||||
}
|
||||
if ($category === '' || !in_array($category, self::CATEGORIES, true)) {
|
||||
$category = '其他';
|
||||
}
|
||||
$description = trim($description);
|
||||
if (mb_strlen($description) > 200) {
|
||||
return ['ok' => false, 'error' => '描述不能超过 200 个字'];
|
||||
}
|
||||
|
||||
// 付款人和分摊人都必须是这个节点所属家庭的成员
|
||||
$stmt = $db->prepare('SELECT a.family_id FROM trip_nodes tn JOIN activities a ON a.id = tn.activity_id WHERE tn.id = ?');
|
||||
$stmt->execute([$nodeId]);
|
||||
$familyId = $stmt->fetchColumn();
|
||||
if ($familyId === false) {
|
||||
return ['ok' => false, 'error' => '节点不存在'];
|
||||
}
|
||||
$familyId = (int)$familyId;
|
||||
|
||||
$memberIds = array_column(Members::familyUsers($familyId), 'id');
|
||||
$memberIds = array_map('intval', $memberIds);
|
||||
|
||||
if (!in_array($payerId, $memberIds, true)) {
|
||||
return ['ok' => false, 'error' => '付款人必须是本家庭成员'];
|
||||
}
|
||||
$splitUserIds = array_values(array_unique(array_intersect(array_map('intval', $splitUserIds), $memberIds)));
|
||||
if (empty($splitUserIds)) {
|
||||
// 没选分摊人时默认由付款人自己承担
|
||||
$splitUserIds = [$payerId];
|
||||
}
|
||||
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$stmt = $db->prepare('INSERT INTO expenses
|
||||
(trip_node_id, payer_user_id, currency, total_amount, category, description, is_budget)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
$stmt->execute([
|
||||
$nodeId, $payerId, $currency, number_format($amount, 2, '.', ''),
|
||||
$category, $description !== '' ? $description : null, $isBudget ? 1 : 0,
|
||||
]);
|
||||
$expenseId = (int)$db->lastInsertId();
|
||||
|
||||
// 以「分」为单位平均分,把除不尽的尾差加到最后一个人身上
|
||||
$totalCents = (int)round($amount * 100);
|
||||
$n = count($splitUserIds);
|
||||
$base = intdiv($totalCents, $n);
|
||||
$rest = $totalCents - $base * $n;
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO expense_splits (expense_id, user_id, share_amount) VALUES (?, ?, ?)');
|
||||
foreach ($splitUserIds as $i => $uid) {
|
||||
$cents = $base + ($i === $n - 1 ? $rest : 0);
|
||||
$stmt->execute([$expenseId, $uid, number_format($cents / 100, 2, '.', '')]);
|
||||
}
|
||||
|
||||
$db->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->rollBack();
|
||||
return ['ok' => false, 'error' => '保存失败:' . $e->getMessage()];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'id' => $expenseId];
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$db = Database::get();
|
||||
$stmt = $db->prepare('SELECT id FROM expenses WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
if (!$stmt->fetch()) return false;
|
||||
|
||||
// expense_splits 有 ON DELETE CASCADE,这里显式删一遍更直观
|
||||
$db->prepare('DELETE FROM expense_splits WHERE expense_id = ?')->execute([$id]);
|
||||
$db->prepare('DELETE FROM expenses WHERE id = ?')->execute([$id]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 某个节点的花销合计 */
|
||||
public static function summaryForNode(int $nodeId): array
|
||||
{
|
||||
$stmt = Database::get()->prepare('SELECT is_budget, SUM(total_amount) AS amount, COUNT(*) AS cnt
|
||||
FROM expenses WHERE trip_node_id = ? GROUP BY is_budget');
|
||||
$stmt->execute([$nodeId]);
|
||||
$budget = 0.0;
|
||||
$actual = 0.0;
|
||||
$count = 0;
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
if ((int)$row['is_budget'] === 1) {
|
||||
$budget = (float)$row['amount'];
|
||||
} else {
|
||||
$actual = (float)$row['amount'];
|
||||
}
|
||||
$count += (int)$row['cnt'];
|
||||
}
|
||||
return ['budget' => $budget, 'actual' => $actual, 'count' => $count, 'diff' => $actual - $budget];
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
/**
|
||||
* 节点参与人:谁参加了这个节点、当时担任什么角色、心情如何、有没有签到
|
||||
* 对应 trip_nodes 的从表 node_members(trip_node_id + user_id 唯一)
|
||||
*/
|
||||
class Members
|
||||
{
|
||||
/**
|
||||
* 情绪目录:emoji 只在这里出现,库里存的就是 emoji 字符本身(列宽 varchar(10) 只够 1 个字符)
|
||||
* 全部选用 Emoji 11.0(2018-06)及以前的码位 —— 更新的码位在旧系统 emoji 字体里没有字形,
|
||||
* 会显示成空白/方框。
|
||||
*/
|
||||
public const EMOTIONS = [
|
||||
'😄' => '开心',
|
||||
'🥰' => '温暖',
|
||||
'😍' => '喜欢',
|
||||
'🤩' => '兴奋',
|
||||
'🤗' => '满足',
|
||||
'😎' => '得意',
|
||||
'🤔' => '沉思',
|
||||
'😌' => '平静',
|
||||
'😋' => '好吃',
|
||||
'😅' => '无奈',
|
||||
'😴' => '累了',
|
||||
'😵' => '晕了',
|
||||
'😢' => '难过',
|
||||
'😭' => '大哭',
|
||||
'😱' => '惊吓',
|
||||
'😠' => '生气',
|
||||
'🤒' => '不舒服',
|
||||
];
|
||||
|
||||
public static function isValidEmotion(string $emoji): bool
|
||||
{
|
||||
return $emoji !== '' && isset(self::EMOTIONS[$emoji]);
|
||||
}
|
||||
|
||||
/** 某个节点的参与人列表(带姓名、头像、角色标签名) */
|
||||
public static function forNode(int $nodeId): array
|
||||
{
|
||||
$stmt = Database::get()->prepare('
|
||||
SELECT nm.*, u.name, u.avatar, u.role
|
||||
FROM node_members nm
|
||||
LEFT JOIN users u ON u.id = nm.user_id
|
||||
WHERE nm.trip_node_id = ?
|
||||
ORDER BY nm.id ASC');
|
||||
$stmt->execute([$nodeId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$tagNames = self::roleTagMap();
|
||||
foreach ($rows as &$r) {
|
||||
$ids = json_decode((string)($r['role_tag_ids'] ?? '[]'), true);
|
||||
$ids = is_array($ids) ? array_map('intval', $ids) : [];
|
||||
$r['role_tag_ids'] = $ids;
|
||||
$r['role_tag_names'] = array_values(array_filter(array_map(
|
||||
static fn($id) => $tagNames[$id] ?? null,
|
||||
$ids
|
||||
)));
|
||||
}
|
||||
unset($r);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** 一次查完多个节点的参与人数与情绪,供列表/卡片使用 */
|
||||
public static function summaryForNodes(array $nodeIds): array
|
||||
{
|
||||
if (empty($nodeIds)) return [];
|
||||
$ph = implode(',', array_fill(0, count($nodeIds), '?'));
|
||||
$stmt = Database::get()->prepare("SELECT trip_node_id, COUNT(*) AS cnt,
|
||||
GROUP_CONCAT(emotion ORDER BY id SEPARATOR '') AS emotions
|
||||
FROM node_members WHERE trip_node_id IN ($ph) GROUP BY trip_node_id");
|
||||
$stmt->execute(array_values($nodeIds));
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$out[(int)$row['trip_node_id']] = [
|
||||
'count' => (int)$row['cnt'],
|
||||
'emotions' => (string)($row['emotions'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增或更新一个参与人(同一节点同一人只留一条)
|
||||
* @return array{ok: bool, error?: string}
|
||||
*/
|
||||
public static function save(int $nodeId, int $userId, array $roleTagIds, string $emotion, ?string $checkIn): array
|
||||
{
|
||||
$db = Database::get();
|
||||
|
||||
if ($userId <= 0) {
|
||||
return ['ok' => false, 'error' => '请选择参与人'];
|
||||
}
|
||||
// 只能选本家庭的成员(注意:必须同时比对 users.family_id,
|
||||
// 否则「节点存在 + 用户存在」就放行,别家用户也能被塞进来)
|
||||
$stmt = $db->prepare('SELECT u.id, a.family_id
|
||||
FROM trip_nodes tn
|
||||
JOIN activities a ON a.id = tn.activity_id
|
||||
JOIN users u ON u.id = ? AND u.family_id = a.family_id
|
||||
WHERE tn.id = ?');
|
||||
$stmt->execute([$userId, $nodeId]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
return ['ok' => false, 'error' => '该成员不属于这个活动所在家庭'];
|
||||
}
|
||||
|
||||
// 角色标签也要属于同一个家庭
|
||||
$validTags = array_keys(self::roleTagMap((int)$row['family_id']));
|
||||
$roleTagIds = array_values(array_intersect(array_map('intval', $roleTagIds), $validTags));
|
||||
|
||||
if ($emotion !== '' && !self::isValidEmotion($emotion)) {
|
||||
return ['ok' => false, 'error' => '情绪表情不在可选范围内'];
|
||||
}
|
||||
|
||||
$checkIn = $checkIn !== null && trim($checkIn) !== '' ? trim($checkIn) : null;
|
||||
if ($checkIn !== null) {
|
||||
$ts = strtotime(str_replace('T', ' ', $checkIn));
|
||||
$checkIn = $ts !== false ? date('Y-m-d H:i:s', $ts) : null;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO node_members (trip_node_id, user_id, role_tag_ids, emotion, check_in_time)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE role_tag_ids = VALUES(role_tag_ids),
|
||||
emotion = VALUES(emotion), check_in_time = VALUES(check_in_time)');
|
||||
$stmt->execute([
|
||||
$nodeId,
|
||||
$userId,
|
||||
$roleTagIds ? json_encode($roleTagIds, JSON_UNESCAPED_UNICODE) : null,
|
||||
$emotion !== '' ? $emotion : null,
|
||||
$checkIn,
|
||||
]);
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public static function remove(int $nodeId, int $userId): void
|
||||
{
|
||||
$stmt = Database::get()->prepare('DELETE FROM node_members WHERE trip_node_id = ? AND user_id = ?');
|
||||
$stmt->execute([$nodeId, $userId]);
|
||||
}
|
||||
|
||||
/** 一次把一批人加进节点(用于「把活动成员一键全部加入」) */
|
||||
public static function addMany(int $nodeId, array $userIds, array $roleTagIds = []): int
|
||||
{
|
||||
$n = 0;
|
||||
foreach (array_unique(array_map('intval', $userIds)) as $uid) {
|
||||
$r = self::save($nodeId, $uid, $roleTagIds, '', null);
|
||||
if ($r['ok']) $n++;
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
|
||||
/** 家庭内的角色标签 [id => name] */
|
||||
public static function roleTagMap(?int $familyId = null): array
|
||||
{
|
||||
$db = Database::get();
|
||||
if ($familyId === null) {
|
||||
$stmt = $db->query('SELECT id, name, family_id FROM role_tags');
|
||||
} else {
|
||||
$stmt = $db->prepare('SELECT id, name, family_id FROM role_tags WHERE family_id = ?');
|
||||
$stmt->execute([$familyId]);
|
||||
}
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$out[(int)$row['id']] = $row['name'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 家庭内可作为参与人的用户(guest 也算,记录出行时常常是"待激活"的亲戚) */
|
||||
public static function familyUsers(int $familyId): array
|
||||
{
|
||||
$stmt = Database::get()->prepare('SELECT id, name, avatar, role FROM users
|
||||
WHERE family_id = ? ORDER BY FIELD(role, "admin","user","guest"), id ASC');
|
||||
$stmt->execute([$familyId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function e($value): string
|
||||
{
|
||||
return htmlspecialchars((string)($value ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 config.php(每个请求只读一次)
|
||||
* 页面里的第三方 Key 一律从这里取,避免同一个值散落在多个文件里
|
||||
*/
|
||||
function app_config(): array
|
||||
{
|
||||
static $config = null;
|
||||
if ($config === null) {
|
||||
$config = require dirname(__DIR__) . '/config.php';
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
function url(string $route, array $params = []): string
|
||||
{
|
||||
$params = array_merge(['r' => $route], $params);
|
||||
return '/index.php?' . http_build_query($params);
|
||||
}
|
||||
|
||||
function redirect(string $route, array $params = [], string $anchor = ''): void
|
||||
{
|
||||
$location = url($route, $params);
|
||||
if ($anchor !== '') {
|
||||
$location .= '#' . ltrim($anchor, '#');
|
||||
}
|
||||
header('Location: ' . $location);
|
||||
exit;
|
||||
}
|
||||
|
||||
function view(string $template, array $data = []): void
|
||||
{
|
||||
extract($data, EXTR_SKIP);
|
||||
$basePath = dirname(__DIR__);
|
||||
$content = $basePath . '/templates/' . $template . '.php';
|
||||
if (!file_exists($content)) {
|
||||
throw new \RuntimeException("模板不存在: $template");
|
||||
}
|
||||
require $basePath . '/templates/layout.php';
|
||||
}
|
||||
|
||||
function flash(string $key, ?string $value = null): ?string
|
||||
{
|
||||
if ($value !== null) {
|
||||
$_SESSION['_flash'][$key] = $value;
|
||||
return null;
|
||||
}
|
||||
$v = $_SESSION['_flash'][$key] ?? null;
|
||||
unset($_SESSION['_flash'][$key]);
|
||||
return $v;
|
||||
}
|
||||
|
||||
function csrf_token(): string
|
||||
{
|
||||
if (empty($_SESSION['_csrf'])) {
|
||||
$_SESSION['_csrf'] = bin2hex(random_bytes(16));
|
||||
}
|
||||
return $_SESSION['_csrf'];
|
||||
}
|
||||
|
||||
function csrf_check(): void
|
||||
{
|
||||
$token = $_POST['_csrf'] ?? '';
|
||||
if (!hash_equals($_SESSION['_csrf'] ?? '', $token)) {
|
||||
http_response_code(419);
|
||||
exit('CSRF token 校验失败');
|
||||
}
|
||||
}
|
||||
|
||||
function json_decode_field($json, $default = [])
|
||||
{
|
||||
if (empty($json)) return $default;
|
||||
if (is_array($json)) return $json;
|
||||
$decoded = json_decode($json, true);
|
||||
return $decoded ?? $default;
|
||||
}
|
||||
@@ -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; ?>
|
||||
@@ -0,0 +1,3 @@
|
||||
<FilesMatch "\.(php|php3|php4|php5|phtml|pl|py|jsp|asp|sh|cgi)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
Reference in New Issue
Block a user