From a79956758b92c1d7c4677ac25be827fa6d3481be Mon Sep 17 00:00:00 2001 From: laoyang Date: Tue, 22 Sep 2026 22:03:08 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=9C=AA=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=9A=84=E5=8A=9F=E8=83=BD=E5=9F=9F=EF=BC=9A?= =?UTF-8?q?=E5=8F=82=E4=B8=8E=E4=BA=BA=20/=20=E8=AF=84=E8=AE=BA=20/=20?= =?UTF-8?q?=E8=8A=B1=E9=94=80=E4=B8=8E=E5=88=86=E6=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据库里早就建好了 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。 --- .gitignore | 15 + assets/style.css | 968 +++++++++++++++++++++++ assets/vendor/cropper/cropper.min.css | 9 + assets/vendor/cropper/cropper.min.js | 10 + config.example.php | 27 + index.php | 143 ++++ src/Access.php | 90 +++ src/Auth.php | 82 ++ src/Comments.php | 151 ++++ src/Controllers/ActivityController.php | 424 ++++++++++ src/Controllers/AttachmentController.php | 197 +++++ src/Controllers/AuthController.php | 40 + src/Controllers/CommentController.php | 82 ++ src/Controllers/ExpenseController.php | 73 ++ src/Controllers/MemberController.php | 64 ++ src/Controllers/NodeController.php | 513 ++++++++++++ src/Controllers/NodeTypeController.php | 190 +++++ src/Controllers/RoleTagController.php | 182 +++++ src/Controllers/TravelModeController.php | 231 ++++++ src/Controllers/UserController.php | 404 ++++++++++ src/Controllers/VehicleController.php | 266 +++++++ src/Database.php | 30 + src/Expenses.php | 213 +++++ src/Members.php | 182 +++++ src/helpers.php | 83 ++ templates/activities/_node_card.php | 129 +++ templates/activities/form.php | 190 +++++ templates/activities/index.php | 30 + templates/activities/show.php | 66 ++ templates/auth/login.php | 22 + templates/comments/_block.php | 43 + templates/comments/_comment.php | 53 ++ templates/layout.php | 58 ++ templates/node_types/form.php | 66 ++ templates/node_types/index.php | 54 ++ templates/nodes/_expenses.php | 159 ++++ templates/nodes/_members.php | 140 ++++ templates/nodes/attachments.php | 139 ++++ templates/nodes/form.php | 369 +++++++++ templates/nodes/show.php | 128 +++ templates/role_tags/form.php | 64 ++ templates/role_tags/index.php | 45 ++ templates/travel_modes/form.php | 87 ++ templates/travel_modes/index.php | 60 ++ templates/users/form.php | 237 ++++++ templates/users/index.php | 69 ++ templates/vehicles/form.php | 112 +++ templates/vehicles/index.php | 67 ++ uploads/.htaccess | 3 + 49 files changed, 7059 insertions(+) create mode 100644 .gitignore create mode 100644 assets/style.css create mode 100644 assets/vendor/cropper/cropper.min.css create mode 100644 assets/vendor/cropper/cropper.min.js create mode 100644 config.example.php create mode 100644 index.php create mode 100644 src/Access.php create mode 100644 src/Auth.php create mode 100644 src/Comments.php create mode 100644 src/Controllers/ActivityController.php create mode 100644 src/Controllers/AttachmentController.php create mode 100644 src/Controllers/AuthController.php create mode 100644 src/Controllers/CommentController.php create mode 100644 src/Controllers/ExpenseController.php create mode 100644 src/Controllers/MemberController.php create mode 100644 src/Controllers/NodeController.php create mode 100644 src/Controllers/NodeTypeController.php create mode 100644 src/Controllers/RoleTagController.php create mode 100644 src/Controllers/TravelModeController.php create mode 100644 src/Controllers/UserController.php create mode 100644 src/Controllers/VehicleController.php create mode 100644 src/Database.php create mode 100644 src/Expenses.php create mode 100644 src/Members.php create mode 100644 src/helpers.php create mode 100644 templates/activities/_node_card.php create mode 100644 templates/activities/form.php create mode 100644 templates/activities/index.php create mode 100644 templates/activities/show.php create mode 100644 templates/auth/login.php create mode 100644 templates/comments/_block.php create mode 100644 templates/comments/_comment.php create mode 100644 templates/layout.php create mode 100644 templates/node_types/form.php create mode 100644 templates/node_types/index.php create mode 100644 templates/nodes/_expenses.php create mode 100644 templates/nodes/_members.php create mode 100644 templates/nodes/attachments.php create mode 100644 templates/nodes/form.php create mode 100644 templates/nodes/show.php create mode 100644 templates/role_tags/form.php create mode 100644 templates/role_tags/index.php create mode 100644 templates/travel_modes/form.php create mode 100644 templates/travel_modes/index.php create mode 100644 templates/users/form.php create mode 100644 templates/users/index.php create mode 100644 templates/vehicles/form.php create mode 100644 templates/vehicles/index.php create mode 100644 uploads/.htaccess diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90b6cfb --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# ── 本地配置(含数据库口令与高德 Key),不入库 ────────────────────────────── +# 部署时由服务器上的 config.php 提供;新环境请复制 config.example.php 再改。 +config.php + +# ── 用户上传内容(头像 / 封面 / 附件),不入库 ────────────────────────────── +# 只保留那行「禁止执行脚本」的保护规则,否则任何人上传的 .php 都能被直接访问。 +uploads/* +!uploads/.htaccess + +# ── 临时产物 ──────────────────────────────────────────────────────────────── +*.bak +*.bak.* +*~ +.DS_Store +Thumbs.db diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..f45f8f3 --- /dev/null +++ b/assets/style.css @@ -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; +} diff --git a/assets/vendor/cropper/cropper.min.css b/assets/vendor/cropper/cropper.min.css new file mode 100644 index 0000000..049cab5 --- /dev/null +++ b/assets/vendor/cropper/cropper.min.css @@ -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} \ No newline at end of file diff --git a/assets/vendor/cropper/cropper.min.js b/assets/vendor/cropper/cropper.min.js new file mode 100644 index 0000000..959d89f --- /dev/null +++ b/assets/vendor/cropper/cropper.min.js @@ -0,0 +1,10 @@ +/*! + * 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:19.860Z + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Cropper=e()}(this,function(){"use strict";function C(e,t){var i,a=Object.keys(e);return Object.getOwnPropertySymbols&&(i=Object.getOwnPropertySymbols(e),t&&(i=i.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),a.push.apply(a,i)),a}function S(a){for(var t=1;tt.length)&&(e=t.length);for(var i=0,a=new Array(e);it.width?3===i?o=t.height*e:h=t.width/e:3===i?h=t.width/e:o=t.height*e,{aspectRatio:e,naturalWidth:n,naturalHeight:a,width:o,height:h});this.canvasData=e,this.limited=1===i||2===i,this.limitCanvas(!0,!0),e.width=Math.min(Math.max(e.width,e.minWidth),e.maxWidth),e.height=Math.min(Math.max(e.height,e.minHeight),e.maxHeight),e.left=(t.width-e.width)/2,e.top=(t.height-e.height)/2,e.oldLeft=e.left,e.oldTop=e.top,this.initialCanvasData=g({},e)},limitCanvas:function(t,e){var i=this.options,a=this.containerData,n=this.canvasData,o=this.cropBoxData,h=i.viewMode,r=n.aspectRatio,s=this.cropped&&o;t&&(t=Number(i.minCanvasWidth)||0,i=Number(i.minCanvasHeight)||0,1=a.width&&(n.minLeft=Math.min(0,r),n.maxLeft=Math.max(0,r)),n.height>=a.height)&&(n.minTop=Math.min(0,t),n.maxTop=Math.max(0,t))):(n.minLeft=-n.width,n.minTop=-n.height,n.maxLeft=a.width,n.maxTop=a.height))},renderCanvas:function(t,e){var i,a,n,o,h=this.canvasData,r=this.imageData;e&&(e={width:r.naturalWidth*Math.abs(r.scaleX||1),height:r.naturalHeight*Math.abs(r.scaleY||1),degree:r.rotate||0},r=e.width,o=e.height,e=e.degree,i=90==(e=Math.abs(e)%180)?{width:o,height:r}:(a=e%90*Math.PI/180,i=Math.sin(a),n=r*(a=Math.cos(a))+o*i,r=r*i+o*a,90h.maxWidth||h.widthh.maxHeight||h.heighte.width?a.height=a.width/i:a.width=a.height*i),this.cropBoxData=a,this.limitCropBox(!0,!0),a.width=Math.min(Math.max(a.width,a.minWidth),a.maxWidth),a.height=Math.min(Math.max(a.height,a.minHeight),a.maxHeight),a.width=Math.max(a.minWidth,a.width*t),a.height=Math.max(a.minHeight,a.height*t),a.left=e.left+(e.width-a.width)/2,a.top=e.top+(e.height-a.height)/2,a.oldLeft=a.left,a.oldTop=a.top,this.initialCropBoxData=g({},a)},limitCropBox:function(t,e){var i,a,n=this.options,o=this.containerData,h=this.canvasData,r=this.cropBoxData,s=this.limited,c=n.aspectRatio;t&&(t=Number(n.minCropBoxWidth)||0,n=Number(n.minCropBoxHeight)||0,i=s?Math.min(o.width,h.width,h.width+h.left,o.width-h.left):o.width,a=s?Math.min(o.height,h.height,h.height+h.top,o.height-h.top):o.height,t=Math.min(t,o.width),n=Math.min(n,o.height),c&&(t&&n?ti.maxWidth||i.widthi.maxHeight||i.height=e.width&&i.height>=e.height?q:I),f(this.cropBox,g({width:i.width,height:i.height},x({translateX:i.left,translateY:i.top}))),this.cropped&&this.limited&&this.limitCanvas(!0,!0),this.disabled||this.output()},output:function(){this.preview(),y(this.element,tt,this.getData())}},i={initPreview:function(){var t=this.element,i=this.crossOrigin,e=this.options.preview,a=i?this.crossOriginUrl:this.url,n=t.alt||"The image to preview",o=document.createElement("img");i&&(o.crossOrigin=i),o.src=a,o.alt=n,this.viewBox.appendChild(o),this.viewBoxImage=o,e&&("string"==typeof(o=e)?o=t.ownerDocument.querySelectorAll(e):e.querySelector&&(o=[e]),z(this.previews=o,function(t){var e=document.createElement("img");w(t,m,{width:t.offsetWidth,height:t.offsetHeight,html:t.innerHTML}),i&&(e.crossOrigin=i),e.src=a,e.alt=n,e.style.cssText='display:block;width:100%;height:auto;min-width:0!important;min-height:0!important;max-width:none!important;max-height:none!important;image-orientation:0deg!important;"',t.innerHTML="",t.appendChild(e)}))},resetPreview:function(){z(this.previews,function(e){var i=Bt(e,m),i=(f(e,{width:i.width,height:i.height}),e.innerHTML=i.html,e),e=m;if(o(i[e]))try{delete i[e]}catch(t){i[e]=void 0}else if(i.dataset)try{delete i.dataset[e]}catch(t){i.dataset[e]=void 0}else i.removeAttribute("data-".concat(Dt(e)))})},preview:function(){var h=this.imageData,t=this.canvasData,e=this.cropBoxData,r=e.width,s=e.height,c=h.width,d=h.height,l=e.left-t.left-h.left,p=e.top-t.top-h.top;this.cropped&&!this.disabled&&(f(this.viewBoxImage,g({width:c,height:d},x(g({translateX:-l,translateY:-p},h)))),z(this.previews,function(t){var e=Bt(t,m),i=e.width,e=e.height,a=i,n=e,o=1;r&&(n=s*(o=i/r)),s&&eMath.abs(a-1)?i:a)&&(t.restore&&(o=this.getCanvasData(),h=this.getCropBoxData()),this.render(),t.restore)&&(this.setCanvasData(z(o,function(t,e){o[e]=t*n})),this.setCropBoxData(z(h,function(t,e){h[e]=t*n}))))},dblclick:function(){var t,e;this.disabled||this.options.dragMode===_||this.setDragMode((t=this.dragBox,e=Q,(t.classList?t.classList.contains(e):-1y&&(D.x=y-f);break;case k:p+D.xx&&(D.y=x-v)}}var i,a,o,n=this.options,h=this.canvasData,r=this.containerData,s=this.cropBoxData,c=this.pointers,d=this.action,l=n.aspectRatio,p=s.left,m=s.top,u=s.width,g=s.height,f=p+u,v=m+g,w=0,b=0,y=r.width,x=r.height,M=!0,C=(!l&&t.shiftKey&&(l=u&&g?u/g:1),this.limited&&(w=s.minLeft,b=s.minTop,y=w+Math.min(r.width,h.width,h.left+h.width),x=b+Math.min(r.height,h.height,h.top+h.height)),c[Object.keys(c)[0]]),D={x:C.endX-C.startX,y:C.endY-C.startY};switch(d){case I:p+=D.x,m+=D.y;break;case B:0<=D.x&&(y<=f||l&&(m<=b||x<=v))?M=!1:(e(B),(u+=D.x)<0&&(d=k,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case T:D.y<=0&&(m<=b||l&&(p<=w||y<=f))?M=!1:(e(T),g-=D.y,m+=D.y,g<0&&(d=O,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case k:D.x<=0&&(p<=w||l&&(m<=b||x<=v))?M=!1:(e(k),u-=D.x,p+=D.x,u<0&&(d=B,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case O:0<=D.y&&(x<=v||l&&(p<=w||y<=f))?M=!1:(e(O),(g+=D.y)<0&&(d=T,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case E:if(l){if(D.y<=0&&(m<=b||y<=f)){M=!1;break}e(T),g-=D.y,m+=D.y,u=g*l}else e(T),e(B),!(0<=D.x)||fMath.abs(o)&&(o=i)})}),o),t),M=!1;break;case U:D.x&&D.y?(i=Wt(this.cropper),p=C.startX-i.left,m=C.startY-i.top,u=s.minWidth,g=s.minHeight,0 or element.");this.element=t,this.options=g({},ut,u(e)&&e),this.cropped=!1,this.disabled=!1,this.pointers={},this.ready=!1,this.reloading=!1,this.replaced=!1,this.sized=!1,this.sizing=!1,this.init()}var t,e,i;return t=n,i=[{key:"noConflict",value:function(){return window.Cropper=Pt,n}},{key:"setDefaults",value:function(t){g(ut,u(t)&&t)}}],(e=[{key:"init",value:function(){var t,e=this.element,i=e.tagName.toLowerCase();if(!e[c]){if(e[c]=this,"img"===i){if(this.isImg=!0,t=e.getAttribute("src")||"",!(this.originalUrl=t))return;t=e.src}else"canvas"===i&&window.HTMLCanvasElement&&(t=e.toDataURL());this.load(t)}}},{key:"load",value:function(t){var e,i,a,n,o,h,r=this;t&&(this.url=t,this.imageData={},e=this.element,(i=this.options).rotatable||i.scalable||(i.checkOrientation=!1),i.checkOrientation&&window.ArrayBuffer?lt.test(t)?pt.test(t)?this.read((h=(h=t).replace(Xt,""),a=atob(h),h=new ArrayBuffer(a.length),z(n=new Uint8Array(h),function(t,e){n[e]=a.charCodeAt(e)}),h)):this.clone():(o=new XMLHttpRequest,h=this.clone.bind(this),this.reloading=!0,(this.xhr=o).onabort=h,o.onerror=h,o.ontimeout=h,o.onprogress=function(){o.getResponseHeader("content-type")!==ct&&o.abort()},o.onload=function(){r.read(o.response)},o.onloadend=function(){r.reloading=!1,r.xhr=null},i.checkCrossOrigin&&Lt(t)&&e.crossOrigin&&(t=zt(t)),o.open("GET",t,!0),o.responseType="arraybuffer",o.withCredentials="use-credentials"===e.crossOrigin,o.send()):this.clone())}},{key:"read",value:function(t){var e=this.options,i=this.imageData,a=Rt(t),n=0,o=1,h=1;1
',o=(n=n.querySelector(".".concat(c,"-container"))).querySelector(".".concat(c,"-canvas")),h=n.querySelector(".".concat(c,"-drag-box")),s=(r=n.querySelector(".".concat(c,"-crop-box"))).querySelector(".".concat(c,"-face")),this.container=a,this.cropper=n,this.canvas=o,this.dragBox=h,this.cropBox=r,this.viewBox=n.querySelector(".".concat(c,"-view-box")),this.face=s,o.appendChild(i),v(t,L),a.insertBefore(n,t.nextSibling),X(i,Z),this.initPreview(),this.bind(),e.initialAspectRatio=Math.max(0,e.initialAspectRatio)||NaN,e.aspectRatio=Math.max(0,e.aspectRatio)||NaN,e.viewMode=Math.max(0,Math.min(3,Math.round(e.viewMode)))||0,v(r,L),e.guides||v(r.getElementsByClassName("".concat(c,"-dashed")),L),e.center||v(r.getElementsByClassName("".concat(c,"-center")),L),e.background&&v(n,"".concat(c,"-bg")),e.highlight||v(s,G),e.cropBoxMovable&&(v(s,V),w(s,d,I)),e.cropBoxResizable||(v(r.getElementsByClassName("".concat(c,"-line")),L),v(r.getElementsByClassName("".concat(c,"-point")),L)),this.render(),this.ready=!0,this.setDragMode(e.dragMode),e.autoCrop&&this.crop(),this.setData(e.data),l(e.ready)&&b(t,"ready",e.ready,{once:!0}),y(t,"ready"))}},{key:"unbuild",value:function(){var t;this.ready&&(this.ready=!1,this.unbind(),this.resetPreview(),(t=this.cropper.parentNode)&&t.removeChild(this.cropper),X(this.element,L))}},{key:"uncreate",value:function(){this.ready?(this.unbuild(),this.ready=!1,this.cropped=!1):this.sizing?(this.sizingImage.onload=null,this.sizing=!1,this.sized=!1):this.reloading?(this.xhr.onabort=null,this.xhr.abort()):this.image&&this.stop()}}])&&j(t.prototype,e),i&&j(t,i),Object.defineProperty(t,"prototype",{writable:!1}),n}();return g(It.prototype,t,i,e,St,jt,At),It}); \ No newline at end of file diff --git a/config.example.php b/config.example.php new file mode 100644 index 0000000..283d86c --- /dev/null +++ b/config.example.php @@ -0,0 +1,27 @@ + [ + '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' => '', + ], +]; diff --git a/index.php b/index.php new file mode 100644 index 0000000..8161c50 --- /dev/null +++ b/index.php @@ -0,0 +1,143 @@ + [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(); diff --git a/src/Access.php b/src/Access.php new file mode 100644 index 0000000..4c6810c --- /dev/null +++ b/src/Access.php @@ -0,0 +1,90 @@ +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('仅管理员可访问此页面'); + } + } +} diff --git a/src/Auth.php b/src/Auth.php new file mode 100644 index 0000000..dc5c987 --- /dev/null +++ b/src/Auth.php @@ -0,0 +1,82 @@ +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; + } +} \ No newline at end of file diff --git a/src/Comments.php b/src/Comments.php new file mode 100644 index 0000000..4681dce --- /dev/null +++ b/src/Comments.php @@ -0,0 +1,151 @@ +, 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(); + } +} diff --git a/src/Controllers/ActivityController.php b/src/Controllers/ActivityController.php new file mode 100644 index 0000000..bc250ba --- /dev/null +++ b/src/Controllers/ActivityController.php @@ -0,0 +1,424 @@ +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 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; + } +} diff --git a/src/Controllers/AttachmentController.php b/src/Controllers/AttachmentController.php new file mode 100644 index 0000000..0be2a8d --- /dev/null +++ b/src/Controllers/AttachmentController.php @@ -0,0 +1,197 @@ + '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'); + } + } +} \ No newline at end of file diff --git a/src/Controllers/AuthController.php b/src/Controllers/AuthController.php new file mode 100644 index 0000000..4a3bdcb --- /dev/null +++ b/src/Controllers/AuthController.php @@ -0,0 +1,40 @@ + '登录']); + } + + 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'); + } +} \ No newline at end of file diff --git a/src/Controllers/CommentController.php b/src/Controllers/CommentController.php new file mode 100644 index 0000000..d6e60d3 --- /dev/null +++ b/src/Controllers/CommentController.php @@ -0,0 +1,82 @@ + 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'); + } +} diff --git a/src/Controllers/ExpenseController.php b/src/Controllers/ExpenseController.php new file mode 100644 index 0000000..b80a0d3 --- /dev/null +++ b/src/Controllers/ExpenseController.php @@ -0,0 +1,73 @@ + 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'); + } +} diff --git a/src/Controllers/MemberController.php b/src/Controllers/MemberController.php new file mode 100644 index 0000000..a7a440f --- /dev/null +++ b/src/Controllers/MemberController.php @@ -0,0 +1,64 @@ + $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'); + } +} diff --git a/src/Controllers/NodeController.php b/src/Controllers/NodeController.php new file mode 100644 index 0000000..065ebd9 --- /dev/null +++ b/src/Controllers/NodeController.php @@ -0,0 +1,513 @@ +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; + } +} diff --git a/src/Controllers/NodeTypeController.php b/src/Controllers/NodeTypeController.php new file mode 100644 index 0000000..eca4b08 --- /dev/null +++ b/src/Controllers/NodeTypeController.php @@ -0,0 +1,190 @@ +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('仅管理员可访问此页面'); + } + } +} \ No newline at end of file diff --git a/src/Controllers/RoleTagController.php b/src/Controllers/RoleTagController.php new file mode 100644 index 0000000..92a0626 --- /dev/null +++ b/src/Controllers/RoleTagController.php @@ -0,0 +1,182 @@ +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; + } +} diff --git a/src/Controllers/TravelModeController.php b/src/Controllers/TravelModeController.php new file mode 100644 index 0000000..052968f --- /dev/null +++ b/src/Controllers/TravelModeController.php @@ -0,0 +1,231 @@ + '自驾(自有车)', + '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'); + } + } +} \ No newline at end of file diff --git a/src/Controllers/UserController.php b/src/Controllers/UserController.php new file mode 100644 index 0000000..0e73802 --- /dev/null +++ b/src/Controllers/UserController.php @@ -0,0 +1,404 @@ +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); + } +} \ No newline at end of file diff --git a/src/Controllers/VehicleController.php b/src/Controllers/VehicleController.php new file mode 100644 index 0000000..2d60e23 --- /dev/null +++ b/src/Controllers/VehicleController.php @@ -0,0 +1,266 @@ + '自有车', + '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'); + } + } +} \ No newline at end of file diff --git a/src/Database.php b/src/Database.php new file mode 100644 index 0000000..a4e2907 --- /dev/null +++ b/src/Database.php @@ -0,0 +1,30 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + return self::$instance; + } +} \ No newline at end of file diff --git a/src/Expenses.php b/src/Expenses.php new file mode 100644 index 0000000..6ff409c --- /dev/null +++ b/src/Expenses.php @@ -0,0 +1,213 @@ + '¥', '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]; + } +} diff --git a/src/Members.php b/src/Members.php new file mode 100644 index 0000000..8919445 --- /dev/null +++ b/src/Members.php @@ -0,0 +1,182 @@ + '开心', + '🥰' => '温暖', + '😍' => '喜欢', + '🤩' => '兴奋', + '🤗' => '满足', + '😎' => '得意', + '🤔' => '沉思', + '😌' => '平静', + '😋' => '好吃', + '😅' => '无奈', + '😴' => '累了', + '😵' => '晕了', + '😢' => '难过', + '😭' => '大哭', + '😱' => '惊吓', + '😠' => '生气', + '🤒' => '不舒服', + ]; + + 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(); + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..3e08a9c --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,83 @@ + $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; +} \ No newline at end of file diff --git a/templates/activities/_node_card.php b/templates/activities/_node_card.php new file mode 100644 index 0000000..795bedb --- /dev/null +++ b/templates/activities/_node_card.php @@ -0,0 +1,129 @@ + '待执行', + '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 = '↺ 恢复'; +} +?> +
+
+ + + + 临时 + +
+
+ - +
+
+ + +
+ + 📍 + + + → + + + 📍 + +
+ + + +
🚄
+ + + +
取消原因:
+ + +
+ + + 详情 + + 📎 附件 () + + 0): ?> + + 👥 + + + 0): ?> + + 💬 + + + 0): ?> + + 💰 + + + 编辑 + + +
+ + + + +
+ + + + + ✓ 已成行 + +
+ + + +
+ + + +
+ + + +
+
+
diff --git a/templates/activities/form.php b/templates/activities/form.php new file mode 100644 index 0000000..0a3353e --- /dev/null +++ b/templates/activities/form.php @@ -0,0 +1,190 @@ + + + +
+ + + + + +
+ +
+
+ +
+
🖼️
+
16:9 封面
+
+
+
+ + + 支持 JPG/PNG/WebP,裁剪为 800×450 +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ 取消 + +
+
+ + + + + diff --git a/templates/activities/index.php b/templates/activities/index.php new file mode 100644 index 0000000..2f9665b --- /dev/null +++ b/templates/activities/index.php @@ -0,0 +1,30 @@ + + + +
+

还没有任何活动

+ 创建第一个活动 +
+ + + \ No newline at end of file diff --git a/templates/activities/show.php b/templates/activities/show.php new file mode 100644 index 0000000..642eb86 --- /dev/null +++ b/templates/activities/show.php @@ -0,0 +1,66 @@ + + + +
+ +
+ + + 0.0, 'actual' => 0.0, 'count' => 0, 'diff' => 0.0]; ?> + 0): ?> +
+ 💰 共 笔 + 预算 ¥ + 实际 ¥ + + 0 ? '超出预算' : '比预算省' ?> + ¥ + +
+ + +
+
+

📋 计划 ()

+ +

暂无计划节点

+ + + + + +
+ +
+

🎬 实际 ()

+ +

暂无执行节点

+ + + + + +
+
+ + diff --git a/templates/auth/login.php b/templates/auth/login.php new file mode 100644 index 0000000..e57e11e --- /dev/null +++ b/templates/auth/login.php @@ -0,0 +1,22 @@ + \ No newline at end of file diff --git a/templates/comments/_block.php b/templates/comments/_block.php new file mode 100644 index 0000000..373a30f --- /dev/null +++ b/templates/comments/_block.php @@ -0,0 +1,43 @@ + [], 'replies' => [], 'total' => 0]; +$ctType = (string)($commentTargetType ?? ''); +$ctId = (int)($commentTargetId ?? 0); +?> +
+

💬 讨论 ()

+ + +

还没有人发言,来说点什么吧

+ +
+ + +
+ + +
+ + + +
+ +
+ +
+ + +
+ + + + +
+ +
+
+
diff --git a/templates/comments/_comment.php b/templates/comments/_comment.php new file mode 100644 index 0000000..16e64a0 --- /dev/null +++ b/templates/comments/_comment.php @@ -0,0 +1,53 @@ + +
+
+ + + + + + + + 管理员 + + + + +
+ + + + + +
+ +
+ +
+ +
+ 回复 +
+ + + + + +
+ +
+
+
+
diff --git a/templates/layout.php b/templates/layout.php new file mode 100644 index 0000000..e81e568 --- /dev/null +++ b/templates/layout.php @@ -0,0 +1,58 @@ + + + + + + + <?= e($pageTitle ?? '家庭出行记录') ?> + + + + + + + + +
+ 🏠 家庭出行记录 + +
+ + +
+ +
+ + +
+ + +
家庭出行记录 · V1.0
+ + + \ No newline at end of file diff --git a/templates/node_types/form.php b/templates/node_types/form.php new file mode 100644 index 0000000..823d656 --- /dev/null +++ b/templates/node_types/form.php @@ -0,0 +1,66 @@ + + + +
+ + + + + +
+ + +

最多 30 个字符

+
+ +
+ + +
+ + + +
+

点击下方表情快速填入

+
+ +
+ 取消 + +
+
+ + \ No newline at end of file diff --git a/templates/node_types/index.php b/templates/node_types/index.php new file mode 100644 index 0000000..fdf599c --- /dev/null +++ b/templates/node_types/index.php @@ -0,0 +1,54 @@ + + + +

暂无节点类型

+ + + + + + + + + + + + + + + + + + + + + + + + +
ID图标名称来源使用次数操作
+ + 系统内置 + + 自定义 + + 个节点 + 编辑 + +
+ + + +
+ + 不可删除 + +
+ \ No newline at end of file diff --git a/templates/nodes/_expenses.php b/templates/nodes/_expenses.php new file mode 100644 index 0000000..ced0588 --- /dev/null +++ b/templates/nodes/_expenses.php @@ -0,0 +1,159 @@ +中文名)、$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); +}; +?> +
+

💰 花销 ()

+ +
+
+ 预算 + ¥ +
+
+ 实际 + ¥ +
+
+ 0 ? '超出预算' : '比预算省' ?> + ¥ +
+
+ + +

还没有记账

+ +
+ +
+
+ + + 预算 + + 实际 + + + +
+
+ 付款: + + · 分摊: + +
+ + + +
+
+
+ +
+ + +
+ + 记一笔花销 +
+ + + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+
+
+
diff --git a/templates/nodes/_members.php b/templates/nodes/_members.php new file mode 100644 index 0000000..e59ea50 --- /dev/null +++ b/templates/nodes/_members.php @@ -0,0 +1,140 @@ +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; +} +?> +
+

👥 参与人 ()

+ + +

还没有添加参与人

+ +
+ +
+ + + + + + + + + + + + + + + + + + + ✓ 签到 + + +
+ + + + +
+
+ +
+ + + +
+ + + +
+ + +
+ + 添加 / 修改参与人 +
+ + + +
+ + +
+ + +
+ +
+ $tagName): ?> + + +
+
+ +

+ 还没有角色标签,去 + 角色标签管理 + 先建几个(例如:司机 / 摄影师 / 记账员) +

+ + +
+ +
+ + $label): ?> + + +
+
+ +
+ + +
+ +
+ +
+
+
+
diff --git a/templates/nodes/attachments.php b/templates/nodes/attachments.php new file mode 100644 index 0000000..e776168 --- /dev/null +++ b/templates/nodes/attachments.php @@ -0,0 +1,139 @@ + + +
+ + + +
+ +
+ +
+
📎
+

点击或拖拽文件到这里

+

+ 支持 JPG / PNG / GIF / WebP / PDF / DOC / XLS / MP4 等,单个不超过 20MB +

+
+
+
+
+ +
+ +
+
+ +

已上传()

+ + +
+

暂无附件,上传第一张机票/门票/照片吧

+
+ +
+ 0 ? ($size < 1024 * 1024 + ? round($size / 1024, 1) . ' KB' + : round($size / 1024 / 1024, 2) . ' MB') : ''; + ?> + + +
+ + + \ No newline at end of file diff --git a/templates/nodes/form.php b/templates/nodes/form.php new file mode 100644 index 0000000..8e6e14e --- /dev/null +++ b/templates/nodes/form.php @@ -0,0 +1,369 @@ + '待执行', + '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); +?> + + +
+ + + + + + + + +
+ + +
+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ 起点信息(可选) +
+ + + + + +
+
+ +
+ 终点信息(可选) +
+ + + + + +
+
+ +
+ 出行方式(可选) +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+ + +
+ 执行状态 +
+ + +
+
+ + +
+
+ + +
+ 取消 + +
+
+ + diff --git a/templates/nodes/show.php b/templates/nodes/show.php new file mode 100644 index 0000000..ef39cbf --- /dev/null +++ b/templates/nodes/show.php @@ -0,0 +1,128 @@ + '待执行', + '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 . ' 分钟'; +} +?> + + +
+
+ 时间 + + + → + + + () + + +
+
+ 类型 + +
+ +
+ 出行方式 + +
+ + +
+ 交通细节 + +
+ + +
+ 起点 + + 📍 + + + + +
+ + +
+ 终点 + + 📍 + + + + +
+ + +
+ 描述 + +
+ + +
+ 取消原因 + +
+ +
+ + + + + + diff --git a/templates/role_tags/form.php b/templates/role_tags/form.php new file mode 100644 index 0000000..30179da --- /dev/null +++ b/templates/role_tags/form.php @@ -0,0 +1,64 @@ + + + +
+ + + + + +
+ + +

最多 30 个字符,同一家庭内不能重名

+
+ +
+ + +
+ + + +
+

+ 点击表情快速填入;留空也可以 +

+
+ +
+ 取消 + +
+
+ + diff --git a/templates/role_tags/index.php b/templates/role_tags/index.php new file mode 100644 index 0000000..48d5299 --- /dev/null +++ b/templates/role_tags/index.php @@ -0,0 +1,45 @@ + + + +
+

还没有角色标签

+ 创建第一个角色 +
+ + + + + + + + + + + + + + + + + + + + + + +
ID图标名称被引用操作
处参与人 + 编辑 +
+ + + +
+
+ diff --git a/templates/travel_modes/form.php b/templates/travel_modes/form.php new file mode 100644 index 0000000..0a6c568 --- /dev/null +++ b/templates/travel_modes/form.php @@ -0,0 +1,87 @@ + + + +
+ + + + + +
+ + +

+ 具体的车次、航班号、座位号在「活动节点」中填写,此处只维护出行方式本身。 +

+
+ + + +
+ + +
+ +
+ 取消 + +
+
+ + \ No newline at end of file diff --git a/templates/travel_modes/index.php b/templates/travel_modes/index.php new file mode 100644 index 0000000..62836c3 --- /dev/null +++ b/templates/travel_modes/index.php @@ -0,0 +1,60 @@ + + + +
+

暂无出行方式

+ 添加第一个 +
+ + + + + + + + + + + + + + + + + + + + + + +
ID类型备注使用操作
+ + + + +
+ +
+ +
+ + + + — + + 次 + 编辑 +
+ + + +
+
+ \ No newline at end of file diff --git a/templates/users/form.php b/templates/users/form.php new file mode 100644 index 0000000..af2e534 --- /dev/null +++ b/templates/users/form.php @@ -0,0 +1,237 @@ + '管理员', 'user' => '普通用户', 'guest' => '待激活']; +$isAdmin = \App\Auth::isAdmin(); +?> + + +
+ + + + + +
+ +
+
+ +
+
+
+
+ + + 支持 JPG/PNG/WebP,裁剪为 400×400 +
+ + +
+
+ +
+ + +
+ + +
+ + +

+ 待激活用户无需手机号/密码,仅供活动记录中标记,不能登录系统。 +

+
+ + +
+ + +
+ +
+ + +
+ +
+ + +

+ 身份证号将被加密存储,用于未来关联车票/机票。 +

+
+ + +
+ + +
+ + +
+ 取消 + +
+
+ + + + + \ No newline at end of file diff --git a/templates/users/index.php b/templates/users/index.php new file mode 100644 index 0000000..69896e8 --- /dev/null +++ b/templates/users/index.php @@ -0,0 +1,69 @@ + + + +

暂无用户

+ + + + + + + + + + + + + + + + + + + + + + + + +
ID姓名角色状态创建时间操作
+
+ + + + + + + + 我 + +
+
+ '管理员', 'user' => '普通用户', 'guest' => '待激活']; + $roleClasses = ['admin' => 'role-admin', 'user' => 'role-user', 'guest' => 'role-guest']; + ?> + + + + + + ● 正常 + + ● 停用 + + + 编辑 + +
+ + + +
+ +
+ \ No newline at end of file diff --git a/templates/vehicles/form.php b/templates/vehicles/form.php new file mode 100644 index 0000000..a66bd82 --- /dev/null +++ b/templates/vehicles/form.php @@ -0,0 +1,112 @@ + + + +
+ + + + + +
+ + +

同一家庭内车牌号不可重复

+
+ +
+ + +
+ +
+ +
+ $label): ?> + + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ +

+ 选择后,其他自有车的默认标记会被自动取消。 +

+
+
+ +
+ + +
+ +
+ 取消 + +
+
+ + \ No newline at end of file diff --git a/templates/vehicles/index.php b/templates/vehicles/index.php new file mode 100644 index 0000000..1f91c5a --- /dev/null +++ b/templates/vehicles/index.php @@ -0,0 +1,67 @@ + + + +
+

暂无车辆

+ 添加第一辆 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ID车牌号车型类型租车信息使用操作
+ + + 默认 + + + + + + + +
+ +
+ + + — + +
次 + 编辑 +
+ + + +
+
+ \ No newline at end of file diff --git a/uploads/.htaccess b/uploads/.htaccess new file mode 100644 index 0000000..8c924c1 --- /dev/null +++ b/uploads/.htaccess @@ -0,0 +1,3 @@ + + Require all denied + \ No newline at end of file